chiark / gitweb /
Bump build-tools to 21.0.2
[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 default_config = {
43     'sdk_path': "$ANDROID_HOME",
44     'ndk_path': "$ANDROID_NDK",
45     'build_tools': "21.0.2",
46     'ant': "ant",
47     'mvn3': "mvn",
48     'gradle': 'gradle',
49     'sync_from_local_copy_dir': False,
50     'update_stats': False,
51     'stats_ignore': [],
52     'stats_server': None,
53     'stats_user': None,
54     'stats_to_carbon': False,
55     'repo_maxage': 0,
56     'build_server_always': False,
57     'keystore': os.path.join("$HOME", '.local', 'share', 'fdroidserver', 'keystore.jks'),
58     'smartcardoptions': [],
59     'char_limits': {
60         'Summary': 50,
61         'Description': 1500
62     },
63     'keyaliases': {},
64     'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
65     'repo_name': "My First FDroid Repo Demo",
66     'repo_icon': "fdroid-icon.png",
67     'repo_description': '''
68         This is a repository of apps to be used with FDroid. Applications in this
69         repository are either official binaries built by the original application
70         developers, or are binaries built from source by the admin of f-droid.org
71         using the tools on https://gitlab.com/u/fdroid.
72         ''',
73     'archive_older': 0,
74 }
75
76
77 def fill_config_defaults(thisconfig):
78     for k, v in default_config.items():
79         if k not in thisconfig:
80             thisconfig[k] = v
81
82     # Expand paths (~users and $vars)
83     for k in ['sdk_path', 'ndk_path', 'ant', 'mvn3', 'gradle', 'keystore', 'repo_icon']:
84         v = thisconfig[k]
85         orig = v
86         v = os.path.expanduser(v)
87         v = os.path.expandvars(v)
88         if orig != v:
89             thisconfig[k] = v
90             thisconfig[k + '_orig'] = orig
91
92
93 def read_config(opts, config_file='config.py'):
94     """Read the repository config
95
96     The config is read from config_file, which is in the current directory when
97     any of the repo management commands are used.
98     """
99     global config, options, env
100
101     if config is not None:
102         return config
103     if not os.path.isfile(config_file):
104         logging.critical("Missing config file - is this a repo directory?")
105         sys.exit(2)
106
107     options = opts
108
109     config = {}
110
111     logging.debug("Reading %s" % config_file)
112     execfile(config_file, config)
113
114     # smartcardoptions must be a list since its command line args for Popen
115     if 'smartcardoptions' in config:
116         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
117     elif 'keystore' in config and config['keystore'] == 'NONE':
118         # keystore='NONE' means use smartcard, these are required defaults
119         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
120                                       'SunPKCS11-OpenSC', '-providerClass',
121                                       'sun.security.pkcs11.SunPKCS11',
122                                       '-providerArg', 'opensc-fdroid.cfg']
123
124     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
125         st = os.stat(config_file)
126         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
127             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
128
129     fill_config_defaults(config)
130
131     if not test_sdk_exists(config):
132         sys.exit(3)
133
134     if not test_build_tools_exists(config):
135         sys.exit(3)
136
137     bin_paths = {
138         'aapt': [
139             os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'aapt'),
140             ],
141         'zipalign': [
142             os.path.join(config['sdk_path'], 'tools', 'zipalign'),
143             os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'zipalign'),
144             ],
145         'android': [
146             os.path.join(config['sdk_path'], 'tools', 'android'),
147             ],
148         'adb': [
149             os.path.join(config['sdk_path'], 'platform-tools', 'adb'),
150             ],
151         }
152
153     for b, paths in bin_paths.items():
154         config[b] = None
155         for path in paths:
156             if os.path.isfile(path):
157                 config[b] = path
158                 break
159         if config[b] is None:
160             logging.warn("Could not find %s in any of the following paths:\n%s" % (
161                 b, '\n'.join(paths)))
162
163     # There is no standard, so just set up the most common environment
164     # variables
165     env = os.environ
166     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
167         env[n] = config['sdk_path']
168     for n in ['ANDROID_NDK', 'NDK']:
169         env[n] = config['ndk_path']
170
171     for k in ["keystorepass", "keypass"]:
172         if k in config:
173             write_password_file(k)
174
175     for k in ["repo_description", "archive_description"]:
176         if k in config:
177             config[k] = clean_description(config[k])
178
179     if 'serverwebroot' in config:
180         if isinstance(config['serverwebroot'], basestring):
181             roots = [config['serverwebroot']]
182         elif all(isinstance(item, basestring) for item in config['serverwebroot']):
183             roots = config['serverwebroot']
184         else:
185             raise TypeError('only accepts strings, lists, and tuples')
186         rootlist = []
187         for rootstr in roots:
188             # since this is used with rsync, where trailing slashes have
189             # meaning, ensure there is always a trailing slash
190             if rootstr[-1] != '/':
191                 rootstr += '/'
192             rootlist.append(rootstr.replace('//', '/'))
193         config['serverwebroot'] = rootlist
194
195     return config
196
197
198 def test_sdk_exists(thisconfig):
199     if thisconfig['sdk_path'] == default_config['sdk_path']:
200         logging.error('No Android SDK found!')
201         logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
202         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
203         return False
204     if not os.path.exists(thisconfig['sdk_path']):
205         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" does not exist!')
206         return False
207     if not os.path.isdir(thisconfig['sdk_path']):
208         logging.critical('Android SDK path "' + thisconfig['sdk_path'] + '" is not a directory!')
209         return False
210     for d in ['build-tools', 'platform-tools', 'tools']:
211         if not os.path.isdir(os.path.join(thisconfig['sdk_path'], d)):
212             logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
213                 thisconfig['sdk_path'], d))
214             return False
215     return True
216
217
218 def test_build_tools_exists(thisconfig):
219     if not test_sdk_exists(thisconfig):
220         return False
221     build_tools = os.path.join(thisconfig['sdk_path'], 'build-tools')
222     versioned_build_tools = os.path.join(build_tools, thisconfig['build_tools'])
223     if not os.path.isdir(versioned_build_tools):
224         logging.critical('Android Build Tools path "'
225                          + versioned_build_tools + '" does not exist!')
226         return False
227     return True
228
229
230 def write_password_file(pwtype, password=None):
231     '''
232     writes out passwords to a protected file instead of passing passwords as
233     command line argments
234     '''
235     filename = '.fdroid.' + pwtype + '.txt'
236     fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
237     if password is None:
238         os.write(fd, config[pwtype])
239     else:
240         os.write(fd, password)
241     os.close(fd)
242     config[pwtype + 'file'] = filename
243
244
245 # Given the arguments in the form of multiple appid:[vc] strings, this returns
246 # a dictionary with the set of vercodes specified for each package.
247 def read_pkg_args(args, allow_vercodes=False):
248
249     vercodes = {}
250     if not args:
251         return vercodes
252
253     for p in args:
254         if allow_vercodes and ':' in p:
255             package, vercode = p.split(':')
256         else:
257             package, vercode = p, None
258         if package not in vercodes:
259             vercodes[package] = [vercode] if vercode else []
260             continue
261         elif vercode and vercode not in vercodes[package]:
262             vercodes[package] += [vercode] if vercode else []
263
264     return vercodes
265
266
267 # On top of what read_pkg_args does, this returns the whole app metadata, but
268 # limiting the builds list to the builds matching the vercodes specified.
269 def read_app_args(args, allapps, allow_vercodes=False):
270
271     vercodes = read_pkg_args(args, allow_vercodes)
272
273     if not vercodes:
274         return allapps
275
276     apps = {}
277     for appid, app in allapps.iteritems():
278         if appid in vercodes:
279             apps[appid] = app
280
281     if len(apps) != len(vercodes):
282         for p in vercodes:
283             if p not in allapps:
284                 logging.critical("No such package: %s" % p)
285         raise FDroidException("Found invalid app ids in arguments")
286     if not apps:
287         raise FDroidException("No packages specified")
288
289     error = False
290     for appid, app in apps.iteritems():
291         vc = vercodes[appid]
292         if not vc:
293             continue
294         app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
295         if len(app['builds']) != len(vercodes[appid]):
296             error = True
297             allvcs = [b['vercode'] for b in app['builds']]
298             for v in vercodes[appid]:
299                 if v not in allvcs:
300                     logging.critical("No such vercode %s for app %s" % (v, appid))
301
302     if error:
303         raise FDroidException("Found invalid vercodes for some apps")
304
305     return apps
306
307
308 def has_extension(filename, extension):
309     name, ext = os.path.splitext(filename)
310     ext = ext.lower()[1:]
311     return ext == extension
312
313 apk_regex = None
314
315
316 def clean_description(description):
317     'Remove unneeded newlines and spaces from a block of description text'
318     returnstring = ''
319     # this is split up by paragraph to make removing the newlines easier
320     for paragraph in re.split(r'\n\n', description):
321         paragraph = re.sub('\r', '', paragraph)
322         paragraph = re.sub('\n', ' ', paragraph)
323         paragraph = re.sub(' {2,}', ' ', paragraph)
324         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
325         returnstring += paragraph + '\n\n'
326     return returnstring.rstrip('\n')
327
328
329 def apknameinfo(filename):
330     global apk_regex
331     filename = os.path.basename(filename)
332     if apk_regex is None:
333         apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
334     m = apk_regex.match(filename)
335     try:
336         result = (m.group(1), m.group(2))
337     except AttributeError:
338         raise FDroidException("Invalid apk name: %s" % filename)
339     return result
340
341
342 def getapkname(app, build):
343     return "%s_%s.apk" % (app['id'], build['vercode'])
344
345
346 def getsrcname(app, build):
347     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
348
349
350 def getappname(app):
351     if app['Name']:
352         return app['Name']
353     if app['Auto Name']:
354         return app['Auto Name']
355     return app['id']
356
357
358 def getcvname(app):
359     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
360
361
362 def getvcs(vcstype, remote, local):
363     if vcstype == 'git':
364         return vcs_git(remote, local)
365     if vcstype == 'git-svn':
366         return vcs_gitsvn(remote, local)
367     if vcstype == 'hg':
368         return vcs_hg(remote, local)
369     if vcstype == 'bzr':
370         return vcs_bzr(remote, local)
371     if vcstype == 'srclib':
372         if local != os.path.join('build', 'srclib', remote):
373             raise VCSException("Error: srclib paths are hard-coded!")
374         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
375     if vcstype == 'svn':
376         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
377     raise VCSException("Invalid vcs type " + vcstype)
378
379
380 def getsrclibvcs(name):
381     if name not in metadata.srclibs:
382         raise VCSException("Missing srclib " + name)
383     return metadata.srclibs[name]['Repo Type']
384
385
386 class vcs:
387     def __init__(self, remote, local):
388
389         # svn, git-svn and bzr may require auth
390         self.username = None
391         if self.repotype() in ('git-svn', 'bzr'):
392             if '@' in remote:
393                 self.username, remote = remote.split('@')
394                 if ':' not in self.username:
395                     raise VCSException("Password required with username")
396                 self.username, self.password = self.username.split(':')
397
398         self.remote = remote
399         self.local = local
400         self.clone_failed = False
401         self.refreshed = False
402         self.srclib = None
403
404     def repotype(self):
405         return None
406
407     # Take the local repository to a clean version of the given revision, which
408     # is specificed in the VCS's native format. Beforehand, the repository can
409     # be dirty, or even non-existent. If the repository does already exist
410     # locally, it will be updated from the origin, but only once in the
411     # lifetime of the vcs object.
412     # None is acceptable for 'rev' if you know you are cloning a clean copy of
413     # the repo - otherwise it must specify a valid revision.
414     def gotorevision(self, rev):
415
416         if self.clone_failed:
417             raise VCSException("Downloading the repository already failed once, not trying again.")
418
419         # The .fdroidvcs-id file for a repo tells us what VCS type
420         # and remote that directory was created from, allowing us to drop it
421         # automatically if either of those things changes.
422         fdpath = os.path.join(self.local, '..',
423                               '.fdroidvcs-' + os.path.basename(self.local))
424         cdata = self.repotype() + ' ' + self.remote
425         writeback = True
426         deleterepo = False
427         if os.path.exists(self.local):
428             if os.path.exists(fdpath):
429                 with open(fdpath, 'r') as f:
430                     fsdata = f.read().strip()
431                 if fsdata == cdata:
432                     writeback = False
433                 else:
434                     deleterepo = True
435                     logging.info(
436                         "Repository details for %s changed - deleting" % (
437                             self.local))
438             else:
439                 deleterepo = True
440                 logging.info("Repository details for %s missing - deleting" % (
441                     self.local))
442         if deleterepo:
443             shutil.rmtree(self.local)
444
445         exc = None
446
447         try:
448             self.gotorevisionx(rev)
449         except FDroidException, e:
450             exc = e
451
452         # If necessary, write the .fdroidvcs file.
453         if writeback and not self.clone_failed:
454             with open(fdpath, 'w') as f:
455                 f.write(cdata)
456
457         if exc is not None:
458             raise exc
459
460     # Derived classes need to implement this. It's called once basic checking
461     # has been performend.
462     def gotorevisionx(self, rev):
463         raise VCSException("This VCS type doesn't define gotorevisionx")
464
465     # Initialise and update submodules
466     def initsubmodules(self):
467         raise VCSException('Submodules not supported for this vcs type')
468
469     # Get a list of all known tags
470     def gettags(self):
471         raise VCSException('gettags not supported for this vcs type')
472
473     # Get a list of latest number tags
474     def latesttags(self, number):
475         raise VCSException('latesttags not supported for this vcs type')
476
477     # Get current commit reference (hash, revision, etc)
478     def getref(self):
479         raise VCSException('getref not supported for this vcs type')
480
481     # Returns the srclib (name, path) used in setting up the current
482     # revision, or None.
483     def getsrclib(self):
484         return self.srclib
485
486
487 class vcs_git(vcs):
488
489     def repotype(self):
490         return 'git'
491
492     # If the local directory exists, but is somehow not a git repository, git
493     # will traverse up the directory tree until it finds one that is (i.e.
494     # fdroidserver) and then we'll proceed to destroy it! This is called as
495     # a safety check.
496     def checkrepo(self):
497         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
498         result = p.output.rstrip()
499         if not result.endswith(self.local):
500             raise VCSException('Repository mismatch')
501
502     def gotorevisionx(self, rev):
503         if not os.path.exists(self.local):
504             # Brand new checkout
505             p = FDroidPopen(['git', 'clone', self.remote, self.local])
506             if p.returncode != 0:
507                 self.clone_failed = True
508                 raise VCSException("Git clone failed", p.output)
509             self.checkrepo()
510         else:
511             self.checkrepo()
512             # Discard any working tree changes
513             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
514             if p.returncode != 0:
515                 raise VCSException("Git reset failed", p.output)
516             # Remove untracked files now, in case they're tracked in the target
517             # revision (it happens!)
518             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
519             if p.returncode != 0:
520                 raise VCSException("Git clean failed", p.output)
521             if not self.refreshed:
522                 # Get latest commits and tags from remote
523                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
524                 if p.returncode != 0:
525                     raise VCSException("Git fetch failed", p.output)
526                 p = SilentPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local)
527                 if p.returncode != 0:
528                     raise VCSException("Git fetch failed", p.output)
529                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
530                 p = SilentPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local)
531                 if p.returncode != 0:
532                     lines = p.output.splitlines()
533                     if 'Multiple remote HEAD branches' not in lines[0]:
534                         raise VCSException("Git remote set-head failed", p.output)
535                     branch = lines[1].split(' ')[-1]
536                     p2 = SilentPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local)
537                     if p2.returncode != 0:
538                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
539                 self.refreshed = True
540         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
541         # a github repo. Most of the time this is the same as origin/master.
542         rev = rev or 'origin/HEAD'
543         p = SilentPopen(['git', 'checkout', '-f', rev], cwd=self.local)
544         if p.returncode != 0:
545             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
546         # Get rid of any uncontrolled files left behind
547         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
548         if p.returncode != 0:
549             raise VCSException("Git clean failed", p.output)
550
551     def initsubmodules(self):
552         self.checkrepo()
553         submfile = os.path.join(self.local, '.gitmodules')
554         if not os.path.isfile(submfile):
555             raise VCSException("No git submodules available")
556
557         # fix submodules not accessible without an account and public key auth
558         with open(submfile, 'r') as f:
559             lines = f.readlines()
560         with open(submfile, 'w') as f:
561             for line in lines:
562                 if 'git@github.com' in line:
563                     line = line.replace('git@github.com:', 'https://github.com/')
564                 f.write(line)
565
566         for cmd in [
567                 ['git', 'reset', '--hard'],
568                 ['git', 'clean', '-dffx'],
569                 ]:
570             p = SilentPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local)
571             if p.returncode != 0:
572                 raise VCSException("Git submodule reset failed", p.output)
573         p = SilentPopen(['git', 'submodule', 'sync'], cwd=self.local)
574         if p.returncode != 0:
575             raise VCSException("Git submodule sync failed", p.output)
576         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
577         if p.returncode != 0:
578             raise VCSException("Git submodule update failed", p.output)
579
580     def gettags(self):
581         self.checkrepo()
582         p = SilentPopen(['git', 'tag'], cwd=self.local)
583         return p.output.splitlines()
584
585     def latesttags(self, alltags, number):
586         self.checkrepo()
587         p = SilentPopen(['echo "' + '\n'.join(alltags) + '" | '
588                         + 'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
589                         + 'sort -n | awk \'{print $2}\''],
590                         cwd=self.local, shell=True)
591         return p.output.splitlines()[-number:]
592
593
594 class vcs_gitsvn(vcs):
595
596     def repotype(self):
597         return 'git-svn'
598
599     # Damn git-svn tries to use a graphical password prompt, so we have to
600     # trick it into taking the password from stdin
601     def userargs(self):
602         if self.username is None:
603             return ('', '')
604         return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
605
606     # If the local directory exists, but is somehow not a git repository, git
607     # will traverse up the directory tree until it finds one that is (i.e.
608     # fdroidserver) and then we'll proceed to destory it! This is called as
609     # a safety check.
610     def checkrepo(self):
611         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
612         result = p.output.rstrip()
613         if not result.endswith(self.local):
614             raise VCSException('Repository mismatch')
615
616     def gotorevisionx(self, rev):
617         if not os.path.exists(self.local):
618             # Brand new checkout
619             gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
620             if ';' in self.remote:
621                 remote_split = self.remote.split(';')
622                 for i in remote_split[1:]:
623                     if i.startswith('trunk='):
624                         gitsvn_cmd += ' -T %s' % i[6:]
625                     elif i.startswith('tags='):
626                         gitsvn_cmd += ' -t %s' % i[5:]
627                     elif i.startswith('branches='):
628                         gitsvn_cmd += ' -b %s' % i[9:]
629                 p = SilentPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True)
630                 if p.returncode != 0:
631                     self.clone_failed = True
632                     raise VCSException("Git svn clone failed", p.output)
633             else:
634                 p = SilentPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True)
635                 if p.returncode != 0:
636                     self.clone_failed = True
637                     raise VCSException("Git svn clone failed", p.output)
638             self.checkrepo()
639         else:
640             self.checkrepo()
641             # Discard any working tree changes
642             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
643             if p.returncode != 0:
644                 raise VCSException("Git reset failed", p.output)
645             # Remove untracked files now, in case they're tracked in the target
646             # revision (it happens!)
647             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
648             if p.returncode != 0:
649                 raise VCSException("Git clean failed", p.output)
650             if not self.refreshed:
651                 # Get new commits, branches and tags from repo
652                 p = SilentPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True)
653                 if p.returncode != 0:
654                     raise VCSException("Git svn fetch failed")
655                 p = SilentPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True)
656                 if p.returncode != 0:
657                     raise VCSException("Git svn rebase failed", p.output)
658                 self.refreshed = True
659
660         rev = rev or 'master'
661         if rev:
662             nospaces_rev = rev.replace(' ', '%20')
663             # Try finding a svn tag
664             for treeish in ['origin/', '']:
665                 p = SilentPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev],
666                                 cwd=self.local)
667                 if p.returncode == 0:
668                     break
669             if p.returncode != 0:
670                 # No tag found, normal svn rev translation
671                 # Translate svn rev into git format
672                 rev_split = rev.split('/')
673
674                 p = None
675                 for treeish in ['origin/', '']:
676                     if len(rev_split) > 1:
677                         treeish += rev_split[0]
678                         svn_rev = rev_split[1]
679
680                     else:
681                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
682                         treeish += 'master'
683                         svn_rev = rev
684
685                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
686
687                     p = SilentPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish],
688                                     cwd=self.local)
689                     git_rev = p.output.rstrip()
690
691                     if p.returncode == 0 and git_rev:
692                         break
693
694                 if p.returncode != 0 or not git_rev:
695                     # Try a plain git checkout as a last resort
696                     p = SilentPopen(['git', 'checkout', rev], cwd=self.local)
697                     if p.returncode != 0:
698                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
699                 else:
700                     # Check out the git rev equivalent to the svn rev
701                     p = SilentPopen(['git', 'checkout', git_rev], cwd=self.local)
702                     if p.returncode != 0:
703                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
704
705         # Get rid of any uncontrolled files left behind
706         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
707         if p.returncode != 0:
708             raise VCSException("Git clean failed", p.output)
709
710     def gettags(self):
711         self.checkrepo()
712         for treeish in ['origin/', '']:
713             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
714             if os.path.isdir(d):
715                 return os.listdir(d)
716
717     def getref(self):
718         self.checkrepo()
719         p = SilentPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local)
720         if p.returncode != 0:
721             return None
722         return p.output.strip()
723
724
725 class vcs_hg(vcs):
726
727     def repotype(self):
728         return 'hg'
729
730     def gotorevisionx(self, rev):
731         if not os.path.exists(self.local):
732             p = SilentPopen(['hg', 'clone', self.remote, self.local])
733             if p.returncode != 0:
734                 self.clone_failed = True
735                 raise VCSException("Hg clone failed", p.output)
736         else:
737             p = SilentPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True)
738             if p.returncode != 0:
739                 raise VCSException("Hg clean failed", p.output)
740             if not self.refreshed:
741                 p = SilentPopen(['hg', 'pull'], cwd=self.local)
742                 if p.returncode != 0:
743                     raise VCSException("Hg pull failed", p.output)
744                 self.refreshed = True
745
746         rev = rev or 'default'
747         if not rev:
748             return
749         p = SilentPopen(['hg', 'update', '-C', rev], cwd=self.local)
750         if p.returncode != 0:
751             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
752         p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
753         # Also delete untracked files, we have to enable purge extension for that:
754         if "'purge' is provided by the following extension" in p.output:
755             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
756                 myfile.write("\n[extensions]\nhgext.purge=\n")
757             p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
758             if p.returncode != 0:
759                 raise VCSException("HG purge failed", p.output)
760         elif p.returncode != 0:
761             raise VCSException("HG purge failed", p.output)
762
763     def gettags(self):
764         p = SilentPopen(['hg', 'tags', '-q'], cwd=self.local)
765         return p.output.splitlines()[1:]
766
767
768 class vcs_bzr(vcs):
769
770     def repotype(self):
771         return 'bzr'
772
773     def gotorevisionx(self, rev):
774         if not os.path.exists(self.local):
775             p = SilentPopen(['bzr', 'branch', self.remote, self.local])
776             if p.returncode != 0:
777                 self.clone_failed = True
778                 raise VCSException("Bzr branch failed", p.output)
779         else:
780             p = SilentPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local)
781             if p.returncode != 0:
782                 raise VCSException("Bzr revert failed", p.output)
783             if not self.refreshed:
784                 p = SilentPopen(['bzr', 'pull'], cwd=self.local)
785                 if p.returncode != 0:
786                     raise VCSException("Bzr update failed", p.output)
787                 self.refreshed = True
788
789         revargs = list(['-r', rev] if rev else [])
790         p = SilentPopen(['bzr', 'revert'] + revargs, cwd=self.local)
791         if p.returncode != 0:
792             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
793
794     def gettags(self):
795         p = SilentPopen(['bzr', 'tags'], cwd=self.local)
796         return [tag.split('   ')[0].strip() for tag in
797                 p.output.splitlines()]
798
799
800 def retrieve_string(app_dir, string, xmlfiles=None):
801
802     res_dirs = [
803         os.path.join(app_dir, 'res'),
804         os.path.join(app_dir, 'src', 'main'),
805         ]
806
807     if xmlfiles is None:
808         xmlfiles = []
809         for res_dir in res_dirs:
810             for r, d, f in os.walk(res_dir):
811                 if os.path.basename(r) == 'values':
812                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
813
814     string_search = None
815     if string.startswith('@string/'):
816         string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
817     elif string.startswith('&') and string.endswith(';'):
818         string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
819
820     if string_search is not None:
821         for xmlfile in xmlfiles:
822             for line in file(xmlfile):
823                 matches = string_search(line)
824                 if matches:
825                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
826         return None
827
828     return string.replace("\\'", "'")
829
830
831 # Return list of existing files that will be used to find the highest vercode
832 def manifest_paths(app_dir, flavours):
833
834     possible_manifests = \
835         [os.path.join(app_dir, 'AndroidManifest.xml'),
836          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
837          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
838          os.path.join(app_dir, 'build.gradle')]
839
840     for flavour in flavours:
841         if flavour == 'yes':
842             continue
843         possible_manifests.append(
844             os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
845
846     return [path for path in possible_manifests if os.path.isfile(path)]
847
848
849 # Retrieve the package name. Returns the name, or None if not found.
850 def fetch_real_name(app_dir, flavours):
851     app_search = re.compile(r'.*<application.*').search
852     name_search = re.compile(r'.*android:label="([^"]+)".*').search
853     app_found = False
854     for f in manifest_paths(app_dir, flavours):
855         if not has_extension(f, 'xml'):
856             continue
857         logging.debug("fetch_real_name: Checking manifest at " + f)
858         for line in file(f):
859             if not app_found:
860                 if app_search(line):
861                     app_found = True
862             if app_found:
863                 matches = name_search(line)
864                 if matches:
865                     stringname = matches.group(1)
866                     logging.debug("fetch_real_name: using string " + stringname)
867                     result = retrieve_string(app_dir, stringname)
868                     if result:
869                         result = result.strip()
870                     return result
871     return None
872
873
874 # Retrieve the version name
875 def version_name(original, app_dir, flavours):
876     for f in manifest_paths(app_dir, flavours):
877         if not has_extension(f, 'xml'):
878             continue
879         string = retrieve_string(app_dir, original)
880         if string:
881             return string
882     return original
883
884
885 def get_library_references(root_dir):
886     libraries = []
887     proppath = os.path.join(root_dir, 'project.properties')
888     if not os.path.isfile(proppath):
889         return libraries
890     with open(proppath) as f:
891         for line in f.readlines():
892             if not line.startswith('android.library.reference.'):
893                 continue
894             path = line.split('=')[1].strip()
895             relpath = os.path.join(root_dir, path)
896             if not os.path.isdir(relpath):
897                 continue
898             logging.debug("Found subproject at %s" % path)
899             libraries.append(path)
900     return libraries
901
902
903 def ant_subprojects(root_dir):
904     subprojects = get_library_references(root_dir)
905     for subpath in subprojects:
906         subrelpath = os.path.join(root_dir, subpath)
907         for p in get_library_references(subrelpath):
908             relp = os.path.normpath(os.path.join(subpath, p))
909             if relp not in subprojects:
910                 subprojects.insert(0, relp)
911     return subprojects
912
913
914 def remove_debuggable_flags(root_dir):
915     # Remove forced debuggable flags
916     logging.debug("Removing debuggable flags from %s" % root_dir)
917     for root, dirs, files in os.walk(root_dir):
918         if 'AndroidManifest.xml' in files:
919             path = os.path.join(root, 'AndroidManifest.xml')
920             p = SilentPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path])
921             if p.returncode != 0:
922                 raise BuildException("Failed to remove debuggable flags of %s" % path)
923
924
925 # Extract some information from the AndroidManifest.xml at the given path.
926 # Returns (version, vercode, package), any or all of which might be None.
927 # All values returned are strings.
928 def parse_androidmanifests(paths, ignoreversions=None):
929
930     if not paths:
931         return (None, None, None)
932
933     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
934     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
935     psearch = re.compile(r'.*package="([^"]+)".*').search
936
937     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
938     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
939     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
940
941     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
942
943     max_version = None
944     max_vercode = None
945     max_package = None
946
947     for path in paths:
948
949         gradle = has_extension(path, 'gradle')
950         version = None
951         vercode = None
952         # Remember package name, may be defined separately from version+vercode
953         package = max_package
954
955         for line in file(path):
956             if not package:
957                 if gradle:
958                     matches = psearch_g(line)
959                 else:
960                     matches = psearch(line)
961                 if matches:
962                     package = matches.group(1)
963             if not version:
964                 if gradle:
965                     matches = vnsearch_g(line)
966                 else:
967                     matches = vnsearch(line)
968                 if matches:
969                     version = matches.group(2 if gradle else 1)
970             if not vercode:
971                 if gradle:
972                     matches = vcsearch_g(line)
973                 else:
974                     matches = vcsearch(line)
975                 if matches:
976                     vercode = matches.group(1)
977
978         # Always grab the package name and version name in case they are not
979         # together with the highest version code
980         if max_package is None and package is not None:
981             max_package = package
982         if max_version is None and version is not None:
983             max_version = version
984
985         if max_vercode is None or (vercode is not None and vercode > max_vercode):
986             if not ignoresearch or not ignoresearch(version):
987                 if version is not None:
988                     max_version = version
989                 if vercode is not None:
990                     max_vercode = vercode
991                 if package is not None:
992                     max_package = package
993             else:
994                 max_version = "Ignore"
995
996     if max_version is None:
997         max_version = "Unknown"
998
999     return (max_version, max_vercode, max_package)
1000
1001
1002 class FDroidException(Exception):
1003     def __init__(self, value, detail=None):
1004         self.value = value
1005         self.detail = detail
1006
1007     def get_wikitext(self):
1008         ret = repr(self.value) + "\n"
1009         if self.detail:
1010             ret += "=detail=\n"
1011             ret += "<pre>\n"
1012             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1013             ret += str(txt)
1014             ret += "</pre>\n"
1015         return ret
1016
1017     def __str__(self):
1018         ret = self.value
1019         if self.detail:
1020             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1021         return ret
1022
1023
1024 class VCSException(FDroidException):
1025     pass
1026
1027
1028 class BuildException(FDroidException):
1029     pass
1030
1031
1032 # Get the specified source library.
1033 # Returns the path to it. Normally this is the path to be used when referencing
1034 # it, which may be a subdirectory of the actual project. If you want the base
1035 # directory of the project, pass 'basepath=True'.
1036 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1037               basepath=False, raw=False, prepare=True, preponly=False):
1038
1039     number = None
1040     subdir = None
1041     if raw:
1042         name = spec
1043         ref = None
1044     else:
1045         name, ref = spec.split('@')
1046         if ':' in name:
1047             number, name = name.split(':', 1)
1048         if '/' in name:
1049             name, subdir = name.split('/', 1)
1050
1051     if name not in metadata.srclibs:
1052         raise VCSException('srclib ' + name + ' not found.')
1053
1054     srclib = metadata.srclibs[name]
1055
1056     sdir = os.path.join(srclib_dir, name)
1057
1058     if not preponly:
1059         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1060         vcs.srclib = (name, number, sdir)
1061         if ref:
1062             vcs.gotorevision(ref)
1063
1064         if raw:
1065             return vcs
1066
1067     libdir = None
1068     if subdir:
1069         libdir = os.path.join(sdir, subdir)
1070     elif srclib["Subdir"]:
1071         for subdir in srclib["Subdir"]:
1072             libdir_candidate = os.path.join(sdir, subdir)
1073             if os.path.exists(libdir_candidate):
1074                 libdir = libdir_candidate
1075                 break
1076
1077     if libdir is None:
1078         libdir = sdir
1079
1080     if srclib["Srclibs"]:
1081         n = 1
1082         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1083             s_tuple = None
1084             for t in srclibpaths:
1085                 if t[0] == lib:
1086                     s_tuple = t
1087                     break
1088             if s_tuple is None:
1089                 raise VCSException('Missing recursive srclib %s for %s' % (
1090                     lib, name))
1091             place_srclib(libdir, n, s_tuple[2])
1092             n += 1
1093
1094     remove_signing_keys(sdir)
1095     remove_debuggable_flags(sdir)
1096
1097     if prepare:
1098
1099         if srclib["Prepare"]:
1100             cmd = replace_config_vars(srclib["Prepare"])
1101
1102             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1103             if p.returncode != 0:
1104                 raise BuildException("Error running prepare command for srclib %s"
1105                                      % name, p.output)
1106
1107     if basepath:
1108         libdir = sdir
1109
1110     return (name, number, libdir)
1111
1112
1113 # Prepare the source code for a particular build
1114 #  'vcs'         - the appropriate vcs object for the application
1115 #  'app'         - the application details from the metadata
1116 #  'build'       - the build details from the metadata
1117 #  'build_dir'   - the path to the build directory, usually
1118 #                   'build/app.id'
1119 #  'srclib_dir'  - the path to the source libraries directory, usually
1120 #                   'build/srclib'
1121 #  'extlib_dir'  - the path to the external libraries directory, usually
1122 #                   'build/extlib'
1123 # Returns the (root, srclibpaths) where:
1124 #   'root' is the root directory, which may be the same as 'build_dir' or may
1125 #          be a subdirectory of it.
1126 #   'srclibpaths' is information on the srclibs being used
1127 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1128
1129     # Optionally, the actual app source can be in a subdirectory
1130     if build['subdir']:
1131         root_dir = os.path.join(build_dir, build['subdir'])
1132     else:
1133         root_dir = build_dir
1134
1135     # Get a working copy of the right revision
1136     logging.info("Getting source for revision " + build['commit'])
1137     vcs.gotorevision(build['commit'])
1138
1139     # Initialise submodules if requred
1140     if build['submodules']:
1141         logging.info("Initialising submodules")
1142         vcs.initsubmodules()
1143
1144     # Check that a subdir (if we're using one) exists. This has to happen
1145     # after the checkout, since it might not exist elsewhere
1146     if not os.path.exists(root_dir):
1147         raise BuildException('Missing subdir ' + root_dir)
1148
1149     # Run an init command if one is required
1150     if build['init']:
1151         cmd = replace_config_vars(build['init'])
1152         logging.info("Running 'init' commands in %s" % root_dir)
1153
1154         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1155         if p.returncode != 0:
1156             raise BuildException("Error running init command for %s:%s" %
1157                                  (app['id'], build['version']), p.output)
1158
1159     # Apply patches if any
1160     if build['patch']:
1161         logging.info("Applying patches")
1162         for patch in build['patch']:
1163             patch = patch.strip()
1164             logging.info("Applying " + patch)
1165             patch_path = os.path.join('metadata', app['id'], patch)
1166             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1167             if p.returncode != 0:
1168                 raise BuildException("Failed to apply patch %s" % patch_path)
1169
1170     # Get required source libraries
1171     srclibpaths = []
1172     if build['srclibs']:
1173         logging.info("Collecting source libraries")
1174         for lib in build['srclibs']:
1175             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1176                                          preponly=onserver))
1177
1178     for name, number, libpath in srclibpaths:
1179         place_srclib(root_dir, int(number) if number else None, libpath)
1180
1181     basesrclib = vcs.getsrclib()
1182     # If one was used for the main source, add that too.
1183     if basesrclib:
1184         srclibpaths.append(basesrclib)
1185
1186     # Update the local.properties file
1187     localprops = [os.path.join(build_dir, 'local.properties')]
1188     if build['subdir']:
1189         localprops += [os.path.join(root_dir, 'local.properties')]
1190     for path in localprops:
1191         props = ""
1192         if os.path.isfile(path):
1193             logging.info("Updating local.properties file at %s" % path)
1194             f = open(path, 'r')
1195             props += f.read()
1196             f.close()
1197             props += '\n'
1198         else:
1199             logging.info("Creating local.properties file at %s" % path)
1200         # Fix old-fashioned 'sdk-location' by copying
1201         # from sdk.dir, if necessary
1202         if build['oldsdkloc']:
1203             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1204                               re.S | re.M).group(1)
1205             props += "sdk-location=%s\n" % sdkloc
1206         else:
1207             props += "sdk.dir=%s\n" % config['sdk_path']
1208             props += "sdk-location=%s\n" % config['sdk_path']
1209         if config['ndk_path']:
1210             # Add ndk location
1211             props += "ndk.dir=%s\n" % config['ndk_path']
1212             props += "ndk-location=%s\n" % config['ndk_path']
1213         # Add java.encoding if necessary
1214         if build['encoding']:
1215             props += "java.encoding=%s\n" % build['encoding']
1216         f = open(path, 'w')
1217         f.write(props)
1218         f.close()
1219
1220     flavours = []
1221     if build['type'] == 'gradle':
1222         flavours = build['gradle']
1223
1224         version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1225         gradlepluginver = None
1226
1227         gradle_files = [os.path.join(root_dir, 'build.gradle')]
1228
1229         # Parent dir build.gradle
1230         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1231         if parent_dir.startswith(build_dir):
1232             gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
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             SilentPopen(['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, flavours):
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, flavours):
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'google.*analytics', re.IGNORECASE),
1406         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1407         re.compile(r'google.*ad.*view', re.IGNORECASE),
1408         re.compile(r'google.*admob', 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'youtube.*android.*player.*api', 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 toignore(fd):
1450             logging.info('Ignoring %s at %s' % (what, fd))
1451         elif todelete(fd):
1452             removeproblem(what, fd, fp)
1453         else:
1454             logging.error('Found %s at %s' % (what, fd))
1455             return True
1456         return False
1457
1458     # Iterate through all files in the source code
1459     for r, d, f in os.walk(build_dir, topdown=True):
1460
1461         # It's topdown, so checking the basename is enough
1462         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1463             if ignoredir in d:
1464                 d.remove(ignoredir)
1465
1466         for curfile in f:
1467
1468             # Path (relative) to the file
1469             fp = os.path.join(r, curfile)
1470             fd = fp[len(build_dir) + 1:]
1471
1472             try:
1473                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1474             except UnicodeError:
1475                 warnproblem('malformed magic number', fd)
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 = None
1664     try:
1665         p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1666                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1667     except OSError, e:
1668         raise BuildException("OSError while trying to execute " + ' '.join(commands) + ': ' + str(e))
1669
1670     stdout_queue = Queue.Queue()
1671     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1672     stdout_reader.start()
1673
1674     # Check the queue for output (until there is no more to get)
1675     while not stdout_reader.eof():
1676         while not stdout_queue.empty():
1677             line = stdout_queue.get()
1678             if output and options.verbose:
1679                 # Output directly to console
1680                 sys.stderr.write(line)
1681                 sys.stderr.flush()
1682             result.output += line
1683
1684         time.sleep(0.1)
1685
1686     result.returncode = p.wait()
1687     return result
1688
1689
1690 def remove_signing_keys(build_dir):
1691     comment = re.compile(r'[ ]*//')
1692     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1693     line_matches = [
1694         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1695         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1696         re.compile(r'.*variant\.outputFile = .*'),
1697         re.compile(r'.*\.readLine\(.*'),
1698         ]
1699     for root, dirs, files in os.walk(build_dir):
1700         if 'build.gradle' in files:
1701             path = os.path.join(root, 'build.gradle')
1702
1703             with open(path, "r") as o:
1704                 lines = o.readlines()
1705
1706             changed = False
1707
1708             opened = 0
1709             with open(path, "w") as o:
1710                 for line in lines:
1711                     if comment.match(line):
1712                         continue
1713
1714                     if opened > 0:
1715                         opened += line.count('{')
1716                         opened -= line.count('}')
1717                         continue
1718
1719                     if signing_configs.match(line):
1720                         changed = True
1721                         opened += 1
1722                         continue
1723
1724                     if any(s.match(line) for s in line_matches):
1725                         changed = True
1726                         continue
1727
1728                     if opened == 0:
1729                         o.write(line)
1730
1731             if changed:
1732                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1733
1734         for propfile in [
1735                 'project.properties',
1736                 'build.properties',
1737                 'default.properties',
1738                 'ant.properties',
1739                 ]:
1740             if propfile in files:
1741                 path = os.path.join(root, propfile)
1742
1743                 with open(path, "r") as o:
1744                     lines = o.readlines()
1745
1746                 changed = False
1747
1748                 with open(path, "w") as o:
1749                     for line in lines:
1750                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1751                             changed = True
1752                             continue
1753
1754                         o.write(line)
1755
1756                 if changed:
1757                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1758
1759
1760 def replace_config_vars(cmd):
1761     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1762     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1763     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1764     return cmd
1765
1766
1767 def place_srclib(root_dir, number, libpath):
1768     if not number:
1769         return
1770     relpath = os.path.relpath(libpath, root_dir)
1771     proppath = os.path.join(root_dir, 'project.properties')
1772
1773     lines = []
1774     if os.path.isfile(proppath):
1775         with open(proppath, "r") as o:
1776             lines = o.readlines()
1777
1778     with open(proppath, "w") as o:
1779         placed = False
1780         for line in lines:
1781             if line.startswith('android.library.reference.%d=' % number):
1782                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1783                 placed = True
1784             else:
1785                 o.write(line)
1786         if not placed:
1787             o.write('android.library.reference.%d=%s\n' % (number, relpath))