chiark / gitweb /
3e88697668ebb72a48eed0c43ba6d848fb69ba4a
[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.1",
60     'ant': "ant",
61     'mvn3': "mvn",
62     'gradle': 'gradle',
63     'accepted_formats': ['txt', 'yaml'],
64     'sync_from_local_copy_dir': False,
65     'per_app_repos': False,
66     'make_current_version_link': True,
67     'current_version_name_source': 'Name',
68     'update_stats': False,
69     'stats_ignore': [],
70     'stats_server': None,
71     'stats_user': None,
72     'stats_to_carbon': False,
73     'repo_maxage': 0,
74     'build_server_always': False,
75     'keystore': 'keystore.jks',
76     'smartcardoptions': [],
77     'char_limits': {
78         'Summary': 80,
79         'Description': 4000,
80     },
81     'keyaliases': {},
82     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
83     'repo_name': "My First FDroid Repo Demo",
84     'repo_icon': "fdroid-icon.png",
85     'repo_description': '''
86         This is a repository of apps to be used with FDroid. Applications in this
87         repository are either official binaries built by the original application
88         developers, or are binaries built from source by the admin of f-droid.org
89         using the tools on https://gitlab.com/u/fdroid.
90         ''',
91     'archive_older': 0,
92 }
93
94
95 def setup_global_opts(parser):
96     parser.add_argument("-v", "--verbose", action="store_true", default=False,
97                         help="Spew out even more information than normal")
98     parser.add_argument("-q", "--quiet", action="store_true", default=False,
99                         help="Restrict output to warnings and errors")
100
101
102 def fill_config_defaults(thisconfig):
103     for k, v in default_config.items():
104         if k not in thisconfig:
105             thisconfig[k] = v
106
107     # Expand paths (~users and $vars)
108     def expand_path(path):
109         if path is None:
110             return None
111         orig = path
112         path = os.path.expanduser(path)
113         path = os.path.expandvars(path)
114         if orig == path:
115             return None
116         return path
117
118     for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
119         v = thisconfig[k]
120         exp = expand_path(v)
121         if exp is not None:
122             thisconfig[k] = exp
123             thisconfig[k + '_orig'] = v
124
125     for k in ['ndk_paths']:
126         d = thisconfig[k]
127         for k2 in d.copy():
128             v = d[k2]
129             exp = expand_path(v)
130             if exp is not None:
131                 thisconfig[k][k2] = exp
132                 thisconfig[k][k2 + '_orig'] = v
133
134
135 def regsub_file(pattern, repl, path):
136     with open(path, 'r') as f:
137         text = f.read()
138     text = re.sub(pattern, repl, text)
139     with open(path, 'w') as f:
140         f.write(text)
141
142
143 def read_config(opts, config_file='config.py'):
144     """Read the repository config
145
146     The config is read from config_file, which is in the current directory when
147     any of the repo management commands are used.
148     """
149     global config, options, env, orig_path
150
151     if config is not None:
152         return config
153     if not os.path.isfile(config_file):
154         logging.critical("Missing config file - is this a repo directory?")
155         sys.exit(2)
156
157     options = opts
158
159     config = {}
160
161     logging.debug("Reading %s" % config_file)
162     execfile(config_file, config)
163
164     # smartcardoptions must be a list since its command line args for Popen
165     if 'smartcardoptions' in config:
166         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
167     elif 'keystore' in config and config['keystore'] == 'NONE':
168         # keystore='NONE' means use smartcard, these are required defaults
169         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
170                                       'SunPKCS11-OpenSC', '-providerClass',
171                                       'sun.security.pkcs11.SunPKCS11',
172                                       '-providerArg', 'opensc-fdroid.cfg']
173
174     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
175         st = os.stat(config_file)
176         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
177             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
178
179     fill_config_defaults(config)
180
181     # There is no standard, so just set up the most common environment
182     # variables
183     env = os.environ
184     orig_path = env['PATH']
185     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
186         env[n] = config['sdk_path']
187
188     for k in ["keystorepass", "keypass"]:
189         if k in config:
190             write_password_file(k)
191
192     for k in ["repo_description", "archive_description"]:
193         if k in config:
194             config[k] = clean_description(config[k])
195
196     if 'serverwebroot' in config:
197         if isinstance(config['serverwebroot'], basestring):
198             roots = [config['serverwebroot']]
199         elif all(isinstance(item, basestring) for item in config['serverwebroot']):
200             roots = config['serverwebroot']
201         else:
202             raise TypeError('only accepts strings, lists, and tuples')
203         rootlist = []
204         for rootstr in roots:
205             # since this is used with rsync, where trailing slashes have
206             # meaning, ensure there is always a trailing slash
207             if rootstr[-1] != '/':
208                 rootstr += '/'
209             rootlist.append(rootstr.replace('//', '/'))
210         config['serverwebroot'] = rootlist
211
212     return config
213
214
215 def get_ndk_path(version):
216     if version is None:
217         version = 'r10e'  # falls back to latest
218     paths = config['ndk_paths']
219     if version not in paths:
220         return ''
221     return paths[version] or ''
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['Auto Name']:
424         return app['Auto Name']
425     return app['id']
426
427
428 def getcvname(app):
429     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
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 "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
963             continue
964         label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
965         result = retrieve_string_singleline(app_dir, label)
966         if result:
967             result = result.strip()
968         return result
969     return None
970
971
972 def get_library_references(root_dir):
973     libraries = []
974     proppath = os.path.join(root_dir, 'project.properties')
975     if not os.path.isfile(proppath):
976         return libraries
977     for line in file(proppath):
978         if not line.startswith('android.library.reference.'):
979             continue
980         path = line.split('=')[1].strip()
981         relpath = os.path.join(root_dir, path)
982         if not os.path.isdir(relpath):
983             continue
984         logging.debug("Found subproject at %s" % path)
985         libraries.append(path)
986     return libraries
987
988
989 def ant_subprojects(root_dir):
990     subprojects = get_library_references(root_dir)
991     for subpath in subprojects:
992         subrelpath = os.path.join(root_dir, subpath)
993         for p in get_library_references(subrelpath):
994             relp = os.path.normpath(os.path.join(subpath, p))
995             if relp not in subprojects:
996                 subprojects.insert(0, relp)
997     return subprojects
998
999
1000 def remove_debuggable_flags(root_dir):
1001     # Remove forced debuggable flags
1002     logging.debug("Removing debuggable flags from %s" % root_dir)
1003     for root, dirs, files in os.walk(root_dir):
1004         if 'AndroidManifest.xml' in files:
1005             regsub_file(r'android:debuggable="[^"]*"',
1006                         '',
1007                         os.path.join(root, 'AndroidManifest.xml'))
1008
1009
1010 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1011 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1012 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1013
1014
1015 def app_matches_packagename(app, package):
1016     if not package:
1017         return False
1018     appid = app['Update Check Name'] or app['id']
1019     if appid == "Ignore":
1020         return True
1021     return appid == package
1022
1023
1024 # Extract some information from the AndroidManifest.xml at the given path.
1025 # Returns (version, vercode, package), any or all of which might be None.
1026 # All values returned are strings.
1027 def parse_androidmanifests(paths, app):
1028
1029     ignoreversions = app['Update Check Ignore']
1030     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1031
1032     if not paths:
1033         return (None, None, None)
1034
1035     max_version = None
1036     max_vercode = None
1037     max_package = None
1038
1039     for path in paths:
1040
1041         if not os.path.isfile(path):
1042             continue
1043
1044         logging.debug("Parsing manifest at {0}".format(path))
1045         gradle = has_extension(path, 'gradle')
1046         version = None
1047         vercode = None
1048         package = None
1049
1050         if gradle:
1051             for line in file(path):
1052                 if gradle_comment.match(line):
1053                     continue
1054                 # Grab first occurence of each to avoid running into
1055                 # alternative flavours and builds.
1056                 if not package:
1057                     matches = psearch_g(line)
1058                     if matches:
1059                         s = matches.group(2)
1060                         if app_matches_packagename(app, s):
1061                             package = s
1062                 if not version:
1063                     matches = vnsearch_g(line)
1064                     if matches:
1065                         version = matches.group(2)
1066                 if not vercode:
1067                     matches = vcsearch_g(line)
1068                     if matches:
1069                         vercode = matches.group(1)
1070         else:
1071             xml = parse_xml(path)
1072             if "package" in xml.attrib:
1073                 s = xml.attrib["package"].encode('utf-8')
1074                 if app_matches_packagename(app, s):
1075                     package = s
1076             if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1077                 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1078                 base_dir = os.path.dirname(path)
1079                 version = retrieve_string_singleline(base_dir, version)
1080             if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1081                 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1082                 if string_is_integer(a):
1083                     vercode = a
1084
1085         # Remember package name, may be defined separately from version+vercode
1086         if package is None:
1087             package = max_package
1088
1089         logging.debug("..got package={0}, version={1}, vercode={2}"
1090                       .format(package, version, vercode))
1091
1092         # Always grab the package name and version name in case they are not
1093         # together with the highest version code
1094         if max_package is None and package is not None:
1095             max_package = package
1096         if max_version is None and version is not None:
1097             max_version = version
1098
1099         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1100             if not ignoresearch or not ignoresearch(version):
1101                 if version is not None:
1102                     max_version = version
1103                 if vercode is not None:
1104                     max_vercode = vercode
1105                 if package is not None:
1106                     max_package = package
1107             else:
1108                 max_version = "Ignore"
1109
1110     if max_version is None:
1111         max_version = "Unknown"
1112
1113     if max_package and not is_valid_package_name(max_package):
1114         raise FDroidException("Invalid package name {0}".format(max_package))
1115
1116     return (max_version, max_vercode, max_package)
1117
1118
1119 def is_valid_package_name(name):
1120     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1121
1122
1123 class FDroidException(Exception):
1124
1125     def __init__(self, value, detail=None):
1126         self.value = value
1127         self.detail = detail
1128
1129     def shortened_detail(self):
1130         if len(self.detail) < 16000:
1131             return self.detail
1132         return '[...]\n' + self.detail[-16000:]
1133
1134     def get_wikitext(self):
1135         ret = repr(self.value) + "\n"
1136         if self.detail:
1137             ret += "=detail=\n"
1138             ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1139         return ret
1140
1141     def __str__(self):
1142         ret = self.value
1143         if self.detail:
1144             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1145         return ret
1146
1147
1148 class VCSException(FDroidException):
1149     pass
1150
1151
1152 class BuildException(FDroidException):
1153     pass
1154
1155
1156 # Get the specified source library.
1157 # Returns the path to it. Normally this is the path to be used when referencing
1158 # it, which may be a subdirectory of the actual project. If you want the base
1159 # directory of the project, pass 'basepath=True'.
1160 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1161               raw=False, prepare=True, preponly=False, refresh=True):
1162
1163     number = None
1164     subdir = None
1165     if raw:
1166         name = spec
1167         ref = None
1168     else:
1169         name, ref = spec.split('@')
1170         if ':' in name:
1171             number, name = name.split(':', 1)
1172         if '/' in name:
1173             name, subdir = name.split('/', 1)
1174
1175     if name not in metadata.srclibs:
1176         raise VCSException('srclib ' + name + ' not found.')
1177
1178     srclib = metadata.srclibs[name]
1179
1180     sdir = os.path.join(srclib_dir, name)
1181
1182     if not preponly:
1183         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1184         vcs.srclib = (name, number, sdir)
1185         if ref:
1186             vcs.gotorevision(ref, refresh)
1187
1188         if raw:
1189             return vcs
1190
1191     libdir = None
1192     if subdir:
1193         libdir = os.path.join(sdir, subdir)
1194     elif srclib["Subdir"]:
1195         for subdir in srclib["Subdir"]:
1196             libdir_candidate = os.path.join(sdir, subdir)
1197             if os.path.exists(libdir_candidate):
1198                 libdir = libdir_candidate
1199                 break
1200
1201     if libdir is None:
1202         libdir = sdir
1203
1204     remove_signing_keys(sdir)
1205     remove_debuggable_flags(sdir)
1206
1207     if prepare:
1208
1209         if srclib["Prepare"]:
1210             cmd = replace_config_vars(srclib["Prepare"], None)
1211
1212             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1213             if p.returncode != 0:
1214                 raise BuildException("Error running prepare command for srclib %s"
1215                                      % name, p.output)
1216
1217     if basepath:
1218         libdir = sdir
1219
1220     return (name, number, libdir)
1221
1222 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1223
1224
1225 # Prepare the source code for a particular build
1226 #  'vcs'         - the appropriate vcs object for the application
1227 #  'app'         - the application details from the metadata
1228 #  'build'       - the build details from the metadata
1229 #  'build_dir'   - the path to the build directory, usually
1230 #                   'build/app.id'
1231 #  'srclib_dir'  - the path to the source libraries directory, usually
1232 #                   'build/srclib'
1233 #  'extlib_dir'  - the path to the external libraries directory, usually
1234 #                   'build/extlib'
1235 # Returns the (root, srclibpaths) where:
1236 #   'root' is the root directory, which may be the same as 'build_dir' or may
1237 #          be a subdirectory of it.
1238 #   'srclibpaths' is information on the srclibs being used
1239 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1240
1241     # Optionally, the actual app source can be in a subdirectory
1242     if build['subdir']:
1243         root_dir = os.path.join(build_dir, build['subdir'])
1244     else:
1245         root_dir = build_dir
1246
1247     # Get a working copy of the right revision
1248     logging.info("Getting source for revision " + build['commit'])
1249     vcs.gotorevision(build['commit'], refresh)
1250
1251     # Initialise submodules if required
1252     if build['submodules']:
1253         logging.info("Initialising submodules")
1254         vcs.initsubmodules()
1255
1256     # Check that a subdir (if we're using one) exists. This has to happen
1257     # after the checkout, since it might not exist elsewhere
1258     if not os.path.exists(root_dir):
1259         raise BuildException('Missing subdir ' + root_dir)
1260
1261     # Run an init command if one is required
1262     if build['init']:
1263         cmd = replace_config_vars(build['init'], build)
1264         logging.info("Running 'init' commands in %s" % root_dir)
1265
1266         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1267         if p.returncode != 0:
1268             raise BuildException("Error running init command for %s:%s" %
1269                                  (app['id'], build['version']), p.output)
1270
1271     # Apply patches if any
1272     if build['patch']:
1273         logging.info("Applying patches")
1274         for patch in build['patch']:
1275             patch = patch.strip()
1276             logging.info("Applying " + patch)
1277             patch_path = os.path.join('metadata', app['id'], patch)
1278             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1279             if p.returncode != 0:
1280                 raise BuildException("Failed to apply patch %s" % patch_path)
1281
1282     # Get required source libraries
1283     srclibpaths = []
1284     if build['srclibs']:
1285         logging.info("Collecting source libraries")
1286         for lib in build['srclibs']:
1287             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1288
1289     for name, number, libpath in srclibpaths:
1290         place_srclib(root_dir, int(number) if number else None, libpath)
1291
1292     basesrclib = vcs.getsrclib()
1293     # If one was used for the main source, add that too.
1294     if basesrclib:
1295         srclibpaths.append(basesrclib)
1296
1297     # Update the local.properties file
1298     localprops = [os.path.join(build_dir, 'local.properties')]
1299     if build['subdir']:
1300         parts = build['subdir'].split(os.sep)
1301         cur = build_dir
1302         for d in parts:
1303             cur = os.path.join(cur, d)
1304             localprops += [os.path.join(cur, 'local.properties')]
1305     for path in localprops:
1306         props = ""
1307         if os.path.isfile(path):
1308             logging.info("Updating local.properties file at %s" % path)
1309             with open(path, 'r') as f:
1310                 props += f.read()
1311             props += '\n'
1312         else:
1313             logging.info("Creating local.properties file at %s" % path)
1314         # Fix old-fashioned 'sdk-location' by copying
1315         # from sdk.dir, if necessary
1316         if build['oldsdkloc']:
1317             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1318                               re.S | re.M).group(1)
1319             props += "sdk-location=%s\n" % sdkloc
1320         else:
1321             props += "sdk.dir=%s\n" % config['sdk_path']
1322             props += "sdk-location=%s\n" % config['sdk_path']
1323         if build['ndk_path']:
1324             # Add ndk location
1325             props += "ndk.dir=%s\n" % build['ndk_path']
1326             props += "ndk-location=%s\n" % build['ndk_path']
1327         # Add java.encoding if necessary
1328         if build['encoding']:
1329             props += "java.encoding=%s\n" % build['encoding']
1330         with open(path, 'w') as f:
1331             f.write(props)
1332
1333     flavours = []
1334     if build['type'] == 'gradle':
1335         flavours = build['gradle']
1336
1337         gradlepluginver = None
1338
1339         gradle_dirs = [root_dir]
1340
1341         # Parent dir build.gradle
1342         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1343         if parent_dir.startswith(build_dir):
1344             gradle_dirs.append(parent_dir)
1345
1346         for dir_path in gradle_dirs:
1347             if gradlepluginver:
1348                 break
1349             if not os.path.isdir(dir_path):
1350                 continue
1351             for filename in os.listdir(dir_path):
1352                 if not filename.endswith('.gradle'):
1353                     continue
1354                 path = os.path.join(dir_path, filename)
1355                 if not os.path.isfile(path):
1356                     continue
1357                 for line in file(path):
1358                     match = gradle_version_regex.match(line)
1359                     if match:
1360                         gradlepluginver = match.group(1)
1361                         break
1362
1363         if gradlepluginver:
1364             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1365         else:
1366             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1367             build['gradlepluginver'] = LooseVersion('0.11')
1368
1369         if build['target']:
1370             n = build["target"].split('-')[1]
1371             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1372                         r'compileSdkVersion %s' % n,
1373                         os.path.join(root_dir, 'build.gradle'))
1374
1375     # Remove forced debuggable flags
1376     remove_debuggable_flags(root_dir)
1377
1378     # Insert version code and number into the manifest if necessary
1379     if build['forceversion']:
1380         logging.info("Changing the version name")
1381         for path in manifest_paths(root_dir, flavours):
1382             if not os.path.isfile(path):
1383                 continue
1384             if has_extension(path, 'xml'):
1385                 regsub_file(r'android:versionName="[^"]*"',
1386                             r'android:versionName="%s"' % build['version'],
1387                             path)
1388             elif has_extension(path, 'gradle'):
1389                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1390                             r"""\1versionName '%s'""" % build['version'],
1391                             path)
1392
1393     if build['forcevercode']:
1394         logging.info("Changing the version code")
1395         for path in manifest_paths(root_dir, flavours):
1396             if not os.path.isfile(path):
1397                 continue
1398             if has_extension(path, 'xml'):
1399                 regsub_file(r'android:versionCode="[^"]*"',
1400                             r'android:versionCode="%s"' % build['vercode'],
1401                             path)
1402             elif has_extension(path, 'gradle'):
1403                 regsub_file(r'versionCode[ =]+[0-9]+',
1404                             r'versionCode %s' % build['vercode'],
1405                             path)
1406
1407     # Delete unwanted files
1408     if build['rm']:
1409         logging.info("Removing specified files")
1410         for part in getpaths(build_dir, build['rm']):
1411             dest = os.path.join(build_dir, part)
1412             logging.info("Removing {0}".format(part))
1413             if os.path.lexists(dest):
1414                 if os.path.islink(dest):
1415                     FDroidPopen(['unlink', dest], output=False)
1416                 else:
1417                     FDroidPopen(['rm', '-rf', dest], output=False)
1418             else:
1419                 logging.info("...but it didn't exist")
1420
1421     remove_signing_keys(build_dir)
1422
1423     # Add required external libraries
1424     if build['extlibs']:
1425         logging.info("Collecting prebuilt libraries")
1426         libsdir = os.path.join(root_dir, 'libs')
1427         if not os.path.exists(libsdir):
1428             os.mkdir(libsdir)
1429         for lib in build['extlibs']:
1430             lib = lib.strip()
1431             logging.info("...installing extlib {0}".format(lib))
1432             libf = os.path.basename(lib)
1433             libsrc = os.path.join(extlib_dir, lib)
1434             if not os.path.exists(libsrc):
1435                 raise BuildException("Missing extlib file {0}".format(libsrc))
1436             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1437
1438     # Run a pre-build command if one is required
1439     if build['prebuild']:
1440         logging.info("Running 'prebuild' commands in %s" % root_dir)
1441
1442         cmd = replace_config_vars(build['prebuild'], build)
1443
1444         # Substitute source library paths into prebuild commands
1445         for name, number, libpath in srclibpaths:
1446             libpath = os.path.relpath(libpath, root_dir)
1447             cmd = cmd.replace('$$' + name + '$$', libpath)
1448
1449         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1450         if p.returncode != 0:
1451             raise BuildException("Error running prebuild command for %s:%s" %
1452                                  (app['id'], build['version']), p.output)
1453
1454     # Generate (or update) the ant build file, build.xml...
1455     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1456         parms = ['android', 'update', 'lib-project']
1457         lparms = ['android', 'update', 'project']
1458
1459         if build['target']:
1460             parms += ['-t', build['target']]
1461             lparms += ['-t', build['target']]
1462         if build['update'] == ['auto']:
1463             update_dirs = ant_subprojects(root_dir) + ['.']
1464         else:
1465             update_dirs = build['update']
1466
1467         for d in update_dirs:
1468             subdir = os.path.join(root_dir, d)
1469             if d == '.':
1470                 logging.debug("Updating main project")
1471                 cmd = parms + ['-p', d]
1472             else:
1473                 logging.debug("Updating subproject %s" % d)
1474                 cmd = lparms + ['-p', d]
1475             p = SdkToolsPopen(cmd, cwd=root_dir)
1476             # Check to see whether an error was returned without a proper exit
1477             # code (this is the case for the 'no target set or target invalid'
1478             # error)
1479             if p.returncode != 0 or p.output.startswith("Error: "):
1480                 raise BuildException("Failed to update project at %s" % d, p.output)
1481             # Clean update dirs via ant
1482             if d != '.':
1483                 logging.info("Cleaning subproject %s" % d)
1484                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1485
1486     return (root_dir, srclibpaths)
1487
1488
1489 # Extend via globbing the paths from a field and return them as a map from
1490 # original path to resulting paths
1491 def getpaths_map(build_dir, globpaths):
1492     paths = dict()
1493     for p in globpaths:
1494         p = p.strip()
1495         full_path = os.path.join(build_dir, p)
1496         full_path = os.path.normpath(full_path)
1497         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1498     return paths
1499
1500
1501 # Extend via globbing the paths from a field and return them as a set
1502 def getpaths(build_dir, globpaths):
1503     paths_map = getpaths_map(build_dir, globpaths)
1504     paths = set()
1505     for k, v in paths_map.iteritems():
1506         for p in v:
1507             paths.add(p)
1508     return paths
1509
1510
1511 def natural_key(s):
1512     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1513
1514
1515 class KnownApks:
1516
1517     def __init__(self):
1518         self.path = os.path.join('stats', 'known_apks.txt')
1519         self.apks = {}
1520         if os.path.isfile(self.path):
1521             for line in file(self.path):
1522                 t = line.rstrip().split(' ')
1523                 if len(t) == 2:
1524                     self.apks[t[0]] = (t[1], None)
1525                 else:
1526                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1527         self.changed = False
1528
1529     def writeifchanged(self):
1530         if not self.changed:
1531             return
1532
1533         if not os.path.exists('stats'):
1534             os.mkdir('stats')
1535
1536         lst = []
1537         for apk, app in self.apks.iteritems():
1538             appid, added = app
1539             line = apk + ' ' + appid
1540             if added:
1541                 line += ' ' + time.strftime('%Y-%m-%d', added)
1542             lst.append(line)
1543
1544         with open(self.path, 'w') as f:
1545             for line in sorted(lst, key=natural_key):
1546                 f.write(line + '\n')
1547
1548     # Record an apk (if it's new, otherwise does nothing)
1549     # Returns the date it was added.
1550     def recordapk(self, apk, app):
1551         if apk not in self.apks:
1552             self.apks[apk] = (app, time.gmtime(time.time()))
1553             self.changed = True
1554         _, added = self.apks[apk]
1555         return added
1556
1557     # Look up information - given the 'apkname', returns (app id, date added/None).
1558     # Or returns None for an unknown apk.
1559     def getapp(self, apkname):
1560         if apkname in self.apks:
1561             return self.apks[apkname]
1562         return None
1563
1564     # Get the most recent 'num' apps added to the repo, as a list of package ids
1565     # with the most recent first.
1566     def getlatest(self, num):
1567         apps = {}
1568         for apk, app in self.apks.iteritems():
1569             appid, added = app
1570             if added:
1571                 if appid in apps:
1572                     if apps[appid] > added:
1573                         apps[appid] = added
1574                 else:
1575                     apps[appid] = added
1576         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1577         lst = [app for app, _ in sortedapps]
1578         lst.reverse()
1579         return lst
1580
1581
1582 def isApkDebuggable(apkfile, config):
1583     """Returns True if the given apk file is debuggable
1584
1585     :param apkfile: full path to the apk to check"""
1586
1587     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1588                       output=False)
1589     if p.returncode != 0:
1590         logging.critical("Failed to get apk manifest information")
1591         sys.exit(1)
1592     for line in p.output.splitlines():
1593         if 'android:debuggable' in line and not line.endswith('0x0'):
1594             return True
1595     return False
1596
1597
1598 class PopenResult:
1599     returncode = None
1600     output = ''
1601
1602
1603 def SdkToolsPopen(commands, cwd=None, output=True):
1604     cmd = commands[0]
1605     if cmd not in config:
1606         config[cmd] = find_sdk_tools_cmd(commands[0])
1607     abscmd = config[cmd]
1608     if abscmd is None:
1609         logging.critical("Could not find '%s' on your system" % cmd)
1610         sys.exit(1)
1611     return FDroidPopen([abscmd] + commands[1:],
1612                        cwd=cwd, output=output)
1613
1614
1615 def FDroidPopen(commands, cwd=None, output=True):
1616     """
1617     Run a command and capture the possibly huge output.
1618
1619     :param commands: command and argument list like in subprocess.Popen
1620     :param cwd: optionally specifies a working directory
1621     :returns: A PopenResult.
1622     """
1623
1624     global env
1625
1626     if cwd:
1627         cwd = os.path.normpath(cwd)
1628         logging.debug("Directory: %s" % cwd)
1629     logging.debug("> %s" % ' '.join(commands))
1630
1631     result = PopenResult()
1632     p = None
1633     try:
1634         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1635                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1636     except OSError, e:
1637         raise BuildException("OSError while trying to execute " +
1638                              ' '.join(commands) + ': ' + str(e))
1639
1640     stdout_queue = Queue.Queue()
1641     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1642
1643     # Check the queue for output (until there is no more to get)
1644     while not stdout_reader.eof():
1645         while not stdout_queue.empty():
1646             line = stdout_queue.get()
1647             if output and options.verbose:
1648                 # Output directly to console
1649                 sys.stderr.write(line)
1650                 sys.stderr.flush()
1651             result.output += line
1652
1653         time.sleep(0.1)
1654
1655     result.returncode = p.wait()
1656     return result
1657
1658
1659 gradle_comment = re.compile(r'[ ]*//')
1660 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1661 gradle_line_matches = [
1662     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1663     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1664     re.compile(r'.*variant\.outputFile = .*'),
1665     re.compile(r'.*output\.outputFile = .*'),
1666     re.compile(r'.*\.readLine\(.*'),
1667 ]
1668
1669
1670 def remove_signing_keys(build_dir):
1671     for root, dirs, files in os.walk(build_dir):
1672         if 'build.gradle' in files:
1673             path = os.path.join(root, 'build.gradle')
1674
1675             with open(path, "r") as o:
1676                 lines = o.readlines()
1677
1678             changed = False
1679
1680             opened = 0
1681             i = 0
1682             with open(path, "w") as o:
1683                 while i < len(lines):
1684                     line = lines[i]
1685                     i += 1
1686                     while line.endswith('\\\n'):
1687                         line = line.rstrip('\\\n') + lines[i]
1688                         i += 1
1689
1690                     if gradle_comment.match(line):
1691                         o.write(line)
1692                         continue
1693
1694                     if opened > 0:
1695                         opened += line.count('{')
1696                         opened -= line.count('}')
1697                         continue
1698
1699                     if gradle_signing_configs.match(line):
1700                         changed = True
1701                         opened += 1
1702                         continue
1703
1704                     if any(s.match(line) for s in gradle_line_matches):
1705                         changed = True
1706                         continue
1707
1708                     if opened == 0:
1709                         o.write(line)
1710
1711             if changed:
1712                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1713
1714         for propfile in [
1715                 'project.properties',
1716                 'build.properties',
1717                 'default.properties',
1718                 'ant.properties', ]:
1719             if propfile in files:
1720                 path = os.path.join(root, propfile)
1721
1722                 with open(path, "r") as o:
1723                     lines = o.readlines()
1724
1725                 changed = False
1726
1727                 with open(path, "w") as o:
1728                     for line in lines:
1729                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1730                             changed = True
1731                             continue
1732
1733                         o.write(line)
1734
1735                 if changed:
1736                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1737
1738
1739 def reset_env_path():
1740     global env, orig_path
1741     env['PATH'] = orig_path
1742
1743
1744 def add_to_env_path(path):
1745     global env
1746     paths = env['PATH'].split(os.pathsep)
1747     if path in paths:
1748         return
1749     paths.append(path)
1750     env['PATH'] = os.pathsep.join(paths)
1751
1752
1753 def replace_config_vars(cmd, build):
1754     global env
1755     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1756     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1757     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1758     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1759     if build is not None:
1760         cmd = cmd.replace('$$COMMIT$$', build['commit'])
1761         cmd = cmd.replace('$$VERSION$$', build['version'])
1762         cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1763     return cmd
1764
1765
1766 def place_srclib(root_dir, number, libpath):
1767     if not number:
1768         return
1769     relpath = os.path.relpath(libpath, root_dir)
1770     proppath = os.path.join(root_dir, 'project.properties')
1771
1772     lines = []
1773     if os.path.isfile(proppath):
1774         with open(proppath, "r") as o:
1775             lines = o.readlines()
1776
1777     with open(proppath, "w") as o:
1778         placed = False
1779         for line in lines:
1780             if line.startswith('android.library.reference.%d=' % number):
1781                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1782                 placed = True
1783             else:
1784                 o.write(line)
1785         if not placed:
1786             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1787
1788 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1789
1790
1791 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1792     """Verify that two apks are the same
1793
1794     One of the inputs is signed, the other is unsigned. The signature metadata
1795     is transferred from the signed to the unsigned apk, and then jarsigner is
1796     used to verify that the signature from the signed apk is also varlid for
1797     the unsigned one.
1798     :param signed_apk: Path to a signed apk file
1799     :param unsigned_apk: Path to an unsigned apk file expected to match it
1800     :param tmp_dir: Path to directory for temporary files
1801     :returns: None if the verification is successful, otherwise a string
1802               describing what went wrong.
1803     """
1804     with ZipFile(signed_apk) as signed_apk_as_zip:
1805         meta_inf_files = ['META-INF/MANIFEST.MF']
1806         for f in signed_apk_as_zip.namelist():
1807             if apk_sigfile.match(f):
1808                 meta_inf_files.append(f)
1809         if len(meta_inf_files) < 3:
1810             return "Signature files missing from {0}".format(signed_apk)
1811         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1812     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1813         for meta_inf_file in meta_inf_files:
1814             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1815
1816     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1817         logging.info("...NOT verified - {0}".format(signed_apk))
1818         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1819     logging.info("...successfully verified")
1820     return None
1821
1822 apk_badchars = re.compile('''[/ :;'"]''')
1823
1824
1825 def compare_apks(apk1, apk2, tmp_dir):
1826     """Compare two apks
1827
1828     Returns None if the apk content is the same (apart from the signing key),
1829     otherwise a string describing what's different, or what went wrong when
1830     trying to do the comparison.
1831     """
1832
1833     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
1834     apk2dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk2[0:-4]))  # trim .apk
1835     for d in [apk1dir, apk2dir]:
1836         if os.path.exists(d):
1837             shutil.rmtree(d)
1838         os.mkdir(d)
1839         os.mkdir(os.path.join(d, 'jar-xf'))
1840
1841     if subprocess.call(['jar', 'xf',
1842                         os.path.abspath(apk1)],
1843                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1844         return("Failed to unpack " + apk1)
1845     if subprocess.call(['jar', 'xf',
1846                         os.path.abspath(apk2)],
1847                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1848         return("Failed to unpack " + apk2)
1849
1850     # try to find apktool in the path, if it hasn't been manually configed
1851     if 'apktool' not in config:
1852         tmp = find_command('apktool')
1853         if tmp is not None:
1854             config['apktool'] = tmp
1855     if 'apktool' in config:
1856         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1857                            cwd=apk1dir) != 0:
1858             return("Failed to unpack " + apk1)
1859         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1860                            cwd=apk2dir) != 0:
1861             return("Failed to unpack " + apk2)
1862
1863     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1864     lines = p.output.splitlines()
1865     if len(lines) != 1 or 'META-INF' not in lines[0]:
1866         meld = find_command('meld')
1867         if meld is not None:
1868             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1869         return("Unexpected diff output - " + p.output)
1870
1871     # since everything verifies, delete the comparison to keep cruft down
1872     shutil.rmtree(apk1dir)
1873     shutil.rmtree(apk2dir)
1874
1875     # If we get here, it seems like they're the same!
1876     return None
1877
1878
1879 def find_command(command):
1880     '''find the full path of a command, or None if it can't be found in the PATH'''
1881
1882     def is_exe(fpath):
1883         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1884
1885     fpath, fname = os.path.split(command)
1886     if fpath:
1887         if is_exe(command):
1888             return command
1889     else:
1890         for path in os.environ["PATH"].split(os.pathsep):
1891             path = path.strip('"')
1892             exe_file = os.path.join(path, command)
1893             if is_exe(exe_file):
1894                 return exe_file
1895
1896     return None
1897
1898
1899 def genpassword():
1900     '''generate a random password for when generating keys'''
1901     h = hashlib.sha256()
1902     h.update(os.urandom(16))  # salt
1903     h.update(bytes(socket.getfqdn()))
1904     return h.digest().encode('base64').strip()
1905
1906
1907 def genkeystore(localconfig):
1908     '''Generate a new key with random passwords and add it to new keystore'''
1909     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1910     keystoredir = os.path.dirname(localconfig['keystore'])
1911     if keystoredir is None or keystoredir == '':
1912         keystoredir = os.path.join(os.getcwd(), keystoredir)
1913     if not os.path.exists(keystoredir):
1914         os.makedirs(keystoredir, mode=0o700)
1915
1916     write_password_file("keystorepass", localconfig['keystorepass'])
1917     write_password_file("keypass", localconfig['keypass'])
1918     p = FDroidPopen(['keytool', '-genkey',
1919                      '-keystore', localconfig['keystore'],
1920                      '-alias', localconfig['repo_keyalias'],
1921                      '-keyalg', 'RSA', '-keysize', '4096',
1922                      '-sigalg', 'SHA256withRSA',
1923                      '-validity', '10000',
1924                      '-storepass:file', config['keystorepassfile'],
1925                      '-keypass:file', config['keypassfile'],
1926                      '-dname', localconfig['keydname']])
1927     # TODO keypass should be sent via stdin
1928     if p.returncode != 0:
1929         raise BuildException("Failed to generate key", p.output)
1930     os.chmod(localconfig['keystore'], 0o0600)
1931     # now show the lovely key that was just generated
1932     p = FDroidPopen(['keytool', '-list', '-v',
1933                      '-keystore', localconfig['keystore'],
1934                      '-alias', localconfig['repo_keyalias'],
1935                      '-storepass:file', config['keystorepassfile']])
1936     logging.info(p.output.strip() + '\n\n')
1937
1938
1939 def write_to_config(thisconfig, key, value=None):
1940     '''write a key/value to the local config.py'''
1941     if value is None:
1942         origkey = key + '_orig'
1943         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1944     with open('config.py', 'r') as f:
1945         data = f.read()
1946     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1947     repl = '\n' + key + ' = "' + value + '"'
1948     data = re.sub(pattern, repl, data)
1949     # if this key is not in the file, append it
1950     if not re.match('\s*' + key + '\s*=\s*"', data):
1951         data += repl
1952     # make sure the file ends with a carraige return
1953     if not re.match('\n$', data):
1954         data += '\n'
1955     with open('config.py', 'w') as f:
1956         f.writelines(data)
1957
1958
1959 def parse_xml(path):
1960     return XMLElementTree.parse(path).getroot()
1961
1962
1963 def string_is_integer(string):
1964     try:
1965         int(string)
1966         return True
1967     except ValueError:
1968         return False
1969
1970
1971 def get_per_app_repos():
1972     '''per-app repos are dirs named with the packageName of a single app'''
1973
1974     # Android packageNames are Java packages, they may contain uppercase or
1975     # lowercase letters ('A' through 'Z'), numbers, and underscores
1976     # ('_'). However, individual package name parts may only start with
1977     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1978     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1979
1980     repos = []
1981     for root, dirs, files in os.walk(os.getcwd()):
1982         for d in dirs:
1983             print 'checking', root, 'for', d
1984             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1985                 # standard parts of an fdroid repo, so never packageNames
1986                 continue
1987             elif p.match(d) \
1988                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
1989                 repos.append(d)
1990         break
1991     return repos