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