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