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