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