chiark / gitweb /
Refuse to try downloading a repo twice
[fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
2 #
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import os
21 import sys
22 import re
23 import shutil
24 import glob
25 import stat
26 import subprocess
27 import time
28 import operator
29 import Queue
30 import threading
31 import magic
32 import logging
33 from distutils.version import LooseVersion
34
35 import metadata
36
37 config = None
38 options = None
39 env = None
40
41
42 def get_default_config():
43     return {
44         'sdk_path': os.getenv("ANDROID_HOME"),
45         'ndk_path': os.getenv("ANDROID_NDK"),
46         'build_tools': "20.0.0",
47         'ant': "ant",
48         'mvn3': "mvn",
49         'gradle': 'gradle',
50         'sync_from_local_copy_dir': False,
51         'update_stats': False,
52         'stats_to_carbon': False,
53         'repo_maxage': 0,
54         'build_server_always': False,
55         'keystore': os.path.join(os.getenv("HOME"), '.local', 'share', 'fdroidserver', 'keystore.jks'),
56         'smartcardoptions': [],
57         'char_limits': {
58             'Summary': 50,
59             'Description': 1500
60         },
61         'keyaliases': {},
62         'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
63         'repo_name': "My First FDroid Repo Demo",
64         'repo_icon': "fdroid-icon.png",
65         'repo_description': (
66             "This is a repository of apps to be used with FDroid. Applications in this "
67             + "repository are either official binaries built by the original application "
68             + "developers, or are binaries built from source by the admin of f-droid.org "
69             + "using the tools on https://gitlab.com/u/fdroid."),
70         'archive_older': 0,
71     }
72
73
74 def read_config(opts, config_file='config.py'):
75     """Read the repository config
76
77     The config is read from config_file, which is in the current directory when
78     any of the repo management commands are used.
79     """
80     global config, options, env
81
82     if config is not None:
83         return config
84     if not os.path.isfile(config_file):
85         logging.critical("Missing config file - is this a repo directory?")
86         sys.exit(2)
87
88     options = opts
89
90     config = {}
91
92     logging.debug("Reading %s" % config_file)
93     execfile(config_file, config)
94
95     # smartcardoptions must be a list since its command line args for Popen
96     if 'smartcardoptions' in config:
97         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
98     elif 'keystore' in config and config['keystore'] == 'NONE':
99         # keystore='NONE' means use smartcard, these are required defaults
100         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
101                                       'SunPKCS11-OpenSC', '-providerClass',
102                                       'sun.security.pkcs11.SunPKCS11',
103                                       '-providerArg', 'opensc-fdroid.cfg']
104
105     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
106         st = os.stat(config_file)
107         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
108             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
109
110     defconfig = get_default_config()
111     for k, v in defconfig.items():
112         if k not in config:
113             config[k] = v
114
115     # Expand environment variables
116     for k, v in config.items():
117         if type(v) != str:
118             continue
119         v = os.path.expanduser(v)
120         config[k] = os.path.expandvars(v)
121
122     if not test_sdk_exists(config):
123         sys.exit(3)
124
125     if not test_build_tools_exists(config):
126         sys.exit(3)
127
128     env = os.environ
129
130     # There is no standard, so just set up the most common environment
131     # variables
132     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
133         env[n] = config['sdk_path']
134     for n in ['ANDROID_NDK', 'NDK']:
135         env[n] = config['ndk_path']
136
137     for k in ["keystorepass", "keypass"]:
138         if k in config:
139             write_password_file(k)
140
141     # since this is used with rsync, where trailing slashes have meaning,
142     # ensure there is always a trailing slash
143     if 'serverwebroot' in config:
144         if config['serverwebroot'][-1] != '/':
145             config['serverwebroot'] += '/'
146         config['serverwebroot'] = config['serverwebroot'].replace('//', '/')
147
148     return config
149
150
151 def test_sdk_exists(c):
152     if c['sdk_path'] is None:
153         # c['sdk_path'] is set to the value of ANDROID_HOME by default
154         logging.critical('No Android SDK found! ANDROID_HOME is not set and sdk_path is not in config.py!')
155         logging.info('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
156         logging.info('\texport ANDROID_HOME=/opt/android-sdk')
157         return False
158     if not os.path.exists(c['sdk_path']):
159         logging.critical('Android SDK path "' + c['sdk_path'] + '" does not exist!')
160         return False
161     if not os.path.isdir(c['sdk_path']):
162         logging.critical('Android SDK path "' + c['sdk_path'] + '" is not a directory!')
163         return False
164     if not os.path.isdir(os.path.join(c['sdk_path'], 'build-tools')):
165         logging.critical('Android SDK path "' + c['sdk_path'] + '" does not contain "build-tools/"!')
166         return False
167     return True
168
169
170 def test_build_tools_exists(c):
171     if not test_sdk_exists(c):
172         return False
173     build_tools = os.path.join(c['sdk_path'], 'build-tools')
174     versioned_build_tools = os.path.join(build_tools, c['build_tools'])
175     if not os.path.isdir(versioned_build_tools):
176         logging.critical('Android Build Tools path "'
177                          + versioned_build_tools + '" does not exist!')
178         return False
179     if not os.path.exists(os.path.join(c['sdk_path'], 'build-tools', c['build_tools'], 'aapt')):
180         logging.critical('Android Build Tools "'
181                          + versioned_build_tools
182                          + '" does not contain "aapt"!')
183         return False
184     return True
185
186
187 def write_password_file(pwtype, password=None):
188     '''
189     writes out passwords to a protected file instead of passing passwords as
190     command line argments
191     '''
192     filename = '.fdroid.' + pwtype + '.txt'
193     fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
194     if password is None:
195         os.write(fd, config[pwtype])
196     else:
197         os.write(fd, password)
198     os.close(fd)
199     config[pwtype + 'file'] = filename
200
201
202 # Given the arguments in the form of multiple appid:[vc] strings, this returns
203 # a dictionary with the set of vercodes specified for each package.
204 def read_pkg_args(args, allow_vercodes=False):
205
206     vercodes = {}
207     if not args:
208         return vercodes
209
210     for p in args:
211         if allow_vercodes and ':' in p:
212             package, vercode = p.split(':')
213         else:
214             package, vercode = p, None
215         if package not in vercodes:
216             vercodes[package] = [vercode] if vercode else []
217             continue
218         elif vercode and vercode not in vercodes[package]:
219             vercodes[package] += [vercode] if vercode else []
220
221     return vercodes
222
223
224 # On top of what read_pkg_args does, this returns the whole app metadata, but
225 # limiting the builds list to the builds matching the vercodes specified.
226 def read_app_args(args, allapps, allow_vercodes=False):
227
228     vercodes = read_pkg_args(args, allow_vercodes)
229
230     if not vercodes:
231         return allapps
232
233     apps = [app for app in allapps if app['id'] in vercodes]
234
235     if len(apps) != len(vercodes):
236         allids = [app["id"] for app in allapps]
237         for p in vercodes:
238             if p not in allids:
239                 logging.critical("No such package: %s" % p)
240         raise Exception("Found invalid app ids in arguments")
241     if not apps:
242         raise Exception("No packages specified")
243
244     error = False
245     for app in apps:
246         vc = vercodes[app['id']]
247         if not vc:
248             continue
249         app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
250         if len(app['builds']) != len(vercodes[app['id']]):
251             error = True
252             allvcs = [b['vercode'] for b in app['builds']]
253             for v in vercodes[app['id']]:
254                 if v not in allvcs:
255                     logging.critical("No such vercode %s for app %s" % (v, app['id']))
256
257     if error:
258         raise Exception("Found invalid vercodes for some apps")
259
260     return apps
261
262
263 def has_extension(filename, extension):
264     name, ext = os.path.splitext(filename)
265     ext = ext.lower()[1:]
266     return ext == extension
267
268 apk_regex = None
269
270
271 def apknameinfo(filename):
272     global apk_regex
273     filename = os.path.basename(filename)
274     if apk_regex is None:
275         apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
276     m = apk_regex.match(filename)
277     try:
278         result = (m.group(1), m.group(2))
279     except AttributeError:
280         raise Exception("Invalid apk name: %s" % filename)
281     return result
282
283
284 def getapkname(app, build):
285     return "%s_%s.apk" % (app['id'], build['vercode'])
286
287
288 def getsrcname(app, build):
289     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
290
291
292 def getappname(app):
293     if app['Name']:
294         return app['Name']
295     if app['Auto Name']:
296         return app['Auto Name']
297     return app['id']
298
299
300 def getcvname(app):
301     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
302
303
304 def getvcs(vcstype, remote, local):
305     if vcstype == 'git':
306         return vcs_git(remote, local)
307     if vcstype == 'svn':
308         return vcs_svn(remote, local)
309     if vcstype == 'git-svn':
310         return vcs_gitsvn(remote, local)
311     if vcstype == 'hg':
312         return vcs_hg(remote, local)
313     if vcstype == 'bzr':
314         return vcs_bzr(remote, local)
315     if vcstype == 'srclib':
316         if local != 'build/srclib/' + remote:
317             raise VCSException("Error: srclib paths are hard-coded!")
318         return getsrclib(remote, 'build/srclib', raw=True)
319     raise VCSException("Invalid vcs type " + vcstype)
320
321
322 def getsrclibvcs(name):
323     if name not in metadata.srclibs:
324         raise VCSException("Missing srclib " + name)
325     return metadata.srclibs[name]['Repo Type']
326
327
328 class vcs:
329     def __init__(self, remote, local):
330
331         # svn, git-svn and bzr may require auth
332         self.username = None
333         if self.repotype() in ('svn', 'git-svn', 'bzr'):
334             if '@' in remote:
335                 self.username, remote = remote.split('@')
336                 if ':' not in self.username:
337                     raise VCSException("Password required with username")
338                 self.username, self.password = self.username.split(':')
339
340         self.remote = remote
341         self.local = local
342         self.clone_failed = False
343         self.refreshed = False
344         self.srclib = None
345
346     def repotype(self):
347         return None
348
349     # Take the local repository to a clean version of the given revision, which
350     # is specificed in the VCS's native format. Beforehand, the repository can
351     # be dirty, or even non-existent. If the repository does already exist
352     # locally, it will be updated from the origin, but only once in the
353     # lifetime of the vcs object.
354     # None is acceptable for 'rev' if you know you are cloning a clean copy of
355     # the repo - otherwise it must specify a valid revision.
356     def gotorevision(self, rev):
357
358         if self.clone_failed:
359             raise VCSException("Downloading the repository already failed once, not trying again.")
360
361         # The .fdroidvcs-id file for a repo tells us what VCS type
362         # and remote that directory was created from, allowing us to drop it
363         # automatically if either of those things changes.
364         fdpath = os.path.join(self.local, '..',
365                               '.fdroidvcs-' + os.path.basename(self.local))
366         cdata = self.repotype() + ' ' + self.remote
367         writeback = True
368         deleterepo = False
369         if os.path.exists(self.local):
370             if os.path.exists(fdpath):
371                 with open(fdpath, 'r') as f:
372                     fsdata = f.read().strip()
373                 if fsdata == cdata:
374                     writeback = False
375                 else:
376                     deleterepo = True
377                     logging.info(
378                         "Repository details for {0} changed - deleting"
379                         .format(self.local))
380             else:
381                 deleterepo = True
382                 logging.info("Repository details missing - deleting")
383         if deleterepo:
384             shutil.rmtree(self.local)
385
386         self.gotorevisionx(rev)
387
388         # If necessary, write the .fdroidvcs file.
389         if writeback:
390             with open(fdpath, 'w') as f:
391                 f.write(cdata)
392
393     # Derived classes need to implement this. It's called once basic checking
394     # has been performend.
395     def gotorevisionx(self, rev):
396         raise VCSException("This VCS type doesn't define gotorevisionx")
397
398     # Initialise and update submodules
399     def initsubmodules(self):
400         raise VCSException('Submodules not supported for this vcs type')
401
402     # Get a list of all known tags
403     def gettags(self):
404         raise VCSException('gettags not supported for this vcs type')
405
406     # Get a list of latest number tags
407     def latesttags(self, number):
408         raise VCSException('latesttags not supported for this vcs type')
409
410     # Get current commit reference (hash, revision, etc)
411     def getref(self):
412         raise VCSException('getref not supported for this vcs type')
413
414     # Returns the srclib (name, path) used in setting up the current
415     # revision, or None.
416     def getsrclib(self):
417         return self.srclib
418
419
420 class vcs_git(vcs):
421
422     def repotype(self):
423         return 'git'
424
425     # If the local directory exists, but is somehow not a git repository, git
426     # will traverse up the directory tree until it finds one that is (i.e.
427     # fdroidserver) and then we'll proceed to destroy it! This is called as
428     # a safety check.
429     def checkrepo(self):
430         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
431         result = p.output.rstrip()
432         if not result.endswith(self.local):
433             raise VCSException('Repository mismatch')
434
435     def gotorevisionx(self, rev):
436         if not os.path.exists(self.local):
437             # Brand new checkout
438             p = FDroidPopen(['git', 'clone', self.remote, self.local])
439             if p.returncode != 0:
440                 self.clone_failed = True
441                 raise VCSException("Git clone failed", p.output)
442             self.checkrepo()
443         else:
444             self.checkrepo()
445             # Discard any working tree changes
446             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
447             if p.returncode != 0:
448                 raise VCSException("Git reset failed", p.output)
449             # Remove untracked files now, in case they're tracked in the target
450             # revision (it happens!)
451             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
452             if p.returncode != 0:
453                 raise VCSException("Git clean failed", p.output)
454             if not self.refreshed:
455                 # Get latest commits and tags from remote
456                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
457                 if p.returncode != 0:
458                     raise VCSException("Git fetch failed", p.output)
459                 p = SilentPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local)
460                 if p.returncode != 0:
461                     raise VCSException("Git fetch failed", p.output)
462                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
463                 p = SilentPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local)
464                 if p.returncode != 0:
465                     raise VCSException("Git remote set-head failed", p.output)
466                 self.refreshed = True
467         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
468         # a github repo. Most of the time this is the same as origin/master.
469         rev = str(rev if rev else 'origin/HEAD')
470         p = SilentPopen(['git', 'checkout', '-f', rev], cwd=self.local)
471         if p.returncode != 0:
472             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
473         # Get rid of any uncontrolled files left behind
474         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
475         if p.returncode != 0:
476             raise VCSException("Git clean failed", p.output)
477
478     def initsubmodules(self):
479         self.checkrepo()
480         submfile = os.path.join(self.local, '.gitmodules')
481         if not os.path.isfile(submfile):
482             raise VCSException("No git submodules available")
483
484         # fix submodules not accessible without an account and public key auth
485         with open(submfile, 'r') as f:
486             lines = f.readlines()
487         with open(submfile, 'w') as f:
488             for line in lines:
489                 if 'git@github.com' in line:
490                     line = line.replace('git@github.com:', 'https://github.com/')
491                 f.write(line)
492
493         for cmd in [
494                 ['git', 'reset', '--hard'],
495                 ['git', 'clean', '-dffx'],
496                 ]:
497             p = SilentPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local)
498             if p.returncode != 0:
499                 raise VCSException("Git submodule reset failed", p.output)
500         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local)
501         if p.returncode != 0:
502             raise VCSException("Git submodule sync failed", p.output)
503         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
504         if p.returncode != 0:
505             raise VCSException("Git submodule update failed", p.output)
506
507     def gettags(self):
508         self.checkrepo()
509         p = SilentPopen(['git', 'tag'], cwd=self.local)
510         return p.output.splitlines()
511
512     def latesttags(self, alltags, number):
513         self.checkrepo()
514         p = SilentPopen(['echo "' + '\n'.join(alltags) + '" | '
515                         + 'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
516                         + 'sort -n | awk \'{print $2}\''],
517                         cwd=self.local, shell=True)
518         return p.output.splitlines()[-number:]
519
520
521 class vcs_gitsvn(vcs):
522
523     def repotype(self):
524         return 'git-svn'
525
526     # Damn git-svn tries to use a graphical password prompt, so we have to
527     # trick it into taking the password from stdin
528     def userargs(self):
529         if self.username is None:
530             return ('', '')
531         return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
532
533     # If the local directory exists, but is somehow not a git repository, git
534     # will traverse up the directory tree until it finds one that is (i.e.
535     # fdroidserver) and then we'll proceed to destory it! This is called as
536     # a safety check.
537     def checkrepo(self):
538         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
539         result = p.output.rstrip()
540         if not result.endswith(self.local):
541             raise VCSException('Repository mismatch')
542
543     def gotorevisionx(self, rev):
544         if not os.path.exists(self.local):
545             # Brand new checkout
546             gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
547             if ';' in self.remote:
548                 remote_split = self.remote.split(';')
549                 for i in remote_split[1:]:
550                     if i.startswith('trunk='):
551                         gitsvn_cmd += ' -T %s' % i[6:]
552                     elif i.startswith('tags='):
553                         gitsvn_cmd += ' -t %s' % i[5:]
554                     elif i.startswith('branches='):
555                         gitsvn_cmd += ' -b %s' % i[9:]
556                 p = SilentPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True)
557                 if p.returncode != 0:
558                     self.clone_failed = True
559                     raise VCSException("Git clone failed", p.output)
560             else:
561                 p = SilentPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True)
562                 if p.returncode != 0:
563                     self.clone_failed = True
564                     raise VCSException("Git clone failed", p.output)
565             self.checkrepo()
566         else:
567             self.checkrepo()
568             # Discard any working tree changes
569             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
570             if p.returncode != 0:
571                 raise VCSException("Git reset failed", p.output)
572             # Remove untracked files now, in case they're tracked in the target
573             # revision (it happens!)
574             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
575             if p.returncode != 0:
576                 raise VCSException("Git clean failed", p.output)
577             if not self.refreshed:
578                 # Get new commits, branches and tags from repo
579                 p = SilentPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True)
580                 if p.returncode != 0:
581                     raise VCSException("Git svn fetch failed")
582                 p = SilentPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True)
583                 if p.returncode != 0:
584                     raise VCSException("Git svn rebase failed", p.output)
585                 self.refreshed = True
586
587         rev = str(rev if rev else 'master')
588         if rev:
589             nospaces_rev = rev.replace(' ', '%20')
590             # Try finding a svn tag
591             p = SilentPopen(['git', 'checkout', 'tags/' + nospaces_rev], cwd=self.local)
592             if p.returncode != 0:
593                 # No tag found, normal svn rev translation
594                 # Translate svn rev into git format
595                 rev_split = rev.split('/')
596                 if len(rev_split) > 1:
597                     treeish = 'origin/' + rev_split[0]
598                     svn_rev = rev_split[1]
599
600                 else:
601                     # if no branch is specified, then assume trunk (ie. 'master'
602                     # branch):
603                     treeish = 'origin/master'
604                     svn_rev = rev
605
606                 p = SilentPopen(['git', 'svn', 'find-rev', 'r' + svn_rev, treeish], cwd=self.local)
607                 git_rev = p.output.rstrip()
608
609                 if p.returncode != 0 or not git_rev:
610                     # Try a plain git checkout as a last resort
611                     p = SilentPopen(['git', 'checkout', rev], cwd=self.local)
612                     if p.returncode != 0:
613                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
614                 else:
615                     # Check out the git rev equivalent to the svn rev
616                     p = SilentPopen(['git', 'checkout', git_rev], cwd=self.local)
617                     if p.returncode != 0:
618                         raise VCSException("Git svn checkout of '%s' failed" % rev, p.output)
619
620         # Get rid of any uncontrolled files left behind
621         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
622         if p.returncode != 0:
623             raise VCSException("Git clean failed", p.output)
624
625     def gettags(self):
626         self.checkrepo()
627         return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
628
629     def getref(self):
630         self.checkrepo()
631         p = SilentPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local)
632         if p.returncode != 0:
633             return None
634         return p.output.strip()
635
636
637 class vcs_svn(vcs):
638
639     def repotype(self):
640         return 'svn'
641
642     def userargs(self):
643         if self.username is None:
644             return ['--non-interactive']
645         return ['--username', self.username,
646                 '--password', self.password,
647                 '--non-interactive']
648
649     def gotorevisionx(self, rev):
650         if not os.path.exists(self.local):
651             p = SilentPopen(['svn', 'checkout', self.remote, self.local] + self.userargs())
652             if p.returncode != 0:
653                 self.clone_failed = True
654                 raise VCSException("Svn checkout of '%s' failed" % rev, p.output)
655         else:
656             for svncommand in (
657                     'svn revert -R .',
658                     r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
659                 p = SilentPopen([svncommand], cwd=self.local, shell=True)
660                 if p.returncode != 0:
661                     raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local), p.output)
662             if not self.refreshed:
663                 p = SilentPopen(['svn', 'update'] + self.userargs(), cwd=self.local)
664                 if p.returncode != 0:
665                     raise VCSException("Svn update failed", p.output)
666                 self.refreshed = True
667
668         revargs = list(['-r', rev] if rev else [])
669         p = SilentPopen(['svn', 'update', '--force'] + revargs + self.userargs(), cwd=self.local)
670         if p.returncode != 0:
671             raise VCSException("Svn update failed", p.output)
672
673     def getref(self):
674         p = SilentPopen(['svn', 'info'], cwd=self.local)
675         for line in p.output.splitlines():
676             if line and line.startswith('Last Changed Rev: '):
677                 return line[18:]
678         return None
679
680
681 class vcs_hg(vcs):
682
683     def repotype(self):
684         return 'hg'
685
686     def gotorevisionx(self, rev):
687         if not os.path.exists(self.local):
688             p = SilentPopen(['hg', 'clone', self.remote, self.local])
689             if p.returncode != 0:
690                 self.clone_failed = True
691                 raise VCSException("Hg clone failed", p.output)
692         else:
693             p = SilentPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True)
694             if p.returncode != 0:
695                 raise VCSException("Hg clean failed", p.output)
696             if not self.refreshed:
697                 p = SilentPopen(['hg', 'pull'], cwd=self.local)
698                 if p.returncode != 0:
699                     raise VCSException("Hg pull failed", p.output)
700                 self.refreshed = True
701
702         rev = str(rev if rev else 'default')
703         if not rev:
704             return
705         p = SilentPopen(['hg', 'update', '-C', rev], cwd=self.local)
706         if p.returncode != 0:
707             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
708         p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
709         # Also delete untracked files, we have to enable purge extension for that:
710         if "'purge' is provided by the following extension" in p.output:
711             with open(self.local + "/.hg/hgrc", "a") as myfile:
712                 myfile.write("\n[extensions]\nhgext.purge=\n")
713             p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
714             if p.returncode != 0:
715                 raise VCSException("HG purge failed", p.output)
716         elif p.returncode != 0:
717             raise VCSException("HG purge failed", p.output)
718
719     def gettags(self):
720         p = SilentPopen(['hg', 'tags', '-q'], cwd=self.local)
721         return p.output.splitlines()[1:]
722
723
724 class vcs_bzr(vcs):
725
726     def repotype(self):
727         return 'bzr'
728
729     def gotorevisionx(self, rev):
730         if not os.path.exists(self.local):
731             p = SilentPopen(['bzr', 'branch', self.remote, self.local])
732             if p.returncode != 0:
733                 self.clone_failed = True
734                 raise VCSException("Bzr branch failed", p.output)
735         else:
736             p = SilentPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local)
737             if p.returncode != 0:
738                 raise VCSException("Bzr revert failed", p.output)
739             if not self.refreshed:
740                 p = SilentPopen(['bzr', 'pull'], cwd=self.local)
741                 if p.returncode != 0:
742                     raise VCSException("Bzr update failed", p.output)
743                 self.refreshed = True
744
745         revargs = list(['-r', rev] if rev else [])
746         p = SilentPopen(['bzr', 'revert'] + revargs, cwd=self.local)
747         if p.returncode != 0:
748             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
749
750     def gettags(self):
751         p = SilentPopen(['bzr', 'tags'], cwd=self.local)
752         return [tag.split('   ')[0].strip() for tag in
753                 p.output.splitlines()]
754
755
756 def retrieve_string(app_dir, string, xmlfiles=None):
757
758     res_dirs = [
759         os.path.join(app_dir, 'res'),
760         os.path.join(app_dir, 'src/main'),
761         ]
762
763     if xmlfiles is None:
764         xmlfiles = []
765         for res_dir in res_dirs:
766             for r, d, f in os.walk(res_dir):
767                 if r.endswith('/values'):
768                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
769
770     string_search = None
771     if string.startswith('@string/'):
772         string_search = re.compile(r'.*name="' + string[8:] + '".*?>([^<]+?)<.*').search
773     elif string.startswith('&') and string.endswith(';'):
774         string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
775
776     if string_search is not None:
777         for xmlfile in xmlfiles:
778             for line in file(xmlfile):
779                 matches = string_search(line)
780                 if matches:
781                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
782         return None
783
784     return string.replace("\\'", "'")
785
786
787 # Return list of existing files that will be used to find the highest vercode
788 def manifest_paths(app_dir, flavour):
789
790     possible_manifests = \
791         [os.path.join(app_dir, 'AndroidManifest.xml'),
792          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
793          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
794          os.path.join(app_dir, 'build.gradle')]
795
796     if flavour:
797         possible_manifests.append(
798             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
799
800     return [path for path in possible_manifests if os.path.isfile(path)]
801
802
803 # Retrieve the package name. Returns the name, or None if not found.
804 def fetch_real_name(app_dir, flavour):
805     app_search = re.compile(r'.*<application.*').search
806     name_search = re.compile(r'.*android:label="([^"]+)".*').search
807     app_found = False
808     for f in manifest_paths(app_dir, flavour):
809         if not has_extension(f, 'xml'):
810             continue
811         logging.debug("fetch_real_name: Checking manifest at " + f)
812         for line in file(f):
813             if not app_found:
814                 if app_search(line):
815                     app_found = True
816             if app_found:
817                 matches = name_search(line)
818                 if matches:
819                     stringname = matches.group(1)
820                     logging.debug("fetch_real_name: using string " + stringname)
821                     result = retrieve_string(app_dir, stringname)
822                     if result:
823                         result = result.strip()
824                     return result
825     return None
826
827
828 # Retrieve the version name
829 def version_name(original, app_dir, flavour):
830     for f in manifest_paths(app_dir, flavour):
831         if not has_extension(f, 'xml'):
832             continue
833         string = retrieve_string(app_dir, original)
834         if string:
835             return string
836     return original
837
838
839 def get_library_references(root_dir):
840     libraries = []
841     proppath = os.path.join(root_dir, 'project.properties')
842     if not os.path.isfile(proppath):
843         return libraries
844     with open(proppath) as f:
845         for line in f.readlines():
846             if not line.startswith('android.library.reference.'):
847                 continue
848             path = line.split('=')[1].strip()
849             relpath = os.path.join(root_dir, path)
850             if not os.path.isdir(relpath):
851                 continue
852             logging.info("Found subproject at %s" % path)
853             libraries.append(path)
854     return libraries
855
856
857 def ant_subprojects(root_dir):
858     subprojects = get_library_references(root_dir)
859     for subpath in subprojects:
860         subrelpath = os.path.join(root_dir, subpath)
861         for p in get_library_references(subrelpath):
862             relp = os.path.normpath(os.path.join(subpath, p))
863             if relp not in subprojects:
864                 subprojects.insert(0, relp)
865     return subprojects
866
867
868 def remove_debuggable_flags(root_dir):
869     # Remove forced debuggable flags
870     logging.info("Removing debuggable flags")
871     for root, dirs, files in os.walk(root_dir):
872         if 'AndroidManifest.xml' in files:
873             path = os.path.join(root, 'AndroidManifest.xml')
874             p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path])
875             if p.returncode != 0:
876                 raise BuildException("Failed to remove debuggable flags of %s" % path)
877
878
879 # Extract some information from the AndroidManifest.xml at the given path.
880 # Returns (version, vercode, package), any or all of which might be None.
881 # All values returned are strings.
882 def parse_androidmanifests(paths, ignoreversions=None):
883
884     if not paths:
885         return (None, None, None)
886
887     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
888     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
889     psearch = re.compile(r'.*package="([^"]+)".*').search
890
891     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
892     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
893     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
894
895     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
896
897     max_version = None
898     max_vercode = None
899     max_package = None
900
901     for path in paths:
902
903         gradle = has_extension(path, 'gradle')
904         version = None
905         vercode = None
906         # Remember package name, may be defined separately from version+vercode
907         package = max_package
908
909         for line in file(path):
910             if not package:
911                 if gradle:
912                     matches = psearch_g(line)
913                 else:
914                     matches = psearch(line)
915                 if matches:
916                     package = matches.group(1)
917             if not version:
918                 if gradle:
919                     matches = vnsearch_g(line)
920                 else:
921                     matches = vnsearch(line)
922                 if matches:
923                     version = matches.group(2 if gradle else 1)
924             if not vercode:
925                 if gradle:
926                     matches = vcsearch_g(line)
927                 else:
928                     matches = vcsearch(line)
929                 if matches:
930                     vercode = matches.group(1)
931
932         # Always grab the package name and version name in case they are not
933         # together with the highest version code
934         if max_package is None and package is not None:
935             max_package = package
936         if max_version is None and version is not None:
937             max_version = version
938
939         if max_vercode is None or (vercode is not None and vercode > max_vercode):
940             if not ignoresearch or not ignoresearch(version):
941                 if version is not None:
942                     max_version = version
943                 if vercode is not None:
944                     max_vercode = vercode
945                 if package is not None:
946                     max_package = package
947             else:
948                 max_version = "Ignore"
949
950     if max_version is None:
951         max_version = "Unknown"
952
953     return (max_version, max_vercode, max_package)
954
955
956 class _FDroidException(Exception):
957     def __init__(self, value, detail=None):
958         self.value = value
959         self.detail = detail
960
961     def get_wikitext(self):
962         ret = repr(self.value) + "\n"
963         if self.detail:
964             ret += "=detail=\n"
965             ret += "<pre>\n"
966             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
967             ret += str(txt)
968             ret += "</pre>\n"
969         return ret
970
971     def __str__(self):
972         ret = self.value
973         if self.detail:
974             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
975         return ret
976
977
978 class VCSException(_FDroidException):
979     pass
980
981
982 class BuildException(_FDroidException):
983     pass
984
985
986 # Get the specified source library.
987 # Returns the path to it. Normally this is the path to be used when referencing
988 # it, which may be a subdirectory of the actual project. If you want the base
989 # directory of the project, pass 'basepath=True'.
990 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
991               basepath=False, raw=False, prepare=True, preponly=False):
992
993     number = None
994     subdir = None
995     if raw:
996         name = spec
997         ref = None
998     else:
999         name, ref = spec.split('@')
1000         if ':' in name:
1001             number, name = name.split(':', 1)
1002         if '/' in name:
1003             name, subdir = name.split('/', 1)
1004
1005     if name not in metadata.srclibs:
1006         raise BuildException('srclib ' + name + ' not found.')
1007
1008     srclib = metadata.srclibs[name]
1009
1010     sdir = os.path.join(srclib_dir, name)
1011
1012     if not preponly:
1013         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1014         vcs.srclib = (name, number, sdir)
1015         if ref:
1016             vcs.gotorevision(ref)
1017
1018         if raw:
1019             return vcs
1020
1021     libdir = None
1022     if subdir:
1023         libdir = os.path.join(sdir, subdir)
1024     elif srclib["Subdir"]:
1025         for subdir in srclib["Subdir"]:
1026             libdir_candidate = os.path.join(sdir, subdir)
1027             if os.path.exists(libdir_candidate):
1028                 libdir = libdir_candidate
1029                 break
1030
1031     if libdir is None:
1032         libdir = sdir
1033
1034     if srclib["Srclibs"]:
1035         n = 1
1036         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1037             s_tuple = None
1038             for t in srclibpaths:
1039                 if t[0] == lib:
1040                     s_tuple = t
1041                     break
1042             if s_tuple is None:
1043                 raise BuildException('Missing recursive srclib %s for %s' % (
1044                     lib, name))
1045             place_srclib(libdir, n, s_tuple[2])
1046             n += 1
1047
1048     remove_signing_keys(sdir)
1049     remove_debuggable_flags(sdir)
1050
1051     if prepare:
1052
1053         if srclib["Prepare"]:
1054             cmd = replace_config_vars(srclib["Prepare"])
1055
1056             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1057             if p.returncode != 0:
1058                 raise BuildException("Error running prepare command for srclib %s"
1059                                      % name, p.output)
1060
1061     if basepath:
1062         libdir = sdir
1063
1064     return (name, number, libdir)
1065
1066
1067 # Prepare the source code for a particular build
1068 #  'vcs'         - the appropriate vcs object for the application
1069 #  'app'         - the application details from the metadata
1070 #  'build'       - the build details from the metadata
1071 #  'build_dir'   - the path to the build directory, usually
1072 #                   'build/app.id'
1073 #  'srclib_dir'  - the path to the source libraries directory, usually
1074 #                   'build/srclib'
1075 #  'extlib_dir'  - the path to the external libraries directory, usually
1076 #                   'build/extlib'
1077 # Returns the (root, srclibpaths) where:
1078 #   'root' is the root directory, which may be the same as 'build_dir' or may
1079 #          be a subdirectory of it.
1080 #   'srclibpaths' is information on the srclibs being used
1081 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1082
1083     # Optionally, the actual app source can be in a subdirectory
1084     if build['subdir']:
1085         root_dir = os.path.join(build_dir, build['subdir'])
1086     else:
1087         root_dir = build_dir
1088
1089     # Get a working copy of the right revision
1090     logging.info("Getting source for revision " + build['commit'])
1091     vcs.gotorevision(build['commit'])
1092
1093     # Initialise submodules if requred
1094     if build['submodules']:
1095         logging.info("Initialising submodules")
1096         vcs.initsubmodules()
1097
1098     # Check that a subdir (if we're using one) exists. This has to happen
1099     # after the checkout, since it might not exist elsewhere
1100     if not os.path.exists(root_dir):
1101         raise BuildException('Missing subdir ' + root_dir)
1102
1103     # Run an init command if one is required
1104     if build['init']:
1105         cmd = replace_config_vars(build['init'])
1106         logging.info("Running 'init' commands in %s" % root_dir)
1107
1108         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1109         if p.returncode != 0:
1110             raise BuildException("Error running init command for %s:%s" %
1111                                  (app['id'], build['version']), p.output)
1112
1113     # Apply patches if any
1114     if build['patch']:
1115         logging.info("Applying patches")
1116         for patch in build['patch']:
1117             patch = patch.strip()
1118             logging.info("Applying " + patch)
1119             patch_path = os.path.join('metadata', app['id'], patch)
1120             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1121             if p.returncode != 0:
1122                 raise BuildException("Failed to apply patch %s" % patch_path)
1123
1124     # Get required source libraries
1125     srclibpaths = []
1126     if build['srclibs']:
1127         logging.info("Collecting source libraries")
1128         for lib in build['srclibs']:
1129             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1130                                          preponly=onserver))
1131
1132     for name, number, libpath in srclibpaths:
1133         place_srclib(root_dir, int(number) if number else None, libpath)
1134
1135     basesrclib = vcs.getsrclib()
1136     # If one was used for the main source, add that too.
1137     if basesrclib:
1138         srclibpaths.append(basesrclib)
1139
1140     # Update the local.properties file
1141     localprops = [os.path.join(build_dir, 'local.properties')]
1142     if build['subdir']:
1143         localprops += [os.path.join(root_dir, 'local.properties')]
1144     for path in localprops:
1145         if not os.path.isfile(path):
1146             continue
1147         logging.info("Updating properties file at %s" % path)
1148         f = open(path, 'r')
1149         props = f.read()
1150         f.close()
1151         props += '\n'
1152         # Fix old-fashioned 'sdk-location' by copying
1153         # from sdk.dir, if necessary
1154         if build['oldsdkloc']:
1155             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1156                               re.S | re.M).group(1)
1157             props += "sdk-location=%s\n" % sdkloc
1158         else:
1159             props += "sdk.dir=%s\n" % config['sdk_path']
1160             props += "sdk-location=%s\n" % config['sdk_path']
1161         if 'ndk_path' in config:
1162             # Add ndk location
1163             props += "ndk.dir=%s\n" % config['ndk_path']
1164             props += "ndk-location=%s\n" % config['ndk_path']
1165         # Add java.encoding if necessary
1166         if build['encoding']:
1167             props += "java.encoding=%s\n" % build['encoding']
1168         f = open(path, 'w')
1169         f.write(props)
1170         f.close()
1171
1172     flavour = None
1173     if build['type'] == 'gradle':
1174         flavour = build['gradle'].split('@')[0]
1175         if flavour in ['main', 'yes', '']:
1176             flavour = None
1177
1178         version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1179         gradlepluginver = None
1180
1181         gradle_files = [os.path.join(root_dir, 'build.gradle')]
1182
1183         # Parent dir build.gradle
1184         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1185         if parent_dir.startswith(build_dir):
1186             gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1187
1188         # Gradle execution dir build.gradle
1189         if '@' in build['gradle']:
1190             gradle_file = os.path.join(root_dir, build['gradle'].split('@', 1)[1], 'build.gradle')
1191             gradle_file = os.path.normpath(gradle_file)
1192             if gradle_file not in gradle_files:
1193                 gradle_files.append(gradle_file)
1194
1195         for path in gradle_files:
1196             if gradlepluginver:
1197                 break
1198             if not os.path.isfile(path):
1199                 continue
1200             with open(path) as f:
1201                 for line in f:
1202                     match = version_regex.match(line)
1203                     if match:
1204                         gradlepluginver = match.group(1)
1205                         break
1206
1207         if gradlepluginver:
1208             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1209         else:
1210             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1211             build['gradlepluginver'] = LooseVersion('0.11')
1212
1213         if build['target']:
1214             n = build["target"].split('-')[1]
1215             FDroidPopen(['sed', '-i',
1216                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1217                          'build.gradle'],
1218                         cwd=root_dir)
1219
1220     # Remove forced debuggable flags
1221     remove_debuggable_flags(root_dir)
1222
1223     # Insert version code and number into the manifest if necessary
1224     if build['forceversion']:
1225         logging.info("Changing the version name")
1226         for path in manifest_paths(root_dir, flavour):
1227             if not os.path.isfile(path):
1228                 continue
1229             if has_extension(path, 'xml'):
1230                 p = SilentPopen(['sed', '-i',
1231                                  's/android:versionName="[^"]*"/android:versionName="'
1232                                  + build['version'] + '"/g',
1233                                  path])
1234                 if p.returncode != 0:
1235                     raise BuildException("Failed to amend manifest")
1236             elif has_extension(path, 'gradle'):
1237                 p = SilentPopen(['sed', '-i',
1238                                  's/versionName *=* *"[^"]*"/versionName = "'
1239                                  + build['version'] + '"/g',
1240                                  path])
1241                 if p.returncode != 0:
1242                     raise BuildException("Failed to amend build.gradle")
1243     if build['forcevercode']:
1244         logging.info("Changing the version code")
1245         for path in manifest_paths(root_dir, flavour):
1246             if not os.path.isfile(path):
1247                 continue
1248             if has_extension(path, 'xml'):
1249                 p = SilentPopen(['sed', '-i',
1250                                  's/android:versionCode="[^"]*"/android:versionCode="'
1251                                  + build['vercode'] + '"/g',
1252                                  path])
1253                 if p.returncode != 0:
1254                     raise BuildException("Failed to amend manifest")
1255             elif has_extension(path, 'gradle'):
1256                 p = SilentPopen(['sed', '-i',
1257                                  's/versionCode *=* *[0-9]*/versionCode = '
1258                                  + build['vercode'] + '/g',
1259                                  path])
1260                 if p.returncode != 0:
1261                     raise BuildException("Failed to amend build.gradle")
1262
1263     # Delete unwanted files
1264     if build['rm']:
1265         logging.info("Removing specified files")
1266         for part in getpaths(build_dir, build, 'rm'):
1267             dest = os.path.join(build_dir, part)
1268             logging.info("Removing {0}".format(part))
1269             if os.path.lexists(dest):
1270                 if os.path.islink(dest):
1271                     SilentPopen(['unlink ' + dest], shell=True)
1272                 else:
1273                     SilentPopen(['rm -rf ' + dest], shell=True)
1274             else:
1275                 logging.info("...but it didn't exist")
1276
1277     remove_signing_keys(build_dir)
1278
1279     # Add required external libraries
1280     if build['extlibs']:
1281         logging.info("Collecting prebuilt libraries")
1282         libsdir = os.path.join(root_dir, 'libs')
1283         if not os.path.exists(libsdir):
1284             os.mkdir(libsdir)
1285         for lib in build['extlibs']:
1286             lib = lib.strip()
1287             logging.info("...installing extlib {0}".format(lib))
1288             libf = os.path.basename(lib)
1289             libsrc = os.path.join(extlib_dir, lib)
1290             if not os.path.exists(libsrc):
1291                 raise BuildException("Missing extlib file {0}".format(libsrc))
1292             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1293
1294     # Run a pre-build command if one is required
1295     if build['prebuild']:
1296         logging.info("Running 'prebuild' commands in %s" % root_dir)
1297
1298         cmd = replace_config_vars(build['prebuild'])
1299
1300         # Substitute source library paths into prebuild commands
1301         for name, number, libpath in srclibpaths:
1302             libpath = os.path.relpath(libpath, root_dir)
1303             cmd = cmd.replace('$$' + name + '$$', libpath)
1304
1305         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1306         if p.returncode != 0:
1307             raise BuildException("Error running prebuild command for %s:%s" %
1308                                  (app['id'], build['version']), p.output)
1309
1310     # Generate (or update) the ant build file, build.xml...
1311     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1312         parms = [os.path.join(config['sdk_path'], 'tools', 'android'), 'update']
1313         lparms = parms + ['lib-project']
1314         parms = parms + ['project']
1315
1316         if build['target']:
1317             parms += ['-t', build['target']]
1318             lparms += ['-t', build['target']]
1319         if build['update'] == ['auto']:
1320             update_dirs = ant_subprojects(root_dir) + ['.']
1321         else:
1322             update_dirs = build['update']
1323
1324         for d in update_dirs:
1325             subdir = os.path.join(root_dir, d)
1326             if d == '.':
1327                 print("Updating main project")
1328                 cmd = parms + ['-p', d]
1329             else:
1330                 print("Updating subproject %s" % d)
1331                 cmd = lparms + ['-p', d]
1332             p = FDroidPopen(cmd, cwd=root_dir)
1333             # Check to see whether an error was returned without a proper exit
1334             # code (this is the case for the 'no target set or target invalid'
1335             # error)
1336             if p.returncode != 0 or p.output.startswith("Error: "):
1337                 raise BuildException("Failed to update project at %s" % d, p.output)
1338             # Clean update dirs via ant
1339             if d != '.':
1340                 logging.info("Cleaning subproject %s" % d)
1341                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1342
1343     return (root_dir, srclibpaths)
1344
1345
1346 # Split and extend via globbing the paths from a field
1347 def getpaths(build_dir, build, field):
1348     paths = []
1349     for p in build[field]:
1350         p = p.strip()
1351         full_path = os.path.join(build_dir, p)
1352         full_path = os.path.normpath(full_path)
1353         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1354     return paths
1355
1356
1357 # Scan the source code in the given directory (and all subdirectories)
1358 # and return the number of fatal problems encountered
1359 def scan_source(build_dir, root_dir, thisbuild):
1360
1361     count = 0
1362
1363     # Common known non-free blobs (always lower case):
1364     usual_suspects = [
1365         re.compile(r'flurryagent', re.IGNORECASE),
1366         re.compile(r'paypal.*mpl', re.IGNORECASE),
1367         re.compile(r'libgoogleanalytics', re.IGNORECASE),
1368         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1369         re.compile(r'googleadview', re.IGNORECASE),
1370         re.compile(r'googleadmobadssdk', re.IGNORECASE),
1371         re.compile(r'google.*play.*services', re.IGNORECASE),
1372         re.compile(r'crittercism', re.IGNORECASE),
1373         re.compile(r'heyzap', re.IGNORECASE),
1374         re.compile(r'jpct.*ae', re.IGNORECASE),
1375         re.compile(r'youtubeandroidplayerapi', re.IGNORECASE),
1376         re.compile(r'bugsense', re.IGNORECASE),
1377         re.compile(r'crashlytics', re.IGNORECASE),
1378         re.compile(r'ouya.*sdk', re.IGNORECASE),
1379         re.compile(r'libspen23', re.IGNORECASE),
1380         ]
1381
1382     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1383     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1384
1385     try:
1386         ms = magic.open(magic.MIME_TYPE)
1387         ms.load()
1388     except AttributeError:
1389         ms = None
1390
1391     def toignore(fd):
1392         for i in scanignore:
1393             if fd.startswith(i):
1394                 return True
1395         return False
1396
1397     def todelete(fd):
1398         for i in scandelete:
1399             if fd.startswith(i):
1400                 return True
1401         return False
1402
1403     def removeproblem(what, fd, fp):
1404         logging.info('Removing %s at %s' % (what, fd))
1405         os.remove(fp)
1406
1407     def warnproblem(what, fd):
1408         logging.warn('Found %s at %s' % (what, fd))
1409
1410     def handleproblem(what, fd, fp):
1411         if todelete(fd):
1412             removeproblem(what, fd, fp)
1413         else:
1414             logging.error('Found %s at %s' % (what, fd))
1415             return True
1416         return False
1417
1418     def insidedir(path, dirname):
1419         return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1420
1421     # Iterate through all files in the source code
1422     for r, d, f in os.walk(build_dir):
1423
1424         if any(insidedir(r, d) for d in ('.hg', '.git', '.svn', '.bzr')):
1425             continue
1426
1427         for curfile in f:
1428
1429             # Path (relative) to the file
1430             fp = os.path.join(r, curfile)
1431             fd = fp[len(build_dir) + 1:]
1432
1433             # Check if this file has been explicitly excluded from scanning
1434             if toignore(fd):
1435                 continue
1436
1437             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1438
1439             if mime == 'application/x-sharedlib':
1440                 count += handleproblem('shared library', fd, fp)
1441
1442             elif mime == 'application/x-archive':
1443                 count += handleproblem('static library', fd, fp)
1444
1445             elif mime == 'application/x-executable':
1446                 count += handleproblem('binary executable', fd, fp)
1447
1448             elif mime == 'application/x-java-applet':
1449                 count += handleproblem('Java compiled class', fd, fp)
1450
1451             elif mime in (
1452                     'application/jar',
1453                     'application/zip',
1454                     'application/java-archive',
1455                     'application/octet-stream',
1456                     'binary',
1457                     ):
1458
1459                 if has_extension(fp, 'apk'):
1460                     removeproblem('APK file', fd, fp)
1461
1462                 elif has_extension(fp, 'jar'):
1463
1464                     if any(suspect.match(curfile) for suspect in usual_suspects):
1465                         count += handleproblem('usual supect', fd, fp)
1466                     else:
1467                         warnproblem('JAR file', fd)
1468
1469                 elif has_extension(fp, 'zip'):
1470                     warnproblem('ZIP file', fd)
1471
1472                 else:
1473                     warnproblem('unknown compressed or binary file', fd)
1474
1475             elif has_extension(fp, 'java'):
1476                 for line in file(fp):
1477                     if 'DexClassLoader' in line:
1478                         count += handleproblem('DexClassLoader', fd, fp)
1479                         break
1480     if ms is not None:
1481         ms.close()
1482
1483     # Presence of a jni directory without buildjni=yes might
1484     # indicate a problem (if it's not a problem, explicitly use
1485     # buildjni=no to bypass this check)
1486     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1487             not thisbuild['buildjni']):
1488         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1489         count += 1
1490
1491     return count
1492
1493
1494 class KnownApks:
1495
1496     def __init__(self):
1497         self.path = os.path.join('stats', 'known_apks.txt')
1498         self.apks = {}
1499         if os.path.exists(self.path):
1500             for line in file(self.path):
1501                 t = line.rstrip().split(' ')
1502                 if len(t) == 2:
1503                     self.apks[t[0]] = (t[1], None)
1504                 else:
1505                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1506         self.changed = False
1507
1508     def writeifchanged(self):
1509         if self.changed:
1510             if not os.path.exists('stats'):
1511                 os.mkdir('stats')
1512             f = open(self.path, 'w')
1513             lst = []
1514             for apk, app in self.apks.iteritems():
1515                 appid, added = app
1516                 line = apk + ' ' + appid
1517                 if added:
1518                     line += ' ' + time.strftime('%Y-%m-%d', added)
1519                 lst.append(line)
1520             for line in sorted(lst):
1521                 f.write(line + '\n')
1522             f.close()
1523
1524     # Record an apk (if it's new, otherwise does nothing)
1525     # Returns the date it was added.
1526     def recordapk(self, apk, app):
1527         if apk not in self.apks:
1528             self.apks[apk] = (app, time.gmtime(time.time()))
1529             self.changed = True
1530         _, added = self.apks[apk]
1531         return added
1532
1533     # Look up information - given the 'apkname', returns (app id, date added/None).
1534     # Or returns None for an unknown apk.
1535     def getapp(self, apkname):
1536         if apkname in self.apks:
1537             return self.apks[apkname]
1538         return None
1539
1540     # Get the most recent 'num' apps added to the repo, as a list of package ids
1541     # with the most recent first.
1542     def getlatest(self, num):
1543         apps = {}
1544         for apk, app in self.apks.iteritems():
1545             appid, added = app
1546             if added:
1547                 if appid in apps:
1548                     if apps[appid] > added:
1549                         apps[appid] = added
1550                 else:
1551                     apps[appid] = added
1552         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1553         lst = [app for app, _ in sortedapps]
1554         lst.reverse()
1555         return lst
1556
1557
1558 def isApkDebuggable(apkfile, config):
1559     """Returns True if the given apk file is debuggable
1560
1561     :param apkfile: full path to the apk to check"""
1562
1563     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1564                                   config['build_tools'], 'aapt'),
1565                      'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1566     if p.returncode != 0:
1567         logging.critical("Failed to get apk manifest information")
1568         sys.exit(1)
1569     for line in p.output.splitlines():
1570         if 'android:debuggable' in line and not line.endswith('0x0'):
1571             return True
1572     return False
1573
1574
1575 class AsynchronousFileReader(threading.Thread):
1576     '''
1577     Helper class to implement asynchronous reading of a file
1578     in a separate thread. Pushes read lines on a queue to
1579     be consumed in another thread.
1580     '''
1581
1582     def __init__(self, fd, queue):
1583         assert isinstance(queue, Queue.Queue)
1584         assert callable(fd.readline)
1585         threading.Thread.__init__(self)
1586         self._fd = fd
1587         self._queue = queue
1588
1589     def run(self):
1590         '''The body of the tread: read lines and put them on the queue.'''
1591         for line in iter(self._fd.readline, ''):
1592             self._queue.put(line)
1593
1594     def eof(self):
1595         '''Check whether there is no more content to expect.'''
1596         return not self.is_alive() and self._queue.empty()
1597
1598
1599 class PopenResult:
1600     returncode = None
1601     output = ''
1602
1603
1604 def SilentPopen(commands, cwd=None, shell=False):
1605     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1606
1607
1608 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1609     """
1610     Run a command and capture the possibly huge output.
1611
1612     :param commands: command and argument list like in subprocess.Popen
1613     :param cwd: optionally specifies a working directory
1614     :returns: A PopenResult.
1615     """
1616
1617     global env
1618
1619     if cwd:
1620         cwd = os.path.normpath(cwd)
1621         logging.debug("Directory: %s" % cwd)
1622     logging.debug("> %s" % ' '.join(commands))
1623
1624     result = PopenResult()
1625     p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1626                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1627
1628     stdout_queue = Queue.Queue()
1629     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1630     stdout_reader.start()
1631
1632     # Check the queue for output (until there is no more to get)
1633     while not stdout_reader.eof():
1634         while not stdout_queue.empty():
1635             line = stdout_queue.get()
1636             if output:
1637                 # Output directly to console
1638                 sys.stdout.write(line)
1639                 sys.stdout.flush()
1640             result.output += line
1641
1642         time.sleep(0.1)
1643
1644     p.communicate()
1645     result.returncode = p.returncode
1646     return result
1647
1648
1649 def remove_signing_keys(build_dir):
1650     comment = re.compile(r'[ ]*//')
1651     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1652     line_matches = [
1653         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1654         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1655         re.compile(r'.*variant\.outputFile = .*'),
1656         re.compile(r'.*\.readLine\(.*'),
1657         ]
1658     for root, dirs, files in os.walk(build_dir):
1659         if 'build.gradle' in files:
1660             path = os.path.join(root, 'build.gradle')
1661
1662             with open(path, "r") as o:
1663                 lines = o.readlines()
1664
1665             changed = False
1666
1667             opened = 0
1668             with open(path, "w") as o:
1669                 for line in lines:
1670                     if comment.match(line):
1671                         continue
1672
1673                     if opened > 0:
1674                         opened += line.count('{')
1675                         opened -= line.count('}')
1676                         continue
1677
1678                     if signing_configs.match(line):
1679                         changed = True
1680                         opened += 1
1681                         continue
1682
1683                     if any(s.match(line) for s in line_matches):
1684                         changed = True
1685                         continue
1686
1687                     if opened == 0:
1688                         o.write(line)
1689
1690             if changed:
1691                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1692
1693         for propfile in [
1694                 'project.properties',
1695                 'build.properties',
1696                 'default.properties',
1697                 'ant.properties',
1698                 ]:
1699             if propfile in files:
1700                 path = os.path.join(root, propfile)
1701
1702                 with open(path, "r") as o:
1703                     lines = o.readlines()
1704
1705                 changed = False
1706
1707                 with open(path, "w") as o:
1708                     for line in lines:
1709                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1710                             changed = True
1711                             continue
1712
1713                         o.write(line)
1714
1715                 if changed:
1716                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1717
1718
1719 def replace_config_vars(cmd):
1720     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1721     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1722     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1723     return cmd
1724
1725
1726 def place_srclib(root_dir, number, libpath):
1727     if not number:
1728         return
1729     relpath = os.path.relpath(libpath, root_dir)
1730     proppath = os.path.join(root_dir, 'project.properties')
1731
1732     lines = []
1733     if os.path.isfile(proppath):
1734         with open(proppath, "r") as o:
1735             lines = o.readlines()
1736
1737     with open(proppath, "w") as o:
1738         placed = False
1739         for line in lines:
1740             if line.startswith('android.library.reference.%d=' % number):
1741                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1742                 placed = True
1743             else:
1744                 o.write(line)
1745         if not placed:
1746             o.write('android.library.reference.%d=%s\n' % (number, relpath))