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