chiark / gitweb /
FDroidPopen: return str again
[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])
342     else:
343         os.write(fd, password)
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.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"].encode('utf-8')
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     for line in file(proppath):
1017         if not line.startswith('android.library.reference.'):
1018             continue
1019         path = line.split('=')[1].strip()
1020         relpath = os.path.join(root_dir, path)
1021         if not os.path.isdir(relpath):
1022             continue
1023         logging.debug("Found subproject at %s" % path)
1024         libraries.append(path)
1025     return libraries
1026
1027
1028 def ant_subprojects(root_dir):
1029     subprojects = get_library_references(root_dir)
1030     for subpath in subprojects:
1031         subrelpath = os.path.join(root_dir, subpath)
1032         for p in get_library_references(subrelpath):
1033             relp = os.path.normpath(os.path.join(subpath, p))
1034             if relp not in subprojects:
1035                 subprojects.insert(0, relp)
1036     return subprojects
1037
1038
1039 def remove_debuggable_flags(root_dir):
1040     # Remove forced debuggable flags
1041     logging.debug("Removing debuggable flags from %s" % root_dir)
1042     for root, dirs, files in os.walk(root_dir):
1043         if 'AndroidManifest.xml' in files:
1044             regsub_file(r'android:debuggable="[^"]*"',
1045                         '',
1046                         os.path.join(root, 'AndroidManifest.xml'))
1047
1048
1049 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1050 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1051 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1052
1053
1054 def app_matches_packagename(app, package):
1055     if not package:
1056         return False
1057     appid = app.UpdateCheckName or app.id
1058     if appid is None or appid == "Ignore":
1059         return True
1060     return appid == package
1061
1062
1063 # Extract some information from the AndroidManifest.xml at the given path.
1064 # Returns (version, vercode, package), any or all of which might be None.
1065 # All values returned are strings.
1066 def parse_androidmanifests(paths, app):
1067
1068     ignoreversions = app.UpdateCheckIgnore
1069     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1070
1071     if not paths:
1072         return (None, None, None)
1073
1074     max_version = None
1075     max_vercode = None
1076     max_package = None
1077
1078     for path in paths:
1079
1080         if not os.path.isfile(path):
1081             continue
1082
1083         logging.debug("Parsing manifest at {0}".format(path))
1084         gradle = has_extension(path, 'gradle')
1085         version = None
1086         vercode = None
1087         package = None
1088
1089         if gradle:
1090             for line in file(path):
1091                 if gradle_comment.match(line):
1092                     continue
1093                 # Grab first occurence of each to avoid running into
1094                 # alternative flavours and builds.
1095                 if not package:
1096                     matches = psearch_g(line)
1097                     if matches:
1098                         s = matches.group(2)
1099                         if app_matches_packagename(app, s):
1100                             package = s
1101                 if not version:
1102                     matches = vnsearch_g(line)
1103                     if matches:
1104                         version = matches.group(2)
1105                 if not vercode:
1106                     matches = vcsearch_g(line)
1107                     if matches:
1108                         vercode = matches.group(1)
1109         else:
1110             try:
1111                 xml = parse_xml(path)
1112                 if "package" in xml.attrib:
1113                     s = xml.attrib["package"].encode('utf-8')
1114                     if app_matches_packagename(app, s):
1115                         package = s
1116                 if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1117                     version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1118                     base_dir = os.path.dirname(path)
1119                     version = retrieve_string_singleline(base_dir, version)
1120                 if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1121                     a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1122                     if string_is_integer(a):
1123                         vercode = a
1124             except Exception:
1125                 logging.warning("Problem with xml at {0}".format(path))
1126
1127         # Remember package name, may be defined separately from version+vercode
1128         if package is None:
1129             package = max_package
1130
1131         logging.debug("..got package={0}, version={1}, vercode={2}"
1132                       .format(package, version, vercode))
1133
1134         # Always grab the package name and version name in case they are not
1135         # together with the highest version code
1136         if max_package is None and package is not None:
1137             max_package = package
1138         if max_version is None and version is not None:
1139             max_version = version
1140
1141         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1142             if not ignoresearch or not ignoresearch(version):
1143                 if version is not None:
1144                     max_version = version
1145                 if vercode is not None:
1146                     max_vercode = vercode
1147                 if package is not None:
1148                     max_package = package
1149             else:
1150                 max_version = "Ignore"
1151
1152     if max_version is None:
1153         max_version = "Unknown"
1154
1155     if max_package and not is_valid_package_name(max_package):
1156         raise FDroidException("Invalid package name {0}".format(max_package))
1157
1158     return (max_version, max_vercode, max_package)
1159
1160
1161 def is_valid_package_name(name):
1162     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1163
1164
1165 class FDroidException(Exception):
1166
1167     def __init__(self, value, detail=None):
1168         self.value = value
1169         self.detail = detail
1170
1171     def shortened_detail(self):
1172         if len(self.detail) < 16000:
1173             return self.detail
1174         return '[...]\n' + self.detail[-16000:]
1175
1176     def get_wikitext(self):
1177         ret = repr(self.value) + "\n"
1178         if self.detail:
1179             ret += "=detail=\n"
1180             ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1181         return ret
1182
1183     def __str__(self):
1184         ret = self.value
1185         if self.detail:
1186             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1187         return ret
1188
1189
1190 class VCSException(FDroidException):
1191     pass
1192
1193
1194 class BuildException(FDroidException):
1195     pass
1196
1197
1198 # Get the specified source library.
1199 # Returns the path to it. Normally this is the path to be used when referencing
1200 # it, which may be a subdirectory of the actual project. If you want the base
1201 # directory of the project, pass 'basepath=True'.
1202 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1203               raw=False, prepare=True, preponly=False, refresh=True):
1204
1205     number = None
1206     subdir = None
1207     if raw:
1208         name = spec
1209         ref = None
1210     else:
1211         name, ref = spec.split('@')
1212         if ':' in name:
1213             number, name = name.split(':', 1)
1214         if '/' in name:
1215             name, subdir = name.split('/', 1)
1216
1217     if name not in fdroidserver.metadata.srclibs:
1218         raise VCSException('srclib ' + name + ' not found.')
1219
1220     srclib = fdroidserver.metadata.srclibs[name]
1221
1222     sdir = os.path.join(srclib_dir, name)
1223
1224     if not preponly:
1225         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1226         vcs.srclib = (name, number, sdir)
1227         if ref:
1228             vcs.gotorevision(ref, refresh)
1229
1230         if raw:
1231             return vcs
1232
1233     libdir = None
1234     if subdir:
1235         libdir = os.path.join(sdir, subdir)
1236     elif srclib["Subdir"]:
1237         for subdir in srclib["Subdir"]:
1238             libdir_candidate = os.path.join(sdir, subdir)
1239             if os.path.exists(libdir_candidate):
1240                 libdir = libdir_candidate
1241                 break
1242
1243     if libdir is None:
1244         libdir = sdir
1245
1246     remove_signing_keys(sdir)
1247     remove_debuggable_flags(sdir)
1248
1249     if prepare:
1250
1251         if srclib["Prepare"]:
1252             cmd = replace_config_vars(srclib["Prepare"], None)
1253
1254             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1255             if p.returncode != 0:
1256                 raise BuildException("Error running prepare command for srclib %s"
1257                                      % name, p.output)
1258
1259     if basepath:
1260         libdir = sdir
1261
1262     return (name, number, libdir)
1263
1264 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1265
1266
1267 # Prepare the source code for a particular build
1268 #  'vcs'         - the appropriate vcs object for the application
1269 #  'app'         - the application details from the metadata
1270 #  'build'       - the build details from the metadata
1271 #  'build_dir'   - the path to the build directory, usually
1272 #                   'build/app.id'
1273 #  'srclib_dir'  - the path to the source libraries directory, usually
1274 #                   'build/srclib'
1275 #  'extlib_dir'  - the path to the external libraries directory, usually
1276 #                   'build/extlib'
1277 # Returns the (root, srclibpaths) where:
1278 #   'root' is the root directory, which may be the same as 'build_dir' or may
1279 #          be a subdirectory of it.
1280 #   'srclibpaths' is information on the srclibs being used
1281 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1282
1283     # Optionally, the actual app source can be in a subdirectory
1284     if build.subdir:
1285         root_dir = os.path.join(build_dir, build.subdir)
1286     else:
1287         root_dir = build_dir
1288
1289     # Get a working copy of the right revision
1290     logging.info("Getting source for revision " + build.commit)
1291     vcs.gotorevision(build.commit, refresh)
1292
1293     # Initialise submodules if required
1294     if build.submodules:
1295         logging.info("Initialising submodules")
1296         vcs.initsubmodules()
1297
1298     # Check that a subdir (if we're using one) exists. This has to happen
1299     # after the checkout, since it might not exist elsewhere
1300     if not os.path.exists(root_dir):
1301         raise BuildException('Missing subdir ' + root_dir)
1302
1303     # Run an init command if one is required
1304     if build.init:
1305         cmd = replace_config_vars(build.init, build)
1306         logging.info("Running 'init' commands in %s" % root_dir)
1307
1308         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1309         if p.returncode != 0:
1310             raise BuildException("Error running init command for %s:%s" %
1311                                  (app.id, build.version), p.output)
1312
1313     # Apply patches if any
1314     if build.patch:
1315         logging.info("Applying patches")
1316         for patch in build.patch:
1317             patch = patch.strip()
1318             logging.info("Applying " + patch)
1319             patch_path = os.path.join('metadata', app.id, patch)
1320             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1321             if p.returncode != 0:
1322                 raise BuildException("Failed to apply patch %s" % patch_path)
1323
1324     # Get required source libraries
1325     srclibpaths = []
1326     if build.srclibs:
1327         logging.info("Collecting source libraries")
1328         for lib in build.srclibs:
1329             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1330
1331     for name, number, libpath in srclibpaths:
1332         place_srclib(root_dir, int(number) if number else None, libpath)
1333
1334     basesrclib = vcs.getsrclib()
1335     # If one was used for the main source, add that too.
1336     if basesrclib:
1337         srclibpaths.append(basesrclib)
1338
1339     # Update the local.properties file
1340     localprops = [os.path.join(build_dir, 'local.properties')]
1341     if build.subdir:
1342         parts = build.subdir.split(os.sep)
1343         cur = build_dir
1344         for d in parts:
1345             cur = os.path.join(cur, d)
1346             localprops += [os.path.join(cur, 'local.properties')]
1347     for path in localprops:
1348         props = ""
1349         if os.path.isfile(path):
1350             logging.info("Updating local.properties file at %s" % path)
1351             with open(path, 'r') as f:
1352                 props += f.read()
1353             props += '\n'
1354         else:
1355             logging.info("Creating local.properties file at %s" % path)
1356         # Fix old-fashioned 'sdk-location' by copying
1357         # from sdk.dir, if necessary
1358         if build.oldsdkloc:
1359             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1360                               re.S | re.M).group(1)
1361             props += "sdk-location=%s\n" % sdkloc
1362         else:
1363             props += "sdk.dir=%s\n" % config['sdk_path']
1364             props += "sdk-location=%s\n" % config['sdk_path']
1365         ndk_path = build.ndk_path()
1366         if ndk_path:
1367             # Add ndk location
1368             props += "ndk.dir=%s\n" % ndk_path
1369             props += "ndk-location=%s\n" % ndk_path
1370         # Add java.encoding if necessary
1371         if build.encoding:
1372             props += "java.encoding=%s\n" % build.encoding
1373         with open(path, 'w') as f:
1374             f.write(props)
1375
1376     flavours = []
1377     if build.build_method() == 'gradle':
1378         flavours = build.gradle
1379
1380         if build.target:
1381             n = build.target.split('-')[1]
1382             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1383                         r'compileSdkVersion %s' % n,
1384                         os.path.join(root_dir, 'build.gradle'))
1385
1386     # Remove forced debuggable flags
1387     remove_debuggable_flags(root_dir)
1388
1389     # Insert version code and number into the manifest if necessary
1390     if build.forceversion:
1391         logging.info("Changing the version name")
1392         for path in manifest_paths(root_dir, flavours):
1393             if not os.path.isfile(path):
1394                 continue
1395             if has_extension(path, 'xml'):
1396                 regsub_file(r'android:versionName="[^"]*"',
1397                             r'android:versionName="%s"' % build.version,
1398                             path)
1399             elif has_extension(path, 'gradle'):
1400                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1401                             r"""\1versionName '%s'""" % build.version,
1402                             path)
1403
1404     if build.forcevercode:
1405         logging.info("Changing the version code")
1406         for path in manifest_paths(root_dir, flavours):
1407             if not os.path.isfile(path):
1408                 continue
1409             if has_extension(path, 'xml'):
1410                 regsub_file(r'android:versionCode="[^"]*"',
1411                             r'android:versionCode="%s"' % build.vercode,
1412                             path)
1413             elif has_extension(path, 'gradle'):
1414                 regsub_file(r'versionCode[ =]+[0-9]+',
1415                             r'versionCode %s' % build.vercode,
1416                             path)
1417
1418     # Delete unwanted files
1419     if build.rm:
1420         logging.info("Removing specified files")
1421         for part in getpaths(build_dir, build.rm):
1422             dest = os.path.join(build_dir, part)
1423             logging.info("Removing {0}".format(part))
1424             if os.path.lexists(dest):
1425                 if os.path.islink(dest):
1426                     FDroidPopen(['unlink', dest], output=False)
1427                 else:
1428                     FDroidPopen(['rm', '-rf', dest], output=False)
1429             else:
1430                 logging.info("...but it didn't exist")
1431
1432     remove_signing_keys(build_dir)
1433
1434     # Add required external libraries
1435     if build.extlibs:
1436         logging.info("Collecting prebuilt libraries")
1437         libsdir = os.path.join(root_dir, 'libs')
1438         if not os.path.exists(libsdir):
1439             os.mkdir(libsdir)
1440         for lib in build.extlibs:
1441             lib = lib.strip()
1442             logging.info("...installing extlib {0}".format(lib))
1443             libf = os.path.basename(lib)
1444             libsrc = os.path.join(extlib_dir, lib)
1445             if not os.path.exists(libsrc):
1446                 raise BuildException("Missing extlib file {0}".format(libsrc))
1447             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1448
1449     # Run a pre-build command if one is required
1450     if build.prebuild:
1451         logging.info("Running 'prebuild' commands in %s" % root_dir)
1452
1453         cmd = replace_config_vars(build.prebuild, build)
1454
1455         # Substitute source library paths into prebuild commands
1456         for name, number, libpath in srclibpaths:
1457             libpath = os.path.relpath(libpath, root_dir)
1458             cmd = cmd.replace('$$' + name + '$$', libpath)
1459
1460         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1461         if p.returncode != 0:
1462             raise BuildException("Error running prebuild command for %s:%s" %
1463                                  (app.id, build.version), p.output)
1464
1465     # Generate (or update) the ant build file, build.xml...
1466     if build.build_method() == 'ant' and build.update != ['no']:
1467         parms = ['android', 'update', 'lib-project']
1468         lparms = ['android', 'update', 'project']
1469
1470         if build.target:
1471             parms += ['-t', build.target]
1472             lparms += ['-t', build.target]
1473         if build.update:
1474             update_dirs = build.update
1475         else:
1476             update_dirs = ant_subprojects(root_dir) + ['.']
1477
1478         for d in update_dirs:
1479             subdir = os.path.join(root_dir, d)
1480             if d == '.':
1481                 logging.debug("Updating main project")
1482                 cmd = parms + ['-p', d]
1483             else:
1484                 logging.debug("Updating subproject %s" % d)
1485                 cmd = lparms + ['-p', d]
1486             p = SdkToolsPopen(cmd, cwd=root_dir)
1487             # Check to see whether an error was returned without a proper exit
1488             # code (this is the case for the 'no target set or target invalid'
1489             # error)
1490             if p.returncode != 0 or p.output.startswith("Error: "):
1491                 raise BuildException("Failed to update project at %s" % d, p.output)
1492             # Clean update dirs via ant
1493             if d != '.':
1494                 logging.info("Cleaning subproject %s" % d)
1495                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1496
1497     return (root_dir, srclibpaths)
1498
1499
1500 # Extend via globbing the paths from a field and return them as a map from
1501 # original path to resulting paths
1502 def getpaths_map(build_dir, globpaths):
1503     paths = dict()
1504     for p in globpaths:
1505         p = p.strip()
1506         full_path = os.path.join(build_dir, p)
1507         full_path = os.path.normpath(full_path)
1508         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1509         if not paths[p]:
1510             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1511     return paths
1512
1513
1514 # Extend via globbing the paths from a field and return them as a set
1515 def getpaths(build_dir, globpaths):
1516     paths_map = getpaths_map(build_dir, globpaths)
1517     paths = set()
1518     for k, v in paths_map.items():
1519         for p in v:
1520             paths.add(p)
1521     return paths
1522
1523
1524 def natural_key(s):
1525     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1526
1527
1528 class KnownApks:
1529
1530     def __init__(self):
1531         self.path = os.path.join('stats', 'known_apks.txt')
1532         self.apks = {}
1533         if os.path.isfile(self.path):
1534             for line in file(self.path):
1535                 t = line.rstrip().split(' ')
1536                 if len(t) == 2:
1537                     self.apks[t[0]] = (t[1], None)
1538                 else:
1539                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1540         self.changed = False
1541
1542     def writeifchanged(self):
1543         if not self.changed:
1544             return
1545
1546         if not os.path.exists('stats'):
1547             os.mkdir('stats')
1548
1549         lst = []
1550         for apk, app in self.apks.items():
1551             appid, added = app
1552             line = apk + ' ' + appid
1553             if added:
1554                 line += ' ' + time.strftime('%Y-%m-%d', added)
1555             lst.append(line)
1556
1557         with open(self.path, 'w') as f:
1558             for line in sorted(lst, key=natural_key):
1559                 f.write(line + '\n')
1560
1561     # Record an apk (if it's new, otherwise does nothing)
1562     # Returns the date it was added.
1563     def recordapk(self, apk, app):
1564         if apk not in self.apks:
1565             self.apks[apk] = (app, time.gmtime(time.time()))
1566             self.changed = True
1567         _, added = self.apks[apk]
1568         return added
1569
1570     # Look up information - given the 'apkname', returns (app id, date added/None).
1571     # Or returns None for an unknown apk.
1572     def getapp(self, apkname):
1573         if apkname in self.apks:
1574             return self.apks[apkname]
1575         return None
1576
1577     # Get the most recent 'num' apps added to the repo, as a list of package ids
1578     # with the most recent first.
1579     def getlatest(self, num):
1580         apps = {}
1581         for apk, app in self.apks.items():
1582             appid, added = app
1583             if added:
1584                 if appid in apps:
1585                     if apps[appid] > added:
1586                         apps[appid] = added
1587                 else:
1588                     apps[appid] = added
1589         sortedapps = sorted(apps.items(), key=operator.itemgetter(1))[-num:]
1590         lst = [app for app, _ in sortedapps]
1591         lst.reverse()
1592         return lst
1593
1594
1595 def isApkDebuggable(apkfile, config):
1596     """Returns True if the given apk file is debuggable
1597
1598     :param apkfile: full path to the apk to check"""
1599
1600     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1601                       output=False)
1602     if p.returncode != 0:
1603         logging.critical("Failed to get apk manifest information")
1604         sys.exit(1)
1605     for line in p.output.splitlines():
1606         if 'android:debuggable' in line and not line.endswith('0x0'):
1607             return True
1608     return False
1609
1610
1611 class PopenResult:
1612     returncode = None
1613     output = ''
1614
1615
1616 def SdkToolsPopen(commands, cwd=None, output=True):
1617     cmd = commands[0]
1618     if cmd not in config:
1619         config[cmd] = find_sdk_tools_cmd(commands[0])
1620     abscmd = config[cmd]
1621     if abscmd is None:
1622         logging.critical("Could not find '%s' on your system" % cmd)
1623         sys.exit(1)
1624     return FDroidPopen([abscmd] + commands[1:],
1625                        cwd=cwd, output=output)
1626
1627
1628 def FDroidPopen(commands, cwd=None, output=True, stderr_to_stdout=True):
1629     """
1630     Run a command and capture the possibly huge output.
1631
1632     :param commands: command and argument list like in subprocess.Popen
1633     :param cwd: optionally specifies a working directory
1634     :returns: A PopenResult.
1635     """
1636
1637     global env
1638
1639     if cwd:
1640         cwd = os.path.normpath(cwd)
1641         logging.debug("Directory: %s" % cwd)
1642     logging.debug("> %s" % ' '.join(commands))
1643
1644     stderr_param = subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE
1645     result = PopenResult()
1646     p = None
1647     try:
1648         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1649                              stdout=subprocess.PIPE, stderr=stderr_param)
1650     except OSError as e:
1651         raise BuildException("OSError while trying to execute " +
1652                              ' '.join(commands) + ': ' + str(e))
1653
1654     if not stderr_to_stdout and options.verbose:
1655         stderr_queue = Queue()
1656         stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1657
1658         while not stderr_reader.eof():
1659             while not stderr_queue.empty():
1660                 line = stderr_queue.get()
1661                 sys.stderr.write(line)
1662                 sys.stderr.flush()
1663
1664             time.sleep(0.1)
1665
1666     stdout_queue = Queue()
1667     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1668
1669     # Check the queue for output (until there is no more to get)
1670     while not stdout_reader.eof():
1671         while not stdout_queue.empty():
1672             line = stdout_queue.get()
1673             if output and options.verbose:
1674                 # Output directly to console
1675                 sys.stderr.buffer.write(line)
1676                 sys.stderr.flush()
1677             result.output += line.decode('utf-8')
1678
1679         time.sleep(0.1)
1680
1681     result.returncode = p.wait()
1682     return result
1683
1684
1685 gradle_comment = re.compile(r'[ ]*//')
1686 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1687 gradle_line_matches = [
1688     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1689     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1690     re.compile(r'.*\.readLine\(.*'),
1691 ]
1692
1693
1694 def remove_signing_keys(build_dir):
1695     for root, dirs, files in os.walk(build_dir):
1696         if 'build.gradle' in files:
1697             path = os.path.join(root, 'build.gradle')
1698
1699             with open(path, "r") as o:
1700                 lines = o.readlines()
1701
1702             changed = False
1703
1704             opened = 0
1705             i = 0
1706             with open(path, "w") as o:
1707                 while i < len(lines):
1708                     line = lines[i]
1709                     i += 1
1710                     while line.endswith('\\\n'):
1711                         line = line.rstrip('\\\n') + lines[i]
1712                         i += 1
1713
1714                     if gradle_comment.match(line):
1715                         o.write(line)
1716                         continue
1717
1718                     if opened > 0:
1719                         opened += line.count('{')
1720                         opened -= line.count('}')
1721                         continue
1722
1723                     if gradle_signing_configs.match(line):
1724                         changed = True
1725                         opened += 1
1726                         continue
1727
1728                     if any(s.match(line) for s in gradle_line_matches):
1729                         changed = True
1730                         continue
1731
1732                     if opened == 0:
1733                         o.write(line)
1734
1735             if changed:
1736                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1737
1738         for propfile in [
1739                 'project.properties',
1740                 'build.properties',
1741                 'default.properties',
1742                 'ant.properties', ]:
1743             if propfile in files:
1744                 path = os.path.join(root, propfile)
1745
1746                 with open(path, "r") as o:
1747                     lines = o.readlines()
1748
1749                 changed = False
1750
1751                 with open(path, "w") as o:
1752                     for line in lines:
1753                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1754                             changed = True
1755                             continue
1756
1757                         o.write(line)
1758
1759                 if changed:
1760                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1761
1762
1763 def reset_env_path():
1764     global env, orig_path
1765     env['PATH'] = orig_path
1766
1767
1768 def add_to_env_path(path):
1769     global env
1770     paths = env['PATH'].split(os.pathsep)
1771     if path in paths:
1772         return
1773     paths.append(path)
1774     env['PATH'] = os.pathsep.join(paths)
1775
1776
1777 def replace_config_vars(cmd, build):
1778     global env
1779     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1780     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1781     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1782     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1783     if build is not None:
1784         cmd = cmd.replace('$$COMMIT$$', build.commit)
1785         cmd = cmd.replace('$$VERSION$$', build.version)
1786         cmd = cmd.replace('$$VERCODE$$', build.vercode)
1787     return cmd
1788
1789
1790 def place_srclib(root_dir, number, libpath):
1791     if not number:
1792         return
1793     relpath = os.path.relpath(libpath, root_dir)
1794     proppath = os.path.join(root_dir, 'project.properties')
1795
1796     lines = []
1797     if os.path.isfile(proppath):
1798         with open(proppath, "r") as o:
1799             lines = o.readlines()
1800
1801     with open(proppath, "w") as o:
1802         placed = False
1803         for line in lines:
1804             if line.startswith('android.library.reference.%d=' % number):
1805                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1806                 placed = True
1807             else:
1808                 o.write(line)
1809         if not placed:
1810             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1811
1812 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1813
1814
1815 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1816     """Verify that two apks are the same
1817
1818     One of the inputs is signed, the other is unsigned. The signature metadata
1819     is transferred from the signed to the unsigned apk, and then jarsigner is
1820     used to verify that the signature from the signed apk is also varlid for
1821     the unsigned one.
1822     :param signed_apk: Path to a signed apk file
1823     :param unsigned_apk: Path to an unsigned apk file expected to match it
1824     :param tmp_dir: Path to directory for temporary files
1825     :returns: None if the verification is successful, otherwise a string
1826               describing what went wrong.
1827     """
1828     with ZipFile(signed_apk) as signed_apk_as_zip:
1829         meta_inf_files = ['META-INF/MANIFEST.MF']
1830         for f in signed_apk_as_zip.namelist():
1831             if apk_sigfile.match(f):
1832                 meta_inf_files.append(f)
1833         if len(meta_inf_files) < 3:
1834             return "Signature files missing from {0}".format(signed_apk)
1835         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1836     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1837         for meta_inf_file in meta_inf_files:
1838             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1839
1840     if subprocess.call([config['jarsigner'], '-verify', unsigned_apk]) != 0:
1841         logging.info("...NOT verified - {0}".format(signed_apk))
1842         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1843     logging.info("...successfully verified")
1844     return None
1845
1846 apk_badchars = re.compile('''[/ :;'"]''')
1847
1848
1849 def compare_apks(apk1, apk2, tmp_dir):
1850     """Compare two apks
1851
1852     Returns None if the apk content is the same (apart from the signing key),
1853     otherwise a string describing what's different, or what went wrong when
1854     trying to do the comparison.
1855     """
1856
1857     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
1858     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
1859     for d in [apk1dir, apk2dir]:
1860         if os.path.exists(d):
1861             shutil.rmtree(d)
1862         os.mkdir(d)
1863         os.mkdir(os.path.join(d, 'jar-xf'))
1864
1865     if subprocess.call(['jar', 'xf',
1866                         os.path.abspath(apk1)],
1867                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1868         return("Failed to unpack " + apk1)
1869     if subprocess.call(['jar', 'xf',
1870                         os.path.abspath(apk2)],
1871                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1872         return("Failed to unpack " + apk2)
1873
1874     # try to find apktool in the path, if it hasn't been manually configed
1875     if 'apktool' not in config:
1876         tmp = find_command('apktool')
1877         if tmp is not None:
1878             config['apktool'] = tmp
1879     if 'apktool' in config:
1880         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1881                            cwd=apk1dir) != 0:
1882             return("Failed to unpack " + apk1)
1883         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1884                            cwd=apk2dir) != 0:
1885             return("Failed to unpack " + apk2)
1886
1887     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1888     lines = p.output.splitlines()
1889     if len(lines) != 1 or 'META-INF' not in lines[0]:
1890         meld = find_command('meld')
1891         if meld is not None:
1892             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1893         return("Unexpected diff output - " + p.output)
1894
1895     # since everything verifies, delete the comparison to keep cruft down
1896     shutil.rmtree(apk1dir)
1897     shutil.rmtree(apk2dir)
1898
1899     # If we get here, it seems like they're the same!
1900     return None
1901
1902
1903 def find_command(command):
1904     '''find the full path of a command, or None if it can't be found in the PATH'''
1905
1906     def is_exe(fpath):
1907         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1908
1909     fpath, fname = os.path.split(command)
1910     if fpath:
1911         if is_exe(command):
1912             return command
1913     else:
1914         for path in os.environ["PATH"].split(os.pathsep):
1915             path = path.strip('"')
1916             exe_file = os.path.join(path, command)
1917             if is_exe(exe_file):
1918                 return exe_file
1919
1920     return None
1921
1922
1923 def genpassword():
1924     '''generate a random password for when generating keys'''
1925     h = hashlib.sha256()
1926     h.update(os.urandom(16))  # salt
1927     h.update(socket.getfqdn().encode('utf-8'))
1928     passwd = base64.b64encode(h.digest()).strip()
1929     return passwd.decode('utf-8')
1930
1931
1932 def genkeystore(localconfig):
1933     '''Generate a new key with random passwords and add it to new keystore'''
1934     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1935     keystoredir = os.path.dirname(localconfig['keystore'])
1936     if keystoredir is None or keystoredir == '':
1937         keystoredir = os.path.join(os.getcwd(), keystoredir)
1938     if not os.path.exists(keystoredir):
1939         os.makedirs(keystoredir, mode=0o700)
1940
1941     write_password_file("keystorepass", localconfig['keystorepass'])
1942     write_password_file("keypass", localconfig['keypass'])
1943     p = FDroidPopen([config['keytool'], '-genkey',
1944                      '-keystore', localconfig['keystore'],
1945                      '-alias', localconfig['repo_keyalias'],
1946                      '-keyalg', 'RSA', '-keysize', '4096',
1947                      '-sigalg', 'SHA256withRSA',
1948                      '-validity', '10000',
1949                      '-storepass:file', config['keystorepassfile'],
1950                      '-keypass:file', config['keypassfile'],
1951                      '-dname', localconfig['keydname']])
1952     # TODO keypass should be sent via stdin
1953     if p.returncode != 0:
1954         raise BuildException("Failed to generate key", p.output)
1955     os.chmod(localconfig['keystore'], 0o0600)
1956     # now show the lovely key that was just generated
1957     p = FDroidPopen([config['keytool'], '-list', '-v',
1958                      '-keystore', localconfig['keystore'],
1959                      '-alias', localconfig['repo_keyalias'],
1960                      '-storepass:file', config['keystorepassfile']])
1961     logging.info(p.output.strip() + '\n\n')
1962
1963
1964 def write_to_config(thisconfig, key, value=None):
1965     '''write a key/value to the local config.py'''
1966     if value is None:
1967         origkey = key + '_orig'
1968         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1969     with open('config.py', 'r') as f:
1970         data = f.read()
1971     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1972     repl = '\n' + key + ' = "' + value + '"'
1973     data = re.sub(pattern, repl, data)
1974     # if this key is not in the file, append it
1975     if not re.match('\s*' + key + '\s*=\s*"', data):
1976         data += repl
1977     # make sure the file ends with a carraige return
1978     if not re.match('\n$', data):
1979         data += '\n'
1980     with open('config.py', 'w') as f:
1981         f.writelines(data)
1982
1983
1984 def parse_xml(path):
1985     return XMLElementTree.parse(path).getroot()
1986
1987
1988 def string_is_integer(string):
1989     try:
1990         int(string)
1991         return True
1992     except ValueError:
1993         return False
1994
1995
1996 def get_per_app_repos():
1997     '''per-app repos are dirs named with the packageName of a single app'''
1998
1999     # Android packageNames are Java packages, they may contain uppercase or
2000     # lowercase letters ('A' through 'Z'), numbers, and underscores
2001     # ('_'). However, individual package name parts may only start with
2002     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
2003     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
2004
2005     repos = []
2006     for root, dirs, files in os.walk(os.getcwd()):
2007         for d in dirs:
2008             print('checking', root, 'for', d)
2009             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
2010                 # standard parts of an fdroid repo, so never packageNames
2011                 continue
2012             elif p.match(d) \
2013                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
2014                 repos.append(d)
2015         break
2016     return repos