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