chiark / gitweb /
a05148af8e85e70586154e775758ee71164a6349
[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 zipfile import ZipFile
39
40 import metadata
41 from fdroidserver.asynchronousfilereader import AsynchronousFileReader
42
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.2",
59     'java_paths': {
60         '1.7': "/usr/lib/jvm/java-7-openjdk",
61         '1.8': None,
62     },
63     'ant': "ant",
64     'mvn3': "mvn",
65     'gradle': 'gradle',
66     'accepted_formats': ['txt', 'yaml'],
67     'sync_from_local_copy_dir': False,
68     'per_app_repos': False,
69     'make_current_version_link': True,
70     'current_version_name_source': 'Name',
71     'update_stats': False,
72     'stats_ignore': [],
73     'stats_server': None,
74     'stats_user': None,
75     'stats_to_carbon': False,
76     'repo_maxage': 0,
77     'build_server_always': False,
78     'keystore': 'keystore.jks',
79     'smartcardoptions': [],
80     'char_limits': {
81         'Summary': 80,
82         'Description': 4000,
83     },
84     'keyaliases': {},
85     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
86     'repo_name': "My First FDroid Repo Demo",
87     'repo_icon': "fdroid-icon.png",
88     'repo_description': '''
89         This is a repository of apps to be used with FDroid. Applications in this
90         repository are either official binaries built by the original application
91         developers, or are binaries built from source by the admin of f-droid.org
92         using the tools on https://gitlab.com/u/fdroid.
93         ''',
94     'archive_older': 0,
95 }
96
97
98 def setup_global_opts(parser):
99     parser.add_argument("-v", "--verbose", action="store_true", default=False,
100                         help="Spew out even more information than normal")
101     parser.add_argument("-q", "--quiet", action="store_true", default=False,
102                         help="Restrict output to warnings and errors")
103
104
105 def fill_config_defaults(thisconfig):
106     for k, v in default_config.items():
107         if k not in thisconfig:
108             thisconfig[k] = v
109
110     # Expand paths (~users and $vars)
111     def expand_path(path):
112         if path is None:
113             return None
114         orig = path
115         path = os.path.expanduser(path)
116         path = os.path.expandvars(path)
117         if orig == path:
118             return None
119         return path
120
121     for k in ['sdk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
122         v = thisconfig[k]
123         exp = expand_path(v)
124         if exp is not None:
125             thisconfig[k] = exp
126             thisconfig[k + '_orig'] = v
127
128     for k in ['ndk_paths', 'java_paths']:
129         d = thisconfig[k]
130         for k2 in d.copy():
131             v = d[k2]
132             exp = expand_path(v)
133             if exp is not None:
134                 thisconfig[k][k2] = exp
135                 thisconfig[k][k2 + '_orig'] = v
136
137
138 def regsub_file(pattern, repl, path):
139     with open(path, 'r') as f:
140         text = f.read()
141     text = re.sub(pattern, repl, text)
142     with open(path, 'w') as f:
143         f.write(text)
144
145
146 def read_config(opts, config_file='config.py'):
147     """Read the repository config
148
149     The config is read from config_file, which is in the current directory when
150     any of the repo management commands are used.
151     """
152     global config, options, env, orig_path
153
154     if config is not None:
155         return config
156     if not os.path.isfile(config_file):
157         logging.critical("Missing config file - is this a repo directory?")
158         sys.exit(2)
159
160     options = opts
161
162     config = {}
163
164     logging.debug("Reading %s" % config_file)
165     execfile(config_file, config)
166
167     # smartcardoptions must be a list since its command line args for Popen
168     if 'smartcardoptions' in config:
169         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
170     elif 'keystore' in config and config['keystore'] == 'NONE':
171         # keystore='NONE' means use smartcard, these are required defaults
172         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
173                                       'SunPKCS11-OpenSC', '-providerClass',
174                                       'sun.security.pkcs11.SunPKCS11',
175                                       '-providerArg', 'opensc-fdroid.cfg']
176
177     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
178         st = os.stat(config_file)
179         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
180             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
181
182     fill_config_defaults(config)
183
184     # There is no standard, so just set up the most common environment
185     # variables
186     env = os.environ
187     orig_path = env['PATH']
188     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
189         env[n] = config['sdk_path']
190
191     for v in ['7', '8']:
192         cpath = config['java_paths']['1.%s' % v]
193         if cpath:
194             env['JAVA%s_HOME' % v] = cpath
195
196     for k in ["keystorepass", "keypass"]:
197         if k in config:
198             write_password_file(k)
199
200     for k in ["repo_description", "archive_description"]:
201         if k in config:
202             config[k] = clean_description(config[k])
203
204     if 'serverwebroot' in config:
205         if isinstance(config['serverwebroot'], basestring):
206             roots = [config['serverwebroot']]
207         elif all(isinstance(item, basestring) for item in config['serverwebroot']):
208             roots = config['serverwebroot']
209         else:
210             raise TypeError('only accepts strings, lists, and tuples')
211         rootlist = []
212         for rootstr in roots:
213             # since this is used with rsync, where trailing slashes have
214             # meaning, ensure there is always a trailing slash
215             if rootstr[-1] != '/':
216                 rootstr += '/'
217             rootlist.append(rootstr.replace('//', '/'))
218         config['serverwebroot'] = rootlist
219
220     return config
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     base, ext = os.path.splitext(filename)
372     if not ext:
373         return base, ''
374     return base, ext.lower()[1:]
375
376
377 def has_extension(filename, ext):
378     _, f_ext = get_extension(filename)
379     return ext == f_ext
380
381
382 apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
383
384
385 def clean_description(description):
386     'Remove unneeded newlines and spaces from a block of description text'
387     returnstring = ''
388     # this is split up by paragraph to make removing the newlines easier
389     for paragraph in re.split(r'\n\n', description):
390         paragraph = re.sub('\r', '', paragraph)
391         paragraph = re.sub('\n', ' ', paragraph)
392         paragraph = re.sub(' {2,}', ' ', paragraph)
393         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
394         returnstring += paragraph + '\n\n'
395     return returnstring.rstrip('\n')
396
397
398 def apknameinfo(filename):
399     filename = os.path.basename(filename)
400     m = apk_regex.match(filename)
401     try:
402         result = (m.group(1), m.group(2))
403     except AttributeError:
404         raise FDroidException("Invalid apk name: %s" % filename)
405     return result
406
407
408 def getapkname(app, build):
409     return "%s_%s.apk" % (app.id, build.vercode)
410
411
412 def getsrcname(app, build):
413     return "%s_%s_src.tar.gz" % (app.id, build.vercode)
414
415
416 def getappname(app):
417     if app.Name:
418         return app.Name
419     if app.AutoName:
420         return app.AutoName
421     return app.id
422
423
424 def getcvname(app):
425     return '%s (%s)' % (app.CurrentVersion, app.CurrentVersionCode)
426
427
428 def getvcs(vcstype, remote, local):
429     if vcstype == 'git':
430         return vcs_git(remote, local)
431     if vcstype == 'git-svn':
432         return vcs_gitsvn(remote, local)
433     if vcstype == 'hg':
434         return vcs_hg(remote, local)
435     if vcstype == 'bzr':
436         return vcs_bzr(remote, local)
437     if vcstype == 'srclib':
438         if local != os.path.join('build', 'srclib', remote):
439             raise VCSException("Error: srclib paths are hard-coded!")
440         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
441     if vcstype == 'svn':
442         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
443     raise VCSException("Invalid vcs type " + vcstype)
444
445
446 def getsrclibvcs(name):
447     if name not in metadata.srclibs:
448         raise VCSException("Missing srclib " + name)
449     return metadata.srclibs[name]['Repo Type']
450
451
452 class vcs:
453
454     def __init__(self, remote, local):
455
456         # svn, git-svn and bzr may require auth
457         self.username = None
458         if self.repotype() in ('git-svn', 'bzr'):
459             if '@' in remote:
460                 if self.repotype == 'git-svn':
461                     raise VCSException("Authentication is not supported for git-svn")
462                 self.username, remote = remote.split('@')
463                 if ':' not in self.username:
464                     raise VCSException("Password required with username")
465                 self.username, self.password = self.username.split(':')
466
467         self.remote = remote
468         self.local = local
469         self.clone_failed = False
470         self.refreshed = False
471         self.srclib = None
472
473     def repotype(self):
474         return None
475
476     # Take the local repository to a clean version of the given revision, which
477     # is specificed in the VCS's native format. Beforehand, the repository can
478     # be dirty, or even non-existent. If the repository does already exist
479     # locally, it will be updated from the origin, but only once in the
480     # lifetime of the vcs object.
481     # None is acceptable for 'rev' if you know you are cloning a clean copy of
482     # the repo - otherwise it must specify a valid revision.
483     def gotorevision(self, rev, refresh=True):
484
485         if self.clone_failed:
486             raise VCSException("Downloading the repository already failed once, not trying again.")
487
488         # The .fdroidvcs-id file for a repo tells us what VCS type
489         # and remote that directory was created from, allowing us to drop it
490         # automatically if either of those things changes.
491         fdpath = os.path.join(self.local, '..',
492                               '.fdroidvcs-' + os.path.basename(self.local))
493         cdata = self.repotype() + ' ' + self.remote
494         writeback = True
495         deleterepo = False
496         if os.path.exists(self.local):
497             if os.path.exists(fdpath):
498                 with open(fdpath, 'r') as f:
499                     fsdata = f.read().strip()
500                 if fsdata == cdata:
501                     writeback = False
502                 else:
503                     deleterepo = True
504                     logging.info("Repository details for %s changed - deleting" % (
505                         self.local))
506             else:
507                 deleterepo = True
508                 logging.info("Repository details for %s missing - deleting" % (
509                     self.local))
510         if deleterepo:
511             shutil.rmtree(self.local)
512
513         exc = None
514         if not refresh:
515             self.refreshed = True
516
517         try:
518             self.gotorevisionx(rev)
519         except FDroidException, e:
520             exc = e
521
522         # If necessary, write the .fdroidvcs file.
523         if writeback and not self.clone_failed:
524             with open(fdpath, 'w') as f:
525                 f.write(cdata)
526
527         if exc is not None:
528             raise exc
529
530     # Derived classes need to implement this. It's called once basic checking
531     # has been performend.
532     def gotorevisionx(self, rev):
533         raise VCSException("This VCS type doesn't define gotorevisionx")
534
535     # Initialise and update submodules
536     def initsubmodules(self):
537         raise VCSException('Submodules not supported for this vcs type')
538
539     # Get a list of all known tags
540     def gettags(self):
541         if not self._gettags:
542             raise VCSException('gettags not supported for this vcs type')
543         rtags = []
544         for tag in self._gettags():
545             if re.match('[-A-Za-z0-9_. /]+$', tag):
546                 rtags.append(tag)
547         return rtags
548
549     def latesttags(self, tags, number):
550         """Get the most recent tags in a given list.
551
552         :param tags: a list of tags
553         :param number: the number to return
554         :returns: A list containing the most recent tags in the provided
555                   list, up to the maximum number given.
556         """
557         raise VCSException('latesttags not supported for this vcs type')
558
559     # Get current commit reference (hash, revision, etc)
560     def getref(self):
561         raise VCSException('getref not supported for this vcs type')
562
563     # Returns the srclib (name, path) used in setting up the current
564     # revision, or None.
565     def getsrclib(self):
566         return self.srclib
567
568
569 class vcs_git(vcs):
570
571     def repotype(self):
572         return 'git'
573
574     # If the local directory exists, but is somehow not a git repository, git
575     # will traverse up the directory tree until it finds one that is (i.e.
576     # fdroidserver) and then we'll proceed to destroy it! This is called as
577     # a safety check.
578     def checkrepo(self):
579         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
580         result = p.output.rstrip()
581         if not result.endswith(self.local):
582             raise VCSException('Repository mismatch')
583
584     def gotorevisionx(self, rev):
585         if not os.path.exists(self.local):
586             # Brand new checkout
587             p = FDroidPopen(['git', 'clone', self.remote, self.local])
588             if p.returncode != 0:
589                 self.clone_failed = True
590                 raise VCSException("Git clone failed", p.output)
591             self.checkrepo()
592         else:
593             self.checkrepo()
594             # Discard any working tree changes
595             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
596                              'git', 'reset', '--hard'], cwd=self.local, output=False)
597             if p.returncode != 0:
598                 raise VCSException("Git reset failed", p.output)
599             # Remove untracked files now, in case they're tracked in the target
600             # revision (it happens!)
601             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive',
602                              'git', 'clean', '-dffx'], cwd=self.local, output=False)
603             if p.returncode != 0:
604                 raise VCSException("Git clean failed", p.output)
605             if not self.refreshed:
606                 # Get latest commits and tags from remote
607                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
608                 if p.returncode != 0:
609                     raise VCSException("Git fetch failed", p.output)
610                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
611                 if p.returncode != 0:
612                     raise VCSException("Git fetch failed", p.output)
613                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
614                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
615                 if p.returncode != 0:
616                     lines = p.output.splitlines()
617                     if 'Multiple remote HEAD branches' not in lines[0]:
618                         raise VCSException("Git remote set-head failed", p.output)
619                     branch = lines[1].split(' ')[-1]
620                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
621                     if p2.returncode != 0:
622                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
623                 self.refreshed = True
624         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
625         # a github repo. Most of the time this is the same as origin/master.
626         rev = rev or 'origin/HEAD'
627         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
628         if p.returncode != 0:
629             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
630         # Get rid of any uncontrolled files left behind
631         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
632         if p.returncode != 0:
633             raise VCSException("Git clean failed", p.output)
634
635     def initsubmodules(self):
636         self.checkrepo()
637         submfile = os.path.join(self.local, '.gitmodules')
638         if not os.path.isfile(submfile):
639             raise VCSException("No git submodules available")
640
641         # fix submodules not accessible without an account and public key auth
642         with open(submfile, 'r') as f:
643             lines = f.readlines()
644         with open(submfile, 'w') as f:
645             for line in lines:
646                 if 'git@github.com' in line:
647                     line = line.replace('git@github.com:', 'https://github.com/')
648                 if 'git@gitlab.com' in line:
649                     line = line.replace('git@gitlab.com:', 'https://gitlab.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 len(string) < 2:
886         return string
887     if string[0] == '"' and string[-1] == '"':
888         return string[1:-1]
889
890     return string.replace("\\'", "'")
891
892
893 def retrieve_string(app_dir, string, xmlfiles=None):
894
895     if not string.startswith('@string/'):
896         return unescape_string(string)
897
898     if xmlfiles is None:
899         xmlfiles = []
900         for res_dir in [
901             os.path.join(app_dir, 'res'),
902             os.path.join(app_dir, 'src', 'main', 'res'),
903         ]:
904             for r, d, f in os.walk(res_dir):
905                 if os.path.basename(r) == 'values':
906                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
907
908     name = string[len('@string/'):]
909
910     def element_content(element):
911         if element.text is None:
912             return ""
913         s = XMLElementTree.tostring(element, encoding='utf-8', method='text')
914         return s.strip()
915
916     for path in xmlfiles:
917         if not os.path.isfile(path):
918             continue
919         xml = parse_xml(path)
920         element = xml.find('string[@name="' + name + '"]')
921         if element is not None:
922             content = element_content(element)
923             return retrieve_string(app_dir, content, xmlfiles)
924
925     return ''
926
927
928 def retrieve_string_singleline(app_dir, string, xmlfiles=None):
929     return retrieve_string(app_dir, string, xmlfiles).replace('\n', ' ').strip()
930
931
932 # Return list of existing files that will be used to find the highest vercode
933 def manifest_paths(app_dir, flavours):
934
935     possible_manifests = \
936         [os.path.join(app_dir, 'AndroidManifest.xml'),
937          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
938          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
939          os.path.join(app_dir, 'build.gradle')]
940
941     for flavour in flavours:
942         if flavour == 'yes':
943             continue
944         possible_manifests.append(
945             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
946
947     return [path for path in possible_manifests if os.path.isfile(path)]
948
949
950 # Retrieve the package name. Returns the name, or None if not found.
951 def fetch_real_name(app_dir, flavours):
952     for path in manifest_paths(app_dir, flavours):
953         if not has_extension(path, 'xml') or not os.path.isfile(path):
954             continue
955         logging.debug("fetch_real_name: Checking manifest at " + path)
956         xml = parse_xml(path)
957         app = xml.find('application')
958         if app is None:
959             continue
960         if "{http://schemas.android.com/apk/res/android}label" not in app.attrib:
961             continue
962         label = app.attrib["{http://schemas.android.com/apk/res/android}label"].encode('utf-8')
963         result = retrieve_string_singleline(app_dir, label)
964         if result:
965             result = result.strip()
966         return result
967     return None
968
969
970 def get_library_references(root_dir):
971     libraries = []
972     proppath = os.path.join(root_dir, 'project.properties')
973     if not os.path.isfile(proppath):
974         return libraries
975     for line in file(proppath):
976         if not line.startswith('android.library.reference.'):
977             continue
978         path = line.split('=')[1].strip()
979         relpath = os.path.join(root_dir, path)
980         if not os.path.isdir(relpath):
981             continue
982         logging.debug("Found subproject at %s" % path)
983         libraries.append(path)
984     return libraries
985
986
987 def ant_subprojects(root_dir):
988     subprojects = get_library_references(root_dir)
989     for subpath in subprojects:
990         subrelpath = os.path.join(root_dir, subpath)
991         for p in get_library_references(subrelpath):
992             relp = os.path.normpath(os.path.join(subpath, p))
993             if relp not in subprojects:
994                 subprojects.insert(0, relp)
995     return subprojects
996
997
998 def remove_debuggable_flags(root_dir):
999     # Remove forced debuggable flags
1000     logging.debug("Removing debuggable flags from %s" % root_dir)
1001     for root, dirs, files in os.walk(root_dir):
1002         if 'AndroidManifest.xml' in files:
1003             regsub_file(r'android:debuggable="[^"]*"',
1004                         '',
1005                         os.path.join(root, 'AndroidManifest.xml'))
1006
1007
1008 vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
1009 vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
1010 psearch_g = re.compile(r'.*(packageName|applicationId) *=* *["\']([^"]+)["\'].*').search
1011
1012
1013 def app_matches_packagename(app, package):
1014     if not package:
1015         return False
1016     appid = app.UpdateCheckName or app.id
1017     if appid is None or appid == "Ignore":
1018         return True
1019     return appid == package
1020
1021
1022 # Extract some information from the AndroidManifest.xml at the given path.
1023 # Returns (version, vercode, package), any or all of which might be None.
1024 # All values returned are strings.
1025 def parse_androidmanifests(paths, app):
1026
1027     ignoreversions = app.UpdateCheckIgnore
1028     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
1029
1030     if not paths:
1031         return (None, None, None)
1032
1033     max_version = None
1034     max_vercode = None
1035     max_package = None
1036
1037     for path in paths:
1038
1039         if not os.path.isfile(path):
1040             continue
1041
1042         logging.debug("Parsing manifest at {0}".format(path))
1043         gradle = has_extension(path, 'gradle')
1044         version = None
1045         vercode = None
1046         package = None
1047
1048         if gradle:
1049             for line in file(path):
1050                 if gradle_comment.match(line):
1051                     continue
1052                 # Grab first occurence of each to avoid running into
1053                 # alternative flavours and builds.
1054                 if not package:
1055                     matches = psearch_g(line)
1056                     if matches:
1057                         s = matches.group(2)
1058                         if app_matches_packagename(app, s):
1059                             package = s
1060                 if not version:
1061                     matches = vnsearch_g(line)
1062                     if matches:
1063                         version = matches.group(2)
1064                 if not vercode:
1065                     matches = vcsearch_g(line)
1066                     if matches:
1067                         vercode = matches.group(1)
1068         else:
1069             xml = parse_xml(path)
1070             if "package" in xml.attrib:
1071                 s = xml.attrib["package"].encode('utf-8')
1072                 if app_matches_packagename(app, s):
1073                     package = s
1074             if "{http://schemas.android.com/apk/res/android}versionName" in xml.attrib:
1075                 version = xml.attrib["{http://schemas.android.com/apk/res/android}versionName"].encode('utf-8')
1076                 base_dir = os.path.dirname(path)
1077                 version = retrieve_string_singleline(base_dir, version)
1078             if "{http://schemas.android.com/apk/res/android}versionCode" in xml.attrib:
1079                 a = xml.attrib["{http://schemas.android.com/apk/res/android}versionCode"].encode('utf-8')
1080                 if string_is_integer(a):
1081                     vercode = a
1082
1083         # Remember package name, may be defined separately from version+vercode
1084         if package is None:
1085             package = max_package
1086
1087         logging.debug("..got package={0}, version={1}, vercode={2}"
1088                       .format(package, version, vercode))
1089
1090         # Always grab the package name and version name in case they are not
1091         # together with the highest version code
1092         if max_package is None and package is not None:
1093             max_package = package
1094         if max_version is None and version is not None:
1095             max_version = version
1096
1097         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1098             if not ignoresearch or not ignoresearch(version):
1099                 if version is not None:
1100                     max_version = version
1101                 if vercode is not None:
1102                     max_vercode = vercode
1103                 if package is not None:
1104                     max_package = package
1105             else:
1106                 max_version = "Ignore"
1107
1108     if max_version is None:
1109         max_version = "Unknown"
1110
1111     if max_package and not is_valid_package_name(max_package):
1112         raise FDroidException("Invalid package name {0}".format(max_package))
1113
1114     return (max_version, max_vercode, max_package)
1115
1116
1117 def is_valid_package_name(name):
1118     return re.match("[A-Za-z_][A-Za-z_0-9.]+$", name)
1119
1120
1121 class FDroidException(Exception):
1122
1123     def __init__(self, value, detail=None):
1124         self.value = value
1125         self.detail = detail
1126
1127     def shortened_detail(self):
1128         if len(self.detail) < 16000:
1129             return self.detail
1130         return '[...]\n' + self.detail[-16000:]
1131
1132     def get_wikitext(self):
1133         ret = repr(self.value) + "\n"
1134         if self.detail:
1135             ret += "=detail=\n"
1136             ret += "<pre>\n" + self.shortened_detail() + "</pre>\n"
1137         return ret
1138
1139     def __str__(self):
1140         ret = self.value
1141         if self.detail:
1142             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1143         return ret
1144
1145
1146 class VCSException(FDroidException):
1147     pass
1148
1149
1150 class BuildException(FDroidException):
1151     pass
1152
1153
1154 # Get the specified source library.
1155 # Returns the path to it. Normally this is the path to be used when referencing
1156 # it, which may be a subdirectory of the actual project. If you want the base
1157 # directory of the project, pass 'basepath=True'.
1158 def getsrclib(spec, srclib_dir, subdir=None, basepath=False,
1159               raw=False, prepare=True, preponly=False, refresh=True):
1160
1161     number = None
1162     subdir = None
1163     if raw:
1164         name = spec
1165         ref = None
1166     else:
1167         name, ref = spec.split('@')
1168         if ':' in name:
1169             number, name = name.split(':', 1)
1170         if '/' in name:
1171             name, subdir = name.split('/', 1)
1172
1173     if name not in metadata.srclibs:
1174         raise VCSException('srclib ' + name + ' not found.')
1175
1176     srclib = metadata.srclibs[name]
1177
1178     sdir = os.path.join(srclib_dir, name)
1179
1180     if not preponly:
1181         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1182         vcs.srclib = (name, number, sdir)
1183         if ref:
1184             vcs.gotorevision(ref, refresh)
1185
1186         if raw:
1187             return vcs
1188
1189     libdir = None
1190     if subdir:
1191         libdir = os.path.join(sdir, subdir)
1192     elif srclib["Subdir"]:
1193         for subdir in srclib["Subdir"]:
1194             libdir_candidate = os.path.join(sdir, subdir)
1195             if os.path.exists(libdir_candidate):
1196                 libdir = libdir_candidate
1197                 break
1198
1199     if libdir is None:
1200         libdir = sdir
1201
1202     remove_signing_keys(sdir)
1203     remove_debuggable_flags(sdir)
1204
1205     if prepare:
1206
1207         if srclib["Prepare"]:
1208             cmd = replace_config_vars(srclib["Prepare"], None)
1209
1210             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1211             if p.returncode != 0:
1212                 raise BuildException("Error running prepare command for srclib %s"
1213                                      % name, p.output)
1214
1215     if basepath:
1216         libdir = sdir
1217
1218     return (name, number, libdir)
1219
1220 gradle_version_regex = re.compile(r"[^/]*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1221
1222
1223 # Prepare the source code for a particular build
1224 #  'vcs'         - the appropriate vcs object for the application
1225 #  'app'         - the application details from the metadata
1226 #  'build'       - the build details from the metadata
1227 #  'build_dir'   - the path to the build directory, usually
1228 #                   'build/app.id'
1229 #  'srclib_dir'  - the path to the source libraries directory, usually
1230 #                   'build/srclib'
1231 #  'extlib_dir'  - the path to the external libraries directory, usually
1232 #                   'build/extlib'
1233 # Returns the (root, srclibpaths) where:
1234 #   'root' is the root directory, which may be the same as 'build_dir' or may
1235 #          be a subdirectory of it.
1236 #   'srclibpaths' is information on the srclibs being used
1237 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False, refresh=True):
1238
1239     # Optionally, the actual app source can be in a subdirectory
1240     if build.subdir:
1241         root_dir = os.path.join(build_dir, build.subdir)
1242     else:
1243         root_dir = build_dir
1244
1245     # Get a working copy of the right revision
1246     logging.info("Getting source for revision " + build.commit)
1247     vcs.gotorevision(build.commit, refresh)
1248
1249     # Initialise submodules if required
1250     if build.submodules:
1251         logging.info("Initialising submodules")
1252         vcs.initsubmodules()
1253
1254     # Check that a subdir (if we're using one) exists. This has to happen
1255     # after the checkout, since it might not exist elsewhere
1256     if not os.path.exists(root_dir):
1257         raise BuildException('Missing subdir ' + root_dir)
1258
1259     # Run an init command if one is required
1260     if build.init:
1261         cmd = replace_config_vars(build.init, build)
1262         logging.info("Running 'init' commands in %s" % root_dir)
1263
1264         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1265         if p.returncode != 0:
1266             raise BuildException("Error running init command for %s:%s" %
1267                                  (app.id, build.version), p.output)
1268
1269     # Apply patches if any
1270     if build.patch:
1271         logging.info("Applying patches")
1272         for patch in build.patch:
1273             patch = patch.strip()
1274             logging.info("Applying " + patch)
1275             patch_path = os.path.join('metadata', app.id, patch)
1276             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1277             if p.returncode != 0:
1278                 raise BuildException("Failed to apply patch %s" % patch_path)
1279
1280     # Get required source libraries
1281     srclibpaths = []
1282     if build.srclibs:
1283         logging.info("Collecting source libraries")
1284         for lib in build.srclibs:
1285             srclibpaths.append(getsrclib(lib, srclib_dir, build, preponly=onserver, refresh=refresh))
1286
1287     for name, number, libpath in srclibpaths:
1288         place_srclib(root_dir, int(number) if number else None, libpath)
1289
1290     basesrclib = vcs.getsrclib()
1291     # If one was used for the main source, add that too.
1292     if basesrclib:
1293         srclibpaths.append(basesrclib)
1294
1295     # Update the local.properties file
1296     localprops = [os.path.join(build_dir, 'local.properties')]
1297     if build.subdir:
1298         parts = build.subdir.split(os.sep)
1299         cur = build_dir
1300         for d in parts:
1301             cur = os.path.join(cur, d)
1302             localprops += [os.path.join(cur, 'local.properties')]
1303     for path in localprops:
1304         props = ""
1305         if os.path.isfile(path):
1306             logging.info("Updating local.properties file at %s" % path)
1307             with open(path, 'r') as f:
1308                 props += f.read()
1309             props += '\n'
1310         else:
1311             logging.info("Creating local.properties file at %s" % path)
1312         # Fix old-fashioned 'sdk-location' by copying
1313         # from sdk.dir, if necessary
1314         if build.oldsdkloc:
1315             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1316                               re.S | re.M).group(1)
1317             props += "sdk-location=%s\n" % sdkloc
1318         else:
1319             props += "sdk.dir=%s\n" % config['sdk_path']
1320             props += "sdk-location=%s\n" % config['sdk_path']
1321         ndk_path = build.ndk_path()
1322         if ndk_path:
1323             # Add ndk location
1324             props += "ndk.dir=%s\n" % ndk_path
1325             props += "ndk-location=%s\n" % ndk_path
1326         # Add java.encoding if necessary
1327         if build.encoding:
1328             props += "java.encoding=%s\n" % build.encoding
1329         with open(path, 'w') as f:
1330             f.write(props)
1331
1332     flavours = []
1333     if build.method() == 'gradle':
1334         flavours = build.gradle
1335
1336         if build.target:
1337             n = build.target.split('-')[1]
1338             regsub_file(r'compileSdkVersion[ =]+[0-9]+',
1339                         r'compileSdkVersion %s' % n,
1340                         os.path.join(root_dir, 'build.gradle'))
1341
1342     # Remove forced debuggable flags
1343     remove_debuggable_flags(root_dir)
1344
1345     # Insert version code and number into the manifest if necessary
1346     if build.forceversion:
1347         logging.info("Changing the version name")
1348         for path in manifest_paths(root_dir, flavours):
1349             if not os.path.isfile(path):
1350                 continue
1351             if has_extension(path, 'xml'):
1352                 regsub_file(r'android:versionName="[^"]*"',
1353                             r'android:versionName="%s"' % build.version,
1354                             path)
1355             elif has_extension(path, 'gradle'):
1356                 regsub_file(r"""(\s*)versionName[\s'"=]+.*""",
1357                             r"""\1versionName '%s'""" % build.version,
1358                             path)
1359
1360     if build.forcevercode:
1361         logging.info("Changing the version code")
1362         for path in manifest_paths(root_dir, flavours):
1363             if not os.path.isfile(path):
1364                 continue
1365             if has_extension(path, 'xml'):
1366                 regsub_file(r'android:versionCode="[^"]*"',
1367                             r'android:versionCode="%s"' % build.vercode,
1368                             path)
1369             elif has_extension(path, 'gradle'):
1370                 regsub_file(r'versionCode[ =]+[0-9]+',
1371                             r'versionCode %s' % build.vercode,
1372                             path)
1373
1374     # Delete unwanted files
1375     if build.rm:
1376         logging.info("Removing specified files")
1377         for part in getpaths(build_dir, build.rm):
1378             dest = os.path.join(build_dir, part)
1379             logging.info("Removing {0}".format(part))
1380             if os.path.lexists(dest):
1381                 if os.path.islink(dest):
1382                     FDroidPopen(['unlink', dest], output=False)
1383                 else:
1384                     FDroidPopen(['rm', '-rf', dest], output=False)
1385             else:
1386                 logging.info("...but it didn't exist")
1387
1388     remove_signing_keys(build_dir)
1389
1390     # Add required external libraries
1391     if build.extlibs:
1392         logging.info("Collecting prebuilt libraries")
1393         libsdir = os.path.join(root_dir, 'libs')
1394         if not os.path.exists(libsdir):
1395             os.mkdir(libsdir)
1396         for lib in build.extlibs:
1397             lib = lib.strip()
1398             logging.info("...installing extlib {0}".format(lib))
1399             libf = os.path.basename(lib)
1400             libsrc = os.path.join(extlib_dir, lib)
1401             if not os.path.exists(libsrc):
1402                 raise BuildException("Missing extlib file {0}".format(libsrc))
1403             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1404
1405     # Run a pre-build command if one is required
1406     if build.prebuild:
1407         logging.info("Running 'prebuild' commands in %s" % root_dir)
1408
1409         cmd = replace_config_vars(build.prebuild, build)
1410
1411         # Substitute source library paths into prebuild commands
1412         for name, number, libpath in srclibpaths:
1413             libpath = os.path.relpath(libpath, root_dir)
1414             cmd = cmd.replace('$$' + name + '$$', libpath)
1415
1416         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1417         if p.returncode != 0:
1418             raise BuildException("Error running prebuild command for %s:%s" %
1419                                  (app.id, build.version), p.output)
1420
1421     # Generate (or update) the ant build file, build.xml...
1422     if build.method() == 'ant' and build.update != ['no']:
1423         parms = ['android', 'update', 'lib-project']
1424         lparms = ['android', 'update', 'project']
1425
1426         if build.target:
1427             parms += ['-t', build.target]
1428             lparms += ['-t', build.target]
1429         if build.update:
1430             update_dirs = build.update
1431         else:
1432             update_dirs = ant_subprojects(root_dir) + ['.']
1433
1434         for d in update_dirs:
1435             subdir = os.path.join(root_dir, d)
1436             if d == '.':
1437                 logging.debug("Updating main project")
1438                 cmd = parms + ['-p', d]
1439             else:
1440                 logging.debug("Updating subproject %s" % d)
1441                 cmd = lparms + ['-p', d]
1442             p = SdkToolsPopen(cmd, cwd=root_dir)
1443             # Check to see whether an error was returned without a proper exit
1444             # code (this is the case for the 'no target set or target invalid'
1445             # error)
1446             if p.returncode != 0 or p.output.startswith("Error: "):
1447                 raise BuildException("Failed to update project at %s" % d, p.output)
1448             # Clean update dirs via ant
1449             if d != '.':
1450                 logging.info("Cleaning subproject %s" % d)
1451                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1452
1453     return (root_dir, srclibpaths)
1454
1455
1456 # Extend via globbing the paths from a field and return them as a map from
1457 # original path to resulting paths
1458 def getpaths_map(build_dir, globpaths):
1459     paths = dict()
1460     for p in globpaths:
1461         p = p.strip()
1462         full_path = os.path.join(build_dir, p)
1463         full_path = os.path.normpath(full_path)
1464         paths[p] = [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1465         if not paths[p]:
1466             raise FDroidException("glob path '%s' did not match any files/dirs" % p)
1467     return paths
1468
1469
1470 # Extend via globbing the paths from a field and return them as a set
1471 def getpaths(build_dir, globpaths):
1472     paths_map = getpaths_map(build_dir, globpaths)
1473     paths = set()
1474     for k, v in paths_map.iteritems():
1475         for p in v:
1476             paths.add(p)
1477     return paths
1478
1479
1480 def natural_key(s):
1481     return [int(sp) if sp.isdigit() else sp for sp in re.split(r'(\d+)', s)]
1482
1483
1484 class KnownApks:
1485
1486     def __init__(self):
1487         self.path = os.path.join('stats', 'known_apks.txt')
1488         self.apks = {}
1489         if os.path.isfile(self.path):
1490             for line in file(self.path):
1491                 t = line.rstrip().split(' ')
1492                 if len(t) == 2:
1493                     self.apks[t[0]] = (t[1], None)
1494                 else:
1495                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1496         self.changed = False
1497
1498     def writeifchanged(self):
1499         if not self.changed:
1500             return
1501
1502         if not os.path.exists('stats'):
1503             os.mkdir('stats')
1504
1505         lst = []
1506         for apk, app in self.apks.iteritems():
1507             appid, added = app
1508             line = apk + ' ' + appid
1509             if added:
1510                 line += ' ' + time.strftime('%Y-%m-%d', added)
1511             lst.append(line)
1512
1513         with open(self.path, 'w') as f:
1514             for line in sorted(lst, key=natural_key):
1515                 f.write(line + '\n')
1516
1517     # Record an apk (if it's new, otherwise does nothing)
1518     # Returns the date it was added.
1519     def recordapk(self, apk, app):
1520         if apk not in self.apks:
1521             self.apks[apk] = (app, time.gmtime(time.time()))
1522             self.changed = True
1523         _, added = self.apks[apk]
1524         return added
1525
1526     # Look up information - given the 'apkname', returns (app id, date added/None).
1527     # Or returns None for an unknown apk.
1528     def getapp(self, apkname):
1529         if apkname in self.apks:
1530             return self.apks[apkname]
1531         return None
1532
1533     # Get the most recent 'num' apps added to the repo, as a list of package ids
1534     # with the most recent first.
1535     def getlatest(self, num):
1536         apps = {}
1537         for apk, app in self.apks.iteritems():
1538             appid, added = app
1539             if added:
1540                 if appid in apps:
1541                     if apps[appid] > added:
1542                         apps[appid] = added
1543                 else:
1544                     apps[appid] = added
1545         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1546         lst = [app for app, _ in sortedapps]
1547         lst.reverse()
1548         return lst
1549
1550
1551 def isApkDebuggable(apkfile, config):
1552     """Returns True if the given apk file is debuggable
1553
1554     :param apkfile: full path to the apk to check"""
1555
1556     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1557                       output=False)
1558     if p.returncode != 0:
1559         logging.critical("Failed to get apk manifest information")
1560         sys.exit(1)
1561     for line in p.output.splitlines():
1562         if 'android:debuggable' in line and not line.endswith('0x0'):
1563             return True
1564     return False
1565
1566
1567 class PopenResult:
1568     returncode = None
1569     output = ''
1570
1571
1572 def SdkToolsPopen(commands, cwd=None, output=True):
1573     cmd = commands[0]
1574     if cmd not in config:
1575         config[cmd] = find_sdk_tools_cmd(commands[0])
1576     abscmd = config[cmd]
1577     if abscmd is None:
1578         logging.critical("Could not find '%s' on your system" % cmd)
1579         sys.exit(1)
1580     return FDroidPopen([abscmd] + commands[1:],
1581                        cwd=cwd, output=output)
1582
1583
1584 def FDroidPopen(commands, cwd=None, output=True):
1585     """
1586     Run a command and capture the possibly huge output.
1587
1588     :param commands: command and argument list like in subprocess.Popen
1589     :param cwd: optionally specifies a working directory
1590     :returns: A PopenResult.
1591     """
1592
1593     global env
1594
1595     if cwd:
1596         cwd = os.path.normpath(cwd)
1597         logging.debug("Directory: %s" % cwd)
1598     logging.debug("> %s" % ' '.join(commands))
1599
1600     result = PopenResult()
1601     p = None
1602     try:
1603         p = subprocess.Popen(commands, cwd=cwd, shell=False, env=env,
1604                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1605     except OSError, e:
1606         raise BuildException("OSError while trying to execute " +
1607                              ' '.join(commands) + ': ' + str(e))
1608
1609     stdout_queue = Queue.Queue()
1610     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1611
1612     # Check the queue for output (until there is no more to get)
1613     while not stdout_reader.eof():
1614         while not stdout_queue.empty():
1615             line = stdout_queue.get()
1616             if output and options.verbose:
1617                 # Output directly to console
1618                 sys.stderr.write(line)
1619                 sys.stderr.flush()
1620             result.output += line
1621
1622         time.sleep(0.1)
1623
1624     result.returncode = p.wait()
1625     return result
1626
1627
1628 gradle_comment = re.compile(r'[ ]*//')
1629 gradle_signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1630 gradle_line_matches = [
1631     re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1632     re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1633     re.compile(r'.*variant\.outputFile = .*'),
1634     re.compile(r'.*output\.outputFile = .*'),
1635     re.compile(r'.*\.readLine\(.*'),
1636 ]
1637
1638
1639 def remove_signing_keys(build_dir):
1640     for root, dirs, files in os.walk(build_dir):
1641         if 'build.gradle' in files:
1642             path = os.path.join(root, 'build.gradle')
1643
1644             with open(path, "r") as o:
1645                 lines = o.readlines()
1646
1647             changed = False
1648
1649             opened = 0
1650             i = 0
1651             with open(path, "w") as o:
1652                 while i < len(lines):
1653                     line = lines[i]
1654                     i += 1
1655                     while line.endswith('\\\n'):
1656                         line = line.rstrip('\\\n') + lines[i]
1657                         i += 1
1658
1659                     if gradle_comment.match(line):
1660                         o.write(line)
1661                         continue
1662
1663                     if opened > 0:
1664                         opened += line.count('{')
1665                         opened -= line.count('}')
1666                         continue
1667
1668                     if gradle_signing_configs.match(line):
1669                         changed = True
1670                         opened += 1
1671                         continue
1672
1673                     if any(s.match(line) for s in gradle_line_matches):
1674                         changed = True
1675                         continue
1676
1677                     if opened == 0:
1678                         o.write(line)
1679
1680             if changed:
1681                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1682
1683         for propfile in [
1684                 'project.properties',
1685                 'build.properties',
1686                 'default.properties',
1687                 'ant.properties', ]:
1688             if propfile in files:
1689                 path = os.path.join(root, propfile)
1690
1691                 with open(path, "r") as o:
1692                     lines = o.readlines()
1693
1694                 changed = False
1695
1696                 with open(path, "w") as o:
1697                     for line in lines:
1698                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1699                             changed = True
1700                             continue
1701
1702                         o.write(line)
1703
1704                 if changed:
1705                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1706
1707
1708 def reset_env_path():
1709     global env, orig_path
1710     env['PATH'] = orig_path
1711
1712
1713 def add_to_env_path(path):
1714     global env
1715     paths = env['PATH'].split(os.pathsep)
1716     if path in paths:
1717         return
1718     paths.append(path)
1719     env['PATH'] = os.pathsep.join(paths)
1720
1721
1722 def replace_config_vars(cmd, build):
1723     global env
1724     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1725     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1726     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1727     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1728     if build is not None:
1729         cmd = cmd.replace('$$COMMIT$$', build.commit)
1730         cmd = cmd.replace('$$VERSION$$', build.version)
1731         cmd = cmd.replace('$$VERCODE$$', build.vercode)
1732     return cmd
1733
1734
1735 def place_srclib(root_dir, number, libpath):
1736     if not number:
1737         return
1738     relpath = os.path.relpath(libpath, root_dir)
1739     proppath = os.path.join(root_dir, 'project.properties')
1740
1741     lines = []
1742     if os.path.isfile(proppath):
1743         with open(proppath, "r") as o:
1744             lines = o.readlines()
1745
1746     with open(proppath, "w") as o:
1747         placed = False
1748         for line in lines:
1749             if line.startswith('android.library.reference.%d=' % number):
1750                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1751                 placed = True
1752             else:
1753                 o.write(line)
1754         if not placed:
1755             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1756
1757 apk_sigfile = re.compile(r'META-INF/[0-9A-Za-z]+\.(SF|RSA)')
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     with ZipFile(signed_apk) as signed_apk_as_zip:
1774         meta_inf_files = ['META-INF/MANIFEST.MF']
1775         for f in signed_apk_as_zip.namelist():
1776             if apk_sigfile.match(f):
1777                 meta_inf_files.append(f)
1778         if len(meta_inf_files) < 3:
1779             return "Signature files missing from {0}".format(signed_apk)
1780         signed_apk_as_zip.extractall(tmp_dir, meta_inf_files)
1781     with ZipFile(unsigned_apk, mode='a') as unsigned_apk_as_zip:
1782         for meta_inf_file in meta_inf_files:
1783             unsigned_apk_as_zip.write(os.path.join(tmp_dir, meta_inf_file), arcname=meta_inf_file)
1784
1785     if subprocess.call(['jarsigner', '-verify', unsigned_apk]) != 0:
1786         logging.info("...NOT verified - {0}".format(signed_apk))
1787         return compare_apks(signed_apk, unsigned_apk, tmp_dir)
1788     logging.info("...successfully verified")
1789     return None
1790
1791 apk_badchars = re.compile('''[/ :;'"]''')
1792
1793
1794 def compare_apks(apk1, apk2, tmp_dir):
1795     """Compare two apks
1796
1797     Returns None if the apk content is the same (apart from the signing key),
1798     otherwise a string describing what's different, or what went wrong when
1799     trying to do the comparison.
1800     """
1801
1802     apk1dir = os.path.join(tmp_dir, apk_badchars.sub('_', apk1[0:-4]))  # trim .apk
1803     apk2dir = os.path.join(tmp_dir, apk_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