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