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