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