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