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