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