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