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