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