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