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