chiark / gitweb /
Properly default to NDK r10e
[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     try:
1456         import magic
1457         ms = None
1458         try:
1459             ms = magic.open(magic.MIME_TYPE)
1460             ms.load()
1461             return magic.from_file(path, mime=True)
1462         except AttributeError:
1463             return ms.file(path)
1464         if ms is not None:
1465             ms.close()
1466     except UnicodeError:
1467         logging.warn('Found malformed magic number at %s' % path)
1468     except ImportError:
1469         import mimetypes
1470         mimetypes.init()
1471         return mimetypes.guess_type(path, strict=False)
1472
1473
1474 # Scan the source code in the given directory (and all subdirectories)
1475 # and return the number of fatal problems encountered
1476 def scan_source(build_dir, root_dir, thisbuild):
1477
1478     count = 0
1479
1480     # Common known non-free blobs (always lower case):
1481     usual_suspects = [
1482         re.compile(r'.*flurryagent', re.IGNORECASE),
1483         re.compile(r'.*paypal.*mpl', re.IGNORECASE),
1484         re.compile(r'.*google.*analytics', re.IGNORECASE),
1485         re.compile(r'.*admob.*sdk.*android', re.IGNORECASE),
1486         re.compile(r'.*google.*ad.*view', re.IGNORECASE),
1487         re.compile(r'.*google.*admob', re.IGNORECASE),
1488         re.compile(r'.*google.*play.*services', re.IGNORECASE),
1489         re.compile(r'.*crittercism', re.IGNORECASE),
1490         re.compile(r'.*heyzap', re.IGNORECASE),
1491         re.compile(r'.*jpct.*ae', re.IGNORECASE),
1492         re.compile(r'.*youtube.*android.*player.*api', re.IGNORECASE),
1493         re.compile(r'.*bugsense', re.IGNORECASE),
1494         re.compile(r'.*crashlytics', re.IGNORECASE),
1495         re.compile(r'.*ouya.*sdk', re.IGNORECASE),
1496         re.compile(r'.*libspen23', re.IGNORECASE),
1497     ]
1498
1499     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1500     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1501
1502     scanignore_worked = set()
1503     scandelete_worked = set()
1504
1505     def toignore(fd):
1506         for p in scanignore:
1507             if fd.startswith(p):
1508                 scanignore_worked.add(p)
1509                 return True
1510         return False
1511
1512     def todelete(fd):
1513         for p in scandelete:
1514             if fd.startswith(p):
1515                 scandelete_worked.add(p)
1516                 return True
1517         return False
1518
1519     def ignoreproblem(what, fd, fp):
1520         logging.info('Ignoring %s at %s' % (what, fd))
1521         return 0
1522
1523     def removeproblem(what, fd, fp):
1524         logging.info('Removing %s at %s' % (what, fd))
1525         os.remove(fp)
1526         return 0
1527
1528     def warnproblem(what, fd):
1529         logging.warn('Found %s at %s' % (what, fd))
1530
1531     def handleproblem(what, fd, fp):
1532         if toignore(fd):
1533             return ignoreproblem(what, fd, fp)
1534         if todelete(fd):
1535             return removeproblem(what, fd, fp)
1536         logging.error('Found %s at %s' % (what, fd))
1537         return 1
1538
1539     # Iterate through all files in the source code
1540     for r, d, f in os.walk(build_dir, topdown=True):
1541
1542         # It's topdown, so checking the basename is enough
1543         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1544             if ignoredir in d:
1545                 d.remove(ignoredir)
1546
1547         for curfile in f:
1548
1549             # Path (relative) to the file
1550             fp = os.path.join(r, curfile)
1551             fd = fp[len(build_dir) + 1:]
1552
1553             mime = get_mime_type(fp)
1554
1555             if mime == 'application/x-sharedlib':
1556                 count += handleproblem('shared library', fd, fp)
1557
1558             elif mime == 'application/x-archive':
1559                 count += handleproblem('static library', fd, fp)
1560
1561             elif mime == 'application/x-executable' or mime == 'application/x-mach-binary':
1562                 count += handleproblem('binary executable', fd, fp)
1563
1564             elif mime == 'application/x-java-applet':
1565                 count += handleproblem('Java compiled class', fd, fp)
1566
1567             elif mime in (
1568                     'application/jar',
1569                     'application/zip',
1570                     'application/java-archive',
1571                     'application/octet-stream',
1572                     'binary', ):
1573
1574                 if has_extension(fp, 'apk'):
1575                     removeproblem('APK file', fd, fp)
1576
1577                 elif has_extension(fp, 'jar'):
1578
1579                     if any(suspect.match(curfile) for suspect in usual_suspects):
1580                         count += handleproblem('usual supect', fd, fp)
1581                     else:
1582                         warnproblem('JAR file', fd)
1583
1584                 elif has_extension(fp, 'zip'):
1585                     warnproblem('ZIP file', fd)
1586
1587                 else:
1588                     warnproblem('unknown compressed or binary file', fd)
1589
1590             elif has_extension(fp, 'java'):
1591                 if not os.path.isfile(fp):
1592                     continue
1593                 for line in file(fp):
1594                     if 'DexClassLoader' in line:
1595                         count += handleproblem('DexClassLoader', fd, fp)
1596                         break
1597
1598             elif has_extension(fp, 'gradle'):
1599                 if not os.path.isfile(fp):
1600                     continue
1601                 for i, line in enumerate(file(fp)):
1602                     if any(suspect.match(line) for suspect in usual_suspects):
1603                         count += handleproblem('usual suspect at line %d' % i, fd, fp)
1604                         break
1605
1606     for p in scanignore:
1607         if p not in scanignore_worked:
1608             logging.error('Unused scanignore path: %s' % p)
1609             count += 1
1610
1611     for p in scandelete:
1612         if p not in scandelete_worked:
1613             logging.error('Unused scandelete path: %s' % p)
1614             count += 1
1615
1616     # Presence of a jni directory without buildjni=yes might
1617     # indicate a problem (if it's not a problem, explicitly use
1618     # buildjni=no to bypass this check)
1619     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1620             not thisbuild['buildjni']):
1621         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1622         count += 1
1623
1624     return count
1625
1626
1627 class KnownApks:
1628
1629     def __init__(self):
1630         self.path = os.path.join('stats', 'known_apks.txt')
1631         self.apks = {}
1632         if os.path.isfile(self.path):
1633             for line in file(self.path):
1634                 t = line.rstrip().split(' ')
1635                 if len(t) == 2:
1636                     self.apks[t[0]] = (t[1], None)
1637                 else:
1638                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1639         self.changed = False
1640
1641     def writeifchanged(self):
1642         if self.changed:
1643             if not os.path.exists('stats'):
1644                 os.mkdir('stats')
1645             f = open(self.path, 'w')
1646             lst = []
1647             for apk, app in self.apks.iteritems():
1648                 appid, added = app
1649                 line = apk + ' ' + appid
1650                 if added:
1651                     line += ' ' + time.strftime('%Y-%m-%d', added)
1652                 lst.append(line)
1653             for line in sorted(lst):
1654                 f.write(line + '\n')
1655             f.close()
1656
1657     # Record an apk (if it's new, otherwise does nothing)
1658     # Returns the date it was added.
1659     def recordapk(self, apk, app):
1660         if apk not in self.apks:
1661             self.apks[apk] = (app, time.gmtime(time.time()))
1662             self.changed = True
1663         _, added = self.apks[apk]
1664         return added
1665
1666     # Look up information - given the 'apkname', returns (app id, date added/None).
1667     # Or returns None for an unknown apk.
1668     def getapp(self, apkname):
1669         if apkname in self.apks:
1670             return self.apks[apkname]
1671         return None
1672
1673     # Get the most recent 'num' apps added to the repo, as a list of package ids
1674     # with the most recent first.
1675     def getlatest(self, num):
1676         apps = {}
1677         for apk, app in self.apks.iteritems():
1678             appid, added = app
1679             if added:
1680                 if appid in apps:
1681                     if apps[appid] > added:
1682                         apps[appid] = added
1683                 else:
1684                     apps[appid] = added
1685         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1686         lst = [app for app, _ in sortedapps]
1687         lst.reverse()
1688         return lst
1689
1690
1691 def isApkDebuggable(apkfile, config):
1692     """Returns True if the given apk file is debuggable
1693
1694     :param apkfile: full path to the apk to check"""
1695
1696     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1697                       output=False)
1698     if p.returncode != 0:
1699         logging.critical("Failed to get apk manifest information")
1700         sys.exit(1)
1701     for line in p.output.splitlines():
1702         if 'android:debuggable' in line and not line.endswith('0x0'):
1703             return True
1704     return False
1705
1706
1707 class AsynchronousFileReader(threading.Thread):
1708
1709     '''
1710     Helper class to implement asynchronous reading of a file
1711     in a separate thread. Pushes read lines on a queue to
1712     be consumed in another thread.
1713     '''
1714
1715     def __init__(self, fd, queue):
1716         assert isinstance(queue, Queue.Queue)
1717         assert callable(fd.readline)
1718         threading.Thread.__init__(self)
1719         self._fd = fd
1720         self._queue = queue
1721
1722     def run(self):
1723         '''The body of the tread: read lines and put them on the queue.'''
1724         for line in iter(self._fd.readline, ''):
1725             self._queue.put(line)
1726
1727     def eof(self):
1728         '''Check whether there is no more content to expect.'''
1729         return not self.is_alive() and self._queue.empty()
1730
1731
1732 class PopenResult:
1733     returncode = None
1734     output = ''
1735
1736
1737 def SdkToolsPopen(commands, cwd=None, output=True):
1738     cmd = commands[0]
1739     if cmd not in config:
1740         config[cmd] = find_sdk_tools_cmd(commands[0])
1741     return FDroidPopen([config[cmd]] + commands[1:],
1742                        cwd=cwd, output=output)
1743
1744
1745 def FDroidPopen(commands, cwd=None, output=True):
1746     """
1747     Run a command and capture the possibly huge output.
1748
1749     :param commands: command and argument list like in subprocess.Popen
1750     :param cwd: optionally specifies a working directory
1751     :returns: A PopenResult.
1752     """
1753
1754     global env
1755
1756     if cwd:
1757         cwd = os.path.normpath(cwd)
1758         logging.debug("Directory: %s" % cwd)
1759     logging.debug("> %s" % ' '.join(commands))
1760
1761     result = PopenResult()
1762     p = None
1763     try:
1764         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1765                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1766     except OSError, e:
1767         raise BuildException("OSError while trying to execute " +
1768                              ' '.join(commands) + ': ' + str(e))
1769
1770     stdout_queue = Queue.Queue()
1771     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1772     stdout_reader.start()
1773
1774     # Check the queue for output (until there is no more to get)
1775     while not stdout_reader.eof():
1776         while not stdout_queue.empty():
1777             line = stdout_queue.get()
1778             if output and options.verbose:
1779                 # Output directly to console
1780                 sys.stderr.write(line)
1781                 sys.stderr.flush()
1782             result.output += line
1783
1784         time.sleep(0.1)
1785
1786     result.returncode = p.wait()
1787     return result
1788
1789
1790 def remove_signing_keys(build_dir):
1791     comment = re.compile(r'[ ]*//')
1792     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1793     line_matches = [
1794         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1795         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1796         re.compile(r'.*variant\.outputFile = .*'),
1797         re.compile(r'.*output\.outputFile = .*'),
1798         re.compile(r'.*\.readLine\(.*'),
1799     ]
1800     for root, dirs, files in os.walk(build_dir):
1801         if 'build.gradle' in files:
1802             path = os.path.join(root, 'build.gradle')
1803
1804             with open(path, "r") as o:
1805                 lines = o.readlines()
1806
1807             changed = False
1808
1809             opened = 0
1810             i = 0
1811             with open(path, "w") as o:
1812                 while i < len(lines):
1813                     line = lines[i]
1814                     i += 1
1815                     while line.endswith('\\\n'):
1816                         line = line.rstrip('\\\n') + lines[i]
1817                         i += 1
1818
1819                     if comment.match(line):
1820                         continue
1821
1822                     if opened > 0:
1823                         opened += line.count('{')
1824                         opened -= line.count('}')
1825                         continue
1826
1827                     if signing_configs.match(line):
1828                         changed = True
1829                         opened += 1
1830                         continue
1831
1832                     if any(s.match(line) for s in line_matches):
1833                         changed = True
1834                         continue
1835
1836                     if opened == 0:
1837                         o.write(line)
1838
1839             if changed:
1840                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1841
1842         for propfile in [
1843                 'project.properties',
1844                 'build.properties',
1845                 'default.properties',
1846                 'ant.properties', ]:
1847             if propfile in files:
1848                 path = os.path.join(root, propfile)
1849
1850                 with open(path, "r") as o:
1851                     lines = o.readlines()
1852
1853                 changed = False
1854
1855                 with open(path, "w") as o:
1856                     for line in lines:
1857                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1858                             changed = True
1859                             continue
1860
1861                         o.write(line)
1862
1863                 if changed:
1864                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1865
1866
1867 def reset_env_path():
1868     global env, orig_path
1869     env['PATH'] = orig_path
1870
1871
1872 def add_to_env_path(path):
1873     global env
1874     paths = env['PATH'].split(os.pathsep)
1875     if path in paths:
1876         return
1877     paths.append(path)
1878     env['PATH'] = os.pathsep.join(paths)
1879
1880
1881 def replace_config_vars(cmd, build):
1882     global env
1883     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1884     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1885     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1886     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1887     if build is not None:
1888         cmd = cmd.replace('$$COMMIT$$', build['commit'])
1889         cmd = cmd.replace('$$VERSION$$', build['version'])
1890         cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1891     return cmd
1892
1893
1894 def place_srclib(root_dir, number, libpath):
1895     if not number:
1896         return
1897     relpath = os.path.relpath(libpath, root_dir)
1898     proppath = os.path.join(root_dir, 'project.properties')
1899
1900     lines = []
1901     if os.path.isfile(proppath):
1902         with open(proppath, "r") as o:
1903             lines = o.readlines()
1904
1905     with open(proppath, "w") as o:
1906         placed = False
1907         for line in lines:
1908             if line.startswith('android.library.reference.%d=' % number):
1909                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1910                 placed = True
1911             else:
1912                 o.write(line)
1913         if not placed:
1914             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1915
1916
1917 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1918     """Verify that two apks are the same
1919
1920     One of the inputs is signed, the other is unsigned. The signature metadata
1921     is transferred from the signed to the unsigned apk, and then jarsigner is
1922     used to verify that the signature from the signed apk is also varlid for
1923     the unsigned one.
1924     :param signed_apk: Path to a signed apk file
1925     :param unsigned_apk: Path to an unsigned apk file expected to match it
1926     :param tmp_dir: Path to directory for temporary files
1927     :returns: None if the verification is successful, otherwise a string
1928               describing what went wrong.
1929     """
1930     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1931     with ZipFile(signed_apk) as signed_apk_as_zip:
1932         meta_inf_files = ['META-INF/MANIFEST.MF']
1933         for f in signed_apk_as_zip.namelist():
1934             if sigfile.match(f):
1935                 meta_inf_files.append(f)
1936         if len(meta_inf_files) < 3:
1937             return "Signature files missing from {0}".format(signed_apk)
1938         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1939     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1940         for meta_inf_file in meta_inf_files:
1941             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1942
1943     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1944         logging.info("...NOT verified - {0}".format(signed_apk))
1945         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1946     logging.info("...successfully verified")
1947     return None
1948
1949
1950 def compare_apks(apk1, apk2, tmp_dir):
1951     """Compare two apks
1952
1953     Returns None if the apk content is the same (apart from the signing key),
1954     otherwise a string describing what's different, or what went wrong when
1955     trying to do the comparison.
1956     """
1957
1958     badchars = re.compile('''[/ :;'"]''')
1959     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1960     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1961     for d in [apk1dir, apk2dir]:
1962         if os.path.exists(d):
1963             shutil.rmtree(d)
1964         os.mkdir(d)
1965         os.mkdir(os.path.join(d, 'jar-xf'))
1966
1967     if subprocess.call(['jar', 'xf',
1968                         os.path.abspath(apk1)],
1969                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1970         return("Failed to unpack " + apk1)
1971     if subprocess.call(['jar', 'xf',
1972                         os.path.abspath(apk2)],
1973                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1974         return("Failed to unpack " + apk2)
1975
1976     # try to find apktool in the path, if it hasn't been manually configed
1977     if 'apktool' not in config:
1978         tmp = find_command('apktool')
1979         if tmp is not None:
1980             config['apktool'] = tmp
1981     if 'apktool' in config:
1982         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1983                            cwd=apk1dir) != 0:
1984             return("Failed to unpack " + apk1)
1985         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1986                            cwd=apk2dir) != 0:
1987             return("Failed to unpack " + apk2)
1988
1989     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1990     lines = p.output.splitlines()
1991     if len(lines) != 1 or 'META-INF' not in lines[0]:
1992         meld = find_command('meld')
1993         if meld is not None:
1994             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1995         return("Unexpected diff output - " + p.output)
1996
1997     # since everything verifies, delete the comparison to keep cruft down
1998     shutil.rmtree(apk1dir)
1999     shutil.rmtree(apk2dir)
2000
2001     # If we get here, it seems like they're the same!
2002     return None
2003
2004
2005 def find_command(command):
2006     '''find the full path of a command, or None if it can't be found in the PATH'''
2007
2008     def is_exe(fpath):
2009         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2010
2011     fpath, fname = os.path.split(command)
2012     if fpath:
2013         if is_exe(command):
2014             return command
2015     else:
2016         for path in os.environ["PATH"].split(os.pathsep):
2017             path = path.strip('"')
2018             exe_file = os.path.join(path, command)
2019             if is_exe(exe_file):
2020                 return exe_file
2021
2022     return None
2023
2024
2025 def genpassword():
2026     '''generate a random password for when generating keys'''
2027     h = hashlib.sha256()
2028     h.update(os.urandom(16))  # salt
2029     h.update(bytes(socket.getfqdn()))
2030     return h.digest().encode('base64').strip()
2031
2032
2033 def genkeystore(localconfig):
2034     '''Generate a new key with random passwords and add it to new keystore'''
2035     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2036     keystoredir = os.path.dirname(localconfig['keystore'])
2037     if keystoredir is None or keystoredir == '':
2038         keystoredir = os.path.join(os.getcwd(), keystoredir)
2039     if not os.path.exists(keystoredir):
2040         os.makedirs(keystoredir, mode=0o700)
2041
2042     write_password_file("keystorepass", localconfig['keystorepass'])
2043     write_password_file("keypass", localconfig['keypass'])
2044     p = FDroidPopen(['keytool', '-genkey',
2045                      '-keystore', localconfig['keystore'],
2046                      '-alias', localconfig['repo_keyalias'],
2047                      '-keyalg', 'RSA', '-keysize', '4096',
2048                      '-sigalg', 'SHA256withRSA',
2049                      '-validity', '10000',
2050                      '-storepass:file', config['keystorepassfile'],
2051                      '-keypass:file', config['keypassfile'],
2052                      '-dname', localconfig['keydname']])
2053     # TODO keypass should be sent via stdin
2054     if p.returncode != 0:
2055         raise BuildException("Failed to generate key", p.output)
2056     os.chmod(localconfig['keystore'], 0o0600)
2057     # now show the lovely key that was just generated
2058     p = FDroidPopen(['keytool', '-list', '-v',
2059                      '-keystore', localconfig['keystore'],
2060                      '-alias', localconfig['repo_keyalias'],
2061                      '-storepass:file', config['keystorepassfile']])
2062     logging.info(p.output.strip() + '\n\n')
2063
2064
2065 def write_to_config(thisconfig, key, value=None):
2066     '''write a key/value to the local config.py'''
2067     if value is None:
2068         origkey = key + '_orig'
2069         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2070     with open('config.py', 'r') as f:
2071         data = f.read()
2072     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2073     repl = '\n' + key + ' = "' + value + '"'
2074     data = re.sub(pattern, repl, data)
2075     # if this key is not in the file, append it
2076     if not re.match('\s*' + key + '\s*=\s*"', data):
2077         data += repl
2078     # make sure the file ends with a carraige return
2079     if not re.match('\n$', data):
2080         data += '\n'
2081     with open('config.py', 'w') as f:
2082         f.writelines(data)
2083
2084
2085 def parse_xml(path):
2086     return XMLElementTree.parse(path).getroot()
2087
2088
2089 def string_is_integer(string):
2090     try:
2091         int(string)
2092         return True
2093     except ValueError:
2094         return False
2095
2096
2097 def download_file(url, local_filename=None, dldir='tmp'):
2098     filename = url.split('/')[-1]
2099     if local_filename is None:
2100         local_filename = os.path.join(dldir, filename)
2101     # the stream=True parameter keeps memory usage low
2102     r = requests.get(url, stream=True)
2103     with open(local_filename, 'wb') as f:
2104         for chunk in r.iter_content(chunk_size=1024):
2105             if chunk:  # filter out keep-alive new chunks
2106                 f.write(chunk)
2107                 f.flush()
2108     return local_filename