chiark / gitweb /
extension simplifications
[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                 f.write(line)
653
654         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
655         if p.returncode != 0:
656             raise VCSException("Git submodule sync failed", p.output)
657         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
658         if p.returncode != 0:
659             raise VCSException("Git submodule update failed", p.output)
660
661     def _gettags(self):
662         self.checkrepo()
663         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
664         return p.output.splitlines()
665
666     def latesttags(self, tags, number):
667         self.checkrepo()
668         tl = []
669         for tag in tags:
670             p = FDroidPopen(
671                 ['git', 'show', '--format=format:%ct', '-s', tag],
672                 cwd=self.local, output=False)
673             # Timestamp is on the last line. For a normal tag, it's the only
674             # line, but for annotated tags, the rest of the info precedes it.
675             ts = int(p.output.splitlines()[-1])
676             tl.append((ts, tag))
677         latest = []
678         for _, t in sorted(tl)[-number:]:
679             latest.append(t)
680         return latest
681
682
683 class vcs_gitsvn(vcs):
684
685     def repotype(self):
686         return 'git-svn'
687
688     # If the local directory exists, but is somehow not a git repository, git
689     # will traverse up the directory tree until it finds one that is (i.e.
690     # fdroidserver) and then we'll proceed to destory it! This is called as
691     # a safety check.
692     def checkrepo(self):
693         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
694         result = p.output.rstrip()
695         if not result.endswith(self.local):
696             raise VCSException('Repository mismatch')
697
698     def gotorevisionx(self, rev):
699         if not os.path.exists(self.local):
700             # Brand new checkout
701             gitsvn_args = ['git', 'svn', 'clone']
702             if ';' in self.remote:
703                 remote_split = self.remote.split(';')
704                 for i in remote_split[1:]:
705                     if i.startswith('trunk='):
706                         gitsvn_args.extend(['-T', i[6:]])
707                     elif i.startswith('tags='):
708                         gitsvn_args.extend(['-t', i[5:]])
709                     elif i.startswith('branches='):
710                         gitsvn_args.extend(['-b', i[9:]])
711                 gitsvn_args.extend([remote_split[0], self.local])
712                 p = FDroidPopen(gitsvn_args, output=False)
713                 if p.returncode != 0:
714                     self.clone_failed = True
715                     raise VCSException("Git svn clone failed", p.output)
716             else:
717                 gitsvn_args.extend([self.remote, self.local])
718                 p = FDroidPopen(gitsvn_args, output=False)
719                 if p.returncode != 0:
720                     self.clone_failed = True
721                     raise VCSException("Git svn clone failed", p.output)
722             self.checkrepo()
723         else:
724             self.checkrepo()
725             # Discard any working tree changes
726             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
727             if p.returncode != 0:
728                 raise VCSException("Git reset failed", p.output)
729             # Remove untracked files now, in case they're tracked in the target
730             # revision (it happens!)
731             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
732             if p.returncode != 0:
733                 raise VCSException("Git clean failed", p.output)
734             if not self.refreshed:
735                 # Get new commits, branches and tags from repo
736                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
737                 if p.returncode != 0:
738                     raise VCSException("Git svn fetch failed")
739                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
740                 if p.returncode != 0:
741                     raise VCSException("Git svn rebase failed", p.output)
742                 self.refreshed = True
743
744         rev = rev or 'master'
745         if rev:
746             nospaces_rev = rev.replace(' ', '%20')
747             # Try finding a svn tag
748             for treeish in ['origin/', '']:
749                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
750                 if p.returncode == 0:
751                     break
752             if p.returncode != 0:
753                 # No tag found, normal svn rev translation
754                 # Translate svn rev into git format
755                 rev_split = rev.split('/')
756
757                 p = None
758                 for treeish in ['origin/', '']:
759                     if len(rev_split) > 1:
760                         treeish += rev_split[0]
761                         svn_rev = rev_split[1]
762
763                     else:
764                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
765                         treeish += 'master'
766                         svn_rev = rev
767
768                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
769
770                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
771                     git_rev = p.output.rstrip()
772
773                     if p.returncode == 0 and git_rev:
774                         break
775
776                 if p.returncode != 0 or not git_rev:
777                     # Try a plain git checkout as a last resort
778                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
779                     if p.returncode != 0:
780                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
781                 else:
782                     # Check out the git rev equivalent to the svn rev
783                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
784                     if p.returncode != 0:
785                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
786
787         # Get rid of any uncontrolled files left behind
788         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
789         if p.returncode != 0:
790             raise VCSException("Git clean failed", p.output)
791
792     def _gettags(self):
793         self.checkrepo()
794         for treeish in ['origin/', '']:
795             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
796             if os.path.isdir(d):
797                 return os.listdir(d)
798
799     def getref(self):
800         self.checkrepo()
801         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
802         if p.returncode != 0:
803             return None
804         return p.output.strip()
805
806
807 class vcs_hg(vcs):
808
809     def repotype(self):
810         return 'hg'
811
812     def gotorevisionx(self, rev):
813         if not os.path.exists(self.local):
814             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
815             if p.returncode != 0:
816                 self.clone_failed = True
817                 raise VCSException("Hg clone failed", p.output)
818         else:
819             p = FDroidPopen(['hg', 'status', '-uS'], cwd=self.local, output=False)
820             if p.returncode != 0:
821                 raise VCSException("Hg status failed", p.output)
822             for line in p.output.splitlines():
823                 if not line.startswith('? '):
824                     raise VCSException("Unexpected output from hg status -uS: " + line)
825                 FDroidPopen(['rm', '-rf', line[2:]], cwd=self.local, output=False)
826             if not self.refreshed:
827                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
828                 if p.returncode != 0:
829                     raise VCSException("Hg pull failed", p.output)
830                 self.refreshed = True
831
832         rev = rev or 'default'
833         if not rev:
834             return
835         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
836         if p.returncode != 0:
837             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
838         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
839         # Also delete untracked files, we have to enable purge extension for that:
840         if "'purge' is provided by the following extension" in p.output:
841             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
842                 myfile.write("\n[extensions]\nhgext.purge=\n")
843             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
844             if p.returncode != 0:
845                 raise VCSException("HG purge failed", p.output)
846         elif p.returncode != 0:
847             raise VCSException("HG purge failed", p.output)
848
849     def _gettags(self):
850         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
851         return p.output.splitlines()[1:]
852
853
854 class vcs_bzr(vcs):
855
856     def repotype(self):
857         return 'bzr'
858
859     def gotorevisionx(self, rev):
860         if not os.path.exists(self.local):
861             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
862             if p.returncode != 0:
863                 self.clone_failed = True
864                 raise VCSException("Bzr branch failed", p.output)
865         else:
866             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
867             if p.returncode != 0:
868                 raise VCSException("Bzr revert failed", p.output)
869             if not self.refreshed:
870                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
871                 if p.returncode != 0:
872                     raise VCSException("Bzr update failed", p.output)
873                 self.refreshed = True
874
875         revargs = list(['-r', rev] if rev else [])
876         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
877         if p.returncode != 0:
878             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
879
880     def _gettags(self):
881         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
882         return [tag.split('   ')[0].strip() for tag in
883                 p.output.splitlines()]
884
885
886 def unescape_string(string):
887     if len(string) < 2:
888         return string
889     if string[0] == '"' and string[-1] == '"':
890         return string[1:-1]
891
892     return string.replace("\\'", "'")
893
894
895 def retrieve_string(app_dir, string, xmlfiles=None):
896
897     if not string.startswith('@string/'):
898         return unescape_string(string)
899
900     if xmlfiles is None:
901         xmlfiles = []
902         for res_dir in [
903             os.path.join(app_dir, 'res'),
904             os.path.join(app_dir, 'src', 'main', 'res'),
905         ]:
906             for r, d, f in os.walk(res_dir):
907                 if os.path.basename(r) == 'values':
908                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
909
910     name = string[len('@string/'):]
911
912     def element_content(element):
913         if element.text is None:
914             return ""
915         return XMLElementTree.tostring(element, encoding='utf-8', method='text')
916
917     for path in xmlfiles:
918         if not os.path.isfile(path):
919             continue
920         xml = parse_xml(path)
921         element = xml.find('string[@name="' + name + '"]')
922         if element is not None:
923             content = element_content(element)
924             return retrieve_string(app_dir, content, xmlfiles)
925
926     return ''
927
928
929 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
930     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
931
932
933 # Return list of existing files that will be used to find the highest vercode
934 def manifest_paths(app_dir, flavours):
935
936     possible_manifests = \
937         [os.path.join(app_dir, 'AndroidManifest.xml'),
938          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
939          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
940          os.path.join(app_dir, 'build.gradle')]
941
942     for flavour in flavours:
943         if flavour == 'yes':
944             continue
945         possible_manifests.append(
946             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
947
948     return [path for path in possible_manifests if os.path.isfile(path)]
949
950
951 # Retrieve the package name. Returns the name, or None if not found.
952 def fetch_real_name(app_dir, flavours):
953     for path in manifest_paths(app_dir, flavours):
954         if not has_extension(path, 'xml') or not os.path.isfile(path):
955             continue
956         logging.debug("fetch_real_name: Checking manifest at " + path)
957         xml = parse_xml(path)
958         app = xml.find('application')
959         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
960             continue
961         label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
962         result = retrieve_string_singleline(app_dir, label)
963         if result:
964             result = result.strip()
965         return result
966     return None
967
968
969 def get_library_references(root_dir):
970     libraries = []
971     proppath = os.path.join(root_dir, 'project.properties')
972     if not os.path.isfile(proppath):
973         return libraries
974     for line in file(proppath):
975         if not line.startswith('android.library.reference.'):
976             continue
977         path = line.split('=')[1].strip()
978         relpath = os.path.join(root_dir, path)
979         if not os.path.isdir(relpath):
980             continue
981         logging.debug("Found subproject at %s" % path)
982         libraries.append(path)
983     return libraries
984
985
986 def ant_subprojects(root_dir):
987     subprojects = get_library_references(root_dir)
988     for subpath in subprojects:
989         subrelpath = os.path.join(root_dir, subpath)
990         for p in get_library_references(subrelpath):
991             relp = os.path.normpath(os.path.join(subpath, p))
992             if relp not in subprojects:
993                 subprojects.insert(0, relp)
994     return subprojects
995
996
997 def remove_debuggable_flags(root_dir):
998     # Remove forced debuggable flags
999     logging.debug("Removing debuggable flags from %s" % root_dir)
1000     for root, dirs, files in os.walk(root_dir):
1001         if 'AndroidManifest.xml' in files:
1002             regsub_file(r'android:debuggable="[^"]*"',
1003                         '',
1004                         os.path.join(root, 'AndroidManifest.xml'))
1005
1006
1007 # Extract some information from the AndroidManifest.xml at the given path.
1008 # Returns (version, vercode, package), any or all of which might be None.
1009 # All values returned are strings.
1010 def parse_androidmanifests(paths, ignoreversions=None):
1011
1012     if not paths:
1013         return (None, None, None)
1014
1015     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1016     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1017     psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1018
1019     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1020
1021     max_version = None
1022     max_vercode = None
1023     max_package = None
1024
1025     for path in paths:
1026
1027         if not os.path.isfile(path):
1028             continue
1029
1030         logging.debug("Parsing manifest at {0}".format(path))
1031         gradle = has_extension(path, 'gradle')
1032         version = None
1033         vercode = None
1034         package = None
1035
1036         if gradle:
1037             for line in file(path):
1038                 if gradle_comment.match(line):
1039                     continue
1040                 # Grab first occurence of each to avoid running into
1041                 # alternative flavours and builds.
1042                 if not package:
1043                     matches = psearch_g(line)
1044                     if matches:
1045                         package = matches.group(2)
1046                 if not version:
1047                     matches = vnsearch_g(line)
1048                     if matches:
1049                         version = matches.group(2)
1050                 if not vercode:
1051                     matches = vcsearch_g(line)
1052                     if matches:
1053                         vercode = matches.group(1)
1054         else:
1055             xml = parse_xml(path)
1056             if "package" in xml.attrib:
1057                 package = xml.attrib["package"].encode('utf-8')
1058             if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1059                 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1060                 base_dir = os.path.dirname(path)
1061                 version = retrieve_string_singleline(base_dir, version)
1062             if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1063                 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1064                 if string_is_integer(a):
1065                     vercode = a
1066
1067         # Remember package name, may be defined separately from version+vercode
1068         if package is None:
1069             package = max_package
1070
1071         logging.debug("..got package={0}, version={1}, vercode={2}"
1072                       .format(package, version, vercode))
1073
1074         # Always grab the package name and version name in case they are not
1075         # together with the highest version code
1076         if max_package is None and package is not None:
1077             max_package = package
1078         if max_version is None and version is not None:
1079             max_version = version
1080
1081         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1082             if not ignoresearch or not ignoresearch(version):
1083                 if version is not None:
1084                     max_version = version
1085                 if vercode is not None:
1086                     max_vercode = vercode
1087                 if package is not None:
1088                     max_package = package
1089             else:
1090                 max_version = "Ignore"
1091
1092     if max_version is None:
1093         max_version = "Unknown"
1094
1095     if max_package and not is_valid_package_name(max_package):
1096         raise FDroidException("Invalid package name {0}".format(max_package))
1097
1098     return (max_version, max_vercode, max_package)
1099
1100
1101 def is_valid_package_name(name):
1102     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1103
1104
1105 class FDroidException(Exception):
1106
1107     def __init__(self, value, detail=None):
1108         self.value = value
1109         self.detail = detail
1110
1111     def shortened_detail(self):
1112         if len(self.detail) < 16000:
1113             return self.detail
1114         return '[...]\n' + self.detail[-16000:]
1115
1116     def get_wikitext(self):
1117         ret = repr(self.value) + "\n"
1118         if self.detail:
1119             ret += "=detail=\n"
1120             ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1121         return ret
1122
1123     def __str__(self):
1124         ret = self.value
1125         if self.detail:
1126             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1127         return ret
1128
1129
1130 class VCSException(FDroidException):
1131     pass
1132
1133
1134 class BuildException(FDroidException):
1135     pass
1136
1137
1138 # Get the specified source library.
1139 # Returns the path to it. Normally this is the path to be used when referencing
1140 # it, which may be a subdirectory of the actual project. If you want the base
1141 # directory of the project, pass 'basepath=True'.
1142 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1143               raw=False, prepare=True, preponly=False, refresh=True):
1144
1145     number = None
1146     subdir = None
1147     if raw:
1148         name = spec
1149         ref = None
1150     else:
1151         name, ref = spec.split('@')
1152         if ':' in name:
1153             number, name = name.split(':', 1)
1154         if '/' in name:
1155             name, subdir = name.split('/', 1)
1156
1157     if name not in metadata.srclibs:
1158         raise VCSException('srclib ' + name + ' not found.')
1159
1160     srclib = metadata.srclibs[name]
1161
1162     sdir = os.path.join(srclib_dir, name)
1163
1164     if not preponly:
1165         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1166         vcs.srclib = (name, number, sdir)
1167         if ref:
1168             vcs.gotorevision(ref, refresh)
1169
1170         if raw:
1171             return vcs
1172
1173     libdir = None
1174     if subdir:
1175         libdir = os.path.join(sdir, subdir)
1176     elif srclib["Subdir"]:
1177         for subdir in srclib["Subdir"]:
1178             libdir_candidate = os.path.join(sdir, subdir)
1179             if os.path.exists(libdir_candidate):
1180                 libdir = libdir_candidate
1181                 break
1182
1183     if libdir is None:
1184         libdir = sdir
1185
1186     remove_signing_keys(sdir)
1187     remove_debuggable_flags(sdir)
1188
1189     if prepare:
1190
1191         if srclib["Prepare"]:
1192             cmd = replace_config_vars(srclib["Prepare"], None)
1193
1194             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1195             if p.returncode != 0:
1196                 raise BuildException("Error running prepare command for srclib %s"
1197                                      % name, p.output)
1198
1199     if basepath:
1200         libdir = sdir
1201
1202     return (name, number, libdir)
1203
1204
1205 # Prepare the source code for a particular build
1206 #  'vcs'         - the appropriate vcs object for the application
1207 #  'app'         - the application details from the metadata
1208 #  'build'       - the build details from the metadata
1209 #  'build_dir'   - the path to the build directory, usually
1210 #                   'build/app.id'
1211 #  'srclib_dir'  - the path to the source libraries directory, usually
1212 #                   'build/srclib'
1213 #  'extlib_dir'  - the path to the external libraries directory, usually
1214 #                   'build/extlib'
1215 # Returns the (root, srclibpaths) where:
1216 #   'root' is the root directory, which may be the same as 'build_dir' or may
1217 #          be a subdirectory of it.
1218 #   'srclibpaths' is information on the srclibs being used
1219 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1220
1221     # Optionally, the actual app source can be in a subdirectory
1222     if build['subdir']:
1223         root_dir = os.path.join(build_dir, build['subdir'])
1224     else:
1225         root_dir = build_dir
1226
1227     # Get a working copy of the right revision
1228     logging.info("Getting source for revision " + build['commit'])
1229     vcs.gotorevision(build['commit'], refresh)
1230
1231     # Initialise submodules if required
1232     if build['submodules']:
1233         logging.info("Initialising submodules")
1234         vcs.initsubmodules()
1235
1236     # Check that a subdir (if we're using one) exists. This has to happen
1237     # after the checkout, since it might not exist elsewhere
1238     if not os.path.exists(root_dir):
1239         raise BuildException('Missing subdir ' + root_dir)
1240
1241     # Run an init command if one is required
1242     if build['init']:
1243         cmd = replace_config_vars(build['init'], build)
1244         logging.info("Running 'init' commands in %s" % root_dir)
1245
1246         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1247         if p.returncode != 0:
1248             raise BuildException("Error running init command for %s:%s" %
1249                                  (app['id'], build['version']), p.output)
1250
1251     # Apply patches if any
1252     if build['patch']:
1253         logging.info("Applying patches")
1254         for patch in build['patch']:
1255             patch = patch.strip()
1256             logging.info("Applying " + patch)
1257             patch_path = os.path.join('metadata', app['id'], patch)
1258             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1259             if p.returncode != 0:
1260                 raise BuildException("Failed to apply patch %s" % patch_path)
1261
1262     # Get required source libraries
1263     srclibpaths = []
1264     if build['srclibs']:
1265         logging.info("Collecting source libraries")
1266         for lib in build['srclibs']:
1267             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1268
1269     for name, number, libpath in srclibpaths:
1270         place_srclib(root_dir, int(number) if number else None, libpath)
1271
1272     basesrclib = vcs.getsrclib()
1273     # If one was used for the main source, add that too.
1274     if basesrclib:
1275         srclibpaths.append(basesrclib)
1276
1277     # Update the local.properties file
1278     localprops = [os.path.join(build_dir, 'local.properties')]
1279     if build['subdir']:
1280         localprops += [os.path.join(root_dir, 'local.properties')]
1281     for path in localprops:
1282         props = ""
1283         if os.path.isfile(path):
1284             logging.info("Updating local.properties file at %s" % path)
1285             with open(path, 'r') as f:
1286                 props += f.read()
1287             props += '\n'
1288         else:
1289             logging.info("Creating local.properties file at %s" % path)
1290         # Fix old-fashioned 'sdk-location' by copying
1291         # from sdk.dir, if necessary
1292         if build['oldsdkloc']:
1293             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1294                               re.S | re.M).group(1)
1295             props += "sdk-location=%s\n" % sdkloc
1296         else:
1297             props += "sdk.dir=%s\n" % config['sdk_path']
1298             props += "sdk-location=%s\n" % config['sdk_path']
1299         if build['ndk_path']:
1300             # Add ndk location
1301             props += "ndk.dir=%s\n" % build['ndk_path']
1302             props += "ndk-location=%s\n" % build['ndk_path']
1303         # Add java.encoding if necessary
1304         if build['encoding']:
1305             props += "java.encoding=%s\n" % build['encoding']
1306         with open(path, 'w') as f:
1307             f.write(props)
1308
1309     flavours = []
1310     if build['type'] == 'gradle':
1311         flavours = build['gradle']
1312
1313         version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1314         gradlepluginver = None
1315
1316         gradle_dirs = [root_dir]
1317
1318         # Parent dir build.gradle
1319         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1320         if parent_dir.startswith(build_dir):
1321             gradle_dirs.append(parent_dir)
1322
1323         for dir_path in gradle_dirs:
1324             if gradlepluginver:
1325                 break
1326             if not os.path.isdir(dir_path):
1327                 continue
1328             for filename in os.listdir(dir_path):
1329                 if not filename.endswith('.gradle'):
1330                     continue
1331                 path = os.path.join(dir_path, filename)
1332                 if not os.path.isfile(path):
1333                     continue
1334                 for line in file(path):
1335                     match = version_regex.match(line)
1336                     if match:
1337                         gradlepluginver = match.group(1)
1338                         break
1339
1340         if gradlepluginver:
1341             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1342         else:
1343             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1344             build['gradlepluginver'] = LooseVersion('0.11')
1345
1346         if build['target']:
1347             n = build["target"].split('-')[1]
1348             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1349                         r'compileSdkVersion %s' % n,
1350                         os.path.join(root_dir, 'build.gradle'))
1351
1352     # Remove forced debuggable flags
1353     remove_debuggable_flags(root_dir)
1354
1355     # Insert version code and number into the manifest if necessary
1356     if build['forceversion']:
1357         logging.info("Changing the version name")
1358         for path in manifest_paths(root_dir, flavours):
1359             if not os.path.isfile(path):
1360                 continue
1361             if has_extension(path, 'xml'):
1362                 regsub_file(r'android:versionName="[^"]*"',
1363                             r'android:versionName="%s"' % build['version'],
1364                             path)
1365             elif has_extension(path, 'gradle'):
1366                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1367                             r"""\1versionName '%s'""" % build['version'],
1368                             path)
1369
1370     if build['forcevercode']:
1371         logging.info("Changing the version code")
1372         for path in manifest_paths(root_dir, flavours):
1373             if not os.path.isfile(path):
1374                 continue
1375             if has_extension(path, 'xml'):
1376                 regsub_file(r'android:versionCode="[^"]*"',
1377                             r'android:versionCode="%s"' % build['vercode'],
1378                             path)
1379             elif has_extension(path, 'gradle'):
1380                 regsub_file(r'versionCode[ =]+[0-9]+',
1381                             r'versionCode %s' % build['vercode'],
1382                             path)
1383
1384     # Delete unwanted files
1385     if build['rm']:
1386         logging.info("Removing specified files")
1387         for part in getpaths(build_dir, build['rm']):
1388             dest = os.path.join(build_dir, part)
1389             logging.info("Removing {0}".format(part))
1390             if os.path.lexists(dest):
1391                 if os.path.islink(dest):
1392                     FDroidPopen(['unlink', dest], output=False)
1393                 else:
1394                     FDroidPopen(['rm', '-rf', dest], output=False)
1395             else:
1396                 logging.info("...but it didn't exist")
1397
1398     remove_signing_keys(build_dir)
1399
1400     # Add required external libraries
1401     if build['extlibs']:
1402         logging.info("Collecting prebuilt libraries")
1403         libsdir = os.path.join(root_dir, 'libs')
1404         if not os.path.exists(libsdir):
1405             os.mkdir(libsdir)
1406         for lib in build['extlibs']:
1407             lib = lib.strip()
1408             logging.info("...installing extlib {0}".format(lib))
1409             libf = os.path.basename(lib)
1410             libsrc = os.path.join(extlib_dir, lib)
1411             if not os.path.exists(libsrc):
1412                 raise BuildException("Missing extlib file {0}".format(libsrc))
1413             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1414
1415     # Run a pre-build command if one is required
1416     if build['prebuild']:
1417         logging.info("Running 'prebuild' commands in %s" % root_dir)
1418
1419         cmd = replace_config_vars(build['prebuild'], build)
1420
1421         # Substitute source library paths into prebuild commands
1422         for name, number, libpath in srclibpaths:
1423             libpath = os.path.relpath(libpath, root_dir)
1424             cmd = cmd.replace('$$' + name + '$$', libpath)
1425
1426         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1427         if p.returncode != 0:
1428             raise BuildException("Error running prebuild command for %s:%s" %
1429                                  (app['id'], build['version']), p.output)
1430
1431     # Generate (or update) the ant build file, build.xml...
1432     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1433         parms = ['android', 'update', 'lib-project']
1434         lparms = ['android', 'update', 'project']
1435
1436         if build['target']:
1437             parms += ['-t', build['target']]
1438             lparms += ['-t', build['target']]
1439         if build['update'] == ['auto']:
1440             update_dirs = ant_subprojects(root_dir) + ['.']
1441         else:
1442             update_dirs = build['update']
1443
1444         for d in update_dirs:
1445             subdir = os.path.join(root_dir, d)
1446             if d == '.':
1447                 logging.debug("Updating main project")
1448                 cmd = parms + ['-p', d]
1449             else:
1450                 logging.debug("Updating subproject %s" % d)
1451                 cmd = lparms + ['-p', d]
1452             p = SdkToolsPopen(cmd, cwd=root_dir)
1453             # Check to see whether an error was returned without a proper exit
1454             # code (this is the case for the 'no target set or target invalid'
1455             # error)
1456             if p.returncode != 0 or p.output.startswith("Error: "):
1457                 raise BuildException("Failed to update project at %s" % d, p.output)
1458             # Clean update dirs via ant
1459             if d != '.':
1460                 logging.info("Cleaning subproject %s" % d)
1461                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1462
1463     return (root_dir, srclibpaths)
1464
1465
1466 # Extend via globbing the paths from a field and return them as a map from
1467 # original path to resulting paths
1468 def getpaths_map(build_dir, globpaths):
1469     paths = dict()
1470     for p in globpaths:
1471         p = p.strip()
1472         full_path = os.path.join(build_dir, p)
1473         full_path = os.path.normpath(full_path)
1474         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1475     return paths
1476
1477
1478 # Extend via globbing the paths from a field and return them as a set
1479 def getpaths(build_dir, globpaths):
1480     paths_map = getpaths_map(build_dir, globpaths)
1481     paths = set()
1482     for k, v in paths_map.iteritems():
1483         for p in v:
1484             paths.add(p)
1485     return paths
1486
1487
1488 def natural_key(s):
1489     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1490
1491
1492 class KnownApks:
1493
1494     def __init__(self):
1495         self.path = os.path.join('stats', 'known_apks.txt')
1496         self.apks = {}
1497         if os.path.isfile(self.path):
1498             for line in file(self.path):
1499                 t = line.rstrip().split(' ')
1500                 if len(t) == 2:
1501                     self.apks[t[0]] = (t[1], None)
1502                 else:
1503                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1504         self.changed = False
1505
1506     def writeifchanged(self):
1507         if not self.changed:
1508             return
1509
1510         if not os.path.exists('stats'):
1511             os.mkdir('stats')
1512
1513         lst = []
1514         for apk, app in self.apks.iteritems():
1515             appid, added = app
1516             line = apk + ' ' + appid
1517             if added:
1518                 line += ' ' + time.strftime('%Y-%m-%d', added)
1519             lst.append(line)
1520
1521         with open(self.path, 'w') as f:
1522             for line in sorted(lst, key=natural_key):
1523                 f.write(line + '\n')
1524
1525     # Record an apk (if it's new, otherwise does nothing)
1526     # Returns the date it was added.
1527     def recordapk(self, apk, app):
1528         if apk not in self.apks:
1529             self.apks[apk] = (app, time.gmtime(time.time()))
1530             self.changed = True
1531         _, added = self.apks[apk]
1532         return added
1533
1534     # Look up information - given the 'apkname', returns (app id, date added/None).
1535     # Or returns None for an unknown apk.
1536     def getapp(self, apkname):
1537         if apkname in self.apks:
1538             return self.apks[apkname]
1539         return None
1540
1541     # Get the most recent 'num' apps added to the repo, as a list of package ids
1542     # with the most recent first.
1543     def getlatest(self, num):
1544         apps = {}
1545         for apk, app in self.apks.iteritems():
1546             appid, added = app
1547             if added:
1548                 if appid in apps:
1549                     if apps[appid] > added:
1550                         apps[appid] = added
1551                 else:
1552                     apps[appid] = added
1553         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1554         lst = [app for app, _ in sortedapps]
1555         lst.reverse()
1556         return lst
1557
1558
1559 def isApkDebuggable(apkfile, config):
1560     """Returns True if the given apk file is debuggable
1561
1562     :param apkfile: full path to the apk to check"""
1563
1564     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1565                       output=False)
1566     if p.returncode != 0:
1567         logging.critical("Failed to get apk manifest information")
1568         sys.exit(1)
1569     for line in p.output.splitlines():
1570         if 'android:debuggable' in line and not line.endswith('0x0'):
1571             return True
1572     return False
1573
1574
1575 class PopenResult:
1576     returncode = None
1577     output = ''
1578
1579
1580 def SdkToolsPopen(commands, cwd=None, output=True):
1581     cmd = commands[0]
1582     if cmd not in config:
1583         config[cmd] = find_sdk_tools_cmd(commands[0])
1584     return FDroidPopen([config[cmd]] + commands[1:],
1585                        cwd=cwd, output=output)
1586
1587
1588 def FDroidPopen(commands, cwd=None, output=True):
1589     """
1590     Run a command and capture the possibly huge output.
1591
1592     :param commands: command and argument list like in subprocess.Popen
1593     :param cwd: optionally specifies a working directory
1594     :returns: A PopenResult.
1595     """
1596
1597     global env
1598
1599     if cwd:
1600         cwd = os.path.normpath(cwd)
1601         logging.debug("Directory: %s" % cwd)
1602     logging.debug("> %s" % ' '.join(commands))
1603
1604     result = PopenResult()
1605     p = None
1606     try:
1607         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1608                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1609     except OSError, e:
1610         raise BuildException("OSError while trying to execute " +
1611                              ' '.join(commands) + ': ' + str(e))
1612
1613     stdout_queue = Queue.Queue()
1614     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1615
1616     # Check the queue for output (until there is no more to get)
1617     while not stdout_reader.eof():
1618         while not stdout_queue.empty():
1619             line = stdout_queue.get()
1620             if output and options.verbose:
1621                 # Output directly to console
1622                 sys.stderr.write(line)
1623                 sys.stderr.flush()
1624             result.output += line
1625
1626         time.sleep(0.1)
1627
1628     result.returncode = p.wait()
1629     return result
1630
1631
1632 gradle_comment = re.compile(r'[ ]*//')
1633
1634
1635 def remove_signing_keys(build_dir):
1636     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1637     line_matches = [
1638         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1639         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1640         re.compile(r'.*variant\.outputFile = .*'),
1641         re.compile(r'.*output\.outputFile = .*'),
1642         re.compile(r'.*\.readLine\(.*'),
1643     ]
1644     for root, dirs, files in os.walk(build_dir):
1645         if 'build.gradle' in files:
1646             path = os.path.join(root, 'build.gradle')
1647
1648             with open(path, "r") as o:
1649                 lines = o.readlines()
1650
1651             changed = False
1652
1653             opened = 0
1654             i = 0
1655             with open(path, "w") as o:
1656                 while i < len(lines):
1657                     line = lines[i]
1658                     i += 1
1659                     while line.endswith('\\\n'):
1660                         line = line.rstrip('\\\n') + lines[i]
1661                         i += 1
1662
1663                     if gradle_comment.match(line):
1664                         o.write(line)
1665                         continue
1666
1667                     if opened > 0:
1668                         opened += line.count('{')
1669                         opened -= line.count('}')
1670                         continue
1671
1672                     if signing_configs.match(line):
1673                         changed = True
1674                         opened += 1
1675                         continue
1676
1677                     if any(s.match(line) for s in line_matches):
1678                         changed = True
1679                         continue
1680
1681                     if opened == 0:
1682                         o.write(line)
1683
1684             if changed:
1685                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1686
1687         for propfile in [
1688                 'project.properties',
1689                 'build.properties',
1690                 'default.properties',
1691                 'ant.properties', ]:
1692             if propfile in files:
1693                 path = os.path.join(root, propfile)
1694
1695                 with open(path, "r") as o:
1696                     lines = o.readlines()
1697
1698                 changed = False
1699
1700                 with open(path, "w") as o:
1701                     for line in lines:
1702                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1703                             changed = True
1704                             continue
1705
1706                         o.write(line)
1707
1708                 if changed:
1709                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1710
1711
1712 def reset_env_path():
1713     global env, orig_path
1714     env['PATH'] = orig_path
1715
1716
1717 def add_to_env_path(path):
1718     global env
1719     paths = env['PATH'].split(os.pathsep)
1720     if path in paths:
1721         return
1722     paths.append(path)
1723     env['PATH'] = os.pathsep.join(paths)
1724
1725
1726 def replace_config_vars(cmd, build):
1727     global env
1728     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1729     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1730     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1731     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1732     if build is not None:
1733         cmd = cmd.replace('$$COMMIT$$', build['commit'])
1734         cmd = cmd.replace('$$VERSION$$', build['version'])
1735         cmd = cmd.replace('$$VERCODE$$', build['vercode'])
1736     return cmd
1737
1738
1739 def place_srclib(root_dir, number, libpath):
1740     if not number:
1741         return
1742     relpath = os.path.relpath(libpath, root_dir)
1743     proppath = os.path.join(root_dir, 'project.properties')
1744
1745     lines = []
1746     if os.path.isfile(proppath):
1747         with open(proppath, "r") as o:
1748             lines = o.readlines()
1749
1750     with open(proppath, "w") as o:
1751         placed = False
1752         for line in lines:
1753             if line.startswith('android.library.reference.%d=' % number):
1754                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1755                 placed = True
1756             else:
1757                 o.write(line)
1758         if not placed:
1759             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1760
1761
1762 def verify_apks(signed_apk, unsigned_apk, tmp_dir):
1763     """Verify that two apks are the same
1764
1765     One of the inputs is signed, the other is unsigned. The signature metadata
1766     is transferred from the signed to the unsigned apk, and then jarsigner is
1767     used to verify that the signature from the signed apk is also varlid for
1768     the unsigned one.
1769     :param signed_apk: Path to a signed apk file
1770     :param unsigned_apk: Path to an unsigned apk file expected to match it
1771     :param tmp_dir: Path to directory for temporary files
1772     :returns: None if the verification is successful, otherwise a string
1773               describing what went wrong.
1774     """
1775     sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
1776     with ZipFile(signed_apk) as signed_apk_as_zip:
1777         meta_inf_files = ['META-INF/MANIFEST.MF']
1778         for f in signed_apk_as_zip.namelist():
1779             if sigfile.match(f):
1780                 meta_inf_files.append(f)
1781         if len(meta_inf_files) < 3:
1782             return "Signature files missing from {0}".format(signed_apk)
1783         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1784     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1785         for meta_inf_file in meta_inf_files:
1786             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1787
1788     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1789         logging.info("...NOT verified - {0}".format(signed_apk))
1790         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1791     logging.info("...successfully verified")
1792     return None
1793
1794
1795 def compare_apks(apk1, apk2, tmp_dir):
1796     """Compare two apks
1797
1798     Returns None if the apk content is the same (apart from the signing key),
1799     otherwise a string describing what's different, or what went wrong when
1800     trying to do the comparison.
1801     """
1802
1803     badchars = re.compile('''[/ :;'"]''')
1804     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1805     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1806     for d in [apk1dir, apk2dir]:
1807         if os.path.exists(d):
1808             shutil.rmtree(d)
1809         os.mkdir(d)
1810         os.mkdir(os.path.join(d, 'jar-xf'))
1811
1812     if subprocess.call(['jar', 'xf',
1813                         os.path.abspath(apk1)],
1814                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1815         return("Failed to unpack " + apk1)
1816     if subprocess.call(['jar', 'xf',
1817                         os.path.abspath(apk2)],
1818                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1819         return("Failed to unpack " + apk2)
1820
1821     # try to find apktool in the path, if it hasn't been manually configed
1822     if 'apktool' not in config:
1823         tmp = find_command('apktool')
1824         if tmp is not None:
1825             config['apktool'] = tmp
1826     if 'apktool' in config:
1827         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1828                            cwd=apk1dir) != 0:
1829             return("Failed to unpack " + apk1)
1830         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1831                            cwd=apk2dir) != 0:
1832             return("Failed to unpack " + apk2)
1833
1834     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1835     lines = p.output.splitlines()
1836     if len(lines) != 1 or 'META-INF' not in lines[0]:
1837         meld = find_command('meld')
1838         if meld is not None:
1839             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1840         return("Unexpected diff output - " + p.output)
1841
1842     # since everything verifies, delete the comparison to keep cruft down
1843     shutil.rmtree(apk1dir)
1844     shutil.rmtree(apk2dir)
1845
1846     # If we get here, it seems like they're the same!
1847     return None
1848
1849
1850 def find_command(command):
1851     '''find the full path of a command, or None if it can't be found in the PATH'''
1852
1853     def is_exe(fpath):
1854         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1855
1856     fpath, fname = os.path.split(command)
1857     if fpath:
1858         if is_exe(command):
1859             return command
1860     else:
1861         for path in os.environ["PATH"].split(os.pathsep):
1862             path = path.strip('"')
1863             exe_file = os.path.join(path, command)
1864             if is_exe(exe_file):
1865                 return exe_file
1866
1867     return None
1868
1869
1870 def genpassword():
1871     '''generate a random password for when generating keys'''
1872     h = hashlib.sha256()
1873     h.update(os.urandom(16))  # salt
1874     h.update(bytes(socket.getfqdn()))
1875     return h.digest().encode('base64').strip()
1876
1877
1878 def genkeystore(localconfig):
1879     '''Generate a new key with random passwords and add it to new keystore'''
1880     logging.info('Generating a new key in "' + localconfig['keystore'] + '"...')
1881     keystoredir = os.path.dirname(localconfig['keystore'])
1882     if keystoredir is None or keystoredir == '':
1883         keystoredir = os.path.join(os.getcwd(), keystoredir)
1884     if not os.path.exists(keystoredir):
1885         os.makedirs(keystoredir, mode=0o700)
1886
1887     write_password_file("keystorepass", localconfig['keystorepass'])
1888     write_password_file("keypass", localconfig['keypass'])
1889     p = FDroidPopen(['keytool', '-genkey',
1890                      '-keystore', localconfig['keystore'],
1891                      '-alias', localconfig['repo_keyalias'],
1892                      '-keyalg', 'RSA', '-keysize', '4096',
1893                      '-sigalg', 'SHA256withRSA',
1894                      '-validity', '10000',
1895                      '-storepass:file', config['keystorepassfile'],
1896                      '-keypass:file', config['keypassfile'],
1897                      '-dname', localconfig['keydname']])
1898     # TODO keypass should be sent via stdin
1899     if p.returncode != 0:
1900         raise BuildException("Failed to generate key", p.output)
1901     os.chmod(localconfig['keystore'], 0o0600)
1902     # now show the lovely key that was just generated
1903     p = FDroidPopen(['keytool', '-list', '-v',
1904                      '-keystore', localconfig['keystore'],
1905                      '-alias', localconfig['repo_keyalias'],
1906                      '-storepass:file', config['keystorepassfile']])
1907     logging.info(p.output.strip() + '\n\n')
1908
1909
1910 def write_to_config(thisconfig, key, value=None):
1911     '''write a key/value to the local config.py'''
1912     if value is None:
1913         origkey = key + '_orig'
1914         value = thisconfig[origkey] if origkey in thisconfig else thisconfig[key]
1915     with open('config.py', 'r') as f:
1916         data = f.read()
1917     pattern = '\n[\s#]*' + key + '\s*=\s*"[^"]*"'
1918     repl = '\n' + key + ' = "' + value + '"'
1919     data = re.sub(pattern, repl, data)
1920     # if this key is not in the file, append it
1921     if not re.match('\s*' + key + '\s*=\s*"', data):
1922         data += repl
1923     # make sure the file ends with a carraige return
1924     if not re.match('\n$', data):
1925         data += '\n'
1926     with open('config.py', 'w') as f:
1927         f.writelines(data)
1928
1929
1930 def parse_xml(path):
1931     return XMLElementTree.parse(path).getroot()
1932
1933
1934 def string_is_integer(string):
1935     try:
1936         int(string)
1937         return True
1938     except ValueError:
1939         return False
1940
1941
1942 def get_per_app_repos():
1943     '''per-app repos are dirs named with the packageName of a single app'''
1944
1945     # Android packageNames are Java packages, they may contain uppercase or
1946     # lowercase letters ('A' through 'Z'), numbers, and underscores
1947     # ('_'). However, individual package name parts may only start with
1948     # letters. https://developer.android.com/guide/topics/manifest/manifest-element.html#package
1949     p = re.compile('^([a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*)?$')
1950
1951     repos = []
1952     for root, dirs, files in os.walk(os.getcwd()):
1953         for d in dirs:
1954             print 'checking', root, 'for', d
1955             if d in ('archive', 'metadata', 'repo', 'srclibs', 'tmp'):
1956                 # standard parts of an fdroid repo, so never packageNames
1957                 continue
1958             elif p.match(d) \
1959                     and os.path.exists(os.path.join(d, 'fdroid', 'repo', 'index.jar')):
1960                 repos.append(d)
1961         break
1962     return repos