chiark / gitweb /
Allow slashes in tags
[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.1",
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|applicationId) *=* *["\']([^"]+)["\'].*').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         package = None
1012
1013         if gradle:
1014             for line in file(path):
1015                 # Grab first occurence of each to avoid running into
1016                 # alternative flavours and builds.
1017                 if not package:
1018                     matches = psearch_g(line)
1019                     if matches:
1020                         package = matches.group(2)
1021                 if not version:
1022                     matches = vnsearch_g(line)
1023                     if matches:
1024                         version = matches.group(2)
1025                 if not vercode:
1026                     matches = vcsearch_g(line)
1027                     if matches:
1028                         vercode = matches.group(1)
1029         else:
1030             xml = parse_xml(path)
1031             if "package" in xml.attrib:
1032                 package = xml.attrib["package"].encode('utf-8')
1033             if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1034                 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1035                 base_dir = os.path.dirname(path)
1036                 version = retrieve_string_singleline(base_dir, version)
1037             if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1038                 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1039                 if string_is_integer(a):
1040                     vercode = a
1041
1042         # Remember package name, may be defined separately from version+vercode
1043         if package is None:
1044             package = max_package
1045
1046         logging.debug("..got package={0}, version={1}, vercode={2}"
1047                       .format(package, version, vercode))
1048
1049         # Always grab the package name and version name in case they are not
1050         # together with the highest version code
1051         if max_package is None and package is not None:
1052             max_package = package
1053         if max_version is None and version is not None:
1054             max_version = version
1055
1056         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1057             if not ignoresearch or not ignoresearch(version):
1058                 if version is not None:
1059                     max_version = version
1060                 if vercode is not None:
1061                     max_vercode = vercode
1062                 if package is not None:
1063                     max_package = package
1064             else:
1065                 max_version = "Ignore"
1066
1067     if max_version is None:
1068         max_version = "Unknown"
1069
1070     if max_package and not is_valid_package_name(max_package):
1071         raise FDroidException("Invalid package name {0}".format(max_package))
1072
1073     return (max_version, max_vercode, max_package)
1074
1075
1076 def is_valid_package_name(name):
1077     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1078
1079
1080 class FDroidException(Exception):
1081
1082     def __init__(self, value, detail=None):
1083         self.value = value
1084         self.detail = detail
1085
1086     def get_wikitext(self):
1087         ret = repr(self.value) + "\n"
1088         if self.detail:
1089             ret += "=detail=\n"
1090             ret += "<pre>\n"
1091             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1092             ret += str(txt)
1093             ret += "</pre>\n"
1094         return ret
1095
1096     def __str__(self):
1097         ret = self.value
1098         if self.detail:
1099             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1100         return ret
1101
1102
1103 class VCSException(FDroidException):
1104     pass
1105
1106
1107 class BuildException(FDroidException):
1108     pass
1109
1110
1111 # Get the specified source library.
1112 # Returns the path to it. Normally this is the path to be used when referencing
1113 # it, which may be a subdirectory of the actual project. If you want the base
1114 # directory of the project, pass 'basepath=True'.
1115 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1116               raw=False, prepare=True, preponly=False, refresh=True):
1117
1118     number = None
1119     subdir = None
1120     if raw:
1121         name = spec
1122         ref = None
1123     else:
1124         name, ref = spec.split('@')
1125         if ':' in name:
1126             number, name = name.split(':', 1)
1127         if '/' in name:
1128             name, subdir = name.split('/', 1)
1129
1130     if name not in metadata.srclibs:
1131         raise VCSException('srclib ' + name + ' not found.')
1132
1133     srclib = metadata.srclibs[name]
1134
1135     sdir = os.path.join(srclib_dir, name)
1136
1137     if not preponly:
1138         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1139         vcs.srclib = (name, number, sdir)
1140         if ref:
1141             vcs.gotorevision(ref, refresh)
1142
1143         if raw:
1144             return vcs
1145
1146     libdir = None
1147     if subdir:
1148         libdir = os.path.join(sdir, subdir)
1149     elif srclib["Subdir"]:
1150         for subdir in srclib["Subdir"]:
1151             libdir_candidate = os.path.join(sdir, subdir)
1152             if os.path.exists(libdir_candidate):
1153                 libdir = libdir_candidate
1154                 break
1155
1156     if libdir is None:
1157         libdir = sdir
1158
1159     remove_signing_keys(sdir)
1160     remove_debuggable_flags(sdir)
1161
1162     if prepare:
1163
1164         if srclib["Prepare"]:
1165             cmd = replace_config_vars(srclib["Prepare"], None)
1166
1167             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1168             if p.returncode != 0:
1169                 raise BuildException("Error running prepare command for srclib %s"
1170                                      % name, p.output)
1171
1172     if basepath:
1173         libdir = sdir
1174
1175     return (name, number, libdir)
1176
1177
1178 # Prepare the source code for a particular build
1179 #  'vcs'         - the appropriate vcs object for the application
1180 #  'app'         - the application details from the metadata
1181 #  'build'       - the build details from the metadata
1182 #  'build_dir'   - the path to the build directory, usually
1183 #                   'build/app.id'
1184 #  'srclib_dir'  - the path to the source libraries directory, usually
1185 #                   'build/srclib'
1186 #  'extlib_dir'  - the path to the external libraries directory, usually
1187 #                   'build/extlib'
1188 # Returns the (root, srclibpaths) where:
1189 #   'root' is the root directory, which may be the same as 'build_dir' or may
1190 #          be a subdirectory of it.
1191 #   'srclibpaths' is information on the srclibs being used
1192 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1193
1194     # Optionally, the actual app source can be in a subdirectory
1195     if build['subdir']:
1196         root_dir = os.path.join(build_dir, build['subdir'])
1197     else:
1198         root_dir = build_dir
1199
1200     # Get a working copy of the right revision
1201     logging.info("Getting source for revision " + build['commit'])
1202     vcs.gotorevision(build['commit'], refresh)
1203
1204     # Initialise submodules if required
1205     if build['submodules']:
1206         logging.info("Initialising submodules")
1207         vcs.initsubmodules()
1208
1209     # Check that a subdir (if we're using one) exists. This has to happen
1210     # after the checkout, since it might not exist elsewhere
1211     if not os.path.exists(root_dir):
1212         raise BuildException('Missing subdir ' + root_dir)
1213
1214     # Run an init command if one is required
1215     if build['init']:
1216         cmd = replace_config_vars(build['init'], build)
1217         logging.info("Running 'init' commands in %s" % root_dir)
1218
1219         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1220         if p.returncode != 0:
1221             raise BuildException("Error running init command for %s:%s" %
1222                                  (app['id'], build['version']), p.output)
1223
1224     # Apply patches if any
1225     if build['patch']:
1226         logging.info("Applying patches")
1227         for patch in build['patch']:
1228             patch = patch.strip()
1229             logging.info("Applying " + patch)
1230             patch_path = os.path.join('metadata', app['id'], patch)
1231             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1232             if p.returncode != 0:
1233                 raise BuildException("Failed to apply patch %s" % patch_path)
1234
1235     # Get required source libraries
1236     srclibpaths = []
1237     if build['srclibs']:
1238         logging.info("Collecting source libraries")
1239         for lib in build['srclibs']:
1240             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1241
1242     for name, number, libpath in srclibpaths:
1243         place_srclib(root_dir, int(number) if number else None, libpath)
1244
1245     basesrclib = vcs.getsrclib()
1246     # If one was used for the main source, add that too.
1247     if basesrclib:
1248         srclibpaths.append(basesrclib)
1249
1250     # Update the local.properties file
1251     localprops = [os.path.join(build_dir, 'local.properties')]
1252     if build['subdir']:
1253         localprops += [os.path.join(root_dir, 'local.properties')]
1254     for path in localprops:
1255         props = ""
1256         if os.path.isfile(path):
1257             logging.info("Updating local.properties file at %s" % path)
1258             with open(path, 'r') as f:
1259                 props += f.read()
1260             props += '\n'
1261         else:
1262             logging.info("Creating local.properties file at %s" % path)
1263         # Fix old-fashioned 'sdk-location' by copying
1264         # from sdk.dir, if necessary
1265         if build['oldsdkloc']:
1266             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1267                               re.S | re.M).group(1)
1268             props += "sdk-location=%s\n" % sdkloc
1269         else:
1270             props += "sdk.dir=%s\n" % config['sdk_path']
1271             props += "sdk-location=%s\n" % config['sdk_path']
1272         if build['ndk_path']:
1273             # Add ndk location
1274             props += "ndk.dir=%s\n" % build['ndk_path']
1275             props += "ndk-location=%s\n" % build['ndk_path']
1276         # Add java.encoding if necessary
1277         if build['encoding']:
1278             props += "java.encoding=%s\n" % build['encoding']
1279         with open(path, 'w') as f:
1280             f.write(props)
1281
1282     flavours = []
1283     if build['type'] == 'gradle':
1284         flavours = build['gradle']
1285
1286         version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1287         gradlepluginver = None
1288
1289         gradle_dirs = [root_dir]
1290
1291         # Parent dir build.gradle
1292         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1293         if parent_dir.startswith(build_dir):
1294             gradle_dirs.append(parent_dir)
1295
1296         for dir_path in gradle_dirs:
1297             if gradlepluginver:
1298                 break
1299             if not os.path.isdir(dir_path):
1300                 continue
1301             for filename in os.listdir(dir_path):
1302                 if not filename.endswith('.gradle'):
1303                     continue
1304                 path = os.path.join(dir_path, filename)
1305                 if not os.path.isfile(path):
1306                     continue
1307                 for line in file(path):
1308                     match = version_regex.match(line)
1309                     if match:
1310                         gradlepluginver = match.group(1)
1311                         break
1312
1313         if gradlepluginver:
1314             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1315         else:
1316             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1317             build['gradlepluginver'] = LooseVersion('0.11')
1318
1319         if build['target']:
1320             n = build["target"].split('-')[1]
1321             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1322                         r'compileSdkVersion %s' % n,
1323                         os.path.join(root_dir, 'build.gradle'))
1324
1325     # Remove forced debuggable flags
1326     remove_debuggable_flags(root_dir)
1327
1328     # Insert version code and number into the manifest if necessary
1329     if build['forceversion']:
1330         logging.info("Changing the version name")
1331         for path in manifest_paths(root_dir, flavours):
1332             if not os.path.isfile(path):
1333                 continue
1334             if has_extension(path, 'xml'):
1335                 regsub_file(r'android:versionName="[^"]*"',
1336                             r'android:versionName="%s"' % build['version'],
1337                             path)
1338             elif has_extension(path, 'gradle'):
1339                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1340                             r"""\1versionName '%s'""" % build['version'],
1341                             path)
1342
1343     if build['forcevercode']:
1344         logging.info("Changing the version code")
1345         for path in manifest_paths(root_dir, flavours):
1346             if not os.path.isfile(path):
1347                 continue
1348             if has_extension(path, 'xml'):
1349                 regsub_file(r'android:versionCode="[^"]*"',
1350                             r'android:versionCode="%s"' % build['vercode'],
1351                             path)
1352             elif has_extension(path, 'gradle'):
1353                 regsub_file(r'versionCode[ =]+[0-9]+',
1354                             r'versionCode %s' % build['vercode'],
1355                             path)
1356
1357     # Delete unwanted files
1358     if build['rm']:
1359         logging.info("Removing specified files")
1360         for part in getpaths(build_dir, build, 'rm'):
1361             dest = os.path.join(build_dir, part)
1362             logging.info("Removing {0}".format(part))
1363             if os.path.lexists(dest):
1364                 if os.path.islink(dest):
1365                     FDroidPopen(['unlink', dest], output=False)
1366                 else:
1367                     FDroidPopen(['rm', '-rf', dest], output=False)
1368             else:
1369                 logging.info("...but it didn't exist")
1370
1371     remove_signing_keys(build_dir)
1372
1373     # Add required external libraries
1374     if build['extlibs']:
1375         logging.info("Collecting prebuilt libraries")
1376         libsdir = os.path.join(root_dir, 'libs')
1377         if not os.path.exists(libsdir):
1378             os.mkdir(libsdir)
1379         for lib in build['extlibs']:
1380             lib = lib.strip()
1381             logging.info("...installing extlib {0}".format(lib))
1382             libf = os.path.basename(lib)
1383             libsrc = os.path.join(extlib_dir, lib)
1384             if not os.path.exists(libsrc):
1385                 raise BuildException("Missing extlib file {0}".format(libsrc))
1386             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1387
1388     # Run a pre-build command if one is required
1389     if build['prebuild']:
1390         logging.info("Running 'prebuild' commands in %s" % root_dir)
1391
1392         cmd = replace_config_vars(build['prebuild'], build)
1393
1394         # Substitute source library paths into prebuild commands
1395         for name, number, libpath in srclibpaths:
1396             libpath = os.path.relpath(libpath, root_dir)
1397             cmd = cmd.replace('$$' + name + '$$', libpath)
1398
1399         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1400         if p.returncode != 0:
1401             raise BuildException("Error running prebuild command for %s:%s" %
1402                                  (app['id'], build['version']), p.output)
1403
1404     # Generate (or update) the ant build file, build.xml...
1405     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1406         parms = ['android', 'update', 'lib-project']
1407         lparms = ['android', 'update', 'project']
1408
1409         if build['target']:
1410             parms += ['-t', build['target']]
1411             lparms += ['-t', build['target']]
1412         if build['update'] == ['auto']:
1413             update_dirs = ant_subprojects(root_dir) + ['.']
1414         else:
1415             update_dirs = build['update']
1416
1417         for d in update_dirs:
1418             subdir = os.path.join(root_dir, d)
1419             if d == '.':
1420                 logging.debug("Updating main project")
1421                 cmd = parms + ['-p', d]
1422             else:
1423                 logging.debug("Updating subproject %s" % d)
1424                 cmd = lparms + ['-p', d]
1425             p = SdkToolsPopen(cmd, cwd=root_dir)
1426             # Check to see whether an error was returned without a proper exit
1427             # code (this is the case for the 'no target set or target invalid'
1428             # error)
1429             if p.returncode != 0 or p.output.startswith("Error: "):
1430                 raise BuildException("Failed to update project at %s" % d, p.output)
1431             # Clean update dirs via ant
1432             if d != '.':
1433                 logging.info("Cleaning subproject %s" % d)
1434                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1435
1436     return (root_dir, srclibpaths)
1437
1438
1439 # Split and extend via globbing the paths from a field
1440 def getpaths(build_dir, build, field):
1441     paths = []
1442     for p in build[field]:
1443         p = p.strip()
1444         full_path = os.path.join(build_dir, p)
1445         full_path = os.path.normpath(full_path)
1446         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1447     return paths
1448
1449
1450 def natural_key(s):
1451     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1452
1453
1454 class KnownApks:
1455
1456     def __init__(self):
1457         self.path = os.path.join('stats', 'known_apks.txt')
1458         self.apks = {}
1459         if os.path.isfile(self.path):
1460             for line in file(self.path):
1461                 t = line.rstrip().split(' ')
1462                 if len(t) == 2:
1463                     self.apks[t[0]] = (t[1], None)
1464                 else:
1465                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1466         self.changed = False
1467
1468     def writeifchanged(self):
1469         if not self.changed:
1470             return
1471
1472         if not os.path.exists('stats'):
1473             os.mkdir('stats')
1474
1475         lst = []
1476         for apk, app in self.apks.iteritems():
1477             appid, added = app
1478             line = apk + ' ' + appid
1479             if added:
1480                 line += ' ' + time.strftime('%Y-%m-%d', added)
1481             lst.append(line)
1482
1483         with open(self.path, 'w') as f:
1484             for line in sorted(lst, key=natural_key):
1485                 f.write(line + '\n')
1486
1487     # Record an apk (if it's new, otherwise does nothing)
1488     # Returns the date it was added.
1489     def recordapk(self, apk, app):
1490         if apk not in self.apks:
1491             self.apks[apk] = (app, time.gmtime(time.time()))
1492             self.changed = True
1493         _, added = self.apks[apk]
1494         return added
1495
1496     # Look up information - given the 'apkname', returns (app id, date added/None).
1497     # Or returns None for an unknown apk.
1498     def getapp(self, apkname):
1499         if apkname in self.apks:
1500             return self.apks[apkname]
1501         return None
1502
1503     # Get the most recent 'num' apps added to the repo, as a list of package ids
1504     # with the most recent first.
1505     def getlatest(self, num):
1506         apps = {}
1507         for apk, app in self.apks.iteritems():
1508             appid, added = app
1509             if added:
1510                 if appid in apps:
1511                     if apps[appid] > added:
1512                         apps[appid] = added
1513                 else:
1514                     apps[appid] = added
1515         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1516         lst = [app for app, _ in sortedapps]
1517         lst.reverse()
1518         return lst
1519
1520
1521 def isApkDebuggable(apkfile, config):
1522     """Returns True if the given apk file is debuggable
1523
1524     :param apkfile: full path to the apk to check"""
1525
1526     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1527                       output=False)
1528     if p.returncode != 0:
1529         logging.critical("Failed to get apk manifest information")
1530         sys.exit(1)
1531     for line in p.output.splitlines():
1532         if 'android:debuggable' in line and not line.endswith('0x0'):
1533             return True
1534     return False
1535
1536
1537 class AsynchronousFileReader(threading.Thread):
1538
1539     '''
1540     Helper class to implement asynchronous reading of a file
1541     in a separate thread. Pushes read lines on a queue to
1542     be consumed in another thread.
1543     '''
1544
1545     def __init__(self, fd, queue):
1546         assert isinstance(queue, Queue.Queue)
1547         assert callable(fd.readline)
1548         threading.Thread.__init__(self)
1549         self._fd = fd
1550         self._queue = queue
1551
1552     def run(self):
1553         '''The body of the tread: read lines and put them on the queue.'''
1554         for line in iter(self._fd.readline, ''):
1555             self._queue.put(line)
1556
1557     def eof(self):
1558         '''Check whether there is no more content to expect.'''
1559         return not self.is_alive() and self._queue.empty()
1560
1561
1562 class PopenResult:
1563     returncode = None
1564     output = ''
1565
1566
1567 def SdkToolsPopen(commands, cwd=None, output=True):
1568     cmd = commands[0]
1569     if cmd not in config:
1570         config[cmd] = find_sdk_tools_cmd(commands[0])
1571     return FDroidPopen([config[cmd]] + commands[1:],
1572                        cwd=cwd, output=output)
1573
1574
1575 def FDroidPopen(commands, cwd=None, output=True):
1576     """
1577     Run a command and capture the possibly huge output.
1578
1579     :param commands: command and argument list like in subprocess.Popen
1580     :param cwd: optionally specifies a working directory
1581     :returns: A PopenResult.
1582     """
1583
1584     global env
1585
1586     if cwd:
1587         cwd = os.path.normpath(cwd)
1588         logging.debug("Directory: %s" % cwd)
1589     logging.debug("> %s" % ' '.join(commands))
1590
1591     result = PopenResult()
1592     p = None
1593     try:
1594         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1595                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1596     except OSError, e:
1597         raise BuildException("OSError while trying to execute " +
1598                              ' '.join(commands) + ': ' + str(e))
1599
1600     stdout_queue = Queue.Queue()
1601     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1602     stdout_reader.start()
1603
1604     # Check the queue for output (until there is no more to get)
1605     while not stdout_reader.eof():
1606         while not stdout_queue.empty():
1607             line = stdout_queue.get()
1608             if output and options.verbose:
1609                 # Output directly to console
1610                 sys.stderr.write(line)
1611                 sys.stderr.flush()
1612             result.output += line
1613
1614         time.sleep(0.1)
1615
1616     result.returncode = p.wait()
1617     return result
1618
1619
1620 def remove_signing_keys(build_dir):
1621     comment = re.compile(r'[ ]*//')
1622     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1623     line_matches = [
1624         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1625         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1626         re.compile(r'.*variant\.outputFile = .*'),
1627         re.compile(r'.*output\.outputFile = .*'),
1628         re.compile(r'.*\.readLine\(.*'),
1629     ]
1630     for root, dirs, files in os.walk(build_dir):
1631         if 'build.gradle' in files:
1632             path = os.path.join(root, 'build.gradle')
1633
1634             with open(path, "r") as o:
1635                 lines = o.readlines()
1636
1637             changed = False
1638
1639             opened = 0
1640             i = 0
1641             with open(path, "w") as o:
1642                 while i < len(lines):
1643                     line = lines[i]
1644                     i += 1
1645                     while line.endswith('\\\n'):
1646                         line = line.rstrip('\\\n') + lines[i]
1647                         i += 1
1648
1649                     if comment.match(line):
1650                         continue
1651
1652                     if opened > 0:
1653                         opened += line.count('{')
1654                         opened -= line.count('}')
1655                         continue
1656
1657                     if signing_configs.match(line):
1658                         changed = True
1659                         opened += 1
1660                         continue
1661
1662                     if any(s.match(line) for s in line_matches):
1663                         changed = True
1664                         continue
1665
1666                     if opened == 0:
1667                         o.write(line)
1668
1669             if changed:
1670                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1671
1672         for propfile in [
1673                 'project.properties',
1674                 'build.properties',
1675                 'default.properties',
1676                 'ant.properties', ]:
1677             if propfile in files:
1678                 path = os.path.join(root, propfile)
1679
1680                 with open(path, "r") as o:
1681                     lines = o.readlines()
1682
1683                 changed = False
1684
1685                 with open(path, "w") as o:
1686                     for line in lines:
1687                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1688                             changed = True
1689                             continue
1690
1691                         o.write(line)
1692
1693                 if changed:
1694                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1695
1696
1697 def reset_env_path():
1698     global env, orig_path
1699     env['PATH'] = orig_path
1700
1701
1702 def add_to_env_path(path):
1703     global env
1704     paths = env['PATH'].split(os.pathsep)
1705     if path in paths:
1706         return
1707     paths.append(path)
1708     env['PATH'] = os.pathsep.join(paths)
1709
1710
1711 def replace_config_vars(cmd, build):
1712     global env
1713     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1714     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1715     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1716     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1717     if build is not None:
1718         cmd = cmd.replace('$$COMMIT$$', build['commit'])
1719         cmd = cmd.replace('$$VERSION$$', build['version'])
1720         cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1721     return cmd
1722
1723
1724 def place_srclib(root_dir, number, libpath):
1725     if not number:
1726         return
1727     relpath = os.path.relpath(libpath, root_dir)
1728     proppath = os.path.join(root_dir, 'project.properties')
1729
1730     lines = []
1731     if os.path.isfile(proppath):
1732         with open(proppath, "r") as o:
1733             lines = o.readlines()
1734
1735     with open(proppath, "w") as o:
1736         placed = False
1737         for line in lines:
1738             if line.startswith('android.library.reference.%d=' % number):
1739                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1740                 placed = True
1741             else:
1742                 o.write(line)
1743         if not placed:
1744             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1745
1746
1747 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1748     """Verify that two apks are the same
1749
1750     One of the inputs is signed, the other is unsigned. The signature metadata
1751     is transferred from the signed to the unsigned apk, and then jarsigner is
1752     used to verify that the signature from the signed apk is also varlid for
1753     the unsigned one.
1754     :param signed_apk: Path to a signed apk file
1755     :param unsigned_apk: Path to an unsigned apk file expected to match it
1756     :param tmp_dir: Path to directory for temporary files
1757     :returns: None if the verification is successful, otherwise a string
1758               describing what went wrong.
1759     """
1760     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1761     with ZipFile(signed_apk) as signed_apk_as_zip:
1762         meta_inf_files = ['META-INF/MANIFEST.MF']
1763         for f in signed_apk_as_zip.namelist():
1764             if sigfile.match(f):
1765                 meta_inf_files.append(f)
1766         if len(meta_inf_files) < 3:
1767             return "Signature files missing from {0}".format(signed_apk)
1768         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1769     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1770         for meta_inf_file in meta_inf_files:
1771             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1772
1773     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1774         logging.info("...NOT verified - {0}".format(signed_apk))
1775         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1776     logging.info("...successfully verified")
1777     return None
1778
1779
1780 def compare_apks(apk1, apk2, tmp_dir):
1781     """Compare two apks
1782
1783     Returns None if the apk content is the same (apart from the signing key),
1784     otherwise a string describing what's different, or what went wrong when
1785     trying to do the comparison.
1786     """
1787
1788     badchars = re.compile('''[/ :;'"]''')
1789     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1790     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1791     for d in [apk1dir, apk2dir]:
1792         if os.path.exists(d):
1793             shutil.rmtree(d)
1794         os.mkdir(d)
1795         os.mkdir(os.path.join(d, 'jar-xf'))
1796
1797     if subprocess.call(['jar', 'xf',
1798                         os.path.abspath(apk1)],
1799                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1800         return("Failed to unpack " + apk1)
1801     if subprocess.call(['jar', 'xf',
1802                         os.path.abspath(apk2)],
1803                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1804         return("Failed to unpack " + apk2)
1805
1806     # try to find apktool in the path, if it hasn't been manually configed
1807     if 'apktool' not in config:
1808         tmp = find_command('apktool')
1809         if tmp is not None:
1810             config['apktool'] = tmp
1811     if 'apktool' in config:
1812         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1813                            cwd=apk1dir) != 0:
1814             return("Failed to unpack " + apk1)
1815         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1816                            cwd=apk2dir) != 0:
1817             return("Failed to unpack " + apk2)
1818
1819     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1820     lines = p.output.splitlines()
1821     if len(lines) != 1 or 'META-INF' not in lines[0]:
1822         meld = find_command('meld')
1823         if meld is not None:
1824             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1825         return("Unexpected diff output - " + p.output)
1826
1827     # since everything verifies, delete the comparison to keep cruft down
1828     shutil.rmtree(apk1dir)
1829     shutil.rmtree(apk2dir)
1830
1831     # If we get here, it seems like they're the same!
1832     return None
1833
1834
1835 def find_command(command):
1836     '''find the full path of a command, or None if it can't be found in the PATH'''
1837
1838     def is_exe(fpath):
1839         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1840
1841     fpath, fname = os.path.split(command)
1842     if fpath:
1843         if is_exe(command):
1844             return command
1845     else:
1846         for path in os.environ["PATH"].split(os.pathsep):
1847             path = path.strip('"')
1848             exe_file = os.path.join(path, command)
1849             if is_exe(exe_file):
1850                 return exe_file
1851
1852     return None
1853
1854
1855 def genpassword():
1856     '''generate a random password for when generating keys'''
1857     h = hashlib.sha256()
1858     h.update(os.urandom(16))  # salt
1859     h.update(bytes(socket.getfqdn()))
1860     return h.digest().encode('base64').strip()
1861
1862
1863 def genkeystore(localconfig):
1864     '''Generate a new key with random passwords and add it to new keystore'''
1865     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1866     keystoredir = os.path.dirname(localconfig['keystore'])
1867     if keystoredir is None or keystoredir == '':
1868         keystoredir = os.path.join(os.getcwd(), keystoredir)
1869     if not os.path.exists(keystoredir):
1870         os.makedirs(keystoredir, mode=0o700)
1871
1872     write_password_file("keystorepass", localconfig['keystorepass'])
1873     write_password_file("keypass", localconfig['keypass'])
1874     p = FDroidPopen(['keytool', '-genkey',
1875                      '-keystore', localconfig['keystore'],
1876                      '-alias', localconfig['repo_keyalias'],
1877                      '-keyalg', 'RSA', '-keysize', '4096',
1878                      '-sigalg', 'SHA256withRSA',
1879                      '-validity', '10000',
1880                      '-storepass:file', config['keystorepassfile'],
1881                      '-keypass:file', config['keypassfile'],
1882                      '-dname', localconfig['keydname']])
1883     # TODO keypass should be sent via stdin
1884     if p.returncode != 0:
1885         raise BuildException("Failed to generate key", p.output)
1886     os.chmod(localconfig['keystore'], 0o0600)
1887     # now show the lovely key that was just generated
1888     p = FDroidPopen(['keytool', '-list', '-v',
1889                      '-keystore', localconfig['keystore'],
1890                      '-alias', localconfig['repo_keyalias'],
1891                      '-storepass:file', config['keystorepassfile']])
1892     logging.info(p.output.strip() + '\n\n')
1893
1894
1895 def write_to_config(thisconfig, key, value=None):
1896     '''write a key/value to the local config.py'''
1897     if value is None:
1898         origkey = key + '_orig'
1899         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1900     with open('config.py', 'r') as f:
1901         data = f.read()
1902     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1903     repl = '\n' + key + ' = "' + value + '"'
1904     data = re.sub(pattern, repl, data)
1905     # if this key is not in the file, append it
1906     if not re.match('\s*' + key + '\s*=\s*"', data):
1907         data += repl
1908     # make sure the file ends with a carraige return
1909     if not re.match('\n$', data):
1910         data += '\n'
1911     with open('config.py', 'w') as f:
1912         f.writelines(data)
1913
1914
1915 def parse_xml(path):
1916     return XMLElementTree.parse(path).getroot()
1917
1918
1919 def string_is_integer(string):
1920     try:
1921         int(string)
1922         return True
1923     except ValueError:
1924         return False
1925
1926
1927 def get_per_app_repos():
1928     '''per-app repos are dirs named with the packageName of a single app'''
1929
1930     # Android packageNames are Java packages, they may contain uppercase or
1931     # lowercase letters ('A' through 'Z'), numbers, and underscores
1932     # ('_'). However, individual package name parts may only start with
1933     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1934     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1935
1936     repos = []
1937     for root, dirs, files in os.walk(os.getcwd()):
1938         for d in dirs:
1939             print 'checking', root, 'for', d
1940             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1941                 # standard parts of an fdroid repo, so never packageNames
1942                 continue
1943             elif p.match(d) \
1944                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
1945                 repos.append(d)
1946         break
1947     return repos