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