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