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