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