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