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