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