chiark / gitweb /
Fix issue introduced in 49549f4cad2097 (fixes #62)
[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     # Get a list of latest number tags
518     def latesttags(self, number):
519         raise VCSException('latesttags not supported for this vcs type')
520
521     # Get current commit reference (hash, revision, etc)
522     def getref(self):
523         raise VCSException('getref not supported for this vcs type')
524
525     # Returns the srclib (name, path) used in setting up the current
526     # revision, or None.
527     def getsrclib(self):
528         return self.srclib
529
530
531 class vcs_git(vcs):
532
533     def repotype(self):
534         return 'git'
535
536     # If the local directory exists, but is somehow not a git repository, git
537     # will traverse up the directory tree until it finds one that is (i.e.
538     # fdroidserver) and then we'll proceed to destroy it! This is called as
539     # a safety check.
540     def checkrepo(self):
541         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
542         result = p.output.rstrip()
543         if not result.endswith(self.local):
544             raise VCSException('Repository mismatch')
545
546     def gotorevisionx(self, rev):
547         if not os.path.exists(self.local):
548             # Brand new checkout
549             p = FDroidPopen(['git', 'clone', self.remote, self.local])
550             if p.returncode != 0:
551                 self.clone_failed = True
552                 raise VCSException("Git clone failed", p.output)
553             self.checkrepo()
554         else:
555             self.checkrepo()
556             # Discard any working tree changes
557             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
558             if p.returncode != 0:
559                 raise VCSException("Git reset failed", p.output)
560             # Remove untracked files now, in case they're tracked in the target
561             # revision (it happens!)
562             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
563             if p.returncode != 0:
564                 raise VCSException("Git clean failed", p.output)
565             if not self.refreshed:
566                 # Get latest commits and tags from remote
567                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
568                 if p.returncode != 0:
569                     raise VCSException("Git fetch failed", p.output)
570                 p = FDroidPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local, output=False)
571                 if p.returncode != 0:
572                     raise VCSException("Git fetch failed", p.output)
573                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
574                 p = FDroidPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local, output=False)
575                 if p.returncode != 0:
576                     lines = p.output.splitlines()
577                     if 'Multiple remote HEAD branches' not in lines[0]:
578                         raise VCSException("Git remote set-head failed", p.output)
579                     branch = lines[1].split(' ')[-1]
580                     p2 = FDroidPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local, output=False)
581                     if p2.returncode != 0:
582                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
583                 self.refreshed = True
584         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
585         # a github repo. Most of the time this is the same as origin/master.
586         rev = rev or 'origin/HEAD'
587         p = FDroidPopen(['git', 'checkout', '-f', rev], cwd=self.local, output=False)
588         if p.returncode != 0:
589             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
590         # Get rid of any uncontrolled files left behind
591         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
592         if p.returncode != 0:
593             raise VCSException("Git clean failed", p.output)
594
595     def initsubmodules(self):
596         self.checkrepo()
597         submfile = os.path.join(self.local, '.gitmodules')
598         if not os.path.isfile(submfile):
599             raise VCSException("No git submodules available")
600
601         # fix submodules not accessible without an account and public key auth
602         with open(submfile, 'r') as f:
603             lines = f.readlines()
604         with open(submfile, 'w') as f:
605             for line in lines:
606                 if 'git@github.com' in line:
607                     line = line.replace('git@github.com:', 'https://github.com/')
608                 f.write(line)
609
610         for cmd in [
611                 ['git', 'reset', '--hard'],
612                 ['git', 'clean', '-dffx'],
613                 ]:
614             p = FDroidPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local, output=False)
615             if p.returncode != 0:
616                 raise VCSException("Git submodule reset failed", p.output)
617         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local, output=False)
618         if p.returncode != 0:
619             raise VCSException("Git submodule sync failed", p.output)
620         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
621         if p.returncode != 0:
622             raise VCSException("Git submodule update failed", p.output)
623
624     def _gettags(self):
625         self.checkrepo()
626         p = FDroidPopen(['git', 'tag'], cwd=self.local, output=False)
627         return p.output.splitlines()
628
629     def latesttags(self, alltags, number):
630         self.checkrepo()
631         p = FDroidPopen(['echo "' + '\n'.join(alltags) + '" | '
632                          +
633                          'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
634                          + 'sort -n | awk \'{print $2}\''],
635                         cwd=self.local, shell=True, output=False)
636         return p.output.splitlines()[-number:]
637
638
639 class vcs_gitsvn(vcs):
640
641     def repotype(self):
642         return 'git-svn'
643
644     # If the local directory exists, but is somehow not a git repository, git
645     # will traverse up the directory tree until it finds one that is (i.e.
646     # fdroidserver) and then we'll proceed to destory it! This is called as
647     # a safety check.
648     def checkrepo(self):
649         p = FDroidPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local, output=False)
650         result = p.output.rstrip()
651         if not result.endswith(self.local):
652             raise VCSException('Repository mismatch')
653
654     def gotorevisionx(self, rev):
655         if not os.path.exists(self.local):
656             # Brand new checkout
657             gitsvn_args = ['git', 'svn', 'clone']
658             if ';' in self.remote:
659                 remote_split = self.remote.split(';')
660                 for i in remote_split[1:]:
661                     if i.startswith('trunk='):
662                         gitsvn_args.extend(['-T', i[6:]])
663                     elif i.startswith('tags='):
664                         gitsvn_args.extend(['-t', i[5:]])
665                     elif i.startswith('branches='):
666                         gitsvn_args.extend(['-b', i[9:]])
667                 gitsvn_args.extend([remote_split[0], self.local])
668                 p = FDroidPopen(gitsvn_args, output=False)
669                 if p.returncode != 0:
670                     self.clone_failed = True
671                     raise VCSException("Git svn clone failed", p.output)
672             else:
673                 gitsvn_args.extend([self.remote, self.local])
674                 p = FDroidPopen(gitsvn_args, output=False)
675                 if p.returncode != 0:
676                     self.clone_failed = True
677                     raise VCSException("Git svn clone failed", p.output)
678             self.checkrepo()
679         else:
680             self.checkrepo()
681             # Discard any working tree changes
682             p = FDroidPopen(['git', 'reset', '--hard'], cwd=self.local, output=False)
683             if p.returncode != 0:
684                 raise VCSException("Git reset failed", p.output)
685             # Remove untracked files now, in case they're tracked in the target
686             # revision (it happens!)
687             p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
688             if p.returncode != 0:
689                 raise VCSException("Git clean failed", p.output)
690             if not self.refreshed:
691                 # Get new commits, branches and tags from repo
692                 p = FDroidPopen(['git', 'svn', 'fetch'], cwd=self.local, output=False)
693                 if p.returncode != 0:
694                     raise VCSException("Git svn fetch failed")
695                 p = FDroidPopen(['git', 'svn', 'rebase'], cwd=self.local, output=False)
696                 if p.returncode != 0:
697                     raise VCSException("Git svn rebase failed", p.output)
698                 self.refreshed = True
699
700         rev = rev or 'master'
701         if rev:
702             nospaces_rev = rev.replace(' ', '%20')
703             # Try finding a svn tag
704             for treeish in ['origin/', '']:
705                 p = FDroidPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev], cwd=self.local, output=False)
706                 if p.returncode == 0:
707                     break
708             if p.returncode != 0:
709                 # No tag found, normal svn rev translation
710                 # Translate svn rev into git format
711                 rev_split = rev.split('/')
712
713                 p = None
714                 for treeish in ['origin/', '']:
715                     if len(rev_split) > 1:
716                         treeish += rev_split[0]
717                         svn_rev = rev_split[1]
718
719                     else:
720                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
721                         treeish += 'master'
722                         svn_rev = rev
723
724                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
725
726                     p = FDroidPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish], cwd=self.local, output=False)
727                     git_rev = p.output.rstrip()
728
729                     if p.returncode == 0 and git_rev:
730                         break
731
732                 if p.returncode != 0 or not git_rev:
733                     # Try a plain git checkout as a last resort
734                     p = FDroidPopen(['git', 'checkout', rev], cwd=self.local, output=False)
735                     if p.returncode != 0:
736                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
737                 else:
738                     # Check out the git rev equivalent to the svn rev
739                     p = FDroidPopen(['git', 'checkout', git_rev], cwd=self.local, output=False)
740                     if p.returncode != 0:
741                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
742
743         # Get rid of any uncontrolled files left behind
744         p = FDroidPopen(['git', 'clean', '-dffx'], cwd=self.local, output=False)
745         if p.returncode != 0:
746             raise VCSException("Git clean failed", p.output)
747
748     def _gettags(self):
749         self.checkrepo()
750         for treeish in ['origin/', '']:
751             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
752             if os.path.isdir(d):
753                 return os.listdir(d)
754
755     def getref(self):
756         self.checkrepo()
757         p = FDroidPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local, output=False)
758         if p.returncode != 0:
759             return None
760         return p.output.strip()
761
762
763 class vcs_hg(vcs):
764
765     def repotype(self):
766         return 'hg'
767
768     def gotorevisionx(self, rev):
769         if not os.path.exists(self.local):
770             p = FDroidPopen(['hg', 'clone', self.remote, self.local], output=False)
771             if p.returncode != 0:
772                 self.clone_failed = True
773                 raise VCSException("Hg clone failed", p.output)
774         else:
775             p = FDroidPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True, output=False)
776             if p.returncode != 0:
777                 raise VCSException("Hg clean failed", p.output)
778             if not self.refreshed:
779                 p = FDroidPopen(['hg', 'pull'], cwd=self.local, output=False)
780                 if p.returncode != 0:
781                     raise VCSException("Hg pull failed", p.output)
782                 self.refreshed = True
783
784         rev = rev or 'default'
785         if not rev:
786             return
787         p = FDroidPopen(['hg', 'update', '-C', rev], cwd=self.local, output=False)
788         if p.returncode != 0:
789             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
790         p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
791         # Also delete untracked files, we have to enable purge extension for that:
792         if "'purge' is provided by the following extension" in p.output:
793             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
794                 myfile.write("\n[extensions]\nhgext.purge=\n")
795             p = FDroidPopen(['hg', 'purge', '--all'], cwd=self.local, output=False)
796             if p.returncode != 0:
797                 raise VCSException("HG purge failed", p.output)
798         elif p.returncode != 0:
799             raise VCSException("HG purge failed", p.output)
800
801     def _gettags(self):
802         p = FDroidPopen(['hg', 'tags', '-q'], cwd=self.local, output=False)
803         return p.output.splitlines()[1:]
804
805
806 class vcs_bzr(vcs):
807
808     def repotype(self):
809         return 'bzr'
810
811     def gotorevisionx(self, rev):
812         if not os.path.exists(self.local):
813             p = FDroidPopen(['bzr', 'branch', self.remote, self.local], output=False)
814             if p.returncode != 0:
815                 self.clone_failed = True
816                 raise VCSException("Bzr branch failed", p.output)
817         else:
818             p = FDroidPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local, output=False)
819             if p.returncode != 0:
820                 raise VCSException("Bzr revert failed", p.output)
821             if not self.refreshed:
822                 p = FDroidPopen(['bzr', 'pull'], cwd=self.local, output=False)
823                 if p.returncode != 0:
824                     raise VCSException("Bzr update failed", p.output)
825                 self.refreshed = True
826
827         revargs = list(['-r', rev] if rev else [])
828         p = FDroidPopen(['bzr', 'revert'] + revargs, cwd=self.local, output=False)
829         if p.returncode != 0:
830             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
831
832     def _gettags(self):
833         p = FDroidPopen(['bzr', 'tags'], cwd=self.local, output=False)
834         return [tag.split('   ')[0].strip() for tag in
835                 p.output.splitlines()]
836
837
838 def retrieve_string(app_dir, string, xmlfiles=None):
839
840     res_dirs = [
841         os.path.join(app_dir, 'res'),
842         os.path.join(app_dir, 'src', 'main'),
843         ]
844
845     if xmlfiles is None:
846         xmlfiles = []
847         for res_dir in res_dirs:
848             for r, d, f in os.walk(res_dir):
849                 if os.path.basename(r) == 'values':
850                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
851
852     string_search = None
853     if string.startswith('@string/'):
854         string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
855     elif string.startswith('&') and string.endswith(';'):
856         string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
857
858     if string_search is not None:
859         for xmlfile in xmlfiles:
860             for line in file(xmlfile):
861                 matches = string_search(line)
862                 if matches:
863                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
864         return None
865
866     return string.replace("\\'", "'")
867
868
869 # Return list of existing files that will be used to find the highest vercode
870 def manifest_paths(app_dir, flavours):
871
872     possible_manifests = \
873         [os.path.join(app_dir, 'AndroidManifest.xml'),
874          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
875          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
876          os.path.join(app_dir, 'build.gradle')]
877
878     for flavour in flavours:
879         if flavour == 'yes':
880             continue
881         possible_manifests.append(
882             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
883
884     return [path for path in possible_manifests if os.path.isfile(path)]
885
886
887 # Retrieve the package name. Returns the name, or None if not found.
888 def fetch_real_name(app_dir, flavours):
889     app_search = re.compile(r'.*<application.*').search
890     name_search = re.compile(r'.*android:label="([^"]+)".*').search
891     app_found = False
892     for f in manifest_paths(app_dir, flavours):
893         if not has_extension(f, 'xml'):
894             continue
895         logging.debug("fetch_real_name: Checking manifest at " + f)
896         for line in file(f):
897             if not app_found:
898                 if app_search(line):
899                     app_found = True
900             if app_found:
901                 matches = name_search(line)
902                 if matches:
903                     stringname = matches.group(1)
904                     logging.debug("fetch_real_name: using string " + stringname)
905                     result = retrieve_string(app_dir, stringname)
906                     if result:
907                         result = result.strip()
908                     return result
909     return None
910
911
912 # Retrieve the version name
913 def version_name(original, app_dir, flavours):
914     for f in manifest_paths(app_dir, flavours):
915         if not has_extension(f, 'xml'):
916             continue
917         string = retrieve_string(app_dir, original)
918         if string:
919             return string
920     return original
921
922
923 def get_library_references(root_dir):
924     libraries = []
925     proppath = os.path.join(root_dir, 'project.properties')
926     if not os.path.isfile(proppath):
927         return libraries
928     with open(proppath) as f:
929         for line in f.readlines():
930             if not line.startswith('android.library.reference.'):
931                 continue
932             path = line.split('=')[1].strip()
933             relpath = os.path.join(root_dir, path)
934             if not os.path.isdir(relpath):
935                 continue
936             logging.debug("Found subproject at %s" % path)
937             libraries.append(path)
938     return libraries
939
940
941 def ant_subprojects(root_dir):
942     subprojects = get_library_references(root_dir)
943     for subpath in subprojects:
944         subrelpath = os.path.join(root_dir, subpath)
945         for p in get_library_references(subrelpath):
946             relp = os.path.normpath(os.path.join(subpath, p))
947             if relp not in subprojects:
948                 subprojects.insert(0, relp)
949     return subprojects
950
951
952 def remove_debuggable_flags(root_dir):
953     # Remove forced debuggable flags
954     logging.debug("Removing debuggable flags from %s" % root_dir)
955     for root, dirs, files in os.walk(root_dir):
956         if 'AndroidManifest.xml' in files:
957             path = os.path.join(root, 'AndroidManifest.xml')
958             p = FDroidPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path], output=False)
959             if p.returncode != 0:
960                 raise BuildException("Failed to remove debuggable flags of %s" % path)
961
962
963 # Extract some information from the AndroidManifest.xml at the given path.
964 # Returns (version, vercode, package), any or all of which might be None.
965 # All values returned are strings.
966 def parse_androidmanifests(paths, ignoreversions=None):
967
968     if not paths:
969         return (None, None, None)
970
971     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
972     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
973     psearch = re.compile(r'.*package="([^"]+)".*').search
974
975     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
976     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
977     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
978
979     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
980
981     max_version = None
982     max_vercode = None
983     max_package = None
984
985     for path in paths:
986
987         logging.debug("Parsing manifest at {0}".format(path))
988         gradle = has_extension(path, 'gradle')
989         version = None
990         vercode = None
991         # Remember package name, may be defined separately from version+vercode
992         package = max_package
993
994         for line in file(path):
995             if not package:
996                 if gradle:
997                     matches = psearch_g(line)
998                 else:
999                     matches = psearch(line)
1000                 if matches:
1001                     package = matches.group(1)
1002             if not version:
1003                 if gradle:
1004                     matches = vnsearch_g(line)
1005                 else:
1006                     matches = vnsearch(line)
1007                 if matches:
1008                     version = matches.group(2 if gradle else 1)
1009             if not vercode:
1010                 if gradle:
1011                     matches = vcsearch_g(line)
1012                 else:
1013                     matches = vcsearch(line)
1014                 if matches:
1015                     vercode = matches.group(1)
1016
1017         logging.debug("..got package={0}, version={1}, vercode={2}"
1018                       .format(package, version, vercode))
1019
1020         # Always grab the package name and version name in case they are not
1021         # together with the highest version code
1022         if max_package is None and package is not None:
1023             max_package = package
1024         if max_version is None and version is not None:
1025             max_version = version
1026
1027         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1028             if not ignoresearch or not ignoresearch(version):
1029                 if version is not None:
1030                     max_version = version
1031                 if vercode is not None:
1032                     max_vercode = vercode
1033                 if package is not None:
1034                     max_package = package
1035             else:
1036                 max_version = "Ignore"
1037
1038     if max_version is None:
1039         max_version = "Unknown"
1040
1041     return (max_version, max_vercode, max_package)
1042
1043
1044 class FDroidException(Exception):
1045
1046     def __init__(self, value, detail=None):
1047         self.value = value
1048         self.detail = detail
1049
1050     def get_wikitext(self):
1051         ret = repr(self.value) + "\n"
1052         if self.detail:
1053             ret += "=detail=\n"
1054             ret += "<pre>\n"
1055             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1056             ret += str(txt)
1057             ret += "</pre>\n"
1058         return ret
1059
1060     def __str__(self):
1061         ret = self.value
1062         if self.detail:
1063             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1064         return ret
1065
1066
1067 class VCSException(FDroidException):
1068     pass
1069
1070
1071 class BuildException(FDroidException):
1072     pass
1073
1074
1075 # Get the specified source library.
1076 # Returns the path to it. Normally this is the path to be used when referencing
1077 # it, which may be a subdirectory of the actual project. If you want the base
1078 # directory of the project, pass 'basepath=True'.
1079 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1080               basepath=False, raw=False, prepare=True, preponly=False):
1081
1082     number = None
1083     subdir = None
1084     if raw:
1085         name = spec
1086         ref = None
1087     else:
1088         name, ref = spec.split('@')
1089         if ':' in name:
1090             number, name = name.split(':', 1)
1091         if '/' in name:
1092             name, subdir = name.split('/', 1)
1093
1094     if name not in metadata.srclibs:
1095         raise VCSException('srclib ' + name + ' not found.')
1096
1097     srclib = metadata.srclibs[name]
1098
1099     sdir = os.path.join(srclib_dir, name)
1100
1101     if not preponly:
1102         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1103         vcs.srclib = (name, number, sdir)
1104         if ref:
1105             vcs.gotorevision(ref)
1106
1107         if raw:
1108             return vcs
1109
1110     libdir = None
1111     if subdir:
1112         libdir = os.path.join(sdir, subdir)
1113     elif srclib["Subdir"]:
1114         for subdir in srclib["Subdir"]:
1115             libdir_candidate = os.path.join(sdir, subdir)
1116             if os.path.exists(libdir_candidate):
1117                 libdir = libdir_candidate
1118                 break
1119
1120     if libdir is None:
1121         libdir = sdir
1122
1123     if srclib["Srclibs"]:
1124         n = 1
1125         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1126             s_tuple = None
1127             for t in srclibpaths:
1128                 if t[0] == lib:
1129                     s_tuple = t
1130                     break
1131             if s_tuple is None:
1132                 raise VCSException('Missing recursive srclib %s for %s' % (
1133                     lib, name))
1134             place_srclib(libdir, n, s_tuple[2])
1135             n += 1
1136
1137     remove_signing_keys(sdir)
1138     remove_debuggable_flags(sdir)
1139
1140     if prepare:
1141
1142         if srclib["Prepare"]:
1143             cmd = replace_config_vars(srclib["Prepare"])
1144
1145             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1146             if p.returncode != 0:
1147                 raise BuildException("Error running prepare command for srclib %s"
1148                                      % name, p.output)
1149
1150     if basepath:
1151         libdir = sdir
1152
1153     return (name, number, libdir)
1154
1155
1156 # Prepare the source code for a particular build
1157 #  'vcs'         - the appropriate vcs object for the application
1158 #  'app'         - the application details from the metadata
1159 #  'build'       - the build details from the metadata
1160 #  'build_dir'   - the path to the build directory, usually
1161 #                   'build/app.id'
1162 #  'srclib_dir'  - the path to the source libraries directory, usually
1163 #                   'build/srclib'
1164 #  'extlib_dir'  - the path to the external libraries directory, usually
1165 #                   'build/extlib'
1166 # Returns the (root, srclibpaths) where:
1167 #   'root' is the root directory, which may be the same as 'build_dir' or may
1168 #          be a subdirectory of it.
1169 #   'srclibpaths' is information on the srclibs being used
1170 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1171
1172     # Optionally, the actual app source can be in a subdirectory
1173     if build['subdir']:
1174         root_dir = os.path.join(build_dir, build['subdir'])
1175     else:
1176         root_dir = build_dir
1177
1178     # Get a working copy of the right revision
1179     logging.info("Getting source for revision " + build['commit'])
1180     vcs.gotorevision(build['commit'])
1181
1182     # Initialise submodules if requred
1183     if build['submodules']:
1184         logging.info("Initialising submodules")
1185         vcs.initsubmodules()
1186
1187     # Check that a subdir (if we're using one) exists. This has to happen
1188     # after the checkout, since it might not exist elsewhere
1189     if not os.path.exists(root_dir):
1190         raise BuildException('Missing subdir ' + root_dir)
1191
1192     # Run an init command if one is required
1193     if build['init']:
1194         cmd = replace_config_vars(build['init'])
1195         logging.info("Running 'init' commands in %s" % root_dir)
1196
1197         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1198         if p.returncode != 0:
1199             raise BuildException("Error running init command for %s:%s" %
1200                                  (app['id'], build['version']), p.output)
1201
1202     # Apply patches if any
1203     if build['patch']:
1204         logging.info("Applying patches")
1205         for patch in build['patch']:
1206             patch = patch.strip()
1207             logging.info("Applying " + patch)
1208             patch_path = os.path.join('metadata', app['id'], patch)
1209             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1210             if p.returncode != 0:
1211                 raise BuildException("Failed to apply patch %s" % patch_path)
1212
1213     # Get required source libraries
1214     srclibpaths = []
1215     if build['srclibs']:
1216         logging.info("Collecting source libraries")
1217         for lib in build['srclibs']:
1218             srclibpaths.append(getsrclib(lib, srclib_dir, build, srclibpaths,
1219                                          preponly=onserver))
1220
1221     for name, number, libpath in srclibpaths:
1222         place_srclib(root_dir, int(number) if number else None, libpath)
1223
1224     basesrclib = vcs.getsrclib()
1225     # If one was used for the main source, add that too.
1226     if basesrclib:
1227         srclibpaths.append(basesrclib)
1228
1229     # Update the local.properties file
1230     localprops = [os.path.join(build_dir, 'local.properties')]
1231     if build['subdir']:
1232         localprops += [os.path.join(root_dir, 'local.properties')]
1233     for path in localprops:
1234         props = ""
1235         if os.path.isfile(path):
1236             logging.info("Updating local.properties file at %s" % path)
1237             f = open(path, 'r')
1238             props += f.read()
1239             f.close()
1240             props += '\n'
1241         else:
1242             logging.info("Creating local.properties file at %s" % path)
1243         # Fix old-fashioned 'sdk-location' by copying
1244         # from sdk.dir, if necessary
1245         if build['oldsdkloc']:
1246             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1247                               re.S | re.M).group(1)
1248             props += "sdk-location=%s\n" % sdkloc
1249         else:
1250             props += "sdk.dir=%s\n" % config['sdk_path']
1251             props += "sdk-location=%s\n" % config['sdk_path']
1252         if build['ndk_path']:
1253             # Add ndk location
1254             props += "ndk.dir=%s\n" % build['ndk_path']
1255             props += "ndk-location=%s\n" % build['ndk_path']
1256         # Add java.encoding if necessary
1257         if build['encoding']:
1258             props += "java.encoding=%s\n" % build['encoding']
1259         f = open(path, 'w')
1260         f.write(props)
1261         f.close()
1262
1263     flavours = []
1264     if build['type'] == 'gradle':
1265         flavours = build['gradle']
1266
1267         version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1268         gradlepluginver = None
1269
1270         gradle_files = [os.path.join(root_dir, 'build.gradle')]
1271
1272         # Parent dir build.gradle
1273         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1274         if parent_dir.startswith(build_dir):
1275             gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1276
1277         for path in gradle_files:
1278             if gradlepluginver:
1279                 break
1280             if not os.path.isfile(path):
1281                 continue
1282             with open(path) as f:
1283                 for line in f:
1284                     match = version_regex.match(line)
1285                     if match:
1286                         gradlepluginver = match.group(1)
1287                         break
1288
1289         if gradlepluginver:
1290             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1291         else:
1292             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1293             build['gradlepluginver'] = LooseVersion('0.11')
1294
1295         if build['target']:
1296             n = build["target"].split('-')[1]
1297             FDroidPopen(['sed', '-i',
1298                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1299                          'build.gradle'], cwd=root_dir, output=False)
1300
1301     # Remove forced debuggable flags
1302     remove_debuggable_flags(root_dir)
1303
1304     # Insert version code and number into the manifest if necessary
1305     if build['forceversion']:
1306         logging.info("Changing the version name")
1307         for path in manifest_paths(root_dir, flavours):
1308             if not os.path.isfile(path):
1309                 continue
1310             if has_extension(path, 'xml'):
1311                 p = FDroidPopen(['sed', '-i',
1312                                  's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1313                                  path], output=False)
1314                 if p.returncode != 0:
1315                     raise BuildException("Failed to amend manifest")
1316             elif has_extension(path, 'gradle'):
1317                 p = FDroidPopen(['sed', '-i',
1318                                  's/versionName *=* *.*/versionName = "' + build['version'] + '"/g',
1319                                  path], output=False)
1320                 if p.returncode != 0:
1321                     raise BuildException("Failed to amend build.gradle")
1322     if build['forcevercode']:
1323         logging.info("Changing the version code")
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:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/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/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1336                                  path], output=False)
1337                 if p.returncode != 0:
1338                     raise BuildException("Failed to amend build.gradle")
1339
1340     # Delete unwanted files
1341     if build['rm']:
1342         logging.info("Removing specified files")
1343         for part in getpaths(build_dir, build, 'rm'):
1344             dest = os.path.join(build_dir, part)
1345             logging.info("Removing {0}".format(part))
1346             if os.path.lexists(dest):
1347                 if os.path.islink(dest):
1348                     FDroidPopen(['unlink ' + dest], shell=True, output=False)
1349                 else:
1350                     FDroidPopen(['rm -rf ' + dest], shell=True, output=False)
1351             else:
1352                 logging.info("...but it didn't exist")
1353
1354     remove_signing_keys(build_dir)
1355
1356     # Add required external libraries
1357     if build['extlibs']:
1358         logging.info("Collecting prebuilt libraries")
1359         libsdir = os.path.join(root_dir, 'libs')
1360         if not os.path.exists(libsdir):
1361             os.mkdir(libsdir)
1362         for lib in build['extlibs']:
1363             lib = lib.strip()
1364             logging.info("...installing extlib {0}".format(lib))
1365             libf = os.path.basename(lib)
1366             libsrc = os.path.join(extlib_dir, lib)
1367             if not os.path.exists(libsrc):
1368                 raise BuildException("Missing extlib file {0}".format(libsrc))
1369             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1370
1371     # Run a pre-build command if one is required
1372     if build['prebuild']:
1373         logging.info("Running 'prebuild' commands in %s" % root_dir)
1374
1375         cmd = replace_config_vars(build['prebuild'])
1376
1377         # Substitute source library paths into prebuild commands
1378         for name, number, libpath in srclibpaths:
1379             libpath = os.path.relpath(libpath, root_dir)
1380             cmd = cmd.replace('$$' + name + '$$', libpath)
1381
1382         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1383         if p.returncode != 0:
1384             raise BuildException("Error running prebuild command for %s:%s" %
1385                                  (app['id'], build['version']), p.output)
1386
1387     # Generate (or update) the ant build file, build.xml...
1388     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1389         parms = ['android', 'update', 'lib-project']
1390         lparms = ['android', 'update', 'project']
1391
1392         if build['target']:
1393             parms += ['-t', build['target']]
1394             lparms += ['-t', build['target']]
1395         if build['update'] == ['auto']:
1396             update_dirs = ant_subprojects(root_dir) + ['.']
1397         else:
1398             update_dirs = build['update']
1399
1400         for d in update_dirs:
1401             subdir = os.path.join(root_dir, d)
1402             if d == '.':
1403                 logging.debug("Updating main project")
1404                 cmd = parms + ['-p', d]
1405             else:
1406                 logging.debug("Updating subproject %s" % d)
1407                 cmd = lparms + ['-p', d]
1408             p = SdkToolsPopen(cmd, cwd=root_dir)
1409             # Check to see whether an error was returned without a proper exit
1410             # code (this is the case for the 'no target set or target invalid'
1411             # error)
1412             if p.returncode != 0 or p.output.startswith("Error: "):
1413                 raise BuildException("Failed to update project at %s" % d, p.output)
1414             # Clean update dirs via ant
1415             if d != '.':
1416                 logging.info("Cleaning subproject %s" % d)
1417                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1418
1419     return (root_dir, srclibpaths)
1420
1421
1422 # Split and extend via globbing the paths from a field
1423 def getpaths(build_dir, build, field):
1424     paths = []
1425     for p in build[field]:
1426         p = p.strip()
1427         full_path = os.path.join(build_dir, p)
1428         full_path = os.path.normpath(full_path)
1429         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1430     return paths
1431
1432
1433 # Scan the source code in the given directory (and all subdirectories)
1434 # and return the number of fatal problems encountered
1435 def scan_source(build_dir, root_dir, thisbuild):
1436
1437     count = 0
1438
1439     # Common known non-free blobs (always lower case):
1440     usual_suspects = [
1441         re.compile(r'flurryagent', re.IGNORECASE),
1442         re.compile(r'paypal.*mpl', re.IGNORECASE),
1443         re.compile(r'google.*analytics', re.IGNORECASE),
1444         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1445         re.compile(r'google.*ad.*view', re.IGNORECASE),
1446         re.compile(r'google.*admob', re.IGNORECASE),
1447         re.compile(r'google.*play.*services', re.IGNORECASE),
1448         re.compile(r'crittercism', re.IGNORECASE),
1449         re.compile(r'heyzap', re.IGNORECASE),
1450         re.compile(r'jpct.*ae', re.IGNORECASE),
1451         re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1452         re.compile(r'bugsense', re.IGNORECASE),
1453         re.compile(r'crashlytics', re.IGNORECASE),
1454         re.compile(r'ouya.*sdk', re.IGNORECASE),
1455         re.compile(r'libspen23', re.IGNORECASE),
1456     ]
1457
1458     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1459     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1460
1461     scanignore_worked = set()
1462     scandelete_worked = set()
1463
1464     try:
1465         ms = magic.open(magic.MIME_TYPE)
1466         ms.load()
1467     except AttributeError:
1468         ms = None
1469
1470     def toignore(fd):
1471         for p in scanignore:
1472             if fd.startswith(p):
1473                 scanignore_worked.add(p)
1474                 return True
1475         return False
1476
1477     def todelete(fd):
1478         for p in scandelete:
1479             if fd.startswith(p):
1480                 scandelete_worked.add(p)
1481                 return True
1482         return False
1483
1484     def ignoreproblem(what, fd, fp):
1485         logging.info('Ignoring %s at %s' % (what, fd))
1486         return 0
1487
1488     def removeproblem(what, fd, fp):
1489         logging.info('Removing %s at %s' % (what, fd))
1490         os.remove(fp)
1491         return 0
1492
1493     def warnproblem(what, fd):
1494         logging.warn('Found %s at %s' % (what, fd))
1495
1496     def handleproblem(what, fd, fp):
1497         if toignore(fd):
1498             return ignoreproblem(what, fd, fp)
1499         if todelete(fd):
1500             return removeproblem(what, fd, fp)
1501         logging.error('Found %s at %s' % (what, fd))
1502         return 1
1503
1504     # Iterate through all files in the source code
1505     for r, d, f in os.walk(build_dir, topdown=True):
1506
1507         # It's topdown, so checking the basename is enough
1508         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1509             if ignoredir in d:
1510                 d.remove(ignoredir)
1511
1512         for curfile in f:
1513
1514             # Path (relative) to the file
1515             fp = os.path.join(r, curfile)
1516             fd = fp[len(build_dir) + 1:]
1517
1518             try:
1519                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1520             except UnicodeError:
1521                 warnproblem('malformed magic number', fd)
1522
1523             if mime == 'application/x-sharedlib':
1524                 count += handleproblem('shared library', fd, fp)
1525
1526             elif mime == 'application/x-archive':
1527                 count += handleproblem('static library', fd, fp)
1528
1529             elif mime == 'application/x-executable':
1530                 count += handleproblem('binary executable', fd, fp)
1531
1532             elif mime == 'application/x-java-applet':
1533                 count += handleproblem('Java compiled class', fd, fp)
1534
1535             elif mime in (
1536                     'application/jar',
1537                     'application/zip',
1538                     'application/java-archive',
1539                     'application/octet-stream',
1540                     'binary',
1541             ):
1542
1543                 if has_extension(fp, 'apk'):
1544                     removeproblem('APK file', fd, fp)
1545
1546                 elif has_extension(fp, 'jar'):
1547
1548                     if any(suspect.match(curfile) for suspect in usual_suspects):
1549                         count += handleproblem('usual supect', fd, fp)
1550                     else:
1551                         warnproblem('JAR file', fd)
1552
1553                 elif has_extension(fp, 'zip'):
1554                     warnproblem('ZIP file', fd)
1555
1556                 else:
1557                     warnproblem('unknown compressed or binary file', fd)
1558
1559             elif has_extension(fp, 'java'):
1560                 for line in file(fp):
1561                     if 'DexClassLoader' in line:
1562                         count += handleproblem('DexClassLoader', fd, fp)
1563                         break
1564     if ms is not None:
1565         ms.close()
1566
1567     for p in scanignore:
1568         if p not in scanignore_worked:
1569             logging.error('Unused scanignore path: %s' % p)
1570             count += 1
1571
1572     for p in scandelete:
1573         if p not in scandelete_worked:
1574             logging.error('Unused scandelete path: %s' % p)
1575             count += 1
1576
1577     # Presence of a jni directory without buildjni=yes might
1578     # indicate a problem (if it's not a problem, explicitly use
1579     # buildjni=no to bypass this check)
1580     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1581             not thisbuild['buildjni']):
1582         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1583         count += 1
1584
1585     return count
1586
1587
1588 class KnownApks:
1589
1590     def __init__(self):
1591         self.path = os.path.join('stats', 'known_apks.txt')
1592         self.apks = {}
1593         if os.path.exists(self.path):
1594             for line in file(self.path):
1595                 t = line.rstrip().split(' ')
1596                 if len(t) == 2:
1597                     self.apks[t[0]] = (t[1], None)
1598                 else:
1599                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1600         self.changed = False
1601
1602     def writeifchanged(self):
1603         if self.changed:
1604             if not os.path.exists('stats'):
1605                 os.mkdir('stats')
1606             f = open(self.path, 'w')
1607             lst = []
1608             for apk, app in self.apks.iteritems():
1609                 appid, added = app
1610                 line = apk + ' ' + appid
1611                 if added:
1612                     line += ' ' + time.strftime('%Y-%m-%d', added)
1613                 lst.append(line)
1614             for line in sorted(lst):
1615                 f.write(line + '\n')
1616             f.close()
1617
1618     # Record an apk (if it's new, otherwise does nothing)
1619     # Returns the date it was added.
1620     def recordapk(self, apk, app):
1621         if apk not in self.apks:
1622             self.apks[apk] = (app, time.gmtime(time.time()))
1623             self.changed = True
1624         _, added = self.apks[apk]
1625         return added
1626
1627     # Look up information - given the 'apkname', returns (app id, date added/None).
1628     # Or returns None for an unknown apk.
1629     def getapp(self, apkname):
1630         if apkname in self.apks:
1631             return self.apks[apkname]
1632         return None
1633
1634     # Get the most recent 'num' apps added to the repo, as a list of package ids
1635     # with the most recent first.
1636     def getlatest(self, num):
1637         apps = {}
1638         for apk, app in self.apks.iteritems():
1639             appid, added = app
1640             if added:
1641                 if appid in apps:
1642                     if apps[appid] > added:
1643                         apps[appid] = added
1644                 else:
1645                     apps[appid] = added
1646         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1647         lst = [app for app, _ in sortedapps]
1648         lst.reverse()
1649         return lst
1650
1651
1652 def isApkDebuggable(apkfile, config):
1653     """Returns True if the given apk file is debuggable
1654
1655     :param apkfile: full path to the apk to check"""
1656
1657     p = SdkToolsPopen(['aapt', 'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1658                       output=False)
1659     if p.returncode != 0:
1660         logging.critical("Failed to get apk manifest information")
1661         sys.exit(1)
1662     for line in p.output.splitlines():
1663         if 'android:debuggable' in line and not line.endswith('0x0'):
1664             return True
1665     return False
1666
1667
1668 class AsynchronousFileReader(threading.Thread):
1669
1670     '''
1671     Helper class to implement asynchronous reading of a file
1672     in a separate thread. Pushes read lines on a queue to
1673     be consumed in another thread.
1674     '''
1675
1676     def __init__(self, fd, queue):
1677         assert isinstance(queue, Queue.Queue)
1678         assert callable(fd.readline)
1679         threading.Thread.__init__(self)
1680         self._fd = fd
1681         self._queue = queue
1682
1683     def run(self):
1684         '''The body of the tread: read lines and put them on the queue.'''
1685         for line in iter(self._fd.readline, ''):
1686             self._queue.put(line)
1687
1688     def eof(self):
1689         '''Check whether there is no more content to expect.'''
1690         return not self.is_alive() and self._queue.empty()
1691
1692
1693 class PopenResult:
1694     returncode = None
1695     output = ''
1696
1697
1698 def SdkToolsPopen(commands, cwd=None, shell=False, output=True):
1699     cmd = commands[0]
1700     if cmd not in config:
1701         config[cmd] = find_sdk_tools_cmd(commands[0])
1702     return FDroidPopen([config[cmd]] + commands[1:],
1703                        cwd=cwd, shell=shell, output=output)
1704
1705
1706 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1707     """
1708     Run a command and capture the possibly huge output.
1709
1710     :param commands: command and argument list like in subprocess.Popen
1711     :param cwd: optionally specifies a working directory
1712     :returns: A PopenResult.
1713     """
1714
1715     global env
1716
1717     if cwd:
1718         cwd = os.path.normpath(cwd)
1719         logging.debug("Directory: %s" % cwd)
1720     logging.debug("> %s" % ' '.join(commands))
1721
1722     result = PopenResult()
1723     p = None
1724     try:
1725         p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1726                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1727     except OSError, e:
1728         raise BuildException("OSError while trying to execute " + ' '.join(commands) + ': ' + str(e))
1729
1730     stdout_queue = Queue.Queue()
1731     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1732     stdout_reader.start()
1733
1734     # Check the queue for output (until there is no more to get)
1735     while not stdout_reader.eof():
1736         while not stdout_queue.empty():
1737             line = stdout_queue.get()
1738             if output and options.verbose:
1739                 # Output directly to console
1740                 sys.stderr.write(line)
1741                 sys.stderr.flush()
1742             result.output += line
1743
1744         time.sleep(0.1)
1745
1746     result.returncode = p.wait()
1747     return result
1748
1749
1750 def remove_signing_keys(build_dir):
1751     comment = re.compile(r'[ ]*//')
1752     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1753     line_matches = [
1754         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1755         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1756         re.compile(r'.*variant\.outputFile = .*'),
1757         re.compile(r'.*output\.outputFile = .*'),
1758         re.compile(r'.*\.readLine\(.*'),
1759     ]
1760     for root, dirs, files in os.walk(build_dir):
1761         if 'build.gradle' in files:
1762             path = os.path.join(root, 'build.gradle')
1763
1764             with open(path, "r") as o:
1765                 lines = o.readlines()
1766
1767             changed = False
1768
1769             opened = 0
1770             i = 0
1771             with open(path, "w") as o:
1772                 while i < len(lines):
1773                     line = lines[i]
1774                     i += 1
1775                     while line.endswith('\\\n'):
1776                         line = line.rstrip('\\\n') + lines[i]
1777                         i += 1
1778
1779                     if comment.match(line):
1780                         continue
1781
1782                     if opened > 0:
1783                         opened += line.count('{')
1784                         opened -= line.count('}')
1785                         continue
1786
1787                     if signing_configs.match(line):
1788                         changed = True
1789                         opened += 1
1790                         continue
1791
1792                     if any(s.match(line) for s in line_matches):
1793                         changed = True
1794                         continue
1795
1796                     if opened == 0:
1797                         o.write(line)
1798
1799             if changed:
1800                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1801
1802         for propfile in [
1803                 'project.properties',
1804                 'build.properties',
1805                 'default.properties',
1806                 'ant.properties',
1807         ]:
1808             if propfile in files:
1809                 path = os.path.join(root, propfile)
1810
1811                 with open(path, "r") as o:
1812                     lines = o.readlines()
1813
1814                 changed = False
1815
1816                 with open(path, "w") as o:
1817                     for line in lines:
1818                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1819                             changed = True
1820                             continue
1821
1822                         o.write(line)
1823
1824                 if changed:
1825                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1826
1827
1828 def reset_env_path():
1829     global env, orig_path
1830     env['PATH'] = orig_path
1831
1832
1833 def add_to_env_path(path):
1834     global env
1835     paths = env['PATH'].split(os.pathsep)
1836     if path in paths:
1837         return
1838     paths += path
1839     env['PATH'] = os.pathsep.join(paths)
1840
1841
1842 def replace_config_vars(cmd):
1843     global env
1844     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1845     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1846     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1847     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1848     return cmd
1849
1850
1851 def place_srclib(root_dir, number, libpath):
1852     if not number:
1853         return
1854     relpath = os.path.relpath(libpath, root_dir)
1855     proppath = os.path.join(root_dir, 'project.properties')
1856
1857     lines = []
1858     if os.path.isfile(proppath):
1859         with open(proppath, "r") as o:
1860             lines = o.readlines()
1861
1862     with open(proppath, "w") as o:
1863         placed = False
1864         for line in lines:
1865             if line.startswith('android.library.reference.%d=' % number):
1866                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1867                 placed = True
1868             else:
1869                 o.write(line)
1870         if not placed:
1871             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1872
1873
1874 def compare_apks(apk1, apk2, tmp_dir):
1875     """Compare two apks
1876
1877     Returns None if the apk content is the same (apart from the signing key),
1878     otherwise a string describing what's different, or what went wrong when
1879     trying to do the comparison.
1880     """
1881
1882     badchars = re.compile('''[/ :;'"]''')
1883     apk1dir = os.path.join(tmp_dir, badchars.sub('_', apk1[0:-4]))  # trim .apk
1884     apk2dir = os.path.join(tmp_dir, badchars.sub('_', apk2[0:-4]))  # trim .apk
1885     for d in [apk1dir, apk2dir]:
1886         if os.path.exists(d):
1887             shutil.rmtree(d)
1888         os.mkdir(d)
1889         os.mkdir(os.path.join(d, 'jar-xf'))
1890
1891     if subprocess.call(['jar', 'xf',
1892                         os.path.abspath(apk1)],
1893                        cwd=os.path.join(apk1dir, 'jar-xf')) != 0:
1894         return("Failed to unpack " + apk1)
1895     if subprocess.call(['jar', 'xf',
1896                         os.path.abspath(apk2)],
1897                        cwd=os.path.join(apk2dir, 'jar-xf')) != 0:
1898         return("Failed to unpack " + apk2)
1899
1900     # try to find apktool in the path, if it hasn't been manually configed
1901     if 'apktool' not in config:
1902         tmp = find_command('apktool')
1903         if tmp is not None:
1904             config['apktool'] = tmp
1905     if 'apktool' in config:
1906         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk1), '--output', 'apktool'],
1907                            cwd=apk1dir) != 0:
1908             return("Failed to unpack " + apk1)
1909         if subprocess.call([config['apktool'], 'd', os.path.abspath(apk2), '--output', 'apktool'],
1910                            cwd=apk2dir) != 0:
1911             return("Failed to unpack " + apk2)
1912
1913     p = FDroidPopen(['diff', '-r', apk1dir, apk2dir], output=False)
1914     lines = p.output.splitlines()
1915     if len(lines) != 1 or 'META-INF' not in lines[0]:
1916         meld = find_command('meld')
1917         if meld is not None:
1918             p = FDroidPopen(['meld', apk1dir, apk2dir], output=False)
1919         return("Unexpected diff output - " + p.output)
1920
1921     # since everything verifies, delete the comparison to keep cruft down
1922     shutil.rmtree(apk1dir)
1923     shutil.rmtree(apk2dir)
1924
1925     # If we get here, it seems like they're the same!
1926     return None
1927
1928
1929 def find_command(command):
1930     '''find the full path of a command, or None if it can't be found in the PATH'''
1931
1932     def is_exe(fpath):
1933         return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
1934
1935     fpath, fname = os.path.split(command)
1936     if fpath:
1937         if is_exe(command):
1938             return command
1939     else:
1940         for path in os.environ["PATH"].split(os.pathsep):
1941             path = path.strip('"')
1942             exe_file = os.path.join(path, command)
1943             if is_exe(exe_file):
1944                 return exe_file
1945
1946     return None