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