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