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