chiark / gitweb /
Much cleaner and nicer way to ignore vcs dot dirs
[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']
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         for path in gradle_files:
1238             if gradlepluginver:
1239                 break
1240             if not os.path.isfile(path):
1241                 continue
1242             with open(path) as f:
1243                 for line in f:
1244                     match = version_regex.match(line)
1245                     if match:
1246                         gradlepluginver = match.group(1)
1247                         break
1248
1249         if gradlepluginver:
1250             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1251         else:
1252             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1253             build['gradlepluginver'] = LooseVersion('0.11')
1254
1255         if build['target']:
1256             n = build["target"].split('-')[1]
1257             FDroidPopen(['sed', '-i',
1258                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1259                          'build.gradle'],
1260                         cwd=root_dir)
1261
1262     # Remove forced debuggable flags
1263     remove_debuggable_flags(root_dir)
1264
1265     # Insert version code and number into the manifest if necessary
1266     if build['forceversion']:
1267         logging.info("Changing the version name")
1268         for path in manifest_paths(root_dir, flavour):
1269             if not os.path.isfile(path):
1270                 continue
1271             if has_extension(path, 'xml'):
1272                 p = SilentPopen(['sed', '-i',
1273                                  's/android:versionName="[^"]*"/android:versionName="'
1274                                  + build['version'] + '"/g',
1275                                  path])
1276                 if p.returncode != 0:
1277                     raise BuildException("Failed to amend manifest")
1278             elif has_extension(path, 'gradle'):
1279                 p = SilentPopen(['sed', '-i',
1280                                  's/versionName *=* *"[^"]*"/versionName = "'
1281                                  + build['version'] + '"/g',
1282                                  path])
1283                 if p.returncode != 0:
1284                     raise BuildException("Failed to amend build.gradle")
1285     if build['forcevercode']:
1286         logging.info("Changing the version code")
1287         for path in manifest_paths(root_dir, flavour):
1288             if not os.path.isfile(path):
1289                 continue
1290             if has_extension(path, 'xml'):
1291                 p = SilentPopen(['sed', '-i',
1292                                  's/android:versionCode="[^"]*"/android:versionCode="'
1293                                  + build['vercode'] + '"/g',
1294                                  path])
1295                 if p.returncode != 0:
1296                     raise BuildException("Failed to amend manifest")
1297             elif has_extension(path, 'gradle'):
1298                 p = SilentPopen(['sed', '-i',
1299                                  's/versionCode *=* *[0-9]*/versionCode = '
1300                                  + build['vercode'] + '/g',
1301                                  path])
1302                 if p.returncode != 0:
1303                     raise BuildException("Failed to amend build.gradle")
1304
1305     # Delete unwanted files
1306     if build['rm']:
1307         logging.info("Removing specified files")
1308         for part in getpaths(build_dir, build, 'rm'):
1309             dest = os.path.join(build_dir, part)
1310             logging.info("Removing {0}".format(part))
1311             if os.path.lexists(dest):
1312                 if os.path.islink(dest):
1313                     SilentPopen(['unlink ' + dest], shell=True)
1314                 else:
1315                     SilentPopen(['rm -rf ' + dest], shell=True)
1316             else:
1317                 logging.info("...but it didn't exist")
1318
1319     remove_signing_keys(build_dir)
1320
1321     # Add required external libraries
1322     if build['extlibs']:
1323         logging.info("Collecting prebuilt libraries")
1324         libsdir = os.path.join(root_dir, 'libs')
1325         if not os.path.exists(libsdir):
1326             os.mkdir(libsdir)
1327         for lib in build['extlibs']:
1328             lib = lib.strip()
1329             logging.info("...installing extlib {0}".format(lib))
1330             libf = os.path.basename(lib)
1331             libsrc = os.path.join(extlib_dir, lib)
1332             if not os.path.exists(libsrc):
1333                 raise BuildException("Missing extlib file {0}".format(libsrc))
1334             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1335
1336     # Run a pre-build command if one is required
1337     if build['prebuild']:
1338         logging.info("Running 'prebuild' commands in %s" % root_dir)
1339
1340         cmd = replace_config_vars(build['prebuild'])
1341
1342         # Substitute source library paths into prebuild commands
1343         for name, number, libpath in srclibpaths:
1344             libpath = os.path.relpath(libpath, root_dir)
1345             cmd = cmd.replace('$$' + name + '$$', libpath)
1346
1347         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1348         if p.returncode != 0:
1349             raise BuildException("Error running prebuild command for %s:%s" %
1350                                  (app['id'], build['version']), p.output)
1351
1352     # Generate (or update) the ant build file, build.xml...
1353     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1354         parms = [config['android'], 'update', 'lib-project']
1355         lparms = [config['android'], 'update', 'project']
1356
1357         if build['target']:
1358             parms += ['-t', build['target']]
1359             lparms += ['-t', build['target']]
1360         if build['update'] == ['auto']:
1361             update_dirs = ant_subprojects(root_dir) + ['.']
1362         else:
1363             update_dirs = build['update']
1364
1365         for d in update_dirs:
1366             subdir = os.path.join(root_dir, d)
1367             if d == '.':
1368                 logging.debug("Updating main project")
1369                 cmd = parms + ['-p', d]
1370             else:
1371                 logging.debug("Updating subproject %s" % d)
1372                 cmd = lparms + ['-p', d]
1373             p = FDroidPopen(cmd, cwd=root_dir)
1374             # Check to see whether an error was returned without a proper exit
1375             # code (this is the case for the 'no target set or target invalid'
1376             # error)
1377             if p.returncode != 0 or p.output.startswith("Error: "):
1378                 raise BuildException("Failed to update project at %s" % d, p.output)
1379             # Clean update dirs via ant
1380             if d != '.':
1381                 logging.info("Cleaning subproject %s" % d)
1382                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1383
1384     return (root_dir, srclibpaths)
1385
1386
1387 # Split and extend via globbing the paths from a field
1388 def getpaths(build_dir, build, field):
1389     paths = []
1390     for p in build[field]:
1391         p = p.strip()
1392         full_path = os.path.join(build_dir, p)
1393         full_path = os.path.normpath(full_path)
1394         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1395     return paths
1396
1397
1398 # Scan the source code in the given directory (and all subdirectories)
1399 # and return the number of fatal problems encountered
1400 def scan_source(build_dir, root_dir, thisbuild):
1401
1402     count = 0
1403
1404     # Common known non-free blobs (always lower case):
1405     usual_suspects = [
1406         re.compile(r'flurryagent', re.IGNORECASE),
1407         re.compile(r'paypal.*mpl', re.IGNORECASE),
1408         re.compile(r'google.*analytics', re.IGNORECASE),
1409         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1410         re.compile(r'google.*ad.*view', re.IGNORECASE),
1411         re.compile(r'google.*admob', re.IGNORECASE),
1412         re.compile(r'google.*play.*services', re.IGNORECASE),
1413         re.compile(r'crittercism', re.IGNORECASE),
1414         re.compile(r'heyzap', re.IGNORECASE),
1415         re.compile(r'jpct.*ae', re.IGNORECASE),
1416         re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1417         re.compile(r'bugsense', re.IGNORECASE),
1418         re.compile(r'crashlytics', re.IGNORECASE),
1419         re.compile(r'ouya.*sdk', re.IGNORECASE),
1420         re.compile(r'libspen23', re.IGNORECASE),
1421         ]
1422
1423     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1424     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1425
1426     try:
1427         ms = magic.open(magic.MIME_TYPE)
1428         ms.load()
1429     except AttributeError:
1430         ms = None
1431
1432     def toignore(fd):
1433         for i in scanignore:
1434             if fd.startswith(i):
1435                 return True
1436         return False
1437
1438     def todelete(fd):
1439         for i in scandelete:
1440             if fd.startswith(i):
1441                 return True
1442         return False
1443
1444     def removeproblem(what, fd, fp):
1445         logging.info('Removing %s at %s' % (what, fd))
1446         os.remove(fp)
1447
1448     def warnproblem(what, fd):
1449         logging.warn('Found %s at %s' % (what, fd))
1450
1451     def handleproblem(what, fd, fp):
1452         if todelete(fd):
1453             removeproblem(what, fd, fp)
1454         else:
1455             logging.error('Found %s at %s' % (what, fd))
1456             return True
1457         return False
1458
1459     # Iterate through all files in the source code
1460     for r, d, f in os.walk(build_dir, topdown=True):
1461
1462         # It's topdown, so checking the basename is enough
1463         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1464             if ignoredir in d:
1465                 d.remove(ignoredir)
1466
1467         for curfile in f:
1468
1469             # Path (relative) to the file
1470             fp = os.path.join(r, curfile)
1471             fd = fp[len(build_dir) + 1:]
1472
1473             # Check if this file has been explicitly excluded from scanning
1474             if toignore(fd):
1475                 continue
1476
1477             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1478
1479             if mime == 'application/x-sharedlib':
1480                 count += handleproblem('shared library', fd, fp)
1481
1482             elif mime == 'application/x-archive':
1483                 count += handleproblem('static library', fd, fp)
1484
1485             elif mime == 'application/x-executable':
1486                 count += handleproblem('binary executable', fd, fp)
1487
1488             elif mime == 'application/x-java-applet':
1489                 count += handleproblem('Java compiled class', fd, fp)
1490
1491             elif mime in (
1492                     'application/jar',
1493                     'application/zip',
1494                     'application/java-archive',
1495                     'application/octet-stream',
1496                     'binary',
1497                     ):
1498
1499                 if has_extension(fp, 'apk'):
1500                     removeproblem('APK file', fd, fp)
1501
1502                 elif has_extension(fp, 'jar'):
1503
1504                     if any(suspect.match(curfile) for suspect in usual_suspects):
1505                         count += handleproblem('usual supect', fd, fp)
1506                     else:
1507                         warnproblem('JAR file', fd)
1508
1509                 elif has_extension(fp, 'zip'):
1510                     warnproblem('ZIP file', fd)
1511
1512                 else:
1513                     warnproblem('unknown compressed or binary file', fd)
1514
1515             elif has_extension(fp, 'java'):
1516                 for line in file(fp):
1517                     if 'DexClassLoader' in line:
1518                         count += handleproblem('DexClassLoader', fd, fp)
1519                         break
1520     if ms is not None:
1521         ms.close()
1522
1523     # Presence of a jni directory without buildjni=yes might
1524     # indicate a problem (if it's not a problem, explicitly use
1525     # buildjni=no to bypass this check)
1526     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1527             not thisbuild['buildjni']):
1528         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1529         count += 1
1530
1531     return count
1532
1533
1534 class KnownApks:
1535
1536     def __init__(self):
1537         self.path = os.path.join('stats', 'known_apks.txt')
1538         self.apks = {}
1539         if os.path.exists(self.path):
1540             for line in file(self.path):
1541                 t = line.rstrip().split(' ')
1542                 if len(t) == 2:
1543                     self.apks[t[0]] = (t[1], None)
1544                 else:
1545                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1546         self.changed = False
1547
1548     def writeifchanged(self):
1549         if self.changed:
1550             if not os.path.exists('stats'):
1551                 os.mkdir('stats')
1552             f = open(self.path, 'w')
1553             lst = []
1554             for apk, app in self.apks.iteritems():
1555                 appid, added = app
1556                 line = apk + ' ' + appid
1557                 if added:
1558                     line += ' ' + time.strftime('%Y-%m-%d', added)
1559                 lst.append(line)
1560             for line in sorted(lst):
1561                 f.write(line + '\n')
1562             f.close()
1563
1564     # Record an apk (if it's new, otherwise does nothing)
1565     # Returns the date it was added.
1566     def recordapk(self, apk, app):
1567         if apk not in self.apks:
1568             self.apks[apk] = (app, time.gmtime(time.time()))
1569             self.changed = True
1570         _, added = self.apks[apk]
1571         return added
1572
1573     # Look up information - given the 'apkname', returns (app id, date added/None).
1574     # Or returns None for an unknown apk.
1575     def getapp(self, apkname):
1576         if apkname in self.apks:
1577             return self.apks[apkname]
1578         return None
1579
1580     # Get the most recent 'num' apps added to the repo, as a list of package ids
1581     # with the most recent first.
1582     def getlatest(self, num):
1583         apps = {}
1584         for apk, app in self.apks.iteritems():
1585             appid, added = app
1586             if added:
1587                 if appid in apps:
1588                     if apps[appid] > added:
1589                         apps[appid] = added
1590                 else:
1591                     apps[appid] = added
1592         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1593         lst = [app for app, _ in sortedapps]
1594         lst.reverse()
1595         return lst
1596
1597
1598 def isApkDebuggable(apkfile, config):
1599     """Returns True if the given apk file is debuggable
1600
1601     :param apkfile: full path to the apk to check"""
1602
1603     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1604                                   config['build_tools'], 'aapt'),
1605                      'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1606     if p.returncode != 0:
1607         logging.critical("Failed to get apk manifest information")
1608         sys.exit(1)
1609     for line in p.output.splitlines():
1610         if 'android:debuggable' in line and not line.endswith('0x0'):
1611             return True
1612     return False
1613
1614
1615 class AsynchronousFileReader(threading.Thread):
1616     '''
1617     Helper class to implement asynchronous reading of a file
1618     in a separate thread. Pushes read lines on a queue to
1619     be consumed in another thread.
1620     '''
1621
1622     def __init__(self, fd, queue):
1623         assert isinstance(queue, Queue.Queue)
1624         assert callable(fd.readline)
1625         threading.Thread.__init__(self)
1626         self._fd = fd
1627         self._queue = queue
1628
1629     def run(self):
1630         '''The body of the tread: read lines and put them on the queue.'''
1631         for line in iter(self._fd.readline, ''):
1632             self._queue.put(line)
1633
1634     def eof(self):
1635         '''Check whether there is no more content to expect.'''
1636         return not self.is_alive() and self._queue.empty()
1637
1638
1639 class PopenResult:
1640     returncode = None
1641     output = ''
1642
1643
1644 def SilentPopen(commands, cwd=None, shell=False):
1645     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1646
1647
1648 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1649     """
1650     Run a command and capture the possibly huge output.
1651
1652     :param commands: command and argument list like in subprocess.Popen
1653     :param cwd: optionally specifies a working directory
1654     :returns: A PopenResult.
1655     """
1656
1657     global env
1658
1659     if cwd:
1660         cwd = os.path.normpath(cwd)
1661         logging.debug("Directory: %s" % cwd)
1662     logging.debug("> %s" % ' '.join(commands))
1663
1664     result = PopenResult()
1665     p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1666                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1667
1668     stdout_queue = Queue.Queue()
1669     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1670     stdout_reader.start()
1671
1672     # Check the queue for output (until there is no more to get)
1673     while not stdout_reader.eof():
1674         while not stdout_queue.empty():
1675             line = stdout_queue.get()
1676             if output and options.verbose:
1677                 # Output directly to console
1678                 sys.stderr.write(line)
1679                 sys.stderr.flush()
1680             result.output += line
1681
1682         time.sleep(0.1)
1683
1684     p.communicate()
1685     result.returncode = p.returncode
1686     return result
1687
1688
1689 def remove_signing_keys(build_dir):
1690     comment = re.compile(r'[ ]*//')
1691     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1692     line_matches = [
1693         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1694         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1695         re.compile(r'.*variant\.outputFile = .*'),
1696         re.compile(r'.*\.readLine\(.*'),
1697         ]
1698     for root, dirs, files in os.walk(build_dir):
1699         if 'build.gradle' in files:
1700             path = os.path.join(root, 'build.gradle')
1701
1702             with open(path, "r") as o:
1703                 lines = o.readlines()
1704
1705             changed = False
1706
1707             opened = 0
1708             with open(path, "w") as o:
1709                 for line in lines:
1710                     if comment.match(line):
1711                         continue
1712
1713                     if opened > 0:
1714                         opened += line.count('{')
1715                         opened -= line.count('}')
1716                         continue
1717
1718                     if signing_configs.match(line):
1719                         changed = True
1720                         opened += 1
1721                         continue
1722
1723                     if any(s.match(line) for s in line_matches):
1724                         changed = True
1725                         continue
1726
1727                     if opened == 0:
1728                         o.write(line)
1729
1730             if changed:
1731                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1732
1733         for propfile in [
1734                 'project.properties',
1735                 'build.properties',
1736                 'default.properties',
1737                 'ant.properties',
1738                 ]:
1739             if propfile in files:
1740                 path = os.path.join(root, propfile)
1741
1742                 with open(path, "r") as o:
1743                     lines = o.readlines()
1744
1745                 changed = False
1746
1747                 with open(path, "w") as o:
1748                     for line in lines:
1749                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1750                             changed = True
1751                             continue
1752
1753                         o.write(line)
1754
1755                 if changed:
1756                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1757
1758
1759 def replace_config_vars(cmd):
1760     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1761     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1762     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1763     return cmd
1764
1765
1766 def place_srclib(root_dir, number, libpath):
1767     if not number:
1768         return
1769     relpath = os.path.relpath(libpath, root_dir)
1770     proppath = os.path.join(root_dir, 'project.properties')
1771
1772     lines = []
1773     if os.path.isfile(proppath):
1774         with open(proppath, "r") as o:
1775             lines = o.readlines()
1776
1777     with open(proppath, "w") as o:
1778         placed = False
1779         for line in lines:
1780             if line.startswith('android.library.reference.%d=' % number):
1781                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1782                 placed = True
1783             else:
1784                 o.write(line)
1785         if not placed:
1786             o.write('android.library.reference.%d=%s\n' % (number, relpath))