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