chiark / gitweb /
init: fix writing into file
[fdroidserver.git] / fdroidserver / common.py
1 # -*- coding: utf-8 -*-
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         cdata = self.repotype() + ' ' + self.remote
535         writeback = True
536         deleterepo = False
537         if os.path.exists(self.local):
538             if os.path.exists(fdpath):
539                 with open(fdpath, 'r') as f:
540                     fsdata = f.read().strip()
541                 if fsdata == cdata:
542                     writeback = False
543                 else:
544                     deleterepo = True
545                     logging.info("Repository details for %s changed - deleting" % (
546                         self.local))
547             else:
548                 deleterepo = True
549                 logging.info("Repository details for %s missing - deleting" % (
550                     self.local))
551         if deleterepo:
552             shutil.rmtree(self.local)
553
554         exc = None
555         if not refresh:
556             self.refreshed = True
557
558         try:
559             self.gotorevisionx(rev)
560         except FDroidException as e:
561             exc = e
562
563         # If necessary, write the .fdroidvcs file.
564         if writeback and not self.clone_failed:
565             with open(fdpath, 'w') as f:
566                 f.write(cdata)
567
568         if exc is not None:
569             raise exc
570
571     # Derived classes need to implement this. It's called once basic checking
572     # has been performend.
573     def gotorevisionx(self, rev):
574         raise VCSException("This VCS type doesn't define gotorevisionx")
575
576     # Initialise and update submodules
577     def initsubmodules(self):
578         raise VCSException('Submodules not supported for this vcs type')
579
580     # Get a list of all known tags
581     def gettags(self):
582         if not self._gettags:
583             raise VCSException('gettags not supported for this vcs type')
584         rtags = []
585         for tag in self._gettags():
586             if re.match('[-A-Za-z0-9_. /]+$', tag):
587                 rtags.append(tag)
588         return rtags
589
590     def latesttags(self, tags, number):
591         """Get the most recent tags in a given list.
592
593         :param tags: a list of tags
594         :param number: the number to return
595         :returns: A list containing the most recent tags in the provided
596                   list, up to the maximum number given.
597         """
598         raise VCSException('latesttags not supported for this vcs type')
599
600     # Get current commit reference (hash, revision, etc)
601     def getref(self):
602         raise VCSException('getref not supported for this vcs type')
603
604     # Returns the srclib (name, path) used in setting up the current
605     # revision, or None.
606     def getsrclib(self):
607         return self.srclib
608
609
610 class vcs_git(vcs):
611
612     def repotype(self):
613         return 'git'
614
615     # If the local directory exists, but is somehow not a git repository, git
616     # will traverse up the directory tree until it finds one that is (i.e.
617     # fdroidserver) and then we'll proceed to destroy it! This is called as
618     # a safety check.
619     def checkrepo(self):
620         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
621         result = p.output.rstrip()
622         if not result.endswith(self.local):
623             raise VCSException('Repository mismatch')
624
625     def gotorevisionx(self, rev):
626         if not os.path.exists(self.local):
627             # Brand new checkout
628             p = FDroidPopen(['git', 'clone', self.remote, self.local])
629             if p.returncode != 0:
630                 self.clone_failed = True
631                 raise VCSException("Git clone failed", p.output)
632             self.checkrepo()
633         else:
634             self.checkrepo()
635             # Discard any working tree changes
636             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
637                              'git', 'reset', '--hard'], cwd=self.local, output=False)
638             if p.returncode != 0:
639                 raise VCSException("Git reset failed", p.output)
640             # Remove untracked files now, in case they're tracked in the target
641             # revision (it happens!)
642             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
643                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
644             if p.returncode != 0:
645                 raise VCSException("Git clean failed", p.output)
646             if not self.refreshed:
647                 # Get latest commits and tags from remote
648                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
649                 if p.returncode != 0:
650                     raise VCSException("Git fetch failed", p.output)
651                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
652                 if p.returncode != 0:
653                     raise VCSException("Git fetch failed", p.output)
654                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
655                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
656                 if p.returncode != 0:
657                     lines = p.output.splitlines()
658                     if 'Multiple remote HEAD branches' not in lines[0]:
659                         raise VCSException("Git remote set-head failed", p.output)
660                     branch = lines[1].split(' ')[-1]
661                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
662                     if p2.returncode != 0:
663                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
664                 self.refreshed = True
665         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
666         # a github repo. Most of the time this is the same as origin/master.
667         rev = rev or 'origin/HEAD'
668         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
669         if p.returncode != 0:
670             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
671         # Get rid of any uncontrolled files left behind
672         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
673         if p.returncode != 0:
674             raise VCSException("Git clean failed", p.output)
675
676     def initsubmodules(self):
677         self.checkrepo()
678         submfile = os.path.join(self.local, '.gitmodules')
679         if not os.path.isfile(submfile):
680             raise VCSException("No git submodules available")
681
682         # fix submodules not accessible without an account and public key auth
683         with open(submfile, 'r') as f:
684             lines = f.readlines()
685         with open(submfile, 'w') as f:
686             for line in lines:
687                 if 'git@github.com' in line:
688                     line = line.replace('git@github.com:', 'https://github.com/')
689                 if 'git@gitlab.com' in line:
690                     line = line.replace('git@gitlab.com:', 'https://gitlab.com/')
691                 f.write(line)
692
693         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
694         if p.returncode != 0:
695             raise VCSException("Git submodule sync failed", p.output)
696         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
697         if p.returncode != 0:
698             raise VCSException("Git submodule update failed", p.output)
699
700     def _gettags(self):
701         self.checkrepo()
702         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
703         return p.output.splitlines()
704
705     def latesttags(self, tags, number):
706         self.checkrepo()
707         tl = []
708         for tag in tags:
709             p = FDroidPopen(
710                 ['git', 'show', '--format=format:%ct', '-s', tag],
711                 cwd=self.local, output=False)
712             # Timestamp is on the last line. For a normal tag, it's the only
713             # line, but for annotated tags, the rest of the info precedes it.
714             ts = int(p.output.splitlines()[-1])
715             tl.append((ts, tag))
716         latest = []
717         for _, t in sorted(tl)[-number:]:
718             latest.append(t)
719         return latest
720
721
722 class vcs_gitsvn(vcs):
723
724     def repotype(self):
725         return 'git-svn'
726
727     # If the local directory exists, but is somehow not a git repository, git
728     # will traverse up the directory tree until it finds one that is (i.e.
729     # fdroidserver) and then we'll proceed to destory it! This is called as
730     # a safety check.
731     def checkrepo(self):
732         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
733         result = p.output.rstrip()
734         if not result.endswith(self.local):
735             raise VCSException('Repository mismatch')
736
737     def gotorevisionx(self, rev):
738         if not os.path.exists(self.local):
739             # Brand new checkout
740             gitsvn_args = ['git', 'svn', 'clone']
741             if ';' in self.remote:
742                 remote_split = self.remote.split(';')
743                 for i in remote_split[1:]:
744                     if i.startswith('trunk='):
745                         gitsvn_args.extend(['-T', i[6:]])
746                     elif i.startswith('tags='):
747                         gitsvn_args.extend(['-t', i[5:]])
748                     elif i.startswith('branches='):
749                         gitsvn_args.extend(['-b', i[9:]])
750                 gitsvn_args.extend([remote_split[0], self.local])
751                 p = FDroidPopen(gitsvn_args, output=False)
752                 if p.returncode != 0:
753                     self.clone_failed = True
754                     raise VCSException("Git svn clone failed", p.output)
755             else:
756                 gitsvn_args.extend([self.remote, self.local])
757                 p = FDroidPopen(gitsvn_args, output=False)
758                 if p.returncode != 0:
759                     self.clone_failed = True
760                     raise VCSException("Git svn clone failed", p.output)
761             self.checkrepo()
762         else:
763             self.checkrepo()
764             # Discard any working tree changes
765             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
766             if p.returncode != 0:
767                 raise VCSException("Git reset failed", p.output)
768             # Remove untracked files now, in case they're tracked in the target
769             # revision (it happens!)
770             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
771             if p.returncode != 0:
772                 raise VCSException("Git clean failed", p.output)
773             if not self.refreshed:
774                 # Get new commits, branches and tags from repo
775                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
776                 if p.returncode != 0:
777                     raise VCSException("Git svn fetch failed")
778                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
779                 if p.returncode != 0:
780                     raise VCSException("Git svn rebase failed", p.output)
781                 self.refreshed = True
782
783         rev = rev or 'master'
784         if rev:
785             nospaces_rev = rev.replace(' ', '%20')
786             # Try finding a svn tag
787             for treeish in ['origin/', '']:
788                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
789                 if p.returncode == 0:
790                     break
791             if p.returncode != 0:
792                 # No tag found, normal svn rev translation
793                 # Translate svn rev into git format
794                 rev_split = rev.split('/')
795
796                 p = None
797                 for treeish in ['origin/', '']:
798                     if len(rev_split) > 1:
799                         treeish += rev_split[0]
800                         svn_rev = rev_split[1]
801
802                     else:
803                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
804                         treeish += 'master'
805                         svn_rev = rev
806
807                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
808
809                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
810                     git_rev = p.output.rstrip()
811
812                     if p.returncode == 0 and git_rev:
813                         break
814
815                 if p.returncode != 0 or not git_rev:
816                     # Try a plain git checkout as a last resort
817                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
818                     if p.returncode != 0:
819                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
820                 else:
821                     # Check out the git rev equivalent to the svn rev
822                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
823                     if p.returncode != 0:
824                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
825
826         # Get rid of any uncontrolled files left behind
827         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
828         if p.returncode != 0:
829             raise VCSException("Git clean failed", p.output)
830
831     def _gettags(self):
832         self.checkrepo()
833         for treeish in ['origin/', '']:
834             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
835             if os.path.isdir(d):
836                 return os.listdir(d)
837
838     def getref(self):
839         self.checkrepo()
840         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
841         if p.returncode != 0:
842             return None
843         return p.output.strip()
844
845
846 class vcs_hg(vcs):
847
848     def repotype(self):
849         return 'hg'
850
851     def gotorevisionx(self, rev):
852         if not os.path.exists(self.local):
853             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
854             if p.returncode != 0:
855                 self.clone_failed = True
856                 raise VCSException("Hg clone failed", p.output)
857         else:
858             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
859             if p.returncode != 0:
860                 raise VCSException("Hg status failed", p.output)
861             for line in p.output.splitlines():
862                 if not line.startswith('? '):
863                     raise VCSException("Unexpected output from hg status -uS: " + line)
864                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
865             if not self.refreshed:
866                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
867                 if p.returncode != 0:
868                     raise VCSException("Hg pull failed", p.output)
869                 self.refreshed = True
870
871         rev = rev or 'default'
872         if not rev:
873             return
874         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
875         if p.returncode != 0:
876             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
877         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
878         # Also delete untracked files, we have to enable purge extension for that:
879         if "'purge' is provided by the following extension" in p.output:
880             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
881                 myfile.write("\n[extensions]\nhgext.purge=\n")
882             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
883             if p.returncode != 0:
884                 raise VCSException("HG purge failed", p.output)
885         elif p.returncode != 0:
886             raise VCSException("HG purge failed", p.output)
887
888     def _gettags(self):
889         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
890         return p.output.splitlines()[1:]
891
892
893 class vcs_bzr(vcs):
894
895     def repotype(self):
896         return 'bzr'
897
898     def gotorevisionx(self, rev):
899         if not os.path.exists(self.local):
900             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
901             if p.returncode != 0:
902                 self.clone_failed = True
903                 raise VCSException("Bzr branch failed", p.output)
904         else:
905             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
906             if p.returncode != 0:
907                 raise VCSException("Bzr revert failed", p.output)
908             if not self.refreshed:
909                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
910                 if p.returncode != 0:
911                     raise VCSException("Bzr update failed", p.output)
912                 self.refreshed = True
913
914         revargs = list(['-r', rev] if rev else [])
915         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
916         if p.returncode != 0:
917             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
918
919     def _gettags(self):
920         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
921         return [tag.split('   ')[0].strip() for tag in
922                 p.output.splitlines()]
923
924
925 def unescape_string(string):
926     if len(string) < 2:
927         return string
928     if string[0] == '"' and string[-1] == '"':
929         return string[1:-1]
930
931     return string.replace("\\'", "'")
932
933
934 def retrieve_string(app_dir, string, xmlfiles=None):
935
936     if not string.startswith('@string/'):
937         return unescape_string(string)
938
939     if xmlfiles is None:
940         xmlfiles = []
941         for res_dir in [
942             os.path.join(app_dir, 'res'),
943             os.path.join(app_dir, 'src', 'main', 'res'),
944         ]:
945             for r, d, f in os.walk(res_dir):
946                 if os.path.basename(r) == 'values':
947                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
948
949     name = string[len('@string/'):]
950
951     def element_content(element):
952         if element.text is None:
953             return ""
954         s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
955         return s.decode('utf-8').strip()
956
957     for path in xmlfiles:
958         if not os.path.isfile(path):
959             continue
960         xml = parse_xml(path)
961         element = xml.find('string[@name="' + name + '"]')
962         if element is not None:
963             content = element_content(element)
964             return retrieve_string(app_dir, content, xmlfiles)
965
966     return ''
967
968
969 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
970     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
971
972
973 # Return list of existing files that will be used to find the highest vercode
974 def manifest_paths(app_dir, flavours):
975
976     possible_manifests = \
977         [os.path.join(app_dir, 'AndroidManifest.xml'),
978          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
979          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
980          os.path.join(app_dir, 'build.gradle')]
981
982     for flavour in flavours:
983         if flavour == 'yes':
984             continue
985         possible_manifests.append(
986             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
987
988     return [path for path in possible_manifests if os.path.isfile(path)]
989
990
991 # Retrieve the package name. Returns the name, or None if not found.
992 def fetch_real_name(app_dir, flavours):
993     for path in manifest_paths(app_dir, flavours):
994         if not has_extension(path, 'xml') or not os.path.isfile(path):
995             continue
996         logging.debug("fetch_real_name: Checking manifest at " + path)
997         xml = parse_xml(path)
998         app = xml.find('application')
999         if app is None:
1000             continue
1001         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
1002             continue
1003         label = app.attrib["{http://schemas.android.com/apk/res/android}label"]
1004         result = retrieve_string_singleline(app_dir, label)
1005         if result:
1006             result = result.strip()
1007         return result
1008     return None
1009
1010
1011 def get_library_references(root_dir):
1012     libraries = []
1013     proppath = os.path.join(root_dir, 'project.properties')
1014     if not os.path.isfile(proppath):
1015         return libraries
1016     with open(proppath, 'r') as f:
1017         for line in f:
1018             if not line.startswith('android.library.reference.'):
1019                 continue
1020             path = line.split('=')[1].strip()
1021             relpath = os.path.join(root_dir, path)
1022             if not os.path.isdir(relpath):
1023                 continue
1024             logging.debug("Found subproject at %s" % path)
1025             libraries.append(path)
1026     return libraries
1027
1028
1029 def ant_subprojects(root_dir):
1030     subprojects = get_library_references(root_dir)
1031     for subpath in subprojects:
1032         subrelpath = os.path.join(root_dir, subpath)
1033         for p in get_library_references(subrelpath):
1034             relp = os.path.normpath(os.path.join(subpath, p))
1035             if relp not in subprojects:
1036                 subprojects.insert(0, relp)
1037     return subprojects
1038
1039
1040 def remove_debuggable_flags(root_dir):
1041     # Remove forced debuggable flags
1042     logging.debug("Removing debuggable flags from %s" % root_dir)
1043     for root, dirs, files in os.walk(root_dir):
1044         if 'AndroidManifest.xml' in files:
1045             regsub_file(r'android:debuggable="[^"]*"',
1046                         '',
1047                         os.path.join(root, 'AndroidManifest.xml'))
1048
1049
1050 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1051 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1052 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1053
1054
1055 def app_matches_packagename(app, package):
1056     if not package:
1057         return False
1058     appid = app.UpdateCheckName or app.id
1059     if appid is None or appid == "Ignore":
1060         return True
1061     return appid == package
1062
1063
1064 # Extract some information from the AndroidManifest.xml at the given path.
1065 # Returns (version, vercode, package), any or all of which might be None.
1066 # All values returned are strings.
1067 def parse_androidmanifests(paths, app):
1068
1069     ignoreversions = app.UpdateCheckIgnore
1070     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1071
1072     if not paths:
1073         return (None, None, None)
1074
1075     max_version = None
1076     max_vercode = None
1077     max_package = None
1078
1079     for path in paths:
1080
1081         if not os.path.isfile(path):
1082             continue
1083
1084         logging.debug("Parsing manifest at {0}".format(path))
1085         gradle = has_extension(path, 'gradle')
1086         version = None
1087         vercode = None
1088         package = None
1089
1090         if gradle:
1091             with open(path, 'r') as f:
1092                 for line in f:
1093                     if gradle_comment.match(line):
1094                         continue
1095                     # Grab first occurence of each to avoid running into
1096                     # alternative flavours and builds.
1097                     if not package:
1098                         matches = psearch_g(line)
1099                         if matches:
1100                             s = matches.group(2)
1101                             if app_matches_packagename(app, s):
1102                                 package = s
1103                     if not version:
1104                         matches = vnsearch_g(line)
1105                         if matches:
1106                             version = matches.group(2)
1107                     if not vercode:
1108                         matches = vcsearch_g(line)
1109                         if matches:
1110                             vercode = matches.group(1)
1111         else:
1112             try:
1113                 xml = parse_xml(path)
1114                 if "package" in xml.attrib:
1115                     s = xml.attrib["package"]
1116                     if app_matches_packagename(app, s):
1117                         package = s
1118                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1119                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"]
1120                     base_dir = os.path.dirname(path)
1121                     version = retrieve_string_singleline(base_dir, version)
1122                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1123                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"]
1124                     if string_is_integer(a):
1125                         vercode = a
1126             except Exception:
1127                 logging.warning("Problem with xml at {0}".format(path))
1128
1129         # Remember package name, may be defined separately from version+vercode
1130         if package is None:
1131             package = max_package
1132
1133         logging.debug("..got package={0}, version={1}, vercode={2}"
1134                       .format(package, version, vercode))
1135
1136         # Always grab the package name and version name in case they are not
1137         # together with the highest version code
1138         if max_package is None and package is not None:
1139             max_package = package
1140         if max_version is None and version is not None:
1141             max_version = version
1142
1143         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1144             if not ignoresearch or not ignoresearch(version):
1145                 if version is not None:
1146                     max_version = version
1147                 if vercode is not None:
1148                     max_vercode = vercode
1149                 if package is not None:
1150                     max_package = package
1151             else:
1152                 max_version = "Ignore"
1153
1154     if max_version is None:
1155         max_version = "Unknown"
1156
1157     if max_package and not is_valid_package_name(max_package):
1158         raise FDroidException("Invalid package name {0}".format(max_package))
1159
1160     return (max_version, max_vercode, max_package)
1161
1162
1163 def is_valid_package_name(name):
1164     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1165
1166
1167 class FDroidException(Exception):
1168
1169     def __init__(self, value, detail=None):
1170         self.value = value
1171         self.detail = detail
1172
1173     def shortened_detail(self):
1174         if len(self.detail) < 16000:
1175             return self.detail
1176         return '[...]\n' + self.detail[-16000:]
1177
1178     def get_wikitext(self):
1179         ret = repr(self.value) + "\n"
1180         if self.detail:
1181             ret += "=detail=\n"
1182             ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1183         return ret
1184
1185     def __str__(self):
1186         ret = self.value
1187         if self.detail:
1188             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1189         return ret
1190
1191
1192 class VCSException(FDroidException):
1193     pass
1194
1195
1196 class BuildException(FDroidException):
1197     pass
1198
1199
1200 # Get the specified source library.
1201 # Returns the path to it. Normally this is the path to be used when referencing
1202 # it, which may be a subdirectory of the actual project. If you want the base
1203 # directory of the project, pass 'basepath=True'.
1204 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1205               raw=False, prepare=True, preponly=False, refresh=True):
1206
1207     number = None
1208     subdir = None
1209     if raw:
1210         name = spec
1211         ref = None
1212     else:
1213         name, ref = spec.split('@')
1214         if ':' in name:
1215             number, name = name.split(':', 1)
1216         if '/' in name:
1217             name, subdir = name.split('/', 1)
1218
1219     if name not in fdroidserver.metadata.srclibs:
1220         raise VCSException('srclib ' + name + ' not found.')
1221
1222     srclib = fdroidserver.metadata.srclibs[name]
1223
1224     sdir = os.path.join(srclib_dir, name)
1225
1226     if not preponly:
1227         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1228         vcs.srclib = (name, number, sdir)
1229         if ref:
1230             vcs.gotorevision(ref, refresh)
1231
1232         if raw:
1233             return vcs
1234
1235     libdir = None
1236     if subdir:
1237         libdir = os.path.join(sdir, subdir)
1238     elif srclib["Subdir"]:
1239         for subdir in srclib["Subdir"]:
1240             libdir_candidate = os.path.join(sdir, subdir)
1241             if os.path.exists(libdir_candidate):
1242                 libdir = libdir_candidate
1243                 break
1244
1245     if libdir is None:
1246         libdir = sdir
1247
1248     remove_signing_keys(sdir)
1249     remove_debuggable_flags(sdir)
1250
1251     if prepare:
1252
1253         if srclib["Prepare"]:
1254             cmd = replace_config_vars(srclib["Prepare"], None)
1255
1256             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1257             if p.returncode != 0:
1258                 raise BuildException("Error running prepare command for srclib %s"
1259                                      % name, p.output)
1260
1261     if basepath:
1262         libdir = sdir
1263
1264     return (name, number, libdir)
1265
1266 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1267
1268
1269 # Prepare the source code for a particular build
1270 #  'vcs'         - the appropriate vcs object for the application
1271 #  'app'         - the application details from the metadata
1272 #  'build'       - the build details from the metadata
1273 #  'build_dir'   - the path to the build directory, usually
1274 #                   'build/app.id'
1275 #  'srclib_dir'  - the path to the source libraries directory, usually
1276 #                   'build/srclib'
1277 #  'extlib_dir'  - the path to the external libraries directory, usually
1278 #                   'build/extlib'
1279 # Returns the (root, srclibpaths) where:
1280 #   'root' is the root directory, which may be the same as 'build_dir' or may
1281 #          be a subdirectory of it.
1282 #   'srclibpaths' is information on the srclibs being used
1283 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1284
1285     # Optionally, the actual app source can be in a subdirectory
1286     if build.subdir:
1287         root_dir = os.path.join(build_dir, build.subdir)
1288     else:
1289         root_dir = build_dir
1290
1291     # Get a working copy of the right revision
1292     logging.info("Getting source for revision " + build.commit)
1293     vcs.gotorevision(build.commit, refresh)
1294
1295     # Initialise submodules if required
1296     if build.submodules:
1297         logging.info("Initialising submodules")
1298         vcs.initsubmodules()
1299
1300     # Check that a subdir (if we're using one) exists. This has to happen
1301     # after the checkout, since it might not exist elsewhere
1302     if not os.path.exists(root_dir):
1303         raise BuildException('Missing subdir ' + root_dir)
1304
1305     # Run an init command if one is required
1306     if build.init:
1307         cmd = replace_config_vars(build.init, build)
1308         logging.info("Running 'init' commands in %s" % root_dir)
1309
1310         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1311         if p.returncode != 0:
1312             raise BuildException("Error running init command for %s:%s" %
1313                                  (app.id, build.version), p.output)
1314
1315     # Apply patches if any
1316     if build.patch:
1317         logging.info("Applying patches")
1318         for patch in build.patch:
1319             patch = patch.strip()
1320             logging.info("Applying " + patch)
1321             patch_path = os.path.join('metadata', app.id, patch)
1322             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1323             if p.returncode != 0:
1324                 raise BuildException("Failed to apply patch %s" % patch_path)
1325
1326     # Get required source libraries
1327     srclibpaths = []
1328     if build.srclibs:
1329         logging.info("Collecting source libraries")
1330         for lib in build.srclibs:
1331             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1332
1333     for name, number, libpath in srclibpaths:
1334         place_srclib(root_dir, int(number) if number else None, libpath)
1335
1336     basesrclib = vcs.getsrclib()
1337     # If one was used for the main source, add that too.
1338     if basesrclib:
1339         srclibpaths.append(basesrclib)
1340
1341     # Update the local.properties file
1342     localprops = [os.path.join(build_dir, 'local.properties')]
1343     if build.subdir:
1344         parts = build.subdir.split(os.sep)
1345         cur = build_dir
1346         for d in parts:
1347             cur = os.path.join(cur, d)
1348             localprops += [os.path.join(cur, 'local.properties')]
1349     for path in localprops:
1350         props = ""
1351         if os.path.isfile(path):
1352             logging.info("Updating local.properties file at %s" % path)
1353             with open(path, 'r') as f:
1354                 props += f.read()
1355             props += '\n'
1356         else:
1357             logging.info("Creating local.properties file at %s" % path)
1358         # Fix old-fashioned 'sdk-location' by copying
1359         # from sdk.dir, if necessary
1360         if build.oldsdkloc:
1361             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1362                               re.S | re.M).group(1)
1363             props += "sdk-location=%s\n" % sdkloc
1364         else:
1365             props += "sdk.dir=%s\n" % config['sdk_path']
1366             props += "sdk-location=%s\n" % config['sdk_path']
1367         ndk_path = build.ndk_path()
1368         if ndk_path:
1369             # Add ndk location
1370             props += "ndk.dir=%s\n" % ndk_path
1371             props += "ndk-location=%s\n" % ndk_path
1372         # Add java.encoding if necessary
1373         if build.encoding:
1374             props += "java.encoding=%s\n" % build.encoding
1375         with open(path, 'w') as f:
1376             f.write(props)
1377
1378     flavours = []
1379     if build.build_method() == 'gradle':
1380         flavours = build.gradle
1381
1382         if build.target:
1383             n = build.target.split('-')[1]
1384             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1385                         r'compileSdkVersion %s' % n,
1386                         os.path.join(root_dir, 'build.gradle'))
1387
1388     # Remove forced debuggable flags
1389     remove_debuggable_flags(root_dir)
1390
1391     # Insert version code and number into the manifest if necessary
1392     if build.forceversion:
1393         logging.info("Changing the version name")
1394         for path in manifest_paths(root_dir, flavours):
1395             if not os.path.isfile(path):
1396                 continue
1397             if has_extension(path, 'xml'):
1398                 regsub_file(r'android:versionName="[^"]*"',
1399                             r'android:versionName="%s"' % build.version,
1400                             path)
1401             elif has_extension(path, 'gradle'):
1402                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1403                             r"""\1versionName '%s'""" % build.version,
1404                             path)
1405
1406     if build.forcevercode:
1407         logging.info("Changing the version code")
1408         for path in manifest_paths(root_dir, flavours):
1409             if not os.path.isfile(path):
1410                 continue
1411             if has_extension(path, 'xml'):
1412                 regsub_file(r'android:versionCode="[^"]*"',
1413                             r'android:versionCode="%s"' % build.vercode,
1414                             path)
1415             elif has_extension(path, 'gradle'):
1416                 regsub_file(r'versionCode[ =]+[0-9]+',
1417                             r'versionCode %s' % build.vercode,
1418                             path)
1419
1420     # Delete unwanted files
1421     if build.rm:
1422         logging.info("Removing specified files")
1423         for part in getpaths(build_dir, build.rm):
1424             dest = os.path.join(build_dir, part)
1425             logging.info("Removing {0}".format(part))
1426             if os.path.lexists(dest):
1427                 if os.path.islink(dest):
1428                     FDroidPopen(['unlink', dest], output=False)
1429                 else:
1430                     FDroidPopen(['rm', '-rf', dest], output=False)
1431             else:
1432                 logging.info("...but it didn't exist")
1433
1434     remove_signing_keys(build_dir)
1435
1436     # Add required external libraries
1437     if build.extlibs:
1438         logging.info("Collecting prebuilt libraries")
1439         libsdir = os.path.join(root_dir, 'libs')
1440         if not os.path.exists(libsdir):
1441             os.mkdir(libsdir)
1442         for lib in build.extlibs:
1443             lib = lib.strip()
1444             logging.info("...installing extlib {0}".format(lib))
1445             libf = os.path.basename(lib)
1446             libsrc = os.path.join(extlib_dir, lib)
1447             if not os.path.exists(libsrc):
1448                 raise BuildException("Missing extlib file {0}".format(libsrc))
1449             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1450
1451     # Run a pre-build command if one is required
1452     if build.prebuild:
1453         logging.info("Running 'prebuild' commands in %s" % root_dir)
1454
1455         cmd = replace_config_vars(build.prebuild, build)
1456
1457         # Substitute source library paths into prebuild commands
1458         for name, number, libpath in srclibpaths:
1459             libpath = os.path.relpath(libpath, root_dir)
1460             cmd = cmd.replace('$$' + name + '$$', libpath)
1461
1462         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1463         if p.returncode != 0:
1464             raise BuildException("Error running prebuild command for %s:%s" %
1465                                  (app.id, build.version), p.output)
1466
1467     # Generate (or update) the ant build file, build.xml...
1468     if build.build_method() == 'ant' and build.update != ['no']:
1469         parms = ['android', 'update', 'lib-project']
1470         lparms = ['android', 'update', 'project']
1471
1472         if build.target:
1473             parms += ['-t', build.target]
1474             lparms += ['-t', build.target]
1475         if build.update:
1476             update_dirs = build.update
1477         else:
1478             update_dirs = ant_subprojects(root_dir) + ['.']
1479
1480         for d in update_dirs:
1481             subdir = os.path.join(root_dir, d)
1482             if d == '.':
1483                 logging.debug("Updating main project")
1484                 cmd = parms + ['-p', d]
1485             else:
1486                 logging.debug("Updating subproject %s" % d)
1487                 cmd = lparms + ['-p', d]
1488             p = SdkToolsPopen(cmd, cwd=root_dir)
1489             # Check to see whether an error was returned without a proper exit
1490             # code (this is the case for the 'no target set or target invalid'
1491             # error)
1492             if p.returncode != 0 or p.output.startswith("Error: "):
1493                 raise BuildException("Failed to update project at %s" % d, p.output)
1494             # Clean update dirs via ant
1495             if d != '.':
1496                 logging.info("Cleaning subproject %s" % d)
1497                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1498
1499     return (root_dir, srclibpaths)
1500
1501
1502 # Extend via globbing the paths from a field and return them as a map from
1503 # original path to resulting paths
1504 def getpaths_map(build_dir, globpaths):
1505     paths = dict()
1506     for p in globpaths:
1507         p = p.strip()
1508         full_path = os.path.join(build_dir, p)
1509         full_path = os.path.normpath(full_path)
1510         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1511         if not paths[p]:
1512             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1513     return paths
1514
1515
1516 # Extend via globbing the paths from a field and return them as a set
1517 def getpaths(build_dir, globpaths):
1518     paths_map = getpaths_map(build_dir, globpaths)
1519     paths = set()
1520     for k, v in paths_map.items():
1521         for p in v:
1522             paths.add(p)
1523     return paths
1524
1525
1526 def natural_key(s):
1527     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1528
1529
1530 class KnownApks:
1531
1532     def __init__(self):
1533         self.path = os.path.join('stats', 'known_apks.txt')
1534         self.apks = {}
1535         if os.path.isfile(self.path):
1536             with open(self.path, 'r') as f:
1537                 for line in f:
1538                     t = line.rstrip().split(' ')
1539                     if len(t) == 2:
1540                         self.apks[t[0]] = (t[1], None)
1541                     else:
1542                         self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1543         self.changed = False
1544
1545     def writeifchanged(self):
1546         if not self.changed:
1547             return
1548
1549         if not os.path.exists('stats'):
1550             os.mkdir('stats')
1551
1552         lst = []
1553         for apk, app in self.apks.items():
1554             appid, added = app
1555             line = apk + ' ' + appid
1556             if added:
1557                 line += ' ' + time.strftime('%Y-%m-%d', added)
1558             lst.append(line)
1559
1560         with open(self.path, 'w') as f:
1561             for line in sorted(lst, key=natural_key):
1562                 f.write(line + '\n')
1563
1564     # Record an apk (if it's new, otherwise does nothing)
1565     # Returns the date it was added.
1566     def recordapk(self, apk, app):
1567         if apk not in self.apks:
1568             self.apks[apk] = (app, time.gmtime(time.time()))
1569             self.changed = True
1570         _, added = self.apks[apk]
1571         return added
1572
1573     # Look up information - given the 'apkname', returns (app id, date added/None).
1574     # Or returns None for an unknown apk.
1575     def getapp(self, apkname):
1576         if apkname in self.apks:
1577             return self.apks[apkname]
1578         return None
1579
1580     # Get the most recent 'num' apps added to the repo, as a list of package ids
1581     # with the most recent first.
1582     def getlatest(self, num):
1583         apps = {}
1584         for apk, app in self.apks.items():
1585             appid, added = app
1586             if added:
1587                 if appid in apps:
1588                     if apps[appid] > added:
1589                         apps[appid] = added
1590                 else:
1591                     apps[appid] = added
1592         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1593         lst = [app for app, _ in sortedapps]
1594         lst.reverse()
1595         return lst
1596
1597
1598 def isApkDebuggable(apkfile, config):
1599     """Returns True if the given apk file is debuggable
1600
1601     :param apkfile: full path to the apk to check"""
1602
1603     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1604                       output=False)
1605     if p.returncode != 0:
1606         logging.critical("Failed to get apk manifest information")
1607         sys.exit(1)
1608     for line in p.output.splitlines():
1609         if 'android:debuggable' in line and not line.endswith('0x0'):
1610             return True
1611     return False
1612
1613
1614 class PopenResult:
1615     returncode = None
1616     output = ''
1617
1618
1619 def SdkToolsPopen(commands, cwd=None, output=True):
1620     cmd = commands[0]
1621     if cmd not in config:
1622         config[cmd] = find_sdk_tools_cmd(commands[0])
1623     abscmd = config[cmd]
1624     if abscmd is None:
1625         logging.critical("Could not find '%s' on your system" % cmd)
1626         sys.exit(1)
1627     return FDroidPopen([abscmd] + commands[1:],
1628                        cwd=cwd, output=output)
1629
1630
1631 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1632     """
1633     Run a command and capture the possibly huge output.
1634
1635     :param commands: command and argument list like in subprocess.Popen
1636     :param cwd: optionally specifies a working directory
1637     :returns: A PopenResult.
1638     """
1639
1640     global env
1641
1642     if cwd:
1643         cwd = os.path.normpath(cwd)
1644         logging.debug("Directory: %s" % cwd)
1645     logging.debug("> %s" % ' '.join(commands))
1646
1647     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1648     result = PopenResult()
1649     p = None
1650     try:
1651         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1652                              stdout=subprocess.PIPE, stderr=stderr_param)
1653     except OSError as e:
1654         raise BuildException("OSError while trying to execute " +
1655                              ' '.join(commands) + ': ' + str(e))
1656
1657     if not stderr_to_stdout and options.verbose:
1658         stderr_queue = Queue()
1659         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1660
1661         while not stderr_reader.eof():
1662             while not stderr_queue.empty():
1663                 line = stderr_queue.get()
1664                 sys.stderr.write(line)
1665                 sys.stderr.flush()
1666
1667             time.sleep(0.1)
1668
1669     stdout_queue = Queue()
1670     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1671
1672     # Check the queue for output (until there is no more to get)
1673     while not stdout_reader.eof():
1674         while not stdout_queue.empty():
1675             line = stdout_queue.get()
1676             if output and options.verbose:
1677                 # Output directly to console
1678                 sys.stderr.buffer.write(line)
1679                 sys.stderr.flush()
1680             result.output += line.decode('utf-8')
1681
1682         time.sleep(0.1)
1683
1684     result.returncode = p.wait()
1685     return result
1686
1687
1688 gradle_comment = re.compile(r'[ ]*//')
1689 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1690 gradle_line_matches = [
1691     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1692     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1693     re.compile(r'.*\.readLine\(.*'),
1694 ]
1695
1696
1697 def remove_signing_keys(build_dir):
1698     for root, dirs, files in os.walk(build_dir):
1699         if 'build.gradle' in files:
1700             path = os.path.join(root, 'build.gradle')
1701
1702             with open(path, "r") as o:
1703                 lines = o.readlines()
1704
1705             changed = False
1706
1707             opened = 0
1708             i = 0
1709             with open(path, "w") as o:
1710                 while i < len(lines):
1711                     line = lines[i]
1712                     i += 1
1713                     while line.endswith('\\\n'):
1714                         line = line.rstrip('\\\n') + lines[i]
1715                         i += 1
1716
1717                     if gradle_comment.match(line):
1718                         o.write(line)
1719                         continue
1720
1721                     if opened > 0:
1722                         opened += line.count('{')
1723                         opened -= line.count('}')
1724                         continue
1725
1726                     if gradle_signing_configs.match(line):
1727                         changed = True
1728                         opened += 1
1729                         continue
1730
1731                     if any(s.match(line) for s in gradle_line_matches):
1732                         changed = True
1733                         continue
1734
1735                     if opened == 0:
1736                         o.write(line)
1737
1738             if changed:
1739                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1740
1741         for propfile in [
1742                 'project.properties',
1743                 'build.properties',
1744                 'default.properties',
1745                 'ant.properties', ]:
1746             if propfile in files:
1747                 path = os.path.join(root, propfile)
1748
1749                 with open(path, "r") as o:
1750                     lines = o.readlines()
1751
1752                 changed = False
1753
1754                 with open(path, "w") as o:
1755                     for line in lines:
1756                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1757                             changed = True
1758                             continue
1759
1760                         o.write(line)
1761
1762                 if changed:
1763                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1764
1765
1766 def reset_env_path():
1767     global env, orig_path
1768     env['PATH'] = orig_path
1769
1770
1771 def add_to_env_path(path):
1772     global env
1773     paths = env['PATH'].split(os.pathsep)
1774     if path in paths:
1775         return
1776     paths.append(path)
1777     env['PATH'] = os.pathsep.join(paths)
1778
1779
1780 def replace_config_vars(cmd, build):
1781     global env
1782     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1783     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1784     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1785     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1786     if build is not None:
1787         cmd = cmd.replace('$$COMMIT$$', build.commit)
1788         cmd = cmd.replace('$$VERSION$$', build.version)
1789         cmd = cmd.replace('$$VERCODE$$', build.vercode)
1790     return cmd
1791
1792
1793 def place_srclib(root_dir, number, libpath):
1794     if not number:
1795         return
1796     relpath = os.path.relpath(libpath, root_dir)
1797     proppath = os.path.join(root_dir, 'project.properties')
1798
1799     lines = []
1800     if os.path.isfile(proppath):
1801         with open(proppath, "r") as o:
1802             lines = o.readlines()
1803
1804     with open(proppath, "w") as o:
1805         placed = False
1806         for line in lines:
1807             if line.startswith('android.library.reference.%d=' % number):
1808                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1809                 placed = True
1810             else:
1811                 o.write(line)
1812         if not placed:
1813             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1814
1815 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1816
1817
1818 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1819     """Verify that two apks are the same
1820
1821     One of the inputs is signed, the other is unsigned. The signature metadata
1822     is transferred from the signed to the unsigned apk, and then jarsigner is
1823     used to verify that the signature from the signed apk is also varlid for
1824     the unsigned one.
1825     :param signed_apk: Path to a signed apk file
1826     :param unsigned_apk: Path to an unsigned apk file expected to match it
1827     :param tmp_dir: Path to directory for temporary files
1828     :returns: None if the verification is successful, otherwise a string
1829               describing what went wrong.
1830     """
1831     with ZipFile(signed_apk) as signed_apk_as_zip:
1832         meta_inf_files = ['META-INF/MANIFEST.MF']
1833         for f in signed_apk_as_zip.namelist():
1834             if apk_sigfile.match(f):
1835                 meta_inf_files.append(f)
1836         if len(meta_inf_files) < 3:
1837             return "Signature files missing from {0}".format(signed_apk)
1838         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1839     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1840         for meta_inf_file in meta_inf_files:
1841             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1842
1843     if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1844         logging.info("...NOT verified - {0}".format(signed_apk))
1845         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1846     logging.info("...successfully verified")
1847     return None
1848
1849 apk_badchars = re.compile('''[/ :;'"]''')
1850
1851
1852 def compare_apks(apk1, apk2, tmp_dir):
1853     """Compare two apks
1854
1855     Returns None if the apk content is the same (apart from the signing key),
1856     otherwise a string describing what's different, or what went wrong when
1857     trying to do the comparison.
1858     """
1859
1860     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
1861     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
1862     for d in [apk1dir, apk2dir]:
1863         if os.path.exists(d):
1864             shutil.rmtree(d)
1865         os.mkdir(d)
1866         os.mkdir(os.path.join(d, 'jar-xf'))
1867
1868     if subprocess.call(['jar', 'xf',
1869                         os.path.abspath(apk1)],
1870                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1871         return("Failed to unpack " + apk1)
1872     if subprocess.call(['jar', 'xf',
1873                         os.path.abspath(apk2)],
1874                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1875         return("Failed to unpack " + apk2)
1876
1877     # try to find apktool in the path, if it hasn't been manually configed
1878     if 'apktool' not in config:
1879         tmp = find_command('apktool')
1880         if tmp is not None:
1881             config['apktool'] = tmp
1882     if 'apktool' in config:
1883         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1884                            cwd=apk1dir) != 0:
1885             return("Failed to unpack " + apk1)
1886         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1887                            cwd=apk2dir) != 0:
1888             return("Failed to unpack " + apk2)
1889
1890     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1891     lines = p.output.splitlines()
1892     if len(lines) != 1 or 'META-INF' not in lines[0]:
1893         meld = find_command('meld')
1894         if meld is not None:
1895             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1896         return("Unexpected diff output - " + p.output)
1897
1898     # since everything verifies, delete the comparison to keep cruft down
1899     shutil.rmtree(apk1dir)
1900     shutil.rmtree(apk2dir)
1901
1902     # If we get here, it seems like they're the same!
1903     return None
1904
1905
1906 def find_command(command):
1907     '''find the full path of a command, or None if it can't be found in the PATH'''
1908
1909     def is_exe(fpath):
1910         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1911
1912     fpath, fname = os.path.split(command)
1913     if fpath:
1914         if is_exe(command):
1915             return command
1916     else:
1917         for path in os.environ["PATH"].split(os.pathsep):
1918             path = path.strip('"')
1919             exe_file = os.path.join(path, command)
1920             if is_exe(exe_file):
1921                 return exe_file
1922
1923     return None
1924
1925
1926 def genpassword():
1927     '''generate a random password for when generating keys'''
1928     h = hashlib.sha256()
1929     h.update(os.urandom(16))  # salt
1930     h.update(socket.getfqdn().encode('utf-8'))
1931     passwd = base64.b64encode(h.digest()).strip()
1932     return passwd.decode('utf-8')
1933
1934
1935 def genkeystore(localconfig):
1936     '''Generate a new key with random passwords and add it to new keystore'''
1937     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1938     keystoredir = os.path.dirname(localconfig['keystore'])
1939     if keystoredir is None or keystoredir == '':
1940         keystoredir = os.path.join(os.getcwd(), keystoredir)
1941     if not os.path.exists(keystoredir):
1942         os.makedirs(keystoredir, mode=0o700)
1943
1944     write_password_file("keystorepass", localconfig['keystorepass'])
1945     write_password_file("keypass", localconfig['keypass'])
1946     p = FDroidPopen([config['keytool'], '-genkey',
1947                      '-keystore', localconfig['keystore'],
1948                      '-alias', localconfig['repo_keyalias'],
1949                      '-keyalg', 'RSA', '-keysize', '4096',
1950                      '-sigalg', 'SHA256withRSA',
1951                      '-validity', '10000',
1952                      '-storepass:file', config['keystorepassfile'],
1953                      '-keypass:file', config['keypassfile'],
1954                      '-dname', localconfig['keydname']])
1955     # TODO keypass should be sent via stdin
1956     if p.returncode != 0:
1957         raise BuildException("Failed to generate key", p.output)
1958     os.chmod(localconfig['keystore'], 0o0600)
1959     # now show the lovely key that was just generated
1960     p = FDroidPopen([config['keytool'], '-list', '-v',
1961                      '-keystore', localconfig['keystore'],
1962                      '-alias', localconfig['repo_keyalias'],
1963                      '-storepass:file', config['keystorepassfile']])
1964     logging.info(p.output.strip() + '\n\n')
1965
1966
1967 def write_to_config(thisconfig, key, value=None):
1968     '''write a key/value to the local config.py'''
1969     if value is None:
1970         origkey = key + '_orig'
1971         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1972     with open('config.py', 'r') as f:
1973         data = f.read()
1974     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1975     repl = '\n' + key + ' = "' + value + '"'
1976     data = re.sub(pattern, repl, data)
1977     # if this key is not in the file, append it
1978     if not re.match('\s*' + key + '\s*=\s*"', data):
1979         data += repl
1980     # make sure the file ends with a carraige return
1981     if not re.match('\n$', data):
1982         data += '\n'
1983     with open('config.py', 'w') as f:
1984         f.writelines(data)
1985
1986
1987 def parse_xml(path):
1988     return XMLElementTree.parse(path).getroot()
1989
1990
1991 def string_is_integer(string):
1992     try:
1993         int(string)
1994         return True
1995     except ValueError:
1996         return False
1997
1998
1999 def get_per_app_repos():
2000     '''per-app repos are dirs named with the packageName of a single app'''
2001
2002     # Android packageNames are Java packages, they may contain uppercase or
2003     # lowercase letters ('A' through 'Z'), numbers, and underscores
2004     # ('_'). However, individual package name parts may only start with
2005     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2006     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2007
2008     repos = []
2009     for root, dirs, files in os.walk(os.getcwd()):
2010         for d in dirs:
2011             print('checking', root, 'for', d)
2012             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2013                 # standard parts of an fdroid repo, so never packageNames
2014                 continue
2015             elif p.match(d) \
2016                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2017                 repos.append(d)
2018         break
2019     return repos