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