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