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