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