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