chiark / gitweb /
5dc989117e874bcd0fcffc87dc5eb307605384e8
[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         'r10e': "$ANDROID_NDK"
55     },
56     'build_tools': "22.0.1",
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': 80,
74         'Description': 4000
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 = 'r10e'  # 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', 'submodule', 'foreach', '--recursive',
571                              'git', 'reset', '--hard'], cwd=self.local, output=False)
572             if p.returncode != 0:
573                 raise VCSException("Git reset failed", p.output)
574             # Remove untracked files now, in case they're tracked in the target
575             # revision (it happens!)
576             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
577                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
578             if p.returncode != 0:
579                 raise VCSException("Git clean failed", p.output)
580             if not self.refreshed:
581                 # Get latest commits and tags from remote
582                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
583                 if p.returncode != 0:
584                     raise VCSException("Git fetch failed", p.output)
585                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
586                 if p.returncode != 0:
587                     raise VCSException("Git fetch failed", p.output)
588                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
589                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
590                 if p.returncode != 0:
591                     lines = p.output.splitlines()
592                     if 'Multiple remote HEAD branches' not in lines[0]:
593                         raise VCSException("Git remote set-head failed", p.output)
594                     branch = lines[1].split(' ')[-1]
595                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
596                     if p2.returncode != 0:
597                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
598                 self.refreshed = True
599         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
600         # a github repo. Most of the time this is the same as origin/master.
601         rev = rev or 'origin/HEAD'
602         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
603         if p.returncode != 0:
604             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
605         # Get rid of any uncontrolled files left behind
606         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
607         if p.returncode != 0:
608             raise VCSException("Git clean failed", p.output)
609
610     def initsubmodules(self):
611         self.checkrepo()
612         submfile = os.path.join(self.local, '.gitmodules')
613         if not os.path.isfile(submfile):
614             raise VCSException("No git submodules available")
615
616         # fix submodules not accessible without an account and public key auth
617         with open(submfile, 'r') as f:
618             lines = f.readlines()
619         with open(submfile, 'w') as f:
620             for line in lines:
621                 if 'git@github.com' in line:
622                     line = line.replace('git@github.com:', 'https://github.com/')
623                 f.write(line)
624
625         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
626         if p.returncode != 0:
627             raise VCSException("Git submodule sync failed", p.output)
628         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
629         if p.returncode != 0:
630             raise VCSException("Git submodule update failed", p.output)
631
632     def _gettags(self):
633         self.checkrepo()
634         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
635         return p.output.splitlines()
636
637     def latesttags(self, tags, number):
638         self.checkrepo()
639         tl = []
640         for tag in tags:
641             p = FDroidPopen(
642                 ['git', 'show', '--format=format:%ct', '-s', tag],
643                 cwd=self.local, output=False)
644             # Timestamp is on the last line. For a normal tag, it's the only
645             # line, but for annotated tags, the rest of the info precedes it.
646             ts = int(p.output.splitlines()[-1])
647             tl.append((ts, tag))
648         latest = []
649         for _, t in sorted(tl)[-number:]:
650             latest.append(t)
651         return latest
652
653
654 class vcs_gitsvn(vcs):
655
656     def repotype(self):
657         return 'git-svn'
658
659     # If the local directory exists, but is somehow not a git repository, git
660     # will traverse up the directory tree until it finds one that is (i.e.
661     # fdroidserver) and then we'll proceed to destory it! This is called as
662     # a safety check.
663     def checkrepo(self):
664         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
665         result = p.output.rstrip()
666         if not result.endswith(self.local):
667             raise VCSException('Repository mismatch')
668
669     def gotorevisionx(self, rev):
670         if not os.path.exists(self.local):
671             # Brand new checkout
672             gitsvn_args = ['git', 'svn', 'clone']
673             if ';' in self.remote:
674                 remote_split = self.remote.split(';')
675                 for i in remote_split[1:]:
676                     if i.startswith('trunk='):
677                         gitsvn_args.extend(['-T', i[6:]])
678                     elif i.startswith('tags='):
679                         gitsvn_args.extend(['-t', i[5:]])
680                     elif i.startswith('branches='):
681                         gitsvn_args.extend(['-b', i[9:]])
682                 gitsvn_args.extend([remote_split[0], self.local])
683                 p = FDroidPopen(gitsvn_args, output=False)
684                 if p.returncode != 0:
685                     self.clone_failed = True
686                     raise VCSException("Git svn clone failed", p.output)
687             else:
688                 gitsvn_args.extend([self.remote, self.local])
689                 p = FDroidPopen(gitsvn_args, output=False)
690                 if p.returncode != 0:
691                     self.clone_failed = True
692                     raise VCSException("Git svn clone failed", p.output)
693             self.checkrepo()
694         else:
695             self.checkrepo()
696             # Discard any working tree changes
697             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
698             if p.returncode != 0:
699                 raise VCSException("Git reset failed", p.output)
700             # Remove untracked files now, in case they're tracked in the target
701             # revision (it happens!)
702             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
703             if p.returncode != 0:
704                 raise VCSException("Git clean failed", p.output)
705             if not self.refreshed:
706                 # Get new commits, branches and tags from repo
707                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
708                 if p.returncode != 0:
709                     raise VCSException("Git svn fetch failed")
710                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
711                 if p.returncode != 0:
712                     raise VCSException("Git svn rebase failed", p.output)
713                 self.refreshed = True
714
715         rev = rev or 'master'
716         if rev:
717             nospaces_rev = rev.replace(' ', '%20')
718             # Try finding a svn tag
719             for treeish in ['origin/', '']:
720                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
721                 if p.returncode == 0:
722                     break
723             if p.returncode != 0:
724                 # No tag found, normal svn rev translation
725                 # Translate svn rev into git format
726                 rev_split = rev.split('/')
727
728                 p = None
729                 for treeish in ['origin/', '']:
730                     if len(rev_split) > 1:
731                         treeish += rev_split[0]
732                         svn_rev = rev_split[1]
733
734                     else:
735                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
736                         treeish += 'master'
737                         svn_rev = rev
738
739                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
740
741                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
742                     git_rev = p.output.rstrip()
743
744                     if p.returncode == 0 and git_rev:
745                         break
746
747                 if p.returncode != 0 or not git_rev:
748                     # Try a plain git checkout as a last resort
749                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
750                     if p.returncode != 0:
751                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
752                 else:
753                     # Check out the git rev equivalent to the svn rev
754                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
755                     if p.returncode != 0:
756                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
757
758         # Get rid of any uncontrolled files left behind
759         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
760         if p.returncode != 0:
761             raise VCSException("Git clean failed", p.output)
762
763     def _gettags(self):
764         self.checkrepo()
765         for treeish in ['origin/', '']:
766             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
767             if os.path.isdir(d):
768                 return os.listdir(d)
769
770     def getref(self):
771         self.checkrepo()
772         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
773         if p.returncode != 0:
774             return None
775         return p.output.strip()
776
777
778 class vcs_hg(vcs):
779
780     def repotype(self):
781         return 'hg'
782
783     def gotorevisionx(self, rev):
784         if not os.path.exists(self.local):
785             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
786             if p.returncode != 0:
787                 self.clone_failed = True
788                 raise VCSException("Hg clone failed", p.output)
789         else:
790             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
791             if p.returncode != 0:
792                 raise VCSException("Hg status failed", p.output)
793             for line in p.output.splitlines():
794                 if not line.startswith('? '):
795                     raise VCSException("Unexpected output from hg status -uS: " + line)
796                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
797             if not self.refreshed:
798                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
799                 if p.returncode != 0:
800                     raise VCSException("Hg pull failed", p.output)
801                 self.refreshed = True
802
803         rev = rev or 'default'
804         if not rev:
805             return
806         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
807         if p.returncode != 0:
808             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
809         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
810         # Also delete untracked files, we have to enable purge extension for that:
811         if "'purge' is provided by the following extension" in p.output:
812             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
813                 myfile.write("\n[extensions]\nhgext.purge=\n")
814             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
815             if p.returncode != 0:
816                 raise VCSException("HG purge failed", p.output)
817         elif p.returncode != 0:
818             raise VCSException("HG purge failed", p.output)
819
820     def _gettags(self):
821         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
822         return p.output.splitlines()[1:]
823
824
825 class vcs_bzr(vcs):
826
827     def repotype(self):
828         return 'bzr'
829
830     def gotorevisionx(self, rev):
831         if not os.path.exists(self.local):
832             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
833             if p.returncode != 0:
834                 self.clone_failed = True
835                 raise VCSException("Bzr branch failed", p.output)
836         else:
837             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
838             if p.returncode != 0:
839                 raise VCSException("Bzr revert failed", p.output)
840             if not self.refreshed:
841                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
842                 if p.returncode != 0:
843                     raise VCSException("Bzr update failed", p.output)
844                 self.refreshed = True
845
846         revargs = list(['-r', rev] if rev else [])
847         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
848         if p.returncode != 0:
849             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
850
851     def _gettags(self):
852         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
853         return [tag.split('   ')[0].strip() for tag in
854                 p.output.splitlines()]
855
856
857 def unescape_string(string):
858     if string[0] == '"' and string[-1] == '"':
859         return string[1:-1]
860
861     return string.replace("\\'", "'")
862
863
864 def retrieve_string(app_dir, string, xmlfiles=None):
865
866     if xmlfiles is None:
867         xmlfiles = []
868         for res_dir in [
869             os.path.join(app_dir, 'res'),
870             os.path.join(app_dir, 'src', 'main', 'res'),
871         ]:
872             for r, d, f in os.walk(res_dir):
873                 if os.path.basename(r) == 'values':
874                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
875
876     if not string.startswith('@string/'):
877         return unescape_string(string)
878
879     name = string[len('@string/'):]
880
881     for path in xmlfiles:
882         if not os.path.isfile(path):
883             continue
884         xml = parse_xml(path)
885         element = xml.find('string[@name="' + name + '"]')
886         if element is not None and element.text is not None:
887             return retrieve_string(app_dir, element.text.encode('utf-8'), xmlfiles)
888
889     return ''
890
891
892 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
893     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
894
895
896 # Return list of existing files that will be used to find the highest vercode
897 def manifest_paths(app_dir, flavours):
898
899     possible_manifests = \
900         [os.path.join(app_dir, 'AndroidManifest.xml'),
901          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
902          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
903          os.path.join(app_dir, 'build.gradle')]
904
905     for flavour in flavours:
906         if flavour == 'yes':
907             continue
908         possible_manifests.append(
909             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
910
911     return [path for path in possible_manifests if os.path.isfile(path)]
912
913
914 # Retrieve the package name. Returns the name, or None if not found.
915 def fetch_real_name(app_dir, flavours):
916     for path in manifest_paths(app_dir, flavours):
917         if not has_extension(path, 'xml') or not os.path.isfile(path):
918             continue
919         logging.debug("fetch_real_name: Checking manifest at " + path)
920         xml = parse_xml(path)
921         app = xml.find('application')
922         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
923             continue
924         label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
925         result = retrieve_string_singleline(app_dir, label)
926         if result:
927             result = result.strip()
928         return result
929     return None
930
931
932 def get_library_references(root_dir):
933     libraries = []
934     proppath = os.path.join(root_dir, 'project.properties')
935     if not os.path.isfile(proppath):
936         return libraries
937     for line in file(proppath):
938         if not line.startswith('android.library.reference.'):
939             continue
940         path = line.split('=')[1].strip()
941         relpath = os.path.join(root_dir, path)
942         if not os.path.isdir(relpath):
943             continue
944         logging.debug("Found subproject at %s" % path)
945         libraries.append(path)
946     return libraries
947
948
949 def ant_subprojects(root_dir):
950     subprojects = get_library_references(root_dir)
951     for subpath in subprojects:
952         subrelpath = os.path.join(root_dir, subpath)
953         for p in get_library_references(subrelpath):
954             relp = os.path.normpath(os.path.join(subpath, p))
955             if relp not in subprojects:
956                 subprojects.insert(0, relp)
957     return subprojects
958
959
960 def remove_debuggable_flags(root_dir):
961     # Remove forced debuggable flags
962     logging.debug("Removing debuggable flags from %s" % root_dir)
963     for root, dirs, files in os.walk(root_dir):
964         if 'AndroidManifest.xml' in files:
965             path = os.path.join(root, 'AndroidManifest.xml')
966             p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
967             if p.returncode != 0:
968                 raise BuildException("Failed to remove debuggable flags of %s" % path)
969
970
971 # Extract some information from the AndroidManifest.xml at the given path.
972 # Returns (version, vercode, package), any or all of which might be None.
973 # All values returned are strings.
974 def parse_androidmanifests(paths, ignoreversions=None):
975
976     if not paths:
977         return (None, None, None)
978
979     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
980     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
981     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
982
983     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
984
985     max_version = None
986     max_vercode = None
987     max_package = None
988
989     for path in paths:
990
991         if not os.path.isfile(path):
992             continue
993
994         logging.debug("Parsing manifest at {0}".format(path))
995         gradle = has_extension(path, 'gradle')
996         version = None
997         vercode = None
998         # Remember package name, may be defined separately from version+vercode
999         package = max_package
1000
1001         if gradle:
1002             for line in file(path):
1003                 if not package:
1004                     matches = psearch_g(line)
1005                     if matches:
1006                         package = matches.group(1)
1007                 if not version:
1008                     matches = vnsearch_g(line)
1009                     if matches:
1010                         version = matches.group(2)
1011                 if not vercode:
1012                     matches = vcsearch_g(line)
1013                     if matches:
1014                         vercode = matches.group(1)
1015         else:
1016             xml = parse_xml(path)
1017             if "package" in xml.attrib:
1018                 package = xml.attrib["package"].encode('utf-8')
1019             if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1020                 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1021                 base_dir = os.path.dirname(path)
1022                 version = retrieve_string_singleline(base_dir, version)
1023             if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1024                 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1025                 if string_is_integer(a):
1026                     vercode = a
1027
1028         logging.debug("..got package={0}, version={1}, vercode={2}"
1029                       .format(package, version, vercode))
1030
1031         # Always grab the package name and version name in case they are not
1032         # together with the highest version code
1033         if max_package is None and package is not None:
1034             max_package = package
1035         if max_version is None and version is not None:
1036             max_version = version
1037
1038         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1039             if not ignoresearch or not ignoresearch(version):
1040                 if version is not None:
1041                     max_version = version
1042                 if vercode is not None:
1043                     max_vercode = vercode
1044                 if package is not None:
1045                     max_package = package
1046             else:
1047                 max_version = "Ignore"
1048
1049     if max_version is None:
1050         max_version = "Unknown"
1051
1052     if max_package and not is_valid_package_name(max_package):
1053         raise FDroidException("Invalid package name {0}".format(max_package))
1054
1055     return (max_version, max_vercode, max_package)
1056
1057
1058 def is_valid_package_name(name):
1059     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1060
1061
1062 class FDroidException(Exception):
1063
1064     def __init__(self, value, detail=None):
1065         self.value = value
1066         self.detail = detail
1067
1068     def get_wikitext(self):
1069         ret = repr(self.value) + "\n"
1070         if self.detail:
1071             ret += "=detail=\n"
1072             ret += "<pre>\n"
1073             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1074             ret += str(txt)
1075             ret += "</pre>\n"
1076         return ret
1077
1078     def __str__(self):
1079         ret = self.value
1080         if self.detail:
1081             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1082         return ret
1083
1084
1085 class VCSException(FDroidException):
1086     pass
1087
1088
1089 class BuildException(FDroidException):
1090     pass
1091
1092
1093 # Get the specified source library.
1094 # Returns the path to it. Normally this is the path to be used when referencing
1095 # it, which may be a subdirectory of the actual project. If you want the base
1096 # directory of the project, pass 'basepath=True'.
1097 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1098               raw=False, prepare=True, preponly=False):
1099
1100     number = None
1101     subdir = None
1102     if raw:
1103         name = spec
1104         ref = None
1105     else:
1106         name, ref = spec.split('@')
1107         if ':' in name:
1108             number, name = name.split(':', 1)
1109         if '/' in name:
1110             name, subdir = name.split('/', 1)
1111
1112     if name not in metadata.srclibs:
1113         raise VCSException('srclib ' + name + ' not found.')
1114
1115     srclib = metadata.srclibs[name]
1116
1117     sdir = os.path.join(srclib_dir, name)
1118
1119     if not preponly:
1120         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1121         vcs.srclib = (name, number, sdir)
1122         if ref:
1123             vcs.gotorevision(ref)
1124
1125         if raw:
1126             return vcs
1127
1128     libdir = None
1129     if subdir:
1130         libdir = os.path.join(sdir, subdir)
1131     elif srclib["Subdir"]:
1132         for subdir in srclib["Subdir"]:
1133             libdir_candidate = os.path.join(sdir, subdir)
1134             if os.path.exists(libdir_candidate):
1135                 libdir = libdir_candidate
1136                 break
1137
1138     if libdir is None:
1139         libdir = sdir
1140
1141     remove_signing_keys(sdir)
1142     remove_debuggable_flags(sdir)
1143
1144     if prepare:
1145
1146         if srclib["Prepare"]:
1147             cmd = replace_config_vars(srclib["Prepare"], None)
1148
1149             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1150             if p.returncode != 0:
1151                 raise BuildException("Error running prepare command for srclib %s"
1152                                      % name, p.output)
1153
1154     if basepath:
1155         libdir = sdir
1156
1157     return (name, number, libdir)
1158
1159
1160 # Prepare the source code for a particular build
1161 #  'vcs'         - the appropriate vcs object for the application
1162 #  'app'         - the application details from the metadata
1163 #  'build'       - the build details from the metadata
1164 #  'build_dir'   - the path to the build directory, usually
1165 #                   'build/app.id'
1166 #  'srclib_dir'  - the path to the source libraries directory, usually
1167 #                   'build/srclib'
1168 #  'extlib_dir'  - the path to the external libraries directory, usually
1169 #                   'build/extlib'
1170 # Returns the (root, srclibpaths) where:
1171 #   'root' is the root directory, which may be the same as 'build_dir' or may
1172 #          be a subdirectory of it.
1173 #   'srclibpaths' is information on the srclibs being used
1174 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1175
1176     # Optionally, the actual app source can be in a subdirectory
1177     if build['subdir']:
1178         root_dir = os.path.join(build_dir, build['subdir'])
1179     else:
1180         root_dir = build_dir
1181
1182     # Get a working copy of the right revision
1183     logging.info("Getting source for revision " + build['commit'])
1184     vcs.gotorevision(build['commit'])
1185
1186     # Initialise submodules if required
1187     if build['submodules']:
1188         logging.info("Initialising submodules")
1189         vcs.initsubmodules()
1190
1191     # Check that a subdir (if we're using one) exists. This has to happen
1192     # after the checkout, since it might not exist elsewhere
1193     if not os.path.exists(root_dir):
1194         raise BuildException('Missing subdir ' + root_dir)
1195
1196     # Run an init command if one is required
1197     if build['init']:
1198         cmd = replace_config_vars(build['init'], build)
1199         logging.info("Running 'init' commands in %s" % root_dir)
1200
1201         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1202         if p.returncode != 0:
1203             raise BuildException("Error running init command for %s:%s" %
1204                                  (app['id'], build['version']), p.output)
1205
1206     # Apply patches if any
1207     if build['patch']:
1208         logging.info("Applying patches")
1209         for patch in build['patch']:
1210             patch = patch.strip()
1211             logging.info("Applying " + patch)
1212             patch_path = os.path.join('metadata', app['id'], patch)
1213             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1214             if p.returncode != 0:
1215                 raise BuildException("Failed to apply patch %s" % patch_path)
1216
1217     # Get required source libraries
1218     srclibpaths = []
1219     if build['srclibs']:
1220         logging.info("Collecting source libraries")
1221         for lib in build['srclibs']:
1222             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver))
1223
1224     for name, number, libpath in srclibpaths:
1225         place_srclib(root_dir, int(number) if number else None, libpath)
1226
1227     basesrclib = vcs.getsrclib()
1228     # If one was used for the main source, add that too.
1229     if basesrclib:
1230         srclibpaths.append(basesrclib)
1231
1232     # Update the local.properties file
1233     localprops = [os.path.join(build_dir, 'local.properties')]
1234     if build['subdir']:
1235         localprops += [os.path.join(root_dir, 'local.properties')]
1236     for path in localprops:
1237         props = ""
1238         if os.path.isfile(path):
1239             logging.info("Updating local.properties file at %s" % path)
1240             f = open(path, 'r')
1241             props += f.read()
1242             f.close()
1243             props += '\n'
1244         else:
1245             logging.info("Creating local.properties file at %s" % path)
1246         # Fix old-fashioned 'sdk-location' by copying
1247         # from sdk.dir, if necessary
1248         if build['oldsdkloc']:
1249             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1250                               re.S | re.M).group(1)
1251             props += "sdk-location=%s\n" % sdkloc
1252         else:
1253             props += "sdk.dir=%s\n" % config['sdk_path']
1254             props += "sdk-location=%s\n" % config['sdk_path']
1255         if build['ndk_path']:
1256             # Add ndk location
1257             props += "ndk.dir=%s\n" % build['ndk_path']
1258             props += "ndk-location=%s\n" % build['ndk_path']
1259         # Add java.encoding if necessary
1260         if build['encoding']:
1261             props += "java.encoding=%s\n" % build['encoding']
1262         f = open(path, 'w')
1263         f.write(props)
1264         f.close()
1265
1266     flavours = []
1267     if build['type'] == 'gradle':
1268         flavours = build['gradle']
1269
1270         version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1271         gradlepluginver = None
1272
1273         gradle_dirs = [root_dir]
1274
1275         # Parent dir build.gradle
1276         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1277         if parent_dir.startswith(build_dir):
1278             gradle_dirs.append(parent_dir)
1279
1280         for dir_path in gradle_dirs:
1281             if gradlepluginver:
1282                 break
1283             if not os.path.isdir(dir_path):
1284                 continue
1285             for filename in os.listdir(dir_path):
1286                 if not filename.endswith('.gradle'):
1287                     continue
1288                 path = os.path.join(dir_path, filename)
1289                 if not os.path.isfile(path):
1290                     continue
1291                 for line in file(path):
1292                     match = version_regex.match(line)
1293                     if match:
1294                         gradlepluginver = match.group(1)
1295                         break
1296
1297         if gradlepluginver:
1298             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1299         else:
1300             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1301             build['gradlepluginver'] = LooseVersion('0.11')
1302
1303         if build['target']:
1304             n = build["target"].split('-')[1]
1305             FDroidPopen(['sed', '-i',
1306                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1307                          'build.gradle'], cwd=root_dir, output=False)
1308
1309     # Remove forced debuggable flags
1310     remove_debuggable_flags(root_dir)
1311
1312     # Insert version code and number into the manifest if necessary
1313     if build['forceversion']:
1314         logging.info("Changing the version name")
1315         for path in manifest_paths(root_dir, flavours):
1316             if not os.path.isfile(path):
1317                 continue
1318             if has_extension(path, 'xml'):
1319                 p = FDroidPopen(['sed', '-i',
1320                                  's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1321                                  path], output=False)
1322                 if p.returncode != 0:
1323                     raise BuildException("Failed to amend manifest")
1324             elif has_extension(path, 'gradle'):
1325                 p = FDroidPopen(['sed', '-i',
1326                                  's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1327                                  path], output=False)
1328                 if p.returncode != 0:
1329                     raise BuildException("Failed to amend build.gradle")
1330     if build['forcevercode']:
1331         logging.info("Changing the version code")
1332         for path in manifest_paths(root_dir, flavours):
1333             if not os.path.isfile(path):
1334                 continue
1335             if has_extension(path, 'xml'):
1336                 p = FDroidPopen(['sed', '-i',
1337                                  's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1338                                  path], output=False)
1339                 if p.returncode != 0:
1340                     raise BuildException("Failed to amend manifest")
1341             elif has_extension(path, 'gradle'):
1342                 p = FDroidPopen(['sed', '-i',
1343                                  's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1344                                  path], output=False)
1345                 if p.returncode != 0:
1346                     raise BuildException("Failed to amend build.gradle")
1347
1348     # Delete unwanted files
1349     if build['rm']:
1350         logging.info("Removing specified files")
1351         for part in getpaths(build_dir, build, 'rm'):
1352             dest = os.path.join(build_dir, part)
1353             logging.info("Removing {0}".format(part))
1354             if os.path.lexists(dest):
1355                 if os.path.islink(dest):
1356                     FDroidPopen(['unlink', dest], output=False)
1357                 else:
1358                     FDroidPopen(['rm', '-rf', dest], output=False)
1359             else:
1360                 logging.info("...but it didn't exist")
1361
1362     remove_signing_keys(build_dir)
1363
1364     # Add required external libraries
1365     if build['extlibs']:
1366         logging.info("Collecting prebuilt libraries")
1367         libsdir = os.path.join(root_dir, 'libs')
1368         if not os.path.exists(libsdir):
1369             os.mkdir(libsdir)
1370         for lib in build['extlibs']:
1371             lib = lib.strip()
1372             logging.info("...installing extlib {0}".format(lib))
1373             libf = os.path.basename(lib)
1374             libsrc = os.path.join(extlib_dir, lib)
1375             if not os.path.exists(libsrc):
1376                 raise BuildException("Missing extlib file {0}".format(libsrc))
1377             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1378
1379     # Run a pre-build command if one is required
1380     if build['prebuild']:
1381         logging.info("Running 'prebuild' commands in %s" % root_dir)
1382
1383         cmd = replace_config_vars(build['prebuild'], build)
1384
1385         # Substitute source library paths into prebuild commands
1386         for name, number, libpath in srclibpaths:
1387             libpath = os.path.relpath(libpath, root_dir)
1388             cmd = cmd.replace('$$' + name + '$$', libpath)
1389
1390         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1391         if p.returncode != 0:
1392             raise BuildException("Error running prebuild command for %s:%s" %
1393                                  (app['id'], build['version']), p.output)
1394
1395     # Generate (or update) the ant build file, build.xml...
1396     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1397         parms = ['android', 'update', 'lib-project']
1398         lparms = ['android', 'update', 'project']
1399
1400         if build['target']:
1401             parms += ['-t', build['target']]
1402             lparms += ['-t', build['target']]
1403         if build['update'] == ['auto']:
1404             update_dirs = ant_subprojects(root_dir) + ['.']
1405         else:
1406             update_dirs = build['update']
1407
1408         for d in update_dirs:
1409             subdir = os.path.join(root_dir, d)
1410             if d == '.':
1411                 logging.debug("Updating main project")
1412                 cmd = parms + ['-p', d]
1413             else:
1414                 logging.debug("Updating subproject %s" % d)
1415                 cmd = lparms + ['-p', d]
1416             p = SdkToolsPopen(cmd, cwd=root_dir)
1417             # Check to see whether an error was returned without a proper exit
1418             # code (this is the case for the 'no target set or target invalid'
1419             # error)
1420             if p.returncode != 0 or p.output.startswith("Error: "):
1421                 raise BuildException("Failed to update project at %s" % d, p.output)
1422             # Clean update dirs via ant
1423             if d != '.':
1424                 logging.info("Cleaning subproject %s" % d)
1425                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1426
1427     return (root_dir, srclibpaths)
1428
1429
1430 # Split and extend via globbing the paths from a field
1431 def getpaths(build_dir, build, field):
1432     paths = []
1433     for p in build[field]:
1434         p = p.strip()
1435         full_path = os.path.join(build_dir, p)
1436         full_path = os.path.normpath(full_path)
1437         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1438     return paths
1439
1440
1441 # Scan the source code in the given directory (and all subdirectories)
1442 # and return the number of fatal problems encountered
1443 def scan_source(build_dir, root_dir, thisbuild):
1444
1445     count = 0
1446
1447     # Common known non-free blobs (always lower case):
1448     usual_suspects = [
1449         re.compile(r'.*flurryagent', re.IGNORECASE),
1450         re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1451         re.compile(r'.*google.*analytics', re.IGNORECASE),
1452         re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1453         re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1454         re.compile(r'.*google.*admob', re.IGNORECASE),
1455         re.compile(r'.*google.*play.*services', re.IGNORECASE),
1456         re.compile(r'.*crittercism', re.IGNORECASE),
1457         re.compile(r'.*heyzap', re.IGNORECASE),
1458         re.compile(r'.*jpct.*ae', re.IGNORECASE),
1459         re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1460         re.compile(r'.*bugsense', re.IGNORECASE),
1461         re.compile(r'.*crashlytics', re.IGNORECASE),
1462         re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1463         re.compile(r'.*libspen23', re.IGNORECASE),
1464     ]
1465
1466     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1467     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1468
1469     scanignore_worked = set()
1470     scandelete_worked = set()
1471
1472     try:
1473         ms = magic.open(magic.MIME_TYPE)
1474         ms.load()
1475     except AttributeError:
1476         ms = None
1477
1478     def toignore(fd):
1479         for p in scanignore:
1480             if fd.startswith(p):
1481                 scanignore_worked.add(p)
1482                 return True
1483         return False
1484
1485     def todelete(fd):
1486         for p in scandelete:
1487             if fd.startswith(p):
1488                 scandelete_worked.add(p)
1489                 return True
1490         return False
1491
1492     def ignoreproblem(what, fd, fp):
1493         logging.info('Ignoring %s at %s' % (what, fd))
1494         return 0
1495
1496     def removeproblem(what, fd, fp):
1497         logging.info('Removing %s at %s' % (what, fd))
1498         os.remove(fp)
1499         return 0
1500
1501     def warnproblem(what, fd):
1502         logging.warn('Found %s at %s' % (what, fd))
1503
1504     def handleproblem(what, fd, fp):
1505         if toignore(fd):
1506             return ignoreproblem(what, fd, fp)
1507         if todelete(fd):
1508             return removeproblem(what, fd, fp)
1509         logging.error('Found %s at %s' % (what, fd))
1510         return 1
1511
1512     # Iterate through all files in the source code
1513     for r, d, f in os.walk(build_dir, topdown=True):
1514
1515         # It's topdown, so checking the basename is enough
1516         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1517             if ignoredir in d:
1518                 d.remove(ignoredir)
1519
1520         for curfile in f:
1521
1522             # Path (relative) to the file
1523             fp = os.path.join(r, curfile)
1524             fd = fp[len(build_dir) + 1:]
1525
1526             try:
1527                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1528             except UnicodeError:
1529                 warnproblem('malformed magic number', fd)
1530
1531             if mime == 'application/x-sharedlib':
1532                 count += handleproblem('shared library', fd, fp)
1533
1534             elif mime == 'application/x-archive':
1535                 count += handleproblem('static library', fd, fp)
1536
1537             elif mime == 'application/x-executable':
1538                 count += handleproblem('binary executable', fd, fp)
1539
1540             elif mime == 'application/x-java-applet':
1541                 count += handleproblem('Java compiled class', fd, fp)
1542
1543             elif mime in (
1544                     'application/jar',
1545                     'application/zip',
1546                     'application/java-archive',
1547                     'application/octet-stream',
1548                     'binary', ):
1549
1550                 if has_extension(fp, 'apk'):
1551                     removeproblem('APK file', fd, fp)
1552
1553                 elif has_extension(fp, 'jar'):
1554
1555                     if any(suspect.match(curfile) for suspect in usual_suspects):
1556                         count += handleproblem('usual supect', fd, fp)
1557                     else:
1558                         warnproblem('JAR file', fd)
1559
1560                 elif has_extension(fp, 'zip'):
1561                     warnproblem('ZIP file', fd)
1562
1563                 else:
1564                     warnproblem('unknown compressed or binary file', fd)
1565
1566             elif has_extension(fp, 'java'):
1567                 if not os.path.isfile(fp):
1568                     continue
1569                 for line in file(fp):
1570                     if 'DexClassLoader' in line:
1571                         count += handleproblem('DexClassLoader', fd, fp)
1572                         break
1573
1574             elif has_extension(fp, 'gradle'):
1575                 if not os.path.isfile(fp):
1576                     continue
1577                 for i, line in enumerate(file(fp)):
1578                     if any(suspect.match(line) for suspect in usual_suspects):
1579                         count += handleproblem('usual suspect at line %d' % i, fd, fp)
1580                         break
1581     if ms is not None:
1582         ms.close()
1583
1584     for p in scanignore:
1585         if p not in scanignore_worked:
1586             logging.error('Unused scanignore path: %s' % p)
1587             count += 1
1588
1589     for p in scandelete:
1590         if p not in scandelete_worked:
1591             logging.error('Unused scandelete path: %s' % p)
1592             count += 1
1593
1594     # Presence of a jni directory without buildjni=yes might
1595     # indicate a problem (if it's not a problem, explicitly use
1596     # buildjni=no to bypass this check)
1597     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1598             not thisbuild['buildjni']):
1599         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1600         count += 1
1601
1602     return count
1603
1604
1605 class KnownApks:
1606
1607     def __init__(self):
1608         self.path = os.path.join('stats', 'known_apks.txt')
1609         self.apks = {}
1610         if os.path.isfile(self.path):
1611             for line in file(self.path):
1612                 t = line.rstrip().split(' ')
1613                 if len(t) == 2:
1614                     self.apks[t[0]] = (t[1], None)
1615                 else:
1616                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1617         self.changed = False
1618
1619     def writeifchanged(self):
1620         if self.changed:
1621             if not os.path.exists('stats'):
1622                 os.mkdir('stats')
1623             f = open(self.path, 'w')
1624             lst = []
1625             for apk, app in self.apks.iteritems():
1626                 appid, added = app
1627                 line = apk + ' ' + appid
1628                 if added:
1629                     line += ' ' + time.strftime('%Y-%m-%d', added)
1630                 lst.append(line)
1631             for line in sorted(lst):
1632                 f.write(line + '\n')
1633             f.close()
1634
1635     # Record an apk (if it's new, otherwise does nothing)
1636     # Returns the date it was added.
1637     def recordapk(self, apk, app):
1638         if apk not in self.apks:
1639             self.apks[apk] = (app, time.gmtime(time.time()))
1640             self.changed = True
1641         _, added = self.apks[apk]
1642         return added
1643
1644     # Look up information - given the 'apkname', returns (app id, date added/None).
1645     # Or returns None for an unknown apk.
1646     def getapp(self, apkname):
1647         if apkname in self.apks:
1648             return self.apks[apkname]
1649         return None
1650
1651     # Get the most recent 'num' apps added to the repo, as a list of package ids
1652     # with the most recent first.
1653     def getlatest(self, num):
1654         apps = {}
1655         for apk, app in self.apks.iteritems():
1656             appid, added = app
1657             if added:
1658                 if appid in apps:
1659                     if apps[appid] > added:
1660                         apps[appid] = added
1661                 else:
1662                     apps[appid] = added
1663         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1664         lst = [app for app, _ in sortedapps]
1665         lst.reverse()
1666         return lst
1667
1668
1669 def isApkDebuggable(apkfile, config):
1670     """Returns True if the given apk file is debuggable
1671
1672     :param apkfile: full path to the apk to check"""
1673
1674     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1675                       output=False)
1676     if p.returncode != 0:
1677         logging.critical("Failed to get apk manifest information")
1678         sys.exit(1)
1679     for line in p.output.splitlines():
1680         if 'android:debuggable' in line and not line.endswith('0x0'):
1681             return True
1682     return False
1683
1684
1685 class AsynchronousFileReader(threading.Thread):
1686
1687     '''
1688     Helper class to implement asynchronous reading of a file
1689     in a separate thread. Pushes read lines on a queue to
1690     be consumed in another thread.
1691     '''
1692
1693     def __init__(self, fd, queue):
1694         assert isinstance(queue, Queue.Queue)
1695         assert callable(fd.readline)
1696         threading.Thread.__init__(self)
1697         self._fd = fd
1698         self._queue = queue
1699
1700     def run(self):
1701         '''The body of the tread: read lines and put them on the queue.'''
1702         for line in iter(self._fd.readline, ''):
1703             self._queue.put(line)
1704
1705     def eof(self):
1706         '''Check whether there is no more content to expect.'''
1707         return not self.is_alive() and self._queue.empty()
1708
1709
1710 class PopenResult:
1711     returncode = None
1712     output = ''
1713
1714
1715 def SdkToolsPopen(commands, cwd=None, output=True):
1716     cmd = commands[0]
1717     if cmd not in config:
1718         config[cmd] = find_sdk_tools_cmd(commands[0])
1719     return FDroidPopen([config[cmd]] + commands[1:],
1720                        cwd=cwd, output=output)
1721
1722
1723 def FDroidPopen(commands, cwd=None, output=True):
1724     """
1725     Run a command and capture the possibly huge output.
1726
1727     :param commands: command and argument list like in subprocess.Popen
1728     :param cwd: optionally specifies a working directory
1729     :returns: A PopenResult.
1730     """
1731
1732     global env
1733
1734     if cwd:
1735         cwd = os.path.normpath(cwd)
1736         logging.debug("Directory: %s" % cwd)
1737     logging.debug("> %s" % ' '.join(commands))
1738
1739     result = PopenResult()
1740     p = None
1741     try:
1742         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1743                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1744     except OSError, e:
1745         raise BuildException("OSError while trying to execute " +
1746                              ' '.join(commands) + ': ' + str(e))
1747
1748     stdout_queue = Queue.Queue()
1749     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1750     stdout_reader.start()
1751
1752     # Check the queue for output (until there is no more to get)
1753     while not stdout_reader.eof():
1754         while not stdout_queue.empty():
1755             line = stdout_queue.get()
1756             if output and options.verbose:
1757                 # Output directly to console
1758                 sys.stderr.write(line)
1759                 sys.stderr.flush()
1760             result.output += line
1761
1762         time.sleep(0.1)
1763
1764     result.returncode = p.wait()
1765     return result
1766
1767
1768 def remove_signing_keys(build_dir):
1769     comment = re.compile(r'[ ]*//')
1770     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1771     line_matches = [
1772         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1773         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1774         re.compile(r'.*variant\.outputFile = .*'),
1775         re.compile(r'.*output\.outputFile = .*'),
1776         re.compile(r'.*\.readLine\(.*'),
1777     ]
1778     for root, dirs, files in os.walk(build_dir):
1779         if 'build.gradle' in files:
1780             path = os.path.join(root, 'build.gradle')
1781
1782             with open(path, "r") as o:
1783                 lines = o.readlines()
1784
1785             changed = False
1786
1787             opened = 0
1788             i = 0
1789             with open(path, "w") as o:
1790                 while i < len(lines):
1791                     line = lines[i]
1792                     i += 1
1793                     while line.endswith('\\\n'):
1794                         line = line.rstrip('\\\n') + lines[i]
1795                         i += 1
1796
1797                     if comment.match(line):
1798                         continue
1799
1800                     if opened > 0:
1801                         opened += line.count('{')
1802                         opened -= line.count('}')
1803                         continue
1804
1805                     if signing_configs.match(line):
1806                         changed = True
1807                         opened += 1
1808                         continue
1809
1810                     if any(s.match(line) for s in line_matches):
1811                         changed = True
1812                         continue
1813
1814                     if opened == 0:
1815                         o.write(line)
1816
1817             if changed:
1818                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1819
1820         for propfile in [
1821                 'project.properties',
1822                 'build.properties',
1823                 'default.properties',
1824                 'ant.properties', ]:
1825             if propfile in files:
1826                 path = os.path.join(root, propfile)
1827
1828                 with open(path, "r") as o:
1829                     lines = o.readlines()
1830
1831                 changed = False
1832
1833                 with open(path, "w") as o:
1834                     for line in lines:
1835                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1836                             changed = True
1837                             continue
1838
1839                         o.write(line)
1840
1841                 if changed:
1842                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1843
1844
1845 def reset_env_path():
1846     global env, orig_path
1847     env['PATH'] = orig_path
1848
1849
1850 def add_to_env_path(path):
1851     global env
1852     paths = env['PATH'].split(os.pathsep)
1853     if path in paths:
1854         return
1855     paths.append(path)
1856     env['PATH'] = os.pathsep.join(paths)
1857
1858
1859 def replace_config_vars(cmd, build):
1860     global env
1861     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1862     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1863     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1864     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1865     if build is not None:
1866         cmd = cmd.replace('$$COMMIT$$', build['commit'])
1867         cmd = cmd.replace('$$VERSION$$', build['version'])
1868         cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1869     return cmd
1870
1871
1872 def place_srclib(root_dir, number, libpath):
1873     if not number:
1874         return
1875     relpath = os.path.relpath(libpath, root_dir)
1876     proppath = os.path.join(root_dir, 'project.properties')
1877
1878     lines = []
1879     if os.path.isfile(proppath):
1880         with open(proppath, "r") as o:
1881             lines = o.readlines()
1882
1883     with open(proppath, "w") as o:
1884         placed = False
1885         for line in lines:
1886             if line.startswith('android.library.reference.%d=' % number):
1887                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1888                 placed = True
1889             else:
1890                 o.write(line)
1891         if not placed:
1892             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1893
1894
1895 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1896     """Verify that two apks are the same
1897
1898     One of the inputs is signed, the other is unsigned. The signature metadata
1899     is transferred from the signed to the unsigned apk, and then jarsigner is
1900     used to verify that the signature from the signed apk is also varlid for
1901     the unsigned one.
1902     :param signed_apk: Path to a signed apk file
1903     :param unsigned_apk: Path to an unsigned apk file expected to match it
1904     :param tmp_dir: Path to directory for temporary files
1905     :returns: None if the verification is successful, otherwise a string
1906               describing what went wrong.
1907     """
1908     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1909     with ZipFile(signed_apk) as signed_apk_as_zip:
1910         meta_inf_files = ['META-INF/MANIFEST.MF']
1911         for f in signed_apk_as_zip.namelist():
1912             if sigfile.match(f):
1913                 meta_inf_files.append(f)
1914         if len(meta_inf_files) < 3:
1915             return "Signature files missing from {0}".format(signed_apk)
1916         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1917     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1918         for meta_inf_file in meta_inf_files:
1919             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1920
1921     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1922         logging.info("...NOT verified - {0}".format(signed_apk))
1923         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1924     logging.info("...successfully verified")
1925     return None
1926
1927
1928 def compare_apks(apk1, apk2, tmp_dir):
1929     """Compare two apks
1930
1931     Returns None if the apk content is the same (apart from the signing key),
1932     otherwise a string describing what's different, or what went wrong when
1933     trying to do the comparison.
1934     """
1935
1936     badchars = re.compile('''[/ :;'"]''')
1937     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1938     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1939     for d in [apk1dir, apk2dir]:
1940         if os.path.exists(d):
1941             shutil.rmtree(d)
1942         os.mkdir(d)
1943         os.mkdir(os.path.join(d, 'jar-xf'))
1944
1945     if subprocess.call(['jar', 'xf',
1946                         os.path.abspath(apk1)],
1947                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1948         return("Failed to unpack " + apk1)
1949     if subprocess.call(['jar', 'xf',
1950                         os.path.abspath(apk2)],
1951                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1952         return("Failed to unpack " + apk2)
1953
1954     # try to find apktool in the path, if it hasn't been manually configed
1955     if 'apktool' not in config:
1956         tmp = find_command('apktool')
1957         if tmp is not None:
1958             config['apktool'] = tmp
1959     if 'apktool' in config:
1960         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1961                            cwd=apk1dir) != 0:
1962             return("Failed to unpack " + apk1)
1963         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1964                            cwd=apk2dir) != 0:
1965             return("Failed to unpack " + apk2)
1966
1967     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1968     lines = p.output.splitlines()
1969     if len(lines) != 1 or 'META-INF' not in lines[0]:
1970         meld = find_command('meld')
1971         if meld is not None:
1972             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1973         return("Unexpected diff output - " + p.output)
1974
1975     # since everything verifies, delete the comparison to keep cruft down
1976     shutil.rmtree(apk1dir)
1977     shutil.rmtree(apk2dir)
1978
1979     # If we get here, it seems like they're the same!
1980     return None
1981
1982
1983 def find_command(command):
1984     '''find the full path of a command, or None if it can't be found in the PATH'''
1985
1986     def is_exe(fpath):
1987         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1988
1989     fpath, fname = os.path.split(command)
1990     if fpath:
1991         if is_exe(command):
1992             return command
1993     else:
1994         for path in os.environ["PATH"].split(os.pathsep):
1995             path = path.strip('"')
1996             exe_file = os.path.join(path, command)
1997             if is_exe(exe_file):
1998                 return exe_file
1999
2000     return None
2001
2002
2003 def genpassword():
2004     '''generate a random password for when generating keys'''
2005     h = hashlib.sha256()
2006     h.update(os.urandom(16))  # salt
2007     h.update(bytes(socket.getfqdn()))
2008     return h.digest().encode('base64').strip()
2009
2010
2011 def genkeystore(localconfig):
2012     '''Generate a new key with random passwords and add it to new keystore'''
2013     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2014     keystoredir = os.path.dirname(localconfig['keystore'])
2015     if keystoredir is None or keystoredir == '':
2016         keystoredir = os.path.join(os.getcwd(), keystoredir)
2017     if not os.path.exists(keystoredir):
2018         os.makedirs(keystoredir, mode=0o700)
2019
2020     write_password_file("keystorepass", localconfig['keystorepass'])
2021     write_password_file("keypass", localconfig['keypass'])
2022     p = FDroidPopen(['keytool', '-genkey',
2023                      '-keystore', localconfig['keystore'],
2024                      '-alias', localconfig['repo_keyalias'],
2025                      '-keyalg', 'RSA', '-keysize', '4096',
2026                      '-sigalg', 'SHA256withRSA',
2027                      '-validity', '10000',
2028                      '-storepass:file', config['keystorepassfile'],
2029                      '-keypass:file', config['keypassfile'],
2030                      '-dname', localconfig['keydname']])
2031     # TODO keypass should be sent via stdin
2032     os.chmod(localconfig['keystore'], 0o0600)
2033     if p.returncode != 0:
2034         raise BuildException("Failed to generate key", p.output)
2035     # now show the lovely key that was just generated
2036     p = FDroidPopen(['keytool', '-list', '-v',
2037                      '-keystore', localconfig['keystore'],
2038                      '-alias', localconfig['repo_keyalias'],
2039                      '-storepass:file', config['keystorepassfile']])
2040     logging.info(p.output.strip() + '\n\n')
2041
2042
2043 def write_to_config(thisconfig, key, value=None):
2044     '''write a key/value to the local config.py'''
2045     if value is None:
2046         origkey = key + '_orig'
2047         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2048     with open('config.py', 'r') as f:
2049         data = f.read()
2050     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2051     repl = '\n' + key + ' = "' + value + '"'
2052     data = re.sub(pattern, repl, data)
2053     # if this key is not in the file, append it
2054     if not re.match('\s*' + key + '\s*=\s*"', data):
2055         data += repl
2056     # make sure the file ends with a carraige return
2057     if not re.match('\n$', data):
2058         data += '\n'
2059     with open('config.py', 'w') as f:
2060         f.writelines(data)
2061
2062
2063 def parse_xml(path):
2064     return XMLElementTree.parse(path).getroot()
2065
2066
2067 def string_is_integer(string):
2068     try:
2069         int(string)
2070         return True
2071     except ValueError:
2072         return False