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