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