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