chiark / gitweb /
Revert "Always print command output when --verbose"
[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'libgoogleanalytics', re.IGNORECASE),
1409         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1410         re.compile(r'googleadview', re.IGNORECASE),
1411         re.compile(r'googleadmobadssdk', 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'youtubeandroidplayerapi', 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     def insidedir(path, dirname):
1460         return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1461
1462     # Iterate through all files in the source code
1463     for r, d, f in os.walk(build_dir):
1464
1465         if any(insidedir(r, d) for d in ('.hg', '.git', '.svn', '.bzr')):
1466             continue
1467
1468         for curfile in f:
1469
1470             # Path (relative) to the file
1471             fp = os.path.join(r, curfile)
1472             fd = fp[len(build_dir) + 1:]
1473
1474             # Check if this file has been explicitly excluded from scanning
1475             if toignore(fd):
1476                 continue
1477
1478             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1479
1480             if mime == 'application/x-sharedlib':
1481                 count += handleproblem('shared library', fd, fp)
1482
1483             elif mime == 'application/x-archive':
1484                 count += handleproblem('static library', fd, fp)
1485
1486             elif mime == 'application/x-executable':
1487                 count += handleproblem('binary executable', fd, fp)
1488
1489             elif mime == 'application/x-java-applet':
1490                 count += handleproblem('Java compiled class', fd, fp)
1491
1492             elif mime in (
1493                     'application/jar',
1494                     'application/zip',
1495                     'application/java-archive',
1496                     'application/octet-stream',
1497                     'binary',
1498                     ):
1499
1500                 if has_extension(fp, 'apk'):
1501                     removeproblem('APK file', fd, fp)
1502
1503                 elif has_extension(fp, 'jar'):
1504
1505                     if any(suspect.match(curfile) for suspect in usual_suspects):
1506                         count += handleproblem('usual supect', fd, fp)
1507                     else:
1508                         warnproblem('JAR file', fd)
1509
1510                 elif has_extension(fp, 'zip'):
1511                     warnproblem('ZIP file', fd)
1512
1513                 else:
1514                     warnproblem('unknown compressed or binary file', fd)
1515
1516             elif has_extension(fp, 'java'):
1517                 for line in file(fp):
1518                     if 'DexClassLoader' in line:
1519                         count += handleproblem('DexClassLoader', fd, fp)
1520                         break
1521     if ms is not None:
1522         ms.close()
1523
1524     # Presence of a jni directory without buildjni=yes might
1525     # indicate a problem (if it's not a problem, explicitly use
1526     # buildjni=no to bypass this check)
1527     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1528             not thisbuild['buildjni']):
1529         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1530         count += 1
1531
1532     return count
1533
1534
1535 class KnownApks:
1536
1537     def __init__(self):
1538         self.path = os.path.join('stats', 'known_apks.txt')
1539         self.apks = {}
1540         if os.path.exists(self.path):
1541             for line in file(self.path):
1542                 t = line.rstrip().split(' ')
1543                 if len(t) == 2:
1544                     self.apks[t[0]] = (t[1], None)
1545                 else:
1546                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1547         self.changed = False
1548
1549     def writeifchanged(self):
1550         if self.changed:
1551             if not os.path.exists('stats'):
1552                 os.mkdir('stats')
1553             f = open(self.path, 'w')
1554             lst = []
1555             for apk, app in self.apks.iteritems():
1556                 appid, added = app
1557                 line = apk + ' ' + appid
1558                 if added:
1559                     line += ' ' + time.strftime('%Y-%m-%d', added)
1560                 lst.append(line)
1561             for line in sorted(lst):
1562                 f.write(line + '\n')
1563             f.close()
1564
1565     # Record an apk (if it's new, otherwise does nothing)
1566     # Returns the date it was added.
1567     def recordapk(self, apk, app):
1568         if apk not in self.apks:
1569             self.apks[apk] = (app, time.gmtime(time.time()))
1570             self.changed = True
1571         _, added = self.apks[apk]
1572         return added
1573
1574     # Look up information - given the 'apkname', returns (app id, date added/None).
1575     # Or returns None for an unknown apk.
1576     def getapp(self, apkname):
1577         if apkname in self.apks:
1578             return self.apks[apkname]
1579         return None
1580
1581     # Get the most recent 'num' apps added to the repo, as a list of package ids
1582     # with the most recent first.
1583     def getlatest(self, num):
1584         apps = {}
1585         for apk, app in self.apks.iteritems():
1586             appid, added = app
1587             if added:
1588                 if appid in apps:
1589                     if apps[appid] > added:
1590                         apps[appid] = added
1591                 else:
1592                     apps[appid] = added
1593         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1594         lst = [app for app, _ in sortedapps]
1595         lst.reverse()
1596         return lst
1597
1598
1599 def isApkDebuggable(apkfile, config):
1600     """Returns True if the given apk file is debuggable
1601
1602     :param apkfile: full path to the apk to check"""
1603
1604     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1605                                   config['build_tools'], 'aapt'),
1606                      'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1607     if p.returncode != 0:
1608         logging.critical("Failed to get apk manifest information")
1609         sys.exit(1)
1610     for line in p.output.splitlines():
1611         if 'android:debuggable' in line and not line.endswith('0x0'):
1612             return True
1613     return False
1614
1615
1616 class AsynchronousFileReader(threading.Thread):
1617     '''
1618     Helper class to implement asynchronous reading of a file
1619     in a separate thread. Pushes read lines on a queue to
1620     be consumed in another thread.
1621     '''
1622
1623     def __init__(self, fd, queue):
1624         assert isinstance(queue, Queue.Queue)
1625         assert callable(fd.readline)
1626         threading.Thread.__init__(self)
1627         self._fd = fd
1628         self._queue = queue
1629
1630     def run(self):
1631         '''The body of the tread: read lines and put them on the queue.'''
1632         for line in iter(self._fd.readline, ''):
1633             self._queue.put(line)
1634
1635     def eof(self):
1636         '''Check whether there is no more content to expect.'''
1637         return not self.is_alive() and self._queue.empty()
1638
1639
1640 class PopenResult:
1641     returncode = None
1642     output = ''
1643
1644
1645 def SilentPopen(commands, cwd=None, shell=False):
1646     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1647
1648
1649 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1650     """
1651     Run a command and capture the possibly huge output.
1652
1653     :param commands: command and argument list like in subprocess.Popen
1654     :param cwd: optionally specifies a working directory
1655     :returns: A PopenResult.
1656     """
1657
1658     global env
1659
1660     if cwd:
1661         cwd = os.path.normpath(cwd)
1662         logging.debug("Directory: %s" % cwd)
1663     logging.debug("> %s" % ' '.join(commands))
1664
1665     result = PopenResult()
1666     p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1667                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1668
1669     stdout_queue = Queue.Queue()
1670     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1671     stdout_reader.start()
1672
1673     # Check the queue for output (until there is no more to get)
1674     while not stdout_reader.eof():
1675         while not stdout_queue.empty():
1676             line = stdout_queue.get()
1677             if output and options.verbose:
1678                 # Output directly to console
1679                 sys.stderr.write(line)
1680                 sys.stderr.flush()
1681             result.output += line
1682
1683         time.sleep(0.1)
1684
1685     p.communicate()
1686     result.returncode = p.returncode
1687     return result
1688
1689
1690 def remove_signing_keys(build_dir):
1691     comment = re.compile(r'[ ]*//')
1692     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1693     line_matches = [
1694         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1695         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1696         re.compile(r'.*variant\.outputFile = .*'),
1697         re.compile(r'.*\.readLine\(.*'),
1698         ]
1699     for root, dirs, files in os.walk(build_dir):
1700         if 'build.gradle' in files:
1701             path = os.path.join(root, 'build.gradle')
1702
1703             with open(path, "r") as o:
1704                 lines = o.readlines()
1705
1706             changed = False
1707
1708             opened = 0
1709             with open(path, "w") as o:
1710                 for line in lines:
1711                     if comment.match(line):
1712                         continue
1713
1714                     if opened > 0:
1715                         opened += line.count('{')
1716                         opened -= line.count('}')
1717                         continue
1718
1719                     if signing_configs.match(line):
1720                         changed = True
1721                         opened += 1
1722                         continue
1723
1724                     if any(s.match(line) for s in line_matches):
1725                         changed = True
1726                         continue
1727
1728                     if opened == 0:
1729                         o.write(line)
1730
1731             if changed:
1732                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1733
1734         for propfile in [
1735                 'project.properties',
1736                 'build.properties',
1737                 'default.properties',
1738                 'ant.properties',
1739                 ]:
1740             if propfile in files:
1741                 path = os.path.join(root, propfile)
1742
1743                 with open(path, "r") as o:
1744                     lines = o.readlines()
1745
1746                 changed = False
1747
1748                 with open(path, "w") as o:
1749                     for line in lines:
1750                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1751                             changed = True
1752                             continue
1753
1754                         o.write(line)
1755
1756                 if changed:
1757                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1758
1759
1760 def replace_config_vars(cmd):
1761     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1762     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1763     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1764     return cmd
1765
1766
1767 def place_srclib(root_dir, number, libpath):
1768     if not number:
1769         return
1770     relpath = os.path.relpath(libpath, root_dir)
1771     proppath = os.path.join(root_dir, 'project.properties')
1772
1773     lines = []
1774     if os.path.isfile(proppath):
1775         with open(proppath, "r") as o:
1776             lines = o.readlines()
1777
1778     with open(proppath, "w") as o:
1779         placed = False
1780         for line in lines:
1781             if line.startswith('android.library.reference.%d=' % number):
1782                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1783                 placed = True
1784             else:
1785                 o.write(line)
1786         if not placed:
1787             o.write('android.library.reference.%d=%s\n' % (number, relpath))