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