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