chiark / gitweb /
9f7e6c1ba37572626cd1cda8796fd23c23955037
[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 it wasn't expanded correctly (because the NDK is not
1377         # installed or $ANDROID_NDK not set properly), don't insert it.
1378         # even if not actually used, Gradle will error with a cryptic
1379         # message.
1380         # https://gitlab.com/fdroid/fdroidserver/issues/171
1381         if ndk_path and ndk_path[0] != '$':
1382             # Add ndk location
1383             props += "ndk.dir=%s\n" % ndk_path
1384             props += "ndk-location=%s\n" % ndk_path
1385         # Add java.encoding if necessary
1386         if build.encoding:
1387             props += "java.encoding=%s\n" % build.encoding
1388         with open(path, 'w', encoding='iso-8859-1') as f:
1389             f.write(props)
1390
1391     flavours = []
1392     if build.build_method() == 'gradle':
1393         flavours = build.gradle
1394
1395         if build.target:
1396             n = build.target.split('-')[1]
1397             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1398                         r'compileSdkVersion %s' % n,
1399                         os.path.join(root_dir, 'build.gradle'))
1400
1401     # Remove forced debuggable flags
1402     remove_debuggable_flags(root_dir)
1403
1404     # Insert version code and number into the manifest if necessary
1405     if build.forceversion:
1406         logging.info("Changing the version name")
1407         for path in manifest_paths(root_dir, flavours):
1408             if not os.path.isfile(path):
1409                 continue
1410             if has_extension(path, 'xml'):
1411                 regsub_file(r'android:versionName="[^"]*"',
1412                             r'android:versionName="%s"' % build.version,
1413                             path)
1414             elif has_extension(path, 'gradle'):
1415                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1416                             r"""\1versionName '%s'""" % build.version,
1417                             path)
1418
1419     if build.forcevercode:
1420         logging.info("Changing the version code")
1421         for path in manifest_paths(root_dir, flavours):
1422             if not os.path.isfile(path):
1423                 continue
1424             if has_extension(path, 'xml'):
1425                 regsub_file(r'android:versionCode="[^"]*"',
1426                             r'android:versionCode="%s"' % build.vercode,
1427                             path)
1428             elif has_extension(path, 'gradle'):
1429                 regsub_file(r'versionCode[ =]+[0-9]+',
1430                             r'versionCode %s' % build.vercode,
1431                             path)
1432
1433     # Delete unwanted files
1434     if build.rm:
1435         logging.info("Removing specified files")
1436         for part in getpaths(build_dir, build.rm):
1437             dest = os.path.join(build_dir, part)
1438             logging.info("Removing {0}".format(part))
1439             if os.path.lexists(dest):
1440                 if os.path.islink(dest):
1441                     FDroidPopen(['unlink', dest], output=False)
1442                 else:
1443                     FDroidPopen(['rm', '-rf', dest], output=False)
1444             else:
1445                 logging.info("...but it didn't exist")
1446
1447     remove_signing_keys(build_dir)
1448
1449     # Add required external libraries
1450     if build.extlibs:
1451         logging.info("Collecting prebuilt libraries")
1452         libsdir = os.path.join(root_dir, 'libs')
1453         if not os.path.exists(libsdir):
1454             os.mkdir(libsdir)
1455         for lib in build.extlibs:
1456             lib = lib.strip()
1457             logging.info("...installing extlib {0}".format(lib))
1458             libf = os.path.basename(lib)
1459             libsrc = os.path.join(extlib_dir, lib)
1460             if not os.path.exists(libsrc):
1461                 raise BuildException("Missing extlib file {0}".format(libsrc))
1462             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1463
1464     # Run a pre-build command if one is required
1465     if build.prebuild:
1466         logging.info("Running 'prebuild' commands in %s" % root_dir)
1467
1468         cmd = replace_config_vars(build.prebuild, build)
1469
1470         # Substitute source library paths into prebuild commands
1471         for name, number, libpath in srclibpaths:
1472             libpath = os.path.relpath(libpath, root_dir)
1473             cmd = cmd.replace('$$' + name + '$$', libpath)
1474
1475         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1476         if p.returncode != 0:
1477             raise BuildException("Error running prebuild command for %s:%s" %
1478                                  (app.id, build.version), p.output)
1479
1480     # Generate (or update) the ant build file, build.xml...
1481     if build.build_method() == 'ant' and build.update != ['no']:
1482         parms = ['android', 'update', 'lib-project']
1483         lparms = ['android', 'update', 'project']
1484
1485         if build.target:
1486             parms += ['-t', build.target]
1487             lparms += ['-t', build.target]
1488         if build.update:
1489             update_dirs = build.update
1490         else:
1491             update_dirs = ant_subprojects(root_dir) + ['.']
1492
1493         for d in update_dirs:
1494             subdir = os.path.join(root_dir, d)
1495             if d == '.':
1496                 logging.debug("Updating main project")
1497                 cmd = parms + ['-p', d]
1498             else:
1499                 logging.debug("Updating subproject %s" % d)
1500                 cmd = lparms + ['-p', d]
1501             p = SdkToolsPopen(cmd, cwd=root_dir)
1502             # Check to see whether an error was returned without a proper exit
1503             # code (this is the case for the 'no target set or target invalid'
1504             # error)
1505             if p.returncode != 0 or p.output.startswith("Error: "):
1506                 raise BuildException("Failed to update project at %s" % d, p.output)
1507             # Clean update dirs via ant
1508             if d != '.':
1509                 logging.info("Cleaning subproject %s" % d)
1510                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1511
1512     return (root_dir, srclibpaths)
1513
1514
1515 # Extend via globbing the paths from a field and return them as a map from
1516 # original path to resulting paths
1517 def getpaths_map(build_dir, globpaths):
1518     paths = dict()
1519     for p in globpaths:
1520         p = p.strip()
1521         full_path = os.path.join(build_dir, p)
1522         full_path = os.path.normpath(full_path)
1523         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1524         if not paths[p]:
1525             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1526     return paths
1527
1528
1529 # Extend via globbing the paths from a field and return them as a set
1530 def getpaths(build_dir, globpaths):
1531     paths_map = getpaths_map(build_dir, globpaths)
1532     paths = set()
1533     for k, v in paths_map.items():
1534         for p in v:
1535             paths.add(p)
1536     return paths
1537
1538
1539 def natural_key(s):
1540     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1541
1542
1543 class KnownApks:
1544
1545     def __init__(self):
1546         self.path = os.path.join('stats', 'known_apks.txt')
1547         self.apks = {}
1548         if os.path.isfile(self.path):
1549             with open(self.path, 'r', encoding='utf8') as f:
1550                 for line in f:
1551                     t = line.rstrip().split(' ')
1552                     if len(t) == 2:
1553                         self.apks[t[0]] = (t[1], None)
1554                     else:
1555                         self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1556         self.changed = False
1557
1558     def writeifchanged(self):
1559         if not self.changed:
1560             return
1561
1562         if not os.path.exists('stats'):
1563             os.mkdir('stats')
1564
1565         lst = []
1566         for apk, app in self.apks.items():
1567             appid, added = app
1568             line = apk + ' ' + appid
1569             if added:
1570                 line += ' ' + time.strftime('%Y-%m-%d', added)
1571             lst.append(line)
1572
1573         with open(self.path, 'w', encoding='utf8') as f:
1574             for line in sorted(lst, key=natural_key):
1575                 f.write(line + '\n')
1576
1577     # Record an apk (if it's new, otherwise does nothing)
1578     # Returns the date it was added.
1579     def recordapk(self, apk, app, default_date=None):
1580         if apk not in self.apks:
1581             if default_date is None:
1582                 default_date = time.gmtime(time.time())
1583             self.apks[apk] = (app, default_date)
1584             self.changed = True
1585         _, added = self.apks[apk]
1586         return added
1587
1588     # Look up information - given the 'apkname', returns (app id, date added/None).
1589     # Or returns None for an unknown apk.
1590     def getapp(self, apkname):
1591         if apkname in self.apks:
1592             return self.apks[apkname]
1593         return None
1594
1595     # Get the most recent 'num' apps added to the repo, as a list of package ids
1596     # with the most recent first.
1597     def getlatest(self, num):
1598         apps = {}
1599         for apk, app in self.apks.items():
1600             appid, added = app
1601             if added:
1602                 if appid in apps:
1603                     if apps[appid] > added:
1604                         apps[appid] = added
1605                 else:
1606                     apps[appid] = added
1607         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1608         lst = [app for app, _ in sortedapps]
1609         lst.reverse()
1610         return lst
1611
1612
1613 def isApkDebuggable(apkfile, config):
1614     """Returns True if the given apk file is debuggable
1615
1616     :param apkfile: full path to the apk to check"""
1617
1618     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1619                       output=False)
1620     if p.returncode != 0:
1621         logging.critical("Failed to get apk manifest information")
1622         sys.exit(1)
1623     for line in p.output.splitlines():
1624         if 'android:debuggable' in line and not line.endswith('0x0'):
1625             return True
1626     return False
1627
1628
1629 class PopenResult:
1630     def __init__(self):
1631         self.returncode = None
1632         self.output = None
1633
1634
1635 def SdkToolsPopen(commands, cwd=None, output=True):
1636     cmd = commands[0]
1637     if cmd not in config:
1638         config[cmd] = find_sdk_tools_cmd(commands[0])
1639     abscmd = config[cmd]
1640     if abscmd is None:
1641         logging.critical("Could not find '%s' on your system" % cmd)
1642         sys.exit(1)
1643     return FDroidPopen([abscmd] + commands[1:],
1644                        cwd=cwd, output=output)
1645
1646
1647 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1648     """
1649     Run a command and capture the possibly huge output as bytes.
1650
1651     :param commands: command and argument list like in subprocess.Popen
1652     :param cwd: optionally specifies a working directory
1653     :returns: A PopenResult.
1654     """
1655
1656     global env
1657     if env is None:
1658         set_FDroidPopen_env()
1659
1660     if cwd:
1661         cwd = os.path.normpath(cwd)
1662         logging.debug("Directory: %s" % cwd)
1663     logging.debug("> %s" % ' '.join(commands))
1664
1665     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1666     result = PopenResult()
1667     p = None
1668     try:
1669         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1670                              stdout=subprocess.PIPE, stderr=stderr_param)
1671     except OSError as e:
1672         raise BuildException("OSError while trying to execute " +
1673                              ' '.join(commands) + ': ' + str(e))
1674
1675     if not stderr_to_stdout and options.verbose:
1676         stderr_queue = Queue()
1677         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1678
1679         while not stderr_reader.eof():
1680             while not stderr_queue.empty():
1681                 line = stderr_queue.get()
1682                 sys.stderr.buffer.write(line)
1683                 sys.stderr.flush()
1684
1685             time.sleep(0.1)
1686
1687     stdout_queue = Queue()
1688     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1689     buf = io.BytesIO()
1690
1691     # Check the queue for output (until there is no more to get)
1692     while not stdout_reader.eof():
1693         while not stdout_queue.empty():
1694             line = stdout_queue.get()
1695             if output and options.verbose:
1696                 # Output directly to console
1697                 sys.stderr.buffer.write(line)
1698                 sys.stderr.flush()
1699             buf.write(line)
1700
1701         time.sleep(0.1)
1702
1703     result.returncode = p.wait()
1704     result.output = buf.getvalue()
1705     buf.close()
1706     return result
1707
1708
1709 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1710     """
1711     Run a command and capture the possibly huge output as a str.
1712
1713     :param commands: command and argument list like in subprocess.Popen
1714     :param cwd: optionally specifies a working directory
1715     :returns: A PopenResult.
1716     """
1717     result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1718     result.output = result.output.decode('utf-8')
1719     return result
1720
1721
1722 gradle_comment = re.compile(r'[ ]*//')
1723 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1724 gradle_line_matches = [
1725     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1726     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1727     re.compile(r'.*\.readLine\(.*'),
1728 ]
1729
1730
1731 def remove_signing_keys(build_dir):
1732     for root, dirs, files in os.walk(build_dir):
1733         if 'build.gradle' in files:
1734             path = os.path.join(root, 'build.gradle')
1735
1736             with open(path, "r", encoding='utf8') as o:
1737                 lines = o.readlines()
1738
1739             changed = False
1740
1741             opened = 0
1742             i = 0
1743             with open(path, "w", encoding='utf8') as o:
1744                 while i < len(lines):
1745                     line = lines[i]
1746                     i += 1
1747                     while line.endswith('\\\n'):
1748                         line = line.rstrip('\\\n') + lines[i]
1749                         i += 1
1750
1751                     if gradle_comment.match(line):
1752                         o.write(line)
1753                         continue
1754
1755                     if opened > 0:
1756                         opened += line.count('{')
1757                         opened -= line.count('}')
1758                         continue
1759
1760                     if gradle_signing_configs.match(line):
1761                         changed = True
1762                         opened += 1
1763                         continue
1764
1765                     if any(s.match(line) for s in gradle_line_matches):
1766                         changed = True
1767                         continue
1768
1769                     if opened == 0:
1770                         o.write(line)
1771
1772             if changed:
1773                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1774
1775         for propfile in [
1776                 'project.properties',
1777                 'build.properties',
1778                 'default.properties',
1779                 'ant.properties', ]:
1780             if propfile in files:
1781                 path = os.path.join(root, propfile)
1782
1783                 with open(path, "r", encoding='iso-8859-1') as o:
1784                     lines = o.readlines()
1785
1786                 changed = False
1787
1788                 with open(path, "w", encoding='iso-8859-1') as o:
1789                     for line in lines:
1790                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1791                             changed = True
1792                             continue
1793
1794                         o.write(line)
1795
1796                 if changed:
1797                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1798
1799
1800 def set_FDroidPopen_env(build=None):
1801     '''
1802     set up the environment variables for the build environment
1803
1804     There is only a weak standard, the variables used by gradle, so also set
1805     up the most commonly used environment variables for SDK and NDK.  Also, if
1806     there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1807     '''
1808     global env, orig_path
1809
1810     if env is None:
1811         env = os.environ
1812         orig_path = env['PATH']
1813         for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1814             env[n] = config['sdk_path']
1815         for k, v in config['java_paths'].items():
1816             env['JAVA%s_HOME' % k] = v
1817
1818     missinglocale = True
1819     for k, v in env.items():
1820         if k == 'LANG' and v != 'C':
1821             missinglocale = False
1822         elif k == 'LC_ALL':
1823             missinglocale = False
1824     if missinglocale:
1825         env['LANG'] = 'en_US.UTF-8'
1826
1827     if build is not None:
1828         path = build.ndk_path()
1829         paths = orig_path.split(os.pathsep)
1830         if path not in paths:
1831             paths = [path] + paths
1832             env['PATH'] = os.pathsep.join(paths)
1833         for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1834             env[n] = build.ndk_path()
1835
1836
1837 def replace_config_vars(cmd, build):
1838     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1839     cmd = cmd.replace('$$NDK$$', build.ndk_path())
1840     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1841     if build is not None:
1842         cmd = cmd.replace('$$COMMIT$$', build.commit)
1843         cmd = cmd.replace('$$VERSION$$', build.version)
1844         cmd = cmd.replace('$$VERCODE$$', build.vercode)
1845     return cmd
1846
1847
1848 def place_srclib(root_dir, number, libpath):
1849     if not number:
1850         return
1851     relpath = os.path.relpath(libpath, root_dir)
1852     proppath = os.path.join(root_dir, 'project.properties')
1853
1854     lines = []
1855     if os.path.isfile(proppath):
1856         with open(proppath, "r", encoding='iso-8859-1') as o:
1857             lines = o.readlines()
1858
1859     with open(proppath, "w", encoding='iso-8859-1') as o:
1860         placed = False
1861         for line in lines:
1862             if line.startswith('android.library.reference.%d=' % number):
1863                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1864                 placed = True
1865             else:
1866                 o.write(line)
1867         if not placed:
1868             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1869
1870 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
1871
1872
1873 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1874     """Verify that two apks are the same
1875
1876     One of the inputs is signed, the other is unsigned. The signature metadata
1877     is transferred from the signed to the unsigned apk, and then jarsigner is
1878     used to verify that the signature from the signed apk is also varlid for
1879     the unsigned one.
1880     :param signed_apk: Path to a signed apk file
1881     :param unsigned_apk: Path to an unsigned apk file expected to match it
1882     :param tmp_dir: Path to directory for temporary files
1883     :returns: None if the verification is successful, otherwise a string
1884               describing what went wrong.
1885     """
1886     with ZipFile(signed_apk) as signed_apk_as_zip:
1887         meta_inf_files = ['META-INF/MANIFEST.MF']
1888         for f in signed_apk_as_zip.namelist():
1889             if apk_sigfile.match(f):
1890                 meta_inf_files.append(f)
1891         if len(meta_inf_files) < 3:
1892             return "Signature files missing from {0}".format(signed_apk)
1893         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1894     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1895         for meta_inf_file in meta_inf_files:
1896             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1897
1898     if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1899         logging.info("...NOT verified - {0}".format(signed_apk))
1900         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1901     logging.info("...successfully verified")
1902     return None
1903
1904 apk_badchars = re.compile('''[/ :;'"]''')
1905
1906
1907 def compare_apks(apk1, apk2, tmp_dir):
1908     """Compare two apks
1909
1910     Returns None if the apk content is the same (apart from the signing key),
1911     otherwise a string describing what's different, or what went wrong when
1912     trying to do the comparison.
1913     """
1914
1915     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
1916     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
1917     for d in [apk1dir, apk2dir]:
1918         if os.path.exists(d):
1919             shutil.rmtree(d)
1920         os.mkdir(d)
1921         os.mkdir(os.path.join(d, 'jar-xf'))
1922
1923     if subprocess.call(['jar', 'xf',
1924                         os.path.abspath(apk1)],
1925                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1926         return("Failed to unpack " + apk1)
1927     if subprocess.call(['jar', 'xf',
1928                         os.path.abspath(apk2)],
1929                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1930         return("Failed to unpack " + apk2)
1931
1932     # try to find apktool in the path, if it hasn't been manually configed
1933     if 'apktool' not in config:
1934         tmp = find_command('apktool')
1935         if tmp is not None:
1936             config['apktool'] = tmp
1937     if 'apktool' in config:
1938         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1939                            cwd=apk1dir) != 0:
1940             return("Failed to unpack " + apk1)
1941         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1942                            cwd=apk2dir) != 0:
1943             return("Failed to unpack " + apk2)
1944
1945     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1946     lines = p.output.splitlines()
1947     if len(lines) != 1 or 'META-INF' not in lines[0]:
1948         meld = find_command('meld')
1949         if meld is not None:
1950             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1951         return("Unexpected diff output - " + p.output)
1952
1953     # since everything verifies, delete the comparison to keep cruft down
1954     shutil.rmtree(apk1dir)
1955     shutil.rmtree(apk2dir)
1956
1957     # If we get here, it seems like they're the same!
1958     return None
1959
1960
1961 def find_command(command):
1962     '''find the full path of a command, or None if it can't be found in the PATH'''
1963
1964     def is_exe(fpath):
1965         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1966
1967     fpath, fname = os.path.split(command)
1968     if fpath:
1969         if is_exe(command):
1970             return command
1971     else:
1972         for path in os.environ["PATH"].split(os.pathsep):
1973             path = path.strip('"')
1974             exe_file = os.path.join(path, command)
1975             if is_exe(exe_file):
1976                 return exe_file
1977
1978     return None
1979
1980
1981 def genpassword():
1982     '''generate a random password for when generating keys'''
1983     h = hashlib.sha256()
1984     h.update(os.urandom(16))  # salt
1985     h.update(socket.getfqdn().encode('utf-8'))
1986     passwd = base64.b64encode(h.digest()).strip()
1987     return passwd.decode('utf-8')
1988
1989
1990 def genkeystore(localconfig):
1991     '''Generate a new key with random passwords and add it to new keystore'''
1992     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1993     keystoredir = os.path.dirname(localconfig['keystore'])
1994     if keystoredir is None or keystoredir == '':
1995         keystoredir = os.path.join(os.getcwd(), keystoredir)
1996     if not os.path.exists(keystoredir):
1997         os.makedirs(keystoredir, mode=0o700)
1998
1999     write_password_file("keystorepass", localconfig['keystorepass'])
2000     write_password_file("keypass", localconfig['keypass'])
2001     p = FDroidPopen([config['keytool'], '-genkey',
2002                      '-keystore', localconfig['keystore'],
2003                      '-alias', localconfig['repo_keyalias'],
2004                      '-keyalg', 'RSA', '-keysize', '4096',
2005                      '-sigalg', 'SHA256withRSA',
2006                      '-validity', '10000',
2007                      '-storepass:file', config['keystorepassfile'],
2008                      '-keypass:file', config['keypassfile'],
2009                      '-dname', localconfig['keydname']])
2010     # TODO keypass should be sent via stdin
2011     if p.returncode != 0:
2012         raise BuildException("Failed to generate key", p.output)
2013     os.chmod(localconfig['keystore'], 0o0600)
2014     # now show the lovely key that was just generated
2015     p = FDroidPopen([config['keytool'], '-list', '-v',
2016                      '-keystore', localconfig['keystore'],
2017                      '-alias', localconfig['repo_keyalias'],
2018                      '-storepass:file', config['keystorepassfile']])
2019     logging.info(p.output.strip() + '\n\n')
2020
2021
2022 def write_to_config(thisconfig, key, value=None):
2023     '''write a key/value to the local config.py'''
2024     if value is None:
2025         origkey = key + '_orig'
2026         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2027     with open('config.py', 'r', encoding='utf8') as f:
2028         data = f.read()
2029     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2030     repl = '\n' + key + ' = "' + value + '"'
2031     data = re.sub(pattern, repl, data)
2032     # if this key is not in the file, append it
2033     if not re.match('\s*' + key + '\s*=\s*"', data):
2034         data += repl
2035     # make sure the file ends with a carraige return
2036     if not re.match('\n$', data):
2037         data += '\n'
2038     with open('config.py', 'w', encoding='utf8') as f:
2039         f.writelines(data)
2040
2041
2042 def parse_xml(path):
2043     return XMLElementTree.parse(path).getroot()
2044
2045
2046 def string_is_integer(string):
2047     try:
2048         int(string)
2049         return True
2050     except ValueError:
2051         return False
2052
2053
2054 def get_per_app_repos():
2055     '''per-app repos are dirs named with the packageName of a single app'''
2056
2057     # Android packageNames are Java packages, they may contain uppercase or
2058     # lowercase letters ('A' through 'Z'), numbers, and underscores
2059     # ('_'). However, individual package name parts may only start with
2060     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2061     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2062
2063     repos = []
2064     for root, dirs, files in os.walk(os.getcwd()):
2065         for d in dirs:
2066             print('checking', root, 'for', d)
2067             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2068                 # standard parts of an fdroid repo, so never packageNames
2069                 continue
2070             elif p.match(d) \
2071                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2072                 repos.append(d)
2073         break
2074     return repos