chiark / gitweb /
prefer apksigner if installed, jarsigner sucks
[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 getsrcname(app, build):
556     return "%s_%s_src.tar.gz" % (app.id, build.versionCode)
557
558
559 def getappname(app):
560     if app.Name:
561         return app.Name
562     if app.AutoName:
563         return app.AutoName
564     return app.id
565
566
567 def getcvname(app):
568     return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
569
570
571 def get_build_dir(app):
572     '''get the dir that this app will be built in'''
573
574     if app.RepoType == 'srclib':
575         return os.path.join('build', 'srclib', app.Repo)
576
577     return os.path.join('build', app.id)
578
579
580 def setup_vcs(app):
581     '''checkout code from VCS and return instance of vcs and the build dir'''
582     build_dir = get_build_dir(app)
583
584     # Set up vcs interface and make sure we have the latest code...
585     logging.debug("Getting {0} vcs interface for {1}"
586                   .format(app.RepoType, app.Repo))
587     if app.RepoType == 'git' and os.path.exists('.fdroid.yml'):
588         remote = os.getcwd()
589     else:
590         remote = app.Repo
591     vcs = getvcs(app.RepoType, remote, build_dir)
592
593     return vcs, build_dir
594
595
596 def getvcs(vcstype, remote, local):
597     if vcstype == 'git':
598         return vcs_git(remote, local)
599     if vcstype == 'git-svn':
600         return vcs_gitsvn(remote, local)
601     if vcstype == 'hg':
602         return vcs_hg(remote, local)
603     if vcstype == 'bzr':
604         return vcs_bzr(remote, local)
605     if vcstype == 'srclib':
606         if local != os.path.join('build', 'srclib', remote):
607             raise VCSException("Error: srclib paths are hard-coded!")
608         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
609     if vcstype == 'svn':
610         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
611     raise VCSException("Invalid vcs type " + vcstype)
612
613
614 def getsrclibvcs(name):
615     if name not in fdroidserver.metadata.srclibs:
616         raise VCSException("Missing srclib " + name)
617     return fdroidserver.metadata.srclibs[name]['Repo Type']
618
619
620 class vcs:
621
622     def __init__(self, remote, local):
623
624         # svn, git-svn and bzr may require auth
625         self.username = None
626         if self.repotype() in ('git-svn', 'bzr'):
627             if '@' in remote:
628                 if self.repotype == 'git-svn':
629                     raise VCSException("Authentication is not supported for git-svn")
630                 self.username, remote = remote.split('@')
631                 if ':' not in self.username:
632                     raise VCSException("Password required with username")
633                 self.username, self.password = self.username.split(':')
634
635         self.remote = remote
636         self.local = local
637         self.clone_failed = False
638         self.refreshed = False
639         self.srclib = None
640
641     def repotype(self):
642         return None
643
644     # Take the local repository to a clean version of the given revision, which
645     # is specificed in the VCS's native format. Beforehand, the repository can
646     # be dirty, or even non-existent. If the repository does already exist
647     # locally, it will be updated from the origin, but only once in the
648     # lifetime of the vcs object.
649     # None is acceptable for 'rev' if you know you are cloning a clean copy of
650     # the repo - otherwise it must specify a valid revision.
651     def gotorevision(self, rev, refresh=True):
652
653         if self.clone_failed:
654             raise VCSException("Downloading the repository already failed once, not trying again.")
655
656         # The .fdroidvcs-id file for a repo tells us what VCS type
657         # and remote that directory was created from, allowing us to drop it
658         # automatically if either of those things changes.
659         fdpath = os.path.join(self.local, '..',
660                               '.fdroidvcs-' + os.path.basename(self.local))
661         fdpath = os.path.normpath(fdpath)
662         cdata = self.repotype() + ' ' + self.remote
663         writeback = True
664         deleterepo = False
665         if os.path.exists(self.local):
666             if os.path.exists(fdpath):
667                 with open(fdpath, 'r') as f:
668                     fsdata = f.read().strip()
669                 if fsdata == cdata:
670                     writeback = False
671                 else:
672                     deleterepo = True
673                     logging.info("Repository details for %s changed - deleting" % (
674                         self.local))
675             else:
676                 deleterepo = True
677                 logging.info("Repository details for %s missing - deleting" % (
678                     self.local))
679         if deleterepo:
680             shutil.rmtree(self.local)
681
682         exc = None
683         if not refresh:
684             self.refreshed = True
685
686         try:
687             self.gotorevisionx(rev)
688         except FDroidException as e:
689             exc = e
690
691         # If necessary, write the .fdroidvcs file.
692         if writeback and not self.clone_failed:
693             os.makedirs(os.path.dirname(fdpath), exist_ok=True)
694             with open(fdpath, 'w+') as f:
695                 f.write(cdata)
696
697         if exc is not None:
698             raise exc
699
700     # Derived classes need to implement this. It's called once basic checking
701     # has been performend.
702     def gotorevisionx(self, rev):
703         raise VCSException("This VCS type doesn't define gotorevisionx")
704
705     # Initialise and update submodules
706     def initsubmodules(self):
707         raise VCSException('Submodules not supported for this vcs type')
708
709     # Get a list of all known tags
710     def gettags(self):
711         if not self._gettags:
712             raise VCSException('gettags not supported for this vcs type')
713         rtags = []
714         for tag in self._gettags():
715             if re.match('[-A-Za-z0-9_. /]+$', tag):
716                 rtags.append(tag)
717         return rtags
718
719     # Get a list of all the known tags, sorted from newest to oldest
720     def latesttags(self):
721         raise VCSException('latesttags not supported for this vcs type')
722
723     # Get current commit reference (hash, revision, etc)
724     def getref(self):
725         raise VCSException('getref not supported for this vcs type')
726
727     # Returns the srclib (name, path) used in setting up the current
728     # revision, or None.
729     def getsrclib(self):
730         return self.srclib
731
732
733 class vcs_git(vcs):
734
735     def repotype(self):
736         return 'git'
737
738     # If the local directory exists, but is somehow not a git repository, git
739     # will traverse up the directory tree until it finds one that is (i.e.
740     # fdroidserver) and then we'll proceed to destroy it! This is called as
741     # a safety check.
742     def checkrepo(self):
743         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
744         result = p.output.rstrip()
745         if not result.endswith(self.local):
746             raise VCSException('Repository mismatch')
747
748     def gotorevisionx(self, rev):
749         if not os.path.exists(self.local):
750             # Brand new checkout
751             p = FDroidPopen(['git', 'clone', self.remote, self.local])
752             if p.returncode != 0:
753                 self.clone_failed = True
754                 raise VCSException("Git clone failed", p.output)
755             self.checkrepo()
756         else:
757             self.checkrepo()
758             # Discard any working tree changes
759             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
760                              'git', 'reset', '--hard'], cwd=self.local, output=False)
761             if p.returncode != 0:
762                 raise VCSException("Git reset failed", p.output)
763             # Remove untracked files now, in case they're tracked in the target
764             # revision (it happens!)
765             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
766                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
767             if p.returncode != 0:
768                 raise VCSException("Git clean failed", p.output)
769             if not self.refreshed:
770                 # Get latest commits and tags from remote
771                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
772                 if p.returncode != 0:
773                     raise VCSException("Git fetch failed", p.output)
774                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
775                 if p.returncode != 0:
776                     raise VCSException("Git fetch failed", p.output)
777                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
778                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
779                 if p.returncode != 0:
780                     lines = p.output.splitlines()
781                     if 'Multiple remote HEAD branches' not in lines[0]:
782                         raise VCSException("Git remote set-head failed", p.output)
783                     branch = lines[1].split(' ')[-1]
784                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
785                     if p2.returncode != 0:
786                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
787                 self.refreshed = True
788         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
789         # a github repo. Most of the time this is the same as origin/master.
790         rev = rev or 'origin/HEAD'
791         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
792         if p.returncode != 0:
793             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
794         # Get rid of any uncontrolled files left behind
795         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
796         if p.returncode != 0:
797             raise VCSException("Git clean failed", p.output)
798
799     def initsubmodules(self):
800         self.checkrepo()
801         submfile = os.path.join(self.local, '.gitmodules')
802         if not os.path.isfile(submfile):
803             raise VCSException("No git submodules available")
804
805         # fix submodules not accessible without an account and public key auth
806         with open(submfile, 'r') as f:
807             lines = f.readlines()
808         with open(submfile, 'w') as f:
809             for line in lines:
810                 if 'git@github.com' in line:
811                     line = line.replace('git@github.com:', 'https://github.com/')
812                 if 'git@gitlab.com' in line:
813                     line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
814                 f.write(line)
815
816         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
817         if p.returncode != 0:
818             raise VCSException("Git submodule sync failed", p.output)
819         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
820         if p.returncode != 0:
821             raise VCSException("Git submodule update failed", p.output)
822
823     def _gettags(self):
824         self.checkrepo()
825         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
826         return p.output.splitlines()
827
828     tag_format = re.compile(r'tag: ([^),]*)')
829
830     def latesttags(self):
831         self.checkrepo()
832         p = FDroidPopen(['git', 'log', '--tags',
833                          '--simplify-by-decoration', '--pretty=format:%d'],
834                         cwd=self.local, output=False)
835         tags = []
836         for line in p.output.splitlines():
837             for tag in self.tag_format.findall(line):
838                 tags.append(tag)
839         return tags
840
841
842 class vcs_gitsvn(vcs):
843
844     def repotype(self):
845         return 'git-svn'
846
847     # If the local directory exists, but is somehow not a git repository, git
848     # will traverse up the directory tree until it finds one that is (i.e.
849     # fdroidserver) and then we'll proceed to destory it! This is called as
850     # a safety check.
851     def checkrepo(self):
852         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
853         result = p.output.rstrip()
854         if not result.endswith(self.local):
855             raise VCSException('Repository mismatch')
856
857     def gotorevisionx(self, rev):
858         if not os.path.exists(self.local):
859             # Brand new checkout
860             gitsvn_args = ['git', 'svn', 'clone']
861             if ';' in self.remote:
862                 remote_split = self.remote.split(';')
863                 for i in remote_split[1:]:
864                     if i.startswith('trunk='):
865                         gitsvn_args.extend(['-T', i[6:]])
866                     elif i.startswith('tags='):
867                         gitsvn_args.extend(['-t', i[5:]])
868                     elif i.startswith('branches='):
869                         gitsvn_args.extend(['-b', i[9:]])
870                 gitsvn_args.extend([remote_split[0], self.local])
871                 p = FDroidPopen(gitsvn_args, output=False)
872                 if p.returncode != 0:
873                     self.clone_failed = True
874                     raise VCSException("Git svn clone failed", p.output)
875             else:
876                 gitsvn_args.extend([self.remote, self.local])
877                 p = FDroidPopen(gitsvn_args, output=False)
878                 if p.returncode != 0:
879                     self.clone_failed = True
880                     raise VCSException("Git svn clone failed", p.output)
881             self.checkrepo()
882         else:
883             self.checkrepo()
884             # Discard any working tree changes
885             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
886             if p.returncode != 0:
887                 raise VCSException("Git reset failed", p.output)
888             # Remove untracked files now, in case they're tracked in the target
889             # revision (it happens!)
890             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
891             if p.returncode != 0:
892                 raise VCSException("Git clean failed", p.output)
893             if not self.refreshed:
894                 # Get new commits, branches and tags from repo
895                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
896                 if p.returncode != 0:
897                     raise VCSException("Git svn fetch failed")
898                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
899                 if p.returncode != 0:
900                     raise VCSException("Git svn rebase failed", p.output)
901                 self.refreshed = True
902
903         rev = rev or 'master'
904         if rev:
905             nospaces_rev = rev.replace(' ', '%20')
906             # Try finding a svn tag
907             for treeish in ['origin/', '']:
908                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
909                 if p.returncode == 0:
910                     break
911             if p.returncode != 0:
912                 # No tag found, normal svn rev translation
913                 # Translate svn rev into git format
914                 rev_split = rev.split('/')
915
916                 p = None
917                 for treeish in ['origin/', '']:
918                     if len(rev_split) > 1:
919                         treeish += rev_split[0]
920                         svn_rev = rev_split[1]
921
922                     else:
923                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
924                         treeish += 'master'
925                         svn_rev = rev
926
927                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
928
929                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
930                     git_rev = p.output.rstrip()
931
932                     if p.returncode == 0 and git_rev:
933                         break
934
935                 if p.returncode != 0 or not git_rev:
936                     # Try a plain git checkout as a last resort
937                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
938                     if p.returncode != 0:
939                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
940                 else:
941                     # Check out the git rev equivalent to the svn rev
942                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
943                     if p.returncode != 0:
944                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
945
946         # Get rid of any uncontrolled files left behind
947         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
948         if p.returncode != 0:
949             raise VCSException("Git clean failed", p.output)
950
951     def _gettags(self):
952         self.checkrepo()
953         for treeish in ['origin/', '']:
954             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
955             if os.path.isdir(d):
956                 return os.listdir(d)
957
958     def getref(self):
959         self.checkrepo()
960         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
961         if p.returncode != 0:
962             return None
963         return p.output.strip()
964
965
966 class vcs_hg(vcs):
967
968     def repotype(self):
969         return 'hg'
970
971     def gotorevisionx(self, rev):
972         if not os.path.exists(self.local):
973             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
974             if p.returncode != 0:
975                 self.clone_failed = True
976                 raise VCSException("Hg clone failed", p.output)
977         else:
978             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
979             if p.returncode != 0:
980                 raise VCSException("Hg status failed", p.output)
981             for line in p.output.splitlines():
982                 if not line.startswith('? '):
983                     raise VCSException("Unexpected output from hg status -uS: " + line)
984                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
985             if not self.refreshed:
986                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
987                 if p.returncode != 0:
988                     raise VCSException("Hg pull failed", p.output)
989                 self.refreshed = True
990
991         rev = rev or 'default'
992         if not rev:
993             return
994         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
995         if p.returncode != 0:
996             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
997         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
998         # Also delete untracked files, we have to enable purge extension for that:
999         if "'purge' is provided by the following extension" in p.output:
1000             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
1001                 myfile.write("\n[extensions]\nhgext.purge=\n")
1002             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
1003             if p.returncode != 0:
1004                 raise VCSException("HG purge failed", p.output)
1005         elif p.returncode != 0:
1006             raise VCSException("HG purge failed", p.output)
1007
1008     def _gettags(self):
1009         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
1010         return p.output.splitlines()[1:]
1011
1012
1013 class vcs_bzr(vcs):
1014
1015     def repotype(self):
1016         return 'bzr'
1017
1018     def gotorevisionx(self, rev):
1019         if not os.path.exists(self.local):
1020             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
1021             if p.returncode != 0:
1022                 self.clone_failed = True
1023                 raise VCSException("Bzr branch failed", p.output)
1024         else:
1025             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
1026             if p.returncode != 0:
1027                 raise VCSException("Bzr revert failed", p.output)
1028             if not self.refreshed:
1029                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
1030                 if p.returncode != 0:
1031                     raise VCSException("Bzr update failed", p.output)
1032                 self.refreshed = True
1033
1034         revargs = list(['-r', rev] if rev else [])
1035         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
1036         if p.returncode != 0:
1037             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
1038
1039     def _gettags(self):
1040         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
1041         return [tag.split('   ')[0].strip() for tag in
1042                 p.output.splitlines()]
1043
1044
1045 def unescape_string(string):
1046     if len(string) < 2:
1047         return string
1048     if string[0] == '"' and string[-1] == '"':
1049         return string[1:-1]
1050
1051     return string.replace("\\'", "'")
1052
1053
1054 def retrieve_string(app_dir, string, xmlfiles=None):
1055
1056     if not string.startswith('@string/'):
1057         return unescape_string(string)
1058
1059     if xmlfiles is None:
1060         xmlfiles = []
1061         for res_dir in [
1062             os.path.join(app_dir, 'res'),
1063             os.path.join(app_dir, 'src', 'main', 'res'),
1064         ]:
1065             for r, d, f in os.walk(res_dir):
1066                 if os.path.basename(r) == 'values':
1067                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
1068
1069     name = string[len('@string/'):]
1070
1071     def element_content(element):
1072         if element.text is None:
1073             return ""
1074         s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
1075         return s.decode('utf-8').strip()
1076
1077     for path in xmlfiles:
1078         if not os.path.isfile(path):
1079             continue
1080         xml = parse_xml(path)
1081         element = xml.find('string[@name="' + name + '"]')
1082         if element is not None:
1083             content = element_content(element)
1084             return retrieve_string(app_dir, content, xmlfiles)
1085
1086     return ''
1087
1088
1089 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
1090     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
1091
1092
1093 def manifest_paths(app_dir, flavours):
1094     '''Return list of existing files that will be used to find the highest vercode'''
1095
1096     possible_manifests = \
1097         [os.path.join(app_dir, 'AndroidManifest.xml'),
1098          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
1099          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
1100          os.path.join(app_dir, 'build.gradle')]
1101
1102     for flavour in flavours:
1103         if flavour == 'yes':
1104             continue
1105         possible_manifests.append(
1106             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
1107
1108     return [path for path in possible_manifests if os.path.isfile(path)]
1109
1110
1111 def fetch_real_name(app_dir, flavours):
1112     '''Retrieve the package name. Returns the name, or None if not found.'''
1113     for path in manifest_paths(app_dir, flavours):
1114         if not has_extension(path, 'xml') or not os.path.isfile(path):
1115             continue
1116         logging.debug("fetch_real_name: Checking manifest at " + path)
1117         xml = parse_xml(path)
1118         app = xml.find('application')
1119         if app is None:
1120             continue
1121         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1122             continue
1123         label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1124         result = retrieve_string_singleline(app_dir, label)
1125         if result:
1126             result = result.strip()
1127         return result
1128     return None
1129
1130
1131 def get_library_references(root_dir):
1132     libraries = []
1133     proppath = os.path.join(root_dir, 'project.properties')
1134     if not os.path.isfile(proppath):
1135         return libraries
1136     with open(proppath, 'r', encoding='iso-8859-1') as f:
1137         for line in f:
1138             if not line.startswith('android.library.reference.'):
1139                 continue
1140             path = line.split('=')[1].strip()
1141             relpath = os.path.join(root_dir, path)
1142             if not os.path.isdir(relpath):
1143                 continue
1144             logging.debug("Found subproject at %s" % path)
1145             libraries.append(path)
1146     return libraries
1147
1148
1149 def ant_subprojects(root_dir):
1150     subprojects = get_library_references(root_dir)
1151     for subpath in subprojects:
1152         subrelpath = os.path.join(root_dir, subpath)
1153         for p in get_library_references(subrelpath):
1154             relp = os.path.normpath(os.path.join(subpath, p))
1155             if relp not in subprojects:
1156                 subprojects.insert(0, relp)
1157     return subprojects
1158
1159
1160 def remove_debuggable_flags(root_dir):
1161     # Remove forced debuggable flags
1162     logging.debug("Removing debuggable flags from %s" % root_dir)
1163     for root, dirs, files in os.walk(root_dir):
1164         if 'AndroidManifest.xml' in files:
1165             regsub_file(r'android:debuggable="[^"]*"',
1166                         '',
1167                         os.path.join(root, 'AndroidManifest.xml'))
1168
1169
1170 vcsearch_g = re.compile(r'''.*[Vv]ersionCode[ =]+["']*([0-9]+)["']*''').search
1171 vnsearch_g = re.compile(r'.*[Vv]ersionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1172 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1173
1174
1175 def app_matches_packagename(app, package):
1176     if not package:
1177         return False
1178     appid = app.UpdateCheckName or app.id
1179     if appid is None or appid == "Ignore":
1180         return True
1181     return appid == package
1182
1183
1184 def parse_androidmanifests(paths, app):
1185     """
1186     Extract some information from the AndroidManifest.xml at the given path.
1187     Returns (version, vercode, package), any or all of which might be None.
1188     All values returned are strings.
1189     """
1190
1191     ignoreversions = app.UpdateCheckIgnore
1192     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1193
1194     if not paths:
1195         return (None, None, None)
1196
1197     max_version = None
1198     max_vercode = None
1199     max_package = None
1200
1201     for path in paths:
1202
1203         if not os.path.isfile(path):
1204             continue
1205
1206         logging.debug("Parsing manifest at {0}".format(path))
1207         version = None
1208         vercode = None
1209         package = None
1210
1211         if has_extension(path, 'gradle'):
1212             with open(path, 'r') as f:
1213                 for line in f:
1214                     if gradle_comment.match(line):
1215                         continue
1216                     # Grab first occurence of each to avoid running into
1217                     # alternative flavours and builds.
1218                     if not package:
1219                         matches = psearch_g(line)
1220                         if matches:
1221                             s = matches.group(2)
1222                             if app_matches_packagename(app, s):
1223                                 package = s
1224                     if not version:
1225                         matches = vnsearch_g(line)
1226                         if matches:
1227                             version = matches.group(2)
1228                     if not vercode:
1229                         matches = vcsearch_g(line)
1230                         if matches:
1231                             vercode = matches.group(1)
1232         else:
1233             try:
1234                 xml = parse_xml(path)
1235                 if "package" in xml.attrib:
1236                     s = xml.attrib["package"]
1237                     if app_matches_packagename(app, s):
1238                         package = s
1239                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1240                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1241                     base_dir = os.path.dirname(path)
1242                     version = retrieve_string_singleline(base_dir, version)
1243                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1244                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1245                     if string_is_integer(a):
1246                         vercode = a
1247             except Exception:
1248                 logging.warning("Problem with xml at {0}".format(path))
1249
1250         # Remember package name, may be defined separately from version+vercode
1251         if package is None:
1252             package = max_package
1253
1254         logging.debug("..got package={0}, version={1}, vercode={2}"
1255                       .format(package, version, vercode))
1256
1257         # Always grab the package name and version name in case they are not
1258         # together with the highest version code
1259         if max_package is None and package is not None:
1260             max_package = package
1261         if max_version is None and version is not None:
1262             max_version = version
1263
1264         if vercode is not None \
1265            and (max_vercode is None or vercode > max_vercode):
1266             if not ignoresearch or not ignoresearch(version):
1267                 if version is not None:
1268                     max_version = version
1269                 if vercode is not None:
1270                     max_vercode = vercode
1271                 if package is not None:
1272                     max_package = package
1273             else:
1274                 max_version = "Ignore"
1275
1276     if max_version is None:
1277         max_version = "Unknown"
1278
1279     if max_package and not is_valid_package_name(max_package):
1280         raise FDroidException("Invalid package name {0}".format(max_package))
1281
1282     return (max_version, max_vercode, max_package)
1283
1284
1285 def is_valid_package_name(name):
1286     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1287
1288
1289 class FDroidException(Exception):
1290
1291     def __init__(self, value, detail=None):
1292         self.value = value
1293         self.detail = detail
1294
1295     def shortened_detail(self):
1296         if len(self.detail) < 16000:
1297             return self.detail
1298         return '[...]\n' + self.detail[-16000:]
1299
1300     def get_wikitext(self):
1301         ret = repr(self.value) + "\n"
1302         if self.detail:
1303             ret += "=detail=\n"
1304             ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1305         return ret
1306
1307     def __str__(self):
1308         ret = self.value
1309         if self.detail:
1310             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1311         return ret
1312
1313
1314 class VCSException(FDroidException):
1315     pass
1316
1317
1318 class BuildException(FDroidException):
1319     pass
1320
1321
1322 # Get the specified source library.
1323 # Returns the path to it. Normally this is the path to be used when referencing
1324 # it, which may be a subdirectory of the actual project. If you want the base
1325 # directory of the project, pass 'basepath=True'.
1326 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1327               raw=False, prepare=True, preponly=False, refresh=True,
1328               build=None):
1329
1330     number = None
1331     subdir = None
1332     if raw:
1333         name = spec
1334         ref = None
1335     else:
1336         name, ref = spec.split('@')
1337         if ':' in name:
1338             number, name = name.split(':', 1)
1339         if '/' in name:
1340             name, subdir = name.split('/', 1)
1341
1342     if name not in fdroidserver.metadata.srclibs:
1343         raise VCSException('srclib ' + name + ' not found.')
1344
1345     srclib = fdroidserver.metadata.srclibs[name]
1346
1347     sdir = os.path.join(srclib_dir, name)
1348
1349     if not preponly:
1350         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1351         vcs.srclib = (name, number, sdir)
1352         if ref:
1353             vcs.gotorevision(ref, refresh)
1354
1355         if raw:
1356             return vcs
1357
1358     libdir = None
1359     if subdir:
1360         libdir = os.path.join(sdir, subdir)
1361     elif srclib["Subdir"]:
1362         for subdir in srclib["Subdir"]:
1363             libdir_candidate = os.path.join(sdir, subdir)
1364             if os.path.exists(libdir_candidate):
1365                 libdir = libdir_candidate
1366                 break
1367
1368     if libdir is None:
1369         libdir = sdir
1370
1371     remove_signing_keys(sdir)
1372     remove_debuggable_flags(sdir)
1373
1374     if prepare:
1375
1376         if srclib["Prepare"]:
1377             cmd = replace_config_vars(srclib["Prepare"], build)
1378
1379             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1380             if p.returncode != 0:
1381                 raise BuildException("Error running prepare command for srclib %s"
1382                                      % name, p.output)
1383
1384     if basepath:
1385         libdir = sdir
1386
1387     return (name, number, libdir)
1388
1389
1390 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1391
1392
1393 # Prepare the source code for a particular build
1394 #  'vcs'         - the appropriate vcs object for the application
1395 #  'app'         - the application details from the metadata
1396 #  'build'       - the build details from the metadata
1397 #  'build_dir'   - the path to the build directory, usually
1398 #                   'build/app.id'
1399 #  'srclib_dir'  - the path to the source libraries directory, usually
1400 #                   'build/srclib'
1401 #  'extlib_dir'  - the path to the external libraries directory, usually
1402 #                   'build/extlib'
1403 # Returns the (root, srclibpaths) where:
1404 #   'root' is the root directory, which may be the same as 'build_dir' or may
1405 #          be a subdirectory of it.
1406 #   'srclibpaths' is information on the srclibs being used
1407 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1408
1409     # Optionally, the actual app source can be in a subdirectory
1410     if build.subdir:
1411         root_dir = os.path.join(build_dir, build.subdir)
1412     else:
1413         root_dir = build_dir
1414
1415     # Get a working copy of the right revision
1416     logging.info("Getting source for revision " + build.commit)
1417     vcs.gotorevision(build.commit, refresh)
1418
1419     # Initialise submodules if required
1420     if build.submodules:
1421         logging.info("Initialising submodules")
1422         vcs.initsubmodules()
1423
1424     # Check that a subdir (if we're using one) exists. This has to happen
1425     # after the checkout, since it might not exist elsewhere
1426     if not os.path.exists(root_dir):
1427         raise BuildException('Missing subdir ' + root_dir)
1428
1429     # Run an init command if one is required
1430     if build.init:
1431         cmd = replace_config_vars(build.init, build)
1432         logging.info("Running 'init' commands in %s" % root_dir)
1433
1434         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1435         if p.returncode != 0:
1436             raise BuildException("Error running init command for %s:%s" %
1437                                  (app.id, build.versionName), p.output)
1438
1439     # Apply patches if any
1440     if build.patch:
1441         logging.info("Applying patches")
1442         for patch in build.patch:
1443             patch = patch.strip()
1444             logging.info("Applying " + patch)
1445             patch_path = os.path.join('metadata', app.id, patch)
1446             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1447             if p.returncode != 0:
1448                 raise BuildException("Failed to apply patch %s" % patch_path)
1449
1450     # Get required source libraries
1451     srclibpaths = []
1452     if build.srclibs:
1453         logging.info("Collecting source libraries")
1454         for lib in build.srclibs:
1455             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver,
1456                                          refresh=refresh, build=build))
1457
1458     for name, number, libpath in srclibpaths:
1459         place_srclib(root_dir, int(number) if number else None, libpath)
1460
1461     basesrclib = vcs.getsrclib()
1462     # If one was used for the main source, add that too.
1463     if basesrclib:
1464         srclibpaths.append(basesrclib)
1465
1466     # Update the local.properties file
1467     localprops = [os.path.join(build_dir, 'local.properties')]
1468     if build.subdir:
1469         parts = build.subdir.split(os.sep)
1470         cur = build_dir
1471         for d in parts:
1472             cur = os.path.join(cur, d)
1473             localprops += [os.path.join(cur, 'local.properties')]
1474     for path in localprops:
1475         props = ""
1476         if os.path.isfile(path):
1477             logging.info("Updating local.properties file at %s" % path)
1478             with open(path, 'r', encoding='iso-8859-1') as f:
1479                 props += f.read()
1480             props += '\n'
1481         else:
1482             logging.info("Creating local.properties file at %s" % path)
1483         # Fix old-fashioned 'sdk-location' by copying
1484         # from sdk.dir, if necessary
1485         if build.oldsdkloc:
1486             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1487                               re.S | re.M).group(1)
1488             props += "sdk-location=%s\n" % sdkloc
1489         else:
1490             props += "sdk.dir=%s\n" % config['sdk_path']
1491             props += "sdk-location=%s\n" % config['sdk_path']
1492         ndk_path = build.ndk_path()
1493         # if for any reason the path isn't valid or the directory
1494         # doesn't exist, some versions of Gradle will error with a
1495         # cryptic message (even if the NDK is not even necessary).
1496         # https://gitlab.com/fdroid/fdroidserver/issues/171
1497         if ndk_path and os.path.exists(ndk_path):
1498             # Add ndk location
1499             props += "ndk.dir=%s\n" % ndk_path
1500             props += "ndk-location=%s\n" % ndk_path
1501         # Add java.encoding if necessary
1502         if build.encoding:
1503             props += "java.encoding=%s\n" % build.encoding
1504         with open(path, 'w', encoding='iso-8859-1') as f:
1505             f.write(props)
1506
1507     flavours = []
1508     if build.build_method() == 'gradle':
1509         flavours = build.gradle
1510
1511         if build.target:
1512             n = build.target.split('-')[1]
1513             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1514                         r'compileSdkVersion %s' % n,
1515                         os.path.join(root_dir, 'build.gradle'))
1516
1517     # Remove forced debuggable flags
1518     remove_debuggable_flags(root_dir)
1519
1520     # Insert version code and number into the manifest if necessary
1521     if build.forceversion:
1522         logging.info("Changing the version name")
1523         for path in manifest_paths(root_dir, flavours):
1524             if not os.path.isfile(path):
1525                 continue
1526             if has_extension(path, 'xml'):
1527                 regsub_file(r'android:versionName="[^"]*"',
1528                             r'android:versionName="%s"' % build.versionName,
1529                             path)
1530             elif has_extension(path, 'gradle'):
1531                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1532                             r"""\1versionName '%s'""" % build.versionName,
1533                             path)
1534
1535     if build.forcevercode:
1536         logging.info("Changing the version code")
1537         for path in manifest_paths(root_dir, flavours):
1538             if not os.path.isfile(path):
1539                 continue
1540             if has_extension(path, 'xml'):
1541                 regsub_file(r'android:versionCode="[^"]*"',
1542                             r'android:versionCode="%s"' % build.versionCode,
1543                             path)
1544             elif has_extension(path, 'gradle'):
1545                 regsub_file(r'versionCode[ =]+[0-9]+',
1546                             r'versionCode %s' % build.versionCode,
1547                             path)
1548
1549     # Delete unwanted files
1550     if build.rm:
1551         logging.info("Removing specified files")
1552         for part in getpaths(build_dir, build.rm):
1553             dest = os.path.join(build_dir, part)
1554             logging.info("Removing {0}".format(part))
1555             if os.path.lexists(dest):
1556                 if os.path.islink(dest):
1557                     FDroidPopen(['unlink', dest], output=False)
1558                 else:
1559                     FDroidPopen(['rm', '-rf', dest], output=False)
1560             else:
1561                 logging.info("...but it didn't exist")
1562
1563     remove_signing_keys(build_dir)
1564
1565     # Add required external libraries
1566     if build.extlibs:
1567         logging.info("Collecting prebuilt libraries")
1568         libsdir = os.path.join(root_dir, 'libs')
1569         if not os.path.exists(libsdir):
1570             os.mkdir(libsdir)
1571         for lib in build.extlibs:
1572             lib = lib.strip()
1573             logging.info("...installing extlib {0}".format(lib))
1574             libf = os.path.basename(lib)
1575             libsrc = os.path.join(extlib_dir, lib)
1576             if not os.path.exists(libsrc):
1577                 raise BuildException("Missing extlib file {0}".format(libsrc))
1578             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1579
1580     # Run a pre-build command if one is required
1581     if build.prebuild:
1582         logging.info("Running 'prebuild' commands in %s" % root_dir)
1583
1584         cmd = replace_config_vars(build.prebuild, build)
1585
1586         # Substitute source library paths into prebuild commands
1587         for name, number, libpath in srclibpaths:
1588             libpath = os.path.relpath(libpath, root_dir)
1589             cmd = cmd.replace('$$' + name + '$$', libpath)
1590
1591         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1592         if p.returncode != 0:
1593             raise BuildException("Error running prebuild command for %s:%s" %
1594                                  (app.id, build.versionName), p.output)
1595
1596     # Generate (or update) the ant build file, build.xml...
1597     if build.build_method() == 'ant' and build.androidupdate != ['no']:
1598         parms = ['android', 'update', 'lib-project']
1599         lparms = ['android', 'update', 'project']
1600
1601         if build.target:
1602             parms += ['-t', build.target]
1603             lparms += ['-t', build.target]
1604         if build.androidupdate:
1605             update_dirs = build.androidupdate
1606         else:
1607             update_dirs = ant_subprojects(root_dir) + ['.']
1608
1609         for d in update_dirs:
1610             subdir = os.path.join(root_dir, d)
1611             if d == '.':
1612                 logging.debug("Updating main project")
1613                 cmd = parms + ['-p', d]
1614             else:
1615                 logging.debug("Updating subproject %s" % d)
1616                 cmd = lparms + ['-p', d]
1617             p = SdkToolsPopen(cmd, cwd=root_dir)
1618             # Check to see whether an error was returned without a proper exit
1619             # code (this is the case for the 'no target set or target invalid'
1620             # error)
1621             if p.returncode != 0 or p.output.startswith("Error: "):
1622                 raise BuildException("Failed to update project at %s" % d, p.output)
1623             # Clean update dirs via ant
1624             if d != '.':
1625                 logging.info("Cleaning subproject %s" % d)
1626                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1627
1628     return (root_dir, srclibpaths)
1629
1630
1631 # Extend via globbing the paths from a field and return them as a map from
1632 # original path to resulting paths
1633 def getpaths_map(build_dir, globpaths):
1634     paths = dict()
1635     for p in globpaths:
1636         p = p.strip()
1637         full_path = os.path.join(build_dir, p)
1638         full_path = os.path.normpath(full_path)
1639         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1640         if not paths[p]:
1641             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1642     return paths
1643
1644
1645 # Extend via globbing the paths from a field and return them as a set
1646 def getpaths(build_dir, globpaths):
1647     paths_map = getpaths_map(build_dir, globpaths)
1648     paths = set()
1649     for k, v in paths_map.items():
1650         for p in v:
1651             paths.add(p)
1652     return paths
1653
1654
1655 def natural_key(s):
1656     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1657
1658
1659 class KnownApks:
1660
1661     def __init__(self):
1662         self.path = os.path.join('stats', 'known_apks.txt')
1663         self.apks = {}
1664         if os.path.isfile(self.path):
1665             with open(self.path, 'r', encoding='utf8') as f:
1666                 for line in f:
1667                     t = line.rstrip().split(' ')
1668                     if len(t) == 2:
1669                         self.apks[t[0]] = (t[1], None)
1670                     else:
1671                         self.apks[t[0]] = (t[1], datetime.strptime(t[2], '%Y-%m-%d'))
1672         self.changed = False
1673
1674     def writeifchanged(self):
1675         if not self.changed:
1676             return
1677
1678         if not os.path.exists('stats'):
1679             os.mkdir('stats')
1680
1681         lst = []
1682         for apk, app in self.apks.items():
1683             appid, added = app
1684             line = apk + ' ' + appid
1685             if added:
1686                 line += ' ' + added.strftime('%Y-%m-%d')
1687             lst.append(line)
1688
1689         with open(self.path, 'w', encoding='utf8') as f:
1690             for line in sorted(lst, key=natural_key):
1691                 f.write(line + '\n')
1692
1693     def recordapk(self, apk, app, default_date=None):
1694         '''
1695         Record an apk (if it's new, otherwise does nothing)
1696         Returns the date it was added as a datetime instance
1697         '''
1698         if apk not in self.apks:
1699             if default_date is None:
1700                 default_date = datetime.utcnow()
1701             self.apks[apk] = (app, default_date)
1702             self.changed = True
1703         _, added = self.apks[apk]
1704         return added
1705
1706     # Look up information - given the 'apkname', returns (app id, date added/None).
1707     # Or returns None for an unknown apk.
1708     def getapp(self, apkname):
1709         if apkname in self.apks:
1710             return self.apks[apkname]
1711         return None
1712
1713     # Get the most recent 'num' apps added to the repo, as a list of package ids
1714     # with the most recent first.
1715     def getlatest(self, num):
1716         apps = {}
1717         for apk, app in self.apks.items():
1718             appid, added = app
1719             if added:
1720                 if appid in apps:
1721                     if apps[appid] > added:
1722                         apps[appid] = added
1723                 else:
1724                     apps[appid] = added
1725         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1726         lst = [app for app, _ in sortedapps]
1727         lst.reverse()
1728         return lst
1729
1730
1731 def get_file_extension(filename):
1732     """get the normalized file extension, can be blank string but never None"""
1733
1734     return os.path.splitext(filename)[1].lower()[1:]
1735
1736
1737 def isApkAndDebuggable(apkfile, config):
1738     """Returns True if the given file is an APK and is debuggable
1739
1740     :param apkfile: full path to the apk to check"""
1741
1742     if get_file_extension(apkfile) != 'apk':
1743         return False
1744
1745     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1746                       output=False)
1747     if p.returncode != 0:
1748         logging.critical("Failed to get apk manifest information")
1749         sys.exit(1)
1750     for line in p.output.splitlines():
1751         if 'android:debuggable' in line and not line.endswith('0x0'):
1752             return True
1753     return False
1754
1755
1756 class PopenResult:
1757     def __init__(self):
1758         self.returncode = None
1759         self.output = None
1760
1761
1762 def SdkToolsPopen(commands, cwd=None, output=True):
1763     cmd = commands[0]
1764     if cmd not in config:
1765         config[cmd] = find_sdk_tools_cmd(commands[0])
1766     abscmd = config[cmd]
1767     if abscmd is None:
1768         logging.critical("Could not find '%s' on your system" % cmd)
1769         sys.exit(1)
1770     if cmd == 'aapt':
1771         test_aapt_version(config['aapt'])
1772     return FDroidPopen([abscmd] + commands[1:],
1773                        cwd=cwd, output=output)
1774
1775
1776 def FDroidPopenBytes(commands, cwd=None, output=True, stderr_to_stdout=True):
1777     """
1778     Run a command and capture the possibly huge output as bytes.
1779
1780     :param commands: command and argument list like in subprocess.Popen
1781     :param cwd: optionally specifies a working directory
1782     :returns: A PopenResult.
1783     """
1784
1785     global env
1786     if env is None:
1787         set_FDroidPopen_env()
1788
1789     if cwd:
1790         cwd = os.path.normpath(cwd)
1791         logging.debug("Directory: %s" % cwd)
1792     logging.debug("> %s" % ' '.join(commands))
1793
1794     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1795     result = PopenResult()
1796     p = None
1797     try:
1798         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1799                              stdout=subprocess.PIPE, stderr=stderr_param)
1800     except OSError as e:
1801         raise BuildException("OSError while trying to execute " +
1802                              ' '.join(commands) + ': ' + str(e))
1803
1804     if not stderr_to_stdout and options.verbose:
1805         stderr_queue = Queue()
1806         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1807
1808         while not stderr_reader.eof():
1809             while not stderr_queue.empty():
1810                 line = stderr_queue.get()
1811                 sys.stderr.buffer.write(line)
1812                 sys.stderr.flush()
1813
1814             time.sleep(0.1)
1815
1816     stdout_queue = Queue()
1817     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1818     buf = io.BytesIO()
1819
1820     # Check the queue for output (until there is no more to get)
1821     while not stdout_reader.eof():
1822         while not stdout_queue.empty():
1823             line = stdout_queue.get()
1824             if output and options.verbose:
1825                 # Output directly to console
1826                 sys.stderr.buffer.write(line)
1827                 sys.stderr.flush()
1828             buf.write(line)
1829
1830         time.sleep(0.1)
1831
1832     result.returncode = p.wait()
1833     result.output = buf.getvalue()
1834     buf.close()
1835     return result
1836
1837
1838 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1839     """
1840     Run a command and capture the possibly huge output as a str.
1841
1842     :param commands: command and argument list like in subprocess.Popen
1843     :param cwd: optionally specifies a working directory
1844     :returns: A PopenResult.
1845     """
1846     result = FDroidPopenBytes(commands, cwd, output, stderr_to_stdout)
1847     result.output = result.output.decode('utf-8', 'ignore')
1848     return result
1849
1850
1851 gradle_comment = re.compile(r'[ ]*//')
1852 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1853 gradle_line_matches = [
1854     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1855     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1856     re.compile(r'.*\.readLine\(.*'),
1857 ]
1858
1859
1860 def remove_signing_keys(build_dir):
1861     for root, dirs, files in os.walk(build_dir):
1862         if 'build.gradle' in files:
1863             path = os.path.join(root, 'build.gradle')
1864
1865             with open(path, "r", encoding='utf8') as o:
1866                 lines = o.readlines()
1867
1868             changed = False
1869
1870             opened = 0
1871             i = 0
1872             with open(path, "w", encoding='utf8') as o:
1873                 while i < len(lines):
1874                     line = lines[i]
1875                     i += 1
1876                     while line.endswith('\\\n'):
1877                         line = line.rstrip('\\\n') + lines[i]
1878                         i += 1
1879
1880                     if gradle_comment.match(line):
1881                         o.write(line)
1882                         continue
1883
1884                     if opened > 0:
1885                         opened += line.count('{')
1886                         opened -= line.count('}')
1887                         continue
1888
1889                     if gradle_signing_configs.match(line):
1890                         changed = True
1891                         opened += 1
1892                         continue
1893
1894                     if any(s.match(line) for s in gradle_line_matches):
1895                         changed = True
1896                         continue
1897
1898                     if opened == 0:
1899                         o.write(line)
1900
1901             if changed:
1902                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1903
1904         for propfile in [
1905                 'project.properties',
1906                 'build.properties',
1907                 'default.properties',
1908                 'ant.properties', ]:
1909             if propfile in files:
1910                 path = os.path.join(root, propfile)
1911
1912                 with open(path, "r", encoding='iso-8859-1') as o:
1913                     lines = o.readlines()
1914
1915                 changed = False
1916
1917                 with open(path, "w", encoding='iso-8859-1') as o:
1918                     for line in lines:
1919                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1920                             changed = True
1921                             continue
1922
1923                         o.write(line)
1924
1925                 if changed:
1926                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1927
1928
1929 def set_FDroidPopen_env(build=None):
1930     '''
1931     set up the environment variables for the build environment
1932
1933     There is only a weak standard, the variables used by gradle, so also set
1934     up the most commonly used environment variables for SDK and NDK.  Also, if
1935     there is no locale set, this will set the locale (e.g. LANG) to en_US.UTF-8.
1936     '''
1937     global env, orig_path
1938
1939     if env is None:
1940         env = os.environ
1941         orig_path = env['PATH']
1942         for n in ['ANDROID_HOME', 'ANDROID_SDK']:
1943             env[n] = config['sdk_path']
1944         for k, v in config['java_paths'].items():
1945             env['JAVA%s_HOME' % k] = v
1946
1947     missinglocale = True
1948     for k, v in env.items():
1949         if k == 'LANG' and v != 'C':
1950             missinglocale = False
1951         elif k == 'LC_ALL':
1952             missinglocale = False
1953     if missinglocale:
1954         env['LANG'] = 'en_US.UTF-8'
1955
1956     if build is not None:
1957         path = build.ndk_path()
1958         paths = orig_path.split(os.pathsep)
1959         if path not in paths:
1960             paths = [path] + paths
1961             env['PATH'] = os.pathsep.join(paths)
1962         for n in ['ANDROID_NDK', 'NDK', 'ANDROID_NDK_HOME']:
1963             env[n] = build.ndk_path()
1964
1965
1966 def replace_build_vars(cmd, build):
1967     cmd = cmd.replace('$$COMMIT$$', build.commit)
1968     cmd = cmd.replace('$$VERSION$$', build.versionName)
1969     cmd = cmd.replace('$$VERCODE$$', build.versionCode)
1970     return cmd
1971
1972
1973 def replace_config_vars(cmd, build):
1974     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1975     cmd = cmd.replace('$$NDK$$', build.ndk_path())
1976     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1977     cmd = cmd.replace('$$QT$$', config['qt_sdk_path'] or '')
1978     if build is not None:
1979         cmd = replace_build_vars(cmd, build)
1980     return cmd
1981
1982
1983 def place_srclib(root_dir, number, libpath):
1984     if not number:
1985         return
1986     relpath = os.path.relpath(libpath, root_dir)
1987     proppath = os.path.join(root_dir, 'project.properties')
1988
1989     lines = []
1990     if os.path.isfile(proppath):
1991         with open(proppath, "r", encoding='iso-8859-1') as o:
1992             lines = o.readlines()
1993
1994     with open(proppath, "w", encoding='iso-8859-1') as o:
1995         placed = False
1996         for line in lines:
1997             if line.startswith('android.library.reference.%d=' % number):
1998                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1999                 placed = True
2000             else:
2001                 o.write(line)
2002         if not placed:
2003             o.write('android.library.reference.%d=%s\n' % (number, relpath))
2004
2005
2006 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA|DSA|EC)')
2007
2008
2009 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
2010     """Verify that two apks are the same
2011
2012     One of the inputs is signed, the other is unsigned. The signature metadata
2013     is transferred from the signed to the unsigned apk, and then jarsigner is
2014     used to verify that the signature from the signed apk is also varlid for
2015     the unsigned one.  If the APK given as unsigned actually does have a
2016     signature, it will be stripped out and ignored.
2017
2018     There are two SHA1 git commit IDs that fdroidserver includes in the builds
2019     it makes: fdroidserverid and buildserverid.  Originally, these were inserted
2020     into AndroidManifest.xml, but that makes the build not reproducible. So
2021     instead they are included as separate files in the APK's META-INF/ folder.
2022     If those files exist in the signed APK, they will be part of the signature
2023     and need to also be included in the unsigned APK for it to validate.
2024
2025     :param signed_apk: Path to a signed apk file
2026     :param unsigned_apk: Path to an unsigned apk file expected to match it
2027     :param tmp_dir: Path to directory for temporary files
2028     :returns: None if the verification is successful, otherwise a string
2029               describing what went wrong.
2030     """
2031
2032     signed = ZipFile(signed_apk, 'r')
2033     meta_inf_files = ['META-INF/MANIFEST.MF']
2034     for f in signed.namelist():
2035         if apk_sigfile.match(f) \
2036            or f in ['META-INF/fdroidserverid', 'META-INF/buildserverid']:
2037             meta_inf_files.append(f)
2038     if len(meta_inf_files) < 3:
2039         return "Signature files missing from {0}".format(signed_apk)
2040
2041     tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
2042     unsigned = ZipFile(unsigned_apk, 'r')
2043     # only read the signature from the signed APK, everything else from unsigned
2044     with ZipFile(tmp_apk, 'w') as tmp:
2045         for filename in meta_inf_files:
2046             tmp.writestr(signed.getinfo(filename), signed.read(filename))
2047         for info in unsigned.infolist():
2048             if info.filename in meta_inf_files:
2049                 logging.warning('Ignoring ' + info.filename + ' from ' + unsigned_apk)
2050                 continue
2051             if info.filename in tmp.namelist():
2052                 return "duplicate filename found: " + info.filename
2053             tmp.writestr(info, unsigned.read(info.filename))
2054     unsigned.close()
2055     signed.close()
2056
2057     verified = verify_apk_signature(tmp_apk)
2058
2059     if not verified:
2060         logging.info("...NOT verified - {0}".format(tmp_apk))
2061         return compare_apks(signed_apk, tmp_apk, tmp_dir, os.path.dirname(unsigned_apk))
2062
2063     logging.info("...successfully verified")
2064     return None
2065
2066
2067 def verify_apk_signature(apk):
2068     """verify the signature on an APK
2069
2070     Try to use apksigner whenever possible since jarsigner is very
2071     shitty: unsigned APKs pass as "verified"! So this has to turn on
2072     -strict then check for result 4.
2073
2074     """
2075     if set_command_in_config('apksigner'):
2076         return subprocess.call([config['apksigner'], 'verify', apk]) == 0
2077     else:
2078         logging.warning("Using Java's jarsigner, not recommended for verifying APKs! Use apksigner")
2079         return subprocess.call([config['jarsigner'], '-strict', '-verify', apk]) == 4
2080
2081
2082 apk_badchars = re.compile('''[/ :;'"]''')
2083
2084
2085 def compare_apks(apk1, apk2, tmp_dir, log_dir=None):
2086     """Compare two apks
2087
2088     Returns None if the apk content is the same (apart from the signing key),
2089     otherwise a string describing what's different, or what went wrong when
2090     trying to do the comparison.
2091     """
2092
2093     if not log_dir:
2094         log_dir = tmp_dir
2095
2096     absapk1 = os.path.abspath(apk1)
2097     absapk2 = os.path.abspath(apk2)
2098
2099     if set_command_in_config('diffoscope'):
2100         logfilename = os.path.join(log_dir, os.path.basename(absapk1))
2101         htmlfile = logfilename + '.diffoscope.html'
2102         textfile = logfilename + '.diffoscope.txt'
2103         if subprocess.call([config['diffoscope'],
2104                             '--max-report-size', '12345678', '--max-diff-block-lines', '100',
2105                             '--html', htmlfile, '--text', textfile,
2106                             absapk1, absapk2]) != 0:
2107             return("Failed to unpack " + apk1)
2108
2109     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
2110     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
2111     for d in [apk1dir, apk2dir]:
2112         if os.path.exists(d):
2113             shutil.rmtree(d)
2114         os.mkdir(d)
2115         os.mkdir(os.path.join(d, 'jar-xf'))
2116
2117     if subprocess.call(['jar', 'xf',
2118                         os.path.abspath(apk1)],
2119                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
2120         return("Failed to unpack " + apk1)
2121     if subprocess.call(['jar', 'xf',
2122                         os.path.abspath(apk2)],
2123                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
2124         return("Failed to unpack " + apk2)
2125
2126     if set_command_in_config('apktool'):
2127         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
2128                            cwd=apk1dir) != 0:
2129             return("Failed to unpack " + apk1)
2130         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
2131                            cwd=apk2dir) != 0:
2132             return("Failed to unpack " + apk2)
2133
2134     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
2135     lines = p.output.splitlines()
2136     if len(lines) != 1 or 'META-INF' not in lines[0]:
2137         meld = find_command('meld')
2138         if meld is not None:
2139             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
2140         return("Unexpected diff output - " + p.output)
2141
2142     # since everything verifies, delete the comparison to keep cruft down
2143     shutil.rmtree(apk1dir)
2144     shutil.rmtree(apk2dir)
2145
2146     # If we get here, it seems like they're the same!
2147     return None
2148
2149
2150 def set_command_in_config(command):
2151     '''Try to find specified command in the path, if it hasn't been
2152     manually set in config.py.  If found, it is added to the config
2153     dict.  The return value says whether the command is available.
2154
2155     '''
2156     if command in config:
2157         return True
2158     else:
2159         tmp = find_command(command)
2160         if tmp is not None:
2161             config[command] = tmp
2162             return True
2163     return False
2164
2165
2166 def find_command(command):
2167     '''find the full path of a command, or None if it can't be found in the PATH'''
2168
2169     def is_exe(fpath):
2170         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
2171
2172     fpath, fname = os.path.split(command)
2173     if fpath:
2174         if is_exe(command):
2175             return command
2176     else:
2177         for path in os.environ["PATH"].split(os.pathsep):
2178             path = path.strip('"')
2179             exe_file = os.path.join(path, command)
2180             if is_exe(exe_file):
2181                 return exe_file
2182
2183     return None
2184
2185
2186 def genpassword():
2187     '''generate a random password for when generating keys'''
2188     h = hashlib.sha256()
2189     h.update(os.urandom(16))  # salt
2190     h.update(socket.getfqdn().encode('utf-8'))
2191     passwd = base64.b64encode(h.digest()).strip()
2192     return passwd.decode('utf-8')
2193
2194
2195 def genkeystore(localconfig):
2196     '''Generate a new key with random passwords and add it to new keystore'''
2197     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
2198     keystoredir = os.path.dirname(localconfig['keystore'])
2199     if keystoredir is None or keystoredir == '':
2200         keystoredir = os.path.join(os.getcwd(), keystoredir)
2201     if not os.path.exists(keystoredir):
2202         os.makedirs(keystoredir, mode=0o700)
2203
2204     write_password_file("keystorepass", localconfig['keystorepass'])
2205     write_password_file("keypass", localconfig['keypass'])
2206     p = FDroidPopen([config['keytool'], '-genkey',
2207                      '-keystore', localconfig['keystore'],
2208                      '-alias', localconfig['repo_keyalias'],
2209                      '-keyalg', 'RSA', '-keysize', '4096',
2210                      '-sigalg', 'SHA256withRSA',
2211                      '-validity', '10000',
2212                      '-storepass:file', config['keystorepassfile'],
2213                      '-keypass:file', config['keypassfile'],
2214                      '-dname', localconfig['keydname']])
2215     # TODO keypass should be sent via stdin
2216     if p.returncode != 0:
2217         raise BuildException("Failed to generate key", p.output)
2218     os.chmod(localconfig['keystore'], 0o0600)
2219     # now show the lovely key that was just generated
2220     p = FDroidPopen([config['keytool'], '-list', '-v',
2221                      '-keystore', localconfig['keystore'],
2222                      '-alias', localconfig['repo_keyalias'],
2223                      '-storepass:file', config['keystorepassfile']])
2224     logging.info(p.output.strip() + '\n\n')
2225
2226
2227 def write_to_config(thisconfig, key, value=None):
2228     '''write a key/value to the local config.py'''
2229     if value is None:
2230         origkey = key + '_orig'
2231         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
2232     with open('config.py', 'r', encoding='utf8') as f:
2233         data = f.read()
2234     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
2235     repl = '\n' + key + ' = "' + value + '"'
2236     data = re.sub(pattern, repl, data)
2237     # if this key is not in the file, append it
2238     if not re.match('\s*' + key + '\s*=\s*"', data):
2239         data += repl
2240     # make sure the file ends with a carraige return
2241     if not re.match('\n$', data):
2242         data += '\n'
2243     with open('config.py', 'w', encoding='utf8') as f:
2244         f.writelines(data)
2245
2246
2247 def parse_xml(path):
2248     return XMLElementTree.parse(path).getroot()
2249
2250
2251 def string_is_integer(string):
2252     try:
2253         int(string)
2254         return True
2255     except ValueError:
2256         return False
2257
2258
2259 def get_per_app_repos():
2260     '''per-app repos are dirs named with the packageName of a single app'''
2261
2262     # Android packageNames are Java packages, they may contain uppercase or
2263     # lowercase letters ('A' through 'Z'), numbers, and underscores
2264     # ('_'). However, individual package name parts may only start with
2265     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2266     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2267
2268     repos = []
2269     for root, dirs, files in os.walk(os.getcwd()):
2270         for d in dirs:
2271             print('checking', root, 'for', d)
2272             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2273                 # standard parts of an fdroid repo, so never packageNames
2274                 continue
2275             elif p.match(d) \
2276                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2277                 repos.append(d)
2278         break
2279     return repos
2280
2281
2282 def is_repo_file(filename):
2283     '''Whether the file in a repo is a build product to be delivered to users'''
2284     return os.path.isfile(filename) \
2285         and not filename.endswith('.asc') \
2286         and not filename.endswith('.sig') \
2287         and os.path.basename(filename) not in [
2288             'index.jar',
2289             'index_unsigned.jar',
2290             'index.xml',
2291             'index.html',
2292             'index-v1.jar',
2293             'index-v1.json',
2294             'categories.txt',
2295         ]