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