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