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