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