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