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