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