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