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