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