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