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