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