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