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