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