chiark / gitweb /
Don't accept non-integer vercodes
[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.encode('utf-8'), 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         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
917             continue
918         label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
919         result = retrieve_string(app_dir, label)
920         if result:
921             result = result.strip()
922         return result
923     return None
924
925
926 # Retrieve the version name
927 def version_name(original, app_dir, flavours):
928     for path in manifest_paths(app_dir, flavours):
929         if not has_extension(path, 'xml'):
930             continue
931         string = retrieve_string(app_dir, original)
932         if string:
933             return string
934     return original
935
936
937 def get_library_references(root_dir):
938     libraries = []
939     proppath = os.path.join(root_dir, 'project.properties')
940     if not os.path.isfile(proppath):
941         return libraries
942     for line in file(proppath):
943         if not line.startswith('android.library.reference.'):
944             continue
945         path = line.split('=')[1].strip()
946         relpath = os.path.join(root_dir, path)
947         if not os.path.isdir(relpath):
948             continue
949         logging.debug("Found subproject at %s" % path)
950         libraries.append(path)
951     return libraries
952
953
954 def ant_subprojects(root_dir):
955     subprojects = get_library_references(root_dir)
956     for subpath in subprojects:
957         subrelpath = os.path.join(root_dir, subpath)
958         for p in get_library_references(subrelpath):
959             relp = os.path.normpath(os.path.join(subpath, p))
960             if relp not in subprojects:
961                 subprojects.insert(0, relp)
962     return subprojects
963
964
965 def remove_debuggable_flags(root_dir):
966     # Remove forced debuggable flags
967     logging.debug("Removing debuggable flags from %s" % root_dir)
968     for root, dirs, files in os.walk(root_dir):
969         if 'AndroidManifest.xml' in files:
970             path = os.path.join(root, 'AndroidManifest.xml')
971             p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
972             if p.returncode != 0:
973                 raise BuildException("Failed to remove debuggable flags of %s" % path)
974
975
976 # Extract some information from the AndroidManifest.xml at the given path.
977 # Returns (version, vercode, package), any or all of which might be None.
978 # All values returned are strings.
979 def parse_androidmanifests(paths, ignoreversions=None):
980
981     if not paths:
982         return (None, None, None)
983
984     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
985     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
986     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
987
988     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
989
990     max_version = None
991     max_vercode = None
992     max_package = None
993
994     for path in paths:
995
996         if not os.path.isfile(path):
997             continue
998
999         logging.debug("Parsing manifest at {0}".format(path))
1000         gradle = has_extension(path, 'gradle')
1001         version = None
1002         vercode = None
1003         # Remember package name, may be defined separately from version+vercode
1004         package = max_package
1005
1006         if gradle:
1007             for line in file(path):
1008                 if not package:
1009                     matches = psearch_g(line)
1010                     if matches:
1011                         package = matches.group(1)
1012                 if not version:
1013                     matches = vnsearch_g(line)
1014                     if matches:
1015                         version = matches.group(2)
1016                 if not vercode:
1017                     matches = vcsearch_g(line)
1018                     if matches:
1019                         vercode = matches.group(1)
1020         else:
1021             xml = parse_xml(path)
1022             if "package" in xml.attrib:
1023                 package = xml.attrib["package"].encode('utf-8')
1024             if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1025                 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1026             if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1027                 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1028                 if string_is_integer(a):
1029                     vercode = a
1030
1031         logging.debug("..got package={0}, version={1}, vercode={2}"
1032                       .format(package, version, vercode))
1033
1034         # Always grab the package name and version name in case they are not
1035         # together with the highest version code
1036         if max_package is None and package is not None:
1037             max_package = package
1038         if max_version is None and version is not None:
1039             max_version = version
1040
1041         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1042             if not ignoresearch or not ignoresearch(version):
1043                 if version is not None:
1044                     max_version = version
1045                 if vercode is not None:
1046                     max_vercode = vercode
1047                 if package is not None:
1048                     max_package = package
1049             else:
1050                 max_version = "Ignore"
1051
1052     if max_version is None:
1053         max_version = "Unknown"
1054
1055     if max_package and not is_valid_package_name(max_package):
1056         raise FDroidException("Invalid package name {0}".format(max_package))
1057
1058     return (max_version, max_vercode, max_package)
1059
1060
1061 def is_valid_package_name(name):
1062     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1063
1064
1065 class FDroidException(Exception):
1066
1067     def __init__(self, value, detail=None):
1068         self.value = value
1069         self.detail = detail
1070
1071     def get_wikitext(self):
1072         ret = repr(self.value) + "\n"
1073         if self.detail:
1074             ret += "=detail=\n"
1075             ret += "<pre>\n"
1076             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1077             ret += str(txt)
1078             ret += "</pre>\n"
1079         return ret
1080
1081     def __str__(self):
1082         ret = self.value
1083         if self.detail:
1084             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1085         return ret
1086
1087
1088 class VCSException(FDroidException):
1089     pass
1090
1091
1092 class BuildException(FDroidException):
1093     pass
1094
1095
1096 # Get the specified source library.
1097 # Returns the path to it. Normally this is the path to be used when referencing
1098 # it, which may be a subdirectory of the actual project. If you want the base
1099 # directory of the project, pass 'basepath=True'.
1100 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1101               raw=False, prepare=True, preponly=False):
1102
1103     number = None
1104     subdir = None
1105     if raw:
1106         name = spec
1107         ref = None
1108     else:
1109         name, ref = spec.split('@')
1110         if ':' in name:
1111             number, name = name.split(':', 1)
1112         if '/' in name:
1113             name, subdir = name.split('/', 1)
1114
1115     if name not in metadata.srclibs:
1116         raise VCSException('srclib ' + name + ' not found.')
1117
1118     srclib = metadata.srclibs[name]
1119
1120     sdir = os.path.join(srclib_dir, name)
1121
1122     if not preponly:
1123         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1124         vcs.srclib = (name, number, sdir)
1125         if ref:
1126             vcs.gotorevision(ref)
1127
1128         if raw:
1129             return vcs
1130
1131     libdir = None
1132     if subdir:
1133         libdir = os.path.join(sdir, subdir)
1134     elif srclib["Subdir"]:
1135         for subdir in srclib["Subdir"]:
1136             libdir_candidate = os.path.join(sdir, subdir)
1137             if os.path.exists(libdir_candidate):
1138                 libdir = libdir_candidate
1139                 break
1140
1141     if libdir is None:
1142         libdir = sdir
1143
1144     remove_signing_keys(sdir)
1145     remove_debuggable_flags(sdir)
1146
1147     if prepare:
1148
1149         if srclib["Prepare"]:
1150             cmd = replace_config_vars(srclib["Prepare"], None)
1151
1152             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1153             if p.returncode != 0:
1154                 raise BuildException("Error running prepare command for srclib %s"
1155                                      % name, p.output)
1156
1157     if basepath:
1158         libdir = sdir
1159
1160     return (name, number, libdir)
1161
1162
1163 # Prepare the source code for a particular build
1164 #  'vcs'         - the appropriate vcs object for the application
1165 #  'app'         - the application details from the metadata
1166 #  'build'       - the build details from the metadata
1167 #  'build_dir'   - the path to the build directory, usually
1168 #                   'build/app.id'
1169 #  'srclib_dir'  - the path to the source libraries directory, usually
1170 #                   'build/srclib'
1171 #  'extlib_dir'  - the path to the external libraries directory, usually
1172 #                   'build/extlib'
1173 # Returns the (root, srclibpaths) where:
1174 #   'root' is the root directory, which may be the same as 'build_dir' or may
1175 #          be a subdirectory of it.
1176 #   'srclibpaths' is information on the srclibs being used
1177 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1178
1179     # Optionally, the actual app source can be in a subdirectory
1180     if build['subdir']:
1181         root_dir = os.path.join(build_dir, build['subdir'])
1182     else:
1183         root_dir = build_dir
1184
1185     # Get a working copy of the right revision
1186     logging.info("Getting source for revision " + build['commit'])
1187     vcs.gotorevision(build['commit'])
1188
1189     # Initialise submodules if requred
1190     if build['submodules']:
1191         logging.info("Initialising submodules")
1192         vcs.initsubmodules()
1193
1194     # Check that a subdir (if we're using one) exists. This has to happen
1195     # after the checkout, since it might not exist elsewhere
1196     if not os.path.exists(root_dir):
1197         raise BuildException('Missing subdir ' + root_dir)
1198
1199     # Run an init command if one is required
1200     if build['init']:
1201         cmd = replace_config_vars(build['init'], build)
1202         logging.info("Running 'init' commands in %s" % root_dir)
1203
1204         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1205         if p.returncode != 0:
1206             raise BuildException("Error running init command for %s:%s" %
1207                                  (app['id'], build['version']), p.output)
1208
1209     # Apply patches if any
1210     if build['patch']:
1211         logging.info("Applying patches")
1212         for patch in build['patch']:
1213             patch = patch.strip()
1214             logging.info("Applying " + patch)
1215             patch_path = os.path.join('metadata', app['id'], patch)
1216             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1217             if p.returncode != 0:
1218                 raise BuildException("Failed to apply patch %s" % patch_path)
1219
1220     # Get required source libraries
1221     srclibpaths = []
1222     if build['srclibs']:
1223         logging.info("Collecting source libraries")
1224         for lib in build['srclibs']:
1225             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver))
1226
1227     for name, number, libpath in srclibpaths:
1228         place_srclib(root_dir, int(number) if number else None, libpath)
1229
1230     basesrclib = vcs.getsrclib()
1231     # If one was used for the main source, add that too.
1232     if basesrclib:
1233         srclibpaths.append(basesrclib)
1234
1235     # Update the local.properties file
1236     localprops = [os.path.join(build_dir, 'local.properties')]
1237     if build['subdir']:
1238         localprops += [os.path.join(root_dir, 'local.properties')]
1239     for path in localprops:
1240         props = ""
1241         if os.path.isfile(path):
1242             logging.info("Updating local.properties file at %s" % path)
1243             f = open(path, 'r')
1244             props += f.read()
1245             f.close()
1246             props += '\n'
1247         else:
1248             logging.info("Creating local.properties file at %s" % path)
1249         # Fix old-fashioned 'sdk-location' by copying
1250         # from sdk.dir, if necessary
1251         if build['oldsdkloc']:
1252             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1253                               re.S | re.M).group(1)
1254             props += "sdk-location=%s\n" % sdkloc
1255         else:
1256             props += "sdk.dir=%s\n" % config['sdk_path']
1257             props += "sdk-location=%s\n" % config['sdk_path']
1258         if build['ndk_path']:
1259             # Add ndk location
1260             props += "ndk.dir=%s\n" % build['ndk_path']
1261             props += "ndk-location=%s\n" % build['ndk_path']
1262         # Add java.encoding if necessary
1263         if build['encoding']:
1264             props += "java.encoding=%s\n" % build['encoding']
1265         f = open(path, 'w')
1266         f.write(props)
1267         f.close()
1268
1269     flavours = []
1270     if build['type'] == 'gradle':
1271         flavours = build['gradle']
1272
1273         version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1274         gradlepluginver = None
1275
1276         gradle_dirs = [root_dir]
1277
1278         # Parent dir build.gradle
1279         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1280         if parent_dir.startswith(build_dir):
1281             gradle_dirs.append(parent_dir)
1282
1283         for dir_path in gradle_dirs:
1284             if gradlepluginver:
1285                 break
1286             if not os.path.isdir(dir_path):
1287                 continue
1288             for filename in os.listdir(dir_path):
1289                 if not filename.endswith('.gradle'):
1290                     continue
1291                 path = os.path.join(dir_path, filename)
1292                 if not os.path.isfile(path):
1293                     continue
1294                 for line in file(path):
1295                     match = version_regex.match(line)
1296                     if match:
1297                         gradlepluginver = match.group(1)
1298                         break
1299
1300         if gradlepluginver:
1301             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1302         else:
1303             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1304             build['gradlepluginver'] = LooseVersion('0.11')
1305
1306         if build['target']:
1307             n = build["target"].split('-')[1]
1308             FDroidPopen(['sed', '-i',
1309                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1310                          'build.gradle'], cwd=root_dir, output=False)
1311
1312     # Remove forced debuggable flags
1313     remove_debuggable_flags(root_dir)
1314
1315     # Insert version code and number into the manifest if necessary
1316     if build['forceversion']:
1317         logging.info("Changing the version name")
1318         for path in manifest_paths(root_dir, flavours):
1319             if not os.path.isfile(path):
1320                 continue
1321             if has_extension(path, 'xml'):
1322                 p = FDroidPopen(['sed', '-i',
1323                                  's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1324                                  path], output=False)
1325                 if p.returncode != 0:
1326                     raise BuildException("Failed to amend manifest")
1327             elif has_extension(path, 'gradle'):
1328                 p = FDroidPopen(['sed', '-i',
1329                                  's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1330                                  path], output=False)
1331                 if p.returncode != 0:
1332                     raise BuildException("Failed to amend build.gradle")
1333     if build['forcevercode']:
1334         logging.info("Changing the version code")
1335         for path in manifest_paths(root_dir, flavours):
1336             if not os.path.isfile(path):
1337                 continue
1338             if has_extension(path, 'xml'):
1339                 p = FDroidPopen(['sed', '-i',
1340                                  's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1341                                  path], output=False)
1342                 if p.returncode != 0:
1343                     raise BuildException("Failed to amend manifest")
1344             elif has_extension(path, 'gradle'):
1345                 p = FDroidPopen(['sed', '-i',
1346                                  's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1347                                  path], output=False)
1348                 if p.returncode != 0:
1349                     raise BuildException("Failed to amend build.gradle")
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 # Scan the source code in the given directory (and all subdirectories)
1445 # and return the number of fatal problems encountered
1446 def scan_source(build_dir, root_dir, thisbuild):
1447
1448     count = 0
1449
1450     # Common known non-free blobs (always lower case):
1451     usual_suspects = [
1452         re.compile(r'flurryagent', re.IGNORECASE),
1453         re.compile(r'paypal.*mpl', re.IGNORECASE),
1454         re.compile(r'google.*analytics', re.IGNORECASE),
1455         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1456         re.compile(r'google.*ad.*view', re.IGNORECASE),
1457         re.compile(r'google.*admob', re.IGNORECASE),
1458         re.compile(r'google.*play.*services', re.IGNORECASE),
1459         re.compile(r'crittercism', re.IGNORECASE),
1460         re.compile(r'heyzap', re.IGNORECASE),
1461         re.compile(r'jpct.*ae', re.IGNORECASE),
1462         re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1463         re.compile(r'bugsense', re.IGNORECASE),
1464         re.compile(r'crashlytics', re.IGNORECASE),
1465         re.compile(r'ouya.*sdk', re.IGNORECASE),
1466         re.compile(r'libspen23', re.IGNORECASE),
1467     ]
1468
1469     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1470     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1471
1472     scanignore_worked = set()
1473     scandelete_worked = set()
1474
1475     try:
1476         ms = magic.open(magic.MIME_TYPE)
1477         ms.load()
1478     except AttributeError:
1479         ms = None
1480
1481     def toignore(fd):
1482         for p in scanignore:
1483             if fd.startswith(p):
1484                 scanignore_worked.add(p)
1485                 return True
1486         return False
1487
1488     def todelete(fd):
1489         for p in scandelete:
1490             if fd.startswith(p):
1491                 scandelete_worked.add(p)
1492                 return True
1493         return False
1494
1495     def ignoreproblem(what, fd, fp):
1496         logging.info('Ignoring %s at %s' % (what, fd))
1497         return 0
1498
1499     def removeproblem(what, fd, fp):
1500         logging.info('Removing %s at %s' % (what, fd))
1501         os.remove(fp)
1502         return 0
1503
1504     def warnproblem(what, fd):
1505         logging.warn('Found %s at %s' % (what, fd))
1506
1507     def handleproblem(what, fd, fp):
1508         if toignore(fd):
1509             return ignoreproblem(what, fd, fp)
1510         if todelete(fd):
1511             return removeproblem(what, fd, fp)
1512         logging.error('Found %s at %s' % (what, fd))
1513         return 1
1514
1515     # Iterate through all files in the source code
1516     for r, d, f in os.walk(build_dir, topdown=True):
1517
1518         # It's topdown, so checking the basename is enough
1519         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1520             if ignoredir in d:
1521                 d.remove(ignoredir)
1522
1523         for curfile in f:
1524
1525             # Path (relative) to the file
1526             fp = os.path.join(r, curfile)
1527             fd = fp[len(build_dir) + 1:]
1528
1529             try:
1530                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1531             except UnicodeError:
1532                 warnproblem('malformed magic number', fd)
1533
1534             if mime == 'application/x-sharedlib':
1535                 count += handleproblem('shared library', fd, fp)
1536
1537             elif mime == 'application/x-archive':
1538                 count += handleproblem('static library', fd, fp)
1539
1540             elif mime == 'application/x-executable':
1541                 count += handleproblem('binary executable', fd, fp)
1542
1543             elif mime == 'application/x-java-applet':
1544                 count += handleproblem('Java compiled class', fd, fp)
1545
1546             elif mime in (
1547                     'application/jar',
1548                     'application/zip',
1549                     'application/java-archive',
1550                     'application/octet-stream',
1551                     'binary', ):
1552
1553                 if has_extension(fp, 'apk'):
1554                     removeproblem('APK file', fd, fp)
1555
1556                 elif has_extension(fp, 'jar'):
1557
1558                     if any(suspect.match(curfile) for suspect in usual_suspects):
1559                         count += handleproblem('usual supect', fd, fp)
1560                     else:
1561                         warnproblem('JAR file', fd)
1562
1563                 elif has_extension(fp, 'zip'):
1564                     warnproblem('ZIP file', fd)
1565
1566                 else:
1567                     warnproblem('unknown compressed or binary file', fd)
1568
1569             elif has_extension(fp, 'java') and os.path.isfile(fp):
1570                 if not os.path.isfile(fp):
1571                     continue
1572                 for line in file(fp):
1573                     if 'DexClassLoader' in line:
1574                         count += handleproblem('DexClassLoader', fd, fp)
1575                         break
1576     if ms is not None:
1577         ms.close()
1578
1579     for p in scanignore:
1580         if p not in scanignore_worked:
1581             logging.error('Unused scanignore path: %s' % p)
1582             count += 1
1583
1584     for p in scandelete:
1585         if p not in scandelete_worked:
1586             logging.error('Unused scandelete path: %s' % p)
1587             count += 1
1588
1589     # Presence of a jni directory without buildjni=yes might
1590     # indicate a problem (if it's not a problem, explicitly use
1591     # buildjni=no to bypass this check)
1592     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1593             not thisbuild['buildjni']):
1594         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1595         count += 1
1596
1597     return count
1598
1599
1600 class KnownApks:
1601
1602     def __init__(self):
1603         self.path = os.path.join('stats', 'known_apks.txt')
1604         self.apks = {}
1605         if os.path.isfile(self.path):
1606             for line in file(self.path):
1607                 t = line.rstrip().split(' ')
1608                 if len(t) == 2:
1609                     self.apks[t[0]] = (t[1], None)
1610                 else:
1611                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1612         self.changed = False
1613
1614     def writeifchanged(self):
1615         if self.changed:
1616             if not os.path.exists('stats'):
1617                 os.mkdir('stats')
1618             f = open(self.path, 'w')
1619             lst = []
1620             for apk, app in self.apks.iteritems():
1621                 appid, added = app
1622                 line = apk + ' ' + appid
1623                 if added:
1624                     line += ' ' + time.strftime('%Y-%m-%d', added)
1625                 lst.append(line)
1626             for line in sorted(lst):
1627                 f.write(line + '\n')
1628             f.close()
1629
1630     # Record an apk (if it's new, otherwise does nothing)
1631     # Returns the date it was added.
1632     def recordapk(self, apk, app):
1633         if apk not in self.apks:
1634             self.apks[apk] = (app, time.gmtime(time.time()))
1635             self.changed = True
1636         _, added = self.apks[apk]
1637         return added
1638
1639     # Look up information - given the 'apkname', returns (app id, date added/None).
1640     # Or returns None for an unknown apk.
1641     def getapp(self, apkname):
1642         if apkname in self.apks:
1643             return self.apks[apkname]
1644         return None
1645
1646     # Get the most recent 'num' apps added to the repo, as a list of package ids
1647     # with the most recent first.
1648     def getlatest(self, num):
1649         apps = {}
1650         for apk, app in self.apks.iteritems():
1651             appid, added = app
1652             if added:
1653                 if appid in apps:
1654                     if apps[appid] > added:
1655                         apps[appid] = added
1656                 else:
1657                     apps[appid] = added
1658         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1659         lst = [app for app, _ in sortedapps]
1660         lst.reverse()
1661         return lst
1662
1663
1664 def isApkDebuggable(apkfile, config):
1665     """Returns True if the given apk file is debuggable
1666
1667     :param apkfile: full path to the apk to check"""
1668
1669     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1670                       output=False)
1671     if p.returncode != 0:
1672         logging.critical("Failed to get apk manifest information")
1673         sys.exit(1)
1674     for line in p.output.splitlines():
1675         if 'android:debuggable' in line and not line.endswith('0x0'):
1676             return True
1677     return False
1678
1679
1680 class AsynchronousFileReader(threading.Thread):
1681
1682     '''
1683     Helper class to implement asynchronous reading of a file
1684     in a separate thread. Pushes read lines on a queue to
1685     be consumed in another thread.
1686     '''
1687
1688     def __init__(self, fd, queue):
1689         assert isinstance(queue, Queue.Queue)
1690         assert callable(fd.readline)
1691         threading.Thread.__init__(self)
1692         self._fd = fd
1693         self._queue = queue
1694
1695     def run(self):
1696         '''The body of the tread: read lines and put them on the queue.'''
1697         for line in iter(self._fd.readline, ''):
1698             self._queue.put(line)
1699
1700     def eof(self):
1701         '''Check whether there is no more content to expect.'''
1702         return not self.is_alive() and self._queue.empty()
1703
1704
1705 class PopenResult:
1706     returncode = None
1707     output = ''
1708
1709
1710 def SdkToolsPopen(commands, cwd=None, output=True):
1711     cmd = commands[0]
1712     if cmd not in config:
1713         config[cmd] = find_sdk_tools_cmd(commands[0])
1714     return FDroidPopen([config[cmd]] + commands[1:],
1715                        cwd=cwd, output=output)
1716
1717
1718 def FDroidPopen(commands, cwd=None, output=True):
1719     """
1720     Run a command and capture the possibly huge output.
1721
1722     :param commands: command and argument list like in subprocess.Popen
1723     :param cwd: optionally specifies a working directory
1724     :returns: A PopenResult.
1725     """
1726
1727     global env
1728
1729     if cwd:
1730         cwd = os.path.normpath(cwd)
1731         logging.debug("Directory: %s" % cwd)
1732     logging.debug("> %s" % ' '.join(commands))
1733
1734     result = PopenResult()
1735     p = None
1736     try:
1737         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1738                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1739     except OSError, e:
1740         raise BuildException("OSError while trying to execute " +
1741                              ' '.join(commands) + ': ' + str(e))
1742
1743     stdout_queue = Queue.Queue()
1744     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1745     stdout_reader.start()
1746
1747     # Check the queue for output (until there is no more to get)
1748     while not stdout_reader.eof():
1749         while not stdout_queue.empty():
1750             line = stdout_queue.get()
1751             if output and options.verbose:
1752                 # Output directly to console
1753                 sys.stderr.write(line)
1754                 sys.stderr.flush()
1755             result.output += line
1756
1757         time.sleep(0.1)
1758
1759     result.returncode = p.wait()
1760     return result
1761
1762
1763 def remove_signing_keys(build_dir):
1764     comment = re.compile(r'[ ]*//')
1765     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1766     line_matches = [
1767         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1768         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1769         re.compile(r'.*variant\.outputFile = .*'),
1770         re.compile(r'.*output\.outputFile = .*'),
1771         re.compile(r'.*\.readLine\(.*'),
1772     ]
1773     for root, dirs, files in os.walk(build_dir):
1774         if 'build.gradle' in files:
1775             path = os.path.join(root, 'build.gradle')
1776
1777             with open(path, "r") as o:
1778                 lines = o.readlines()
1779
1780             changed = False
1781
1782             opened = 0
1783             i = 0
1784             with open(path, "w") as o:
1785                 while i < len(lines):
1786                     line = lines[i]
1787                     i += 1
1788                     while line.endswith('\\\n'):
1789                         line = line.rstrip('\\\n') + lines[i]
1790                         i += 1
1791
1792                     if comment.match(line):
1793                         continue
1794
1795                     if opened > 0:
1796                         opened += line.count('{')
1797                         opened -= line.count('}')
1798                         continue
1799
1800                     if signing_configs.match(line):
1801                         changed = True
1802                         opened += 1
1803                         continue
1804
1805                     if any(s.match(line) for s in line_matches):
1806                         changed = True
1807                         continue
1808
1809                     if opened == 0:
1810                         o.write(line)
1811
1812             if changed:
1813                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1814
1815         for propfile in [
1816                 'project.properties',
1817                 'build.properties',
1818                 'default.properties',
1819                 'ant.properties', ]:
1820             if propfile in files:
1821                 path = os.path.join(root, propfile)
1822
1823                 with open(path, "r") as o:
1824                     lines = o.readlines()
1825
1826                 changed = False
1827
1828                 with open(path, "w") as o:
1829                     for line in lines:
1830                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1831                             changed = True
1832                             continue
1833
1834                         o.write(line)
1835
1836                 if changed:
1837                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1838
1839
1840 def reset_env_path():
1841     global env, orig_path
1842     env['PATH'] = orig_path
1843
1844
1845 def add_to_env_path(path):
1846     global env
1847     paths = env['PATH'].split(os.pathsep)
1848     if path in paths:
1849         return
1850     paths.append(path)
1851     env['PATH'] = os.pathsep.join(paths)
1852
1853
1854 def replace_config_vars(cmd, build):
1855     global env
1856     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1857     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1858     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1859     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1860     if build is not None:
1861         cmd = cmd.replace('$$COMMIT$$', build['commit'])
1862         cmd = cmd.replace('$$VERSION$$', build['version'])
1863         cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1864     return cmd
1865
1866
1867 def place_srclib(root_dir, number, libpath):
1868     if not number:
1869         return
1870     relpath = os.path.relpath(libpath, root_dir)
1871     proppath = os.path.join(root_dir, 'project.properties')
1872
1873     lines = []
1874     if os.path.isfile(proppath):
1875         with open(proppath, "r") as o:
1876             lines = o.readlines()
1877
1878     with open(proppath, "w") as o:
1879         placed = False
1880         for line in lines:
1881             if line.startswith('android.library.reference.%d=' % number):
1882                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1883                 placed = True
1884             else:
1885                 o.write(line)
1886         if not placed:
1887             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1888
1889
1890 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1891     """Verify that two apks are the same
1892
1893     One of the inputs is signed, the other is unsigned. The signature metadata
1894     is transferred from the signed to the unsigned apk, and then jarsigner is
1895     used to verify that the signature from the signed apk is also varlid for
1896     the unsigned one.
1897     :param signed_apk: Path to a signed apk file
1898     :param unsigned_apk: Path to an unsigned apk file expected to match it
1899     :param tmp_dir: Path to directory for temporary files
1900     :returns: None if the verification is successful, otherwise a string
1901               describing what went wrong.
1902     """
1903     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1904     with ZipFile(signed_apk) as signed_apk_as_zip:
1905         meta_inf_files = ['META-INF/MANIFEST.MF']
1906         for f in signed_apk_as_zip.namelist():
1907             if sigfile.match(f):
1908                 meta_inf_files.append(f)
1909         if len(meta_inf_files) < 3:
1910             return "Signature files missing from {0}".format(signed_apk)
1911         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1912     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1913         for meta_inf_file in meta_inf_files:
1914             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1915
1916     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1917         logging.info("...NOT verified - {0}".format(signed_apk))
1918         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1919     logging.info("...successfully verified")
1920     return None
1921
1922
1923 def compare_apks(apk1, apk2, tmp_dir):
1924     """Compare two apks
1925
1926     Returns None if the apk content is the same (apart from the signing key),
1927     otherwise a string describing what's different, or what went wrong when
1928     trying to do the comparison.
1929     """
1930
1931     badchars = re.compile('''[/ :;'"]''')
1932     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1933     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1934     for d in [apk1dir, apk2dir]:
1935         if os.path.exists(d):
1936             shutil.rmtree(d)
1937         os.mkdir(d)
1938         os.mkdir(os.path.join(d, 'jar-xf'))
1939
1940     if subprocess.call(['jar', 'xf',
1941                         os.path.abspath(apk1)],
1942                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1943         return("Failed to unpack " + apk1)
1944     if subprocess.call(['jar', 'xf',
1945                         os.path.abspath(apk2)],
1946                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1947         return("Failed to unpack " + apk2)
1948
1949     # try to find apktool in the path, if it hasn't been manually configed
1950     if 'apktool' not in config:
1951         tmp = find_command('apktool')
1952         if tmp is not None:
1953             config['apktool'] = tmp
1954     if 'apktool' in config:
1955         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1956                            cwd=apk1dir) != 0:
1957             return("Failed to unpack " + apk1)
1958         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1959                            cwd=apk2dir) != 0:
1960             return("Failed to unpack " + apk2)
1961
1962     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1963     lines = p.output.splitlines()
1964     if len(lines) != 1 or 'META-INF' not in lines[0]:
1965         meld = find_command('meld')
1966         if meld is not None:
1967             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1968         return("Unexpected diff output - " + p.output)
1969
1970     # since everything verifies, delete the comparison to keep cruft down
1971     shutil.rmtree(apk1dir)
1972     shutil.rmtree(apk2dir)
1973
1974     # If we get here, it seems like they're the same!
1975     return None
1976
1977
1978 def find_command(command):
1979     '''find the full path of a command, or None if it can't be found in the PATH'''
1980
1981     def is_exe(fpath):
1982         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1983
1984     fpath, fname = os.path.split(command)
1985     if fpath:
1986         if is_exe(command):
1987             return command
1988     else:
1989         for path in os.environ["PATH"].split(os.pathsep):
1990             path = path.strip('"')
1991             exe_file = os.path.join(path, command)
1992             if is_exe(exe_file):
1993                 return exe_file
1994
1995     return None
1996
1997
1998 def genpassword():
1999     '''generate a random password for when generating keys'''
2000     h = hashlib.sha256()
2001     h.update(os.urandom(16))  # salt
2002     h.update(bytes(socket.getfqdn()))
2003     return h.digest().encode('base64').strip()
2004
2005
2006 def genkeystore(localconfig):
2007     '''Generate a new key with random passwords and add it to new keystore'''
2008     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2009     keystoredir = os.path.dirname(localconfig['keystore'])
2010     if keystoredir is None or keystoredir == '':
2011         keystoredir = os.path.join(os.getcwd(), keystoredir)
2012     if not os.path.exists(keystoredir):
2013         os.makedirs(keystoredir, mode=0o700)
2014
2015     write_password_file("keystorepass", localconfig['keystorepass'])
2016     write_password_file("keypass", localconfig['keypass'])
2017     p = FDroidPopen(['keytool', '-genkey',
2018                      '-keystore', localconfig['keystore'],
2019                      '-alias', localconfig['repo_keyalias'],
2020                      '-keyalg', 'RSA', '-keysize', '4096',
2021                      '-sigalg', 'SHA256withRSA',
2022                      '-validity', '10000',
2023                      '-storepass:file', config['keystorepassfile'],
2024                      '-keypass:file', config['keypassfile'],
2025                      '-dname', localconfig['keydname']])
2026     # TODO keypass should be sent via stdin
2027     os.chmod(localconfig['keystore'], 0o0600)
2028     if p.returncode != 0:
2029         raise BuildException("Failed to generate key", p.output)
2030     # now show the lovely key that was just generated
2031     p = FDroidPopen(['keytool', '-list', '-v',
2032                      '-keystore', localconfig['keystore'],
2033                      '-alias', localconfig['repo_keyalias'],
2034                      '-storepass:file', config['keystorepassfile']])
2035     logging.info(p.output.strip() + '\n\n')
2036
2037
2038 def write_to_config(thisconfig, key, value=None):
2039     '''write a key/value to the local config.py'''
2040     if value is None:
2041         origkey = key + '_orig'
2042         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2043     with open('config.py', 'r') as f:
2044         data = f.read()
2045     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2046     repl = '\n' + key + ' = "' + value + '"'
2047     data = re.sub(pattern, repl, data)
2048     # if this key is not in the file, append it
2049     if not re.match('\s*' + key + '\s*=\s*"', data):
2050         data += repl
2051     # make sure the file ends with a carraige return
2052     if not re.match('\n$', data):
2053         data += '\n'
2054     with open('config.py', 'w') as f:
2055         f.writelines(data)
2056
2057
2058 def parse_xml(path):
2059     return XMLElementTree.parse(path).getroot()
2060
2061
2062 def string_is_integer(string):
2063     try:
2064         int(string)
2065         return True
2066     except ValueError:
2067         return False