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