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