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