chiark / gitweb /
Make gradle and antcommands (previously antcommand) proper lists
[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_ignore': [],
53         'stats_server': None,
54         'stats_user': None,
55         'stats_to_carbon': False,
56         'repo_maxage': 0,
57         'build_server_always': False,
58         'keystore': os.path.join(os.getenv("HOME"), '.local', 'share', 'fdroidserver', 'keystore.jks'),
59         'smartcardoptions': [],
60         'char_limits': {
61             'Summary': 50,
62             'Description': 1500
63         },
64         'keyaliases': {},
65         'repo_url': "https://MyFirstFDroidRepo.org/fdroid/repo",
66         'repo_name': "My First FDroid Repo Demo",
67         'repo_icon': "fdroid-icon.png",
68         'repo_description': '''
69             This is a repository of apps to be used with FDroid. Applications in this
70             repository are either official binaries built by the original application
71             developers, or are binaries built from source by the admin of f-droid.org
72             using the tools on https://gitlab.com/u/fdroid.
73             ''',
74         'archive_older': 0,
75     }
76
77
78 def read_config(opts, config_file='config.py'):
79     """Read the repository config
80
81     The config is read from config_file, which is in the current directory when
82     any of the repo management commands are used.
83     """
84     global config, options, env
85
86     if config is not None:
87         return config
88     if not os.path.isfile(config_file):
89         logging.critical("Missing config file - is this a repo directory?")
90         sys.exit(2)
91
92     options = opts
93
94     config = {}
95
96     logging.debug("Reading %s" % config_file)
97     execfile(config_file, config)
98
99     # smartcardoptions must be a list since its command line args for Popen
100     if 'smartcardoptions' in config:
101         config['smartcardoptions'] = config['smartcardoptions'].split(' ')
102     elif 'keystore' in config and config['keystore'] == 'NONE':
103         # keystore='NONE' means use smartcard, these are required defaults
104         config['smartcardoptions'] = ['-storetype', 'PKCS11', '-providerName',
105                                       'SunPKCS11-OpenSC', '-providerClass',
106                                       'sun.security.pkcs11.SunPKCS11',
107                                       '-providerArg', 'opensc-fdroid.cfg']
108
109     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
110         st = os.stat(config_file)
111         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
112             logging.warn("unsafe permissions on {0} (should be 0600)!".format(config_file))
113
114     defconfig = get_default_config()
115     for k, v in defconfig.items():
116         if k not in config:
117             config[k] = v
118
119     # Expand environment variables
120     for k, v in config.items():
121         if type(v) != str:
122             continue
123         v = os.path.expanduser(v)
124         config[k] = os.path.expandvars(v)
125
126     if not test_sdk_exists(config):
127         sys.exit(3)
128
129     if not test_build_tools_exists(config):
130         sys.exit(3)
131
132     bin_paths = {
133         'aapt': [
134             os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'aapt'),
135             ],
136         'zipalign': [
137             os.path.join(config['sdk_path'], 'tools', 'zipalign'),
138             os.path.join(config['sdk_path'], 'build-tools', config['build_tools'], 'zipalign'),
139             ],
140         'android': [
141             os.path.join(config['sdk_path'], 'tools', 'android'),
142             ],
143         'adb': [
144             os.path.join(config['sdk_path'], 'platform-tools', 'adb'),
145             ],
146         }
147
148     for b, paths in bin_paths.items():
149         config[b] = None
150         for path in paths:
151             if os.path.isfile(path):
152                 config[b] = path
153                 break
154         if config[b] is None:
155             logging.warn("Could not find %s in any of the following paths:\n%s" % (
156                 b, '\n'.join(paths)))
157
158     # There is no standard, so just set up the most common environment
159     # variables
160     env = os.environ
161     for n in ['ANDROID_HOME', 'ANDROID_SDK']:
162         env[n] = config['sdk_path']
163     for n in ['ANDROID_NDK', 'NDK']:
164         env[n] = config['ndk_path']
165
166     for k in ["keystorepass", "keypass"]:
167         if k in config:
168             write_password_file(k)
169
170     for k in ["repo_description", "archive_description"]:
171         if k in config:
172             config[k] = clean_description(config[k])
173
174     if 'serverwebroot' in config:
175         if isinstance(config['serverwebroot'], basestring):
176             roots = [config['serverwebroot']]
177         elif all(isinstance(item, basestring) for item in config['serverwebroot']):
178             roots = config['serverwebroot']
179         else:
180             raise TypeError('only accepts strings, lists, and tuples')
181         rootlist = []
182         for rootstr in roots:
183             # since this is used with rsync, where trailing slashes have
184             # meaning, ensure there is always a trailing slash
185             if rootstr[-1] != '/':
186                 rootstr += '/'
187             rootlist.append(rootstr.replace('//', '/'))
188         config['serverwebroot'] = rootlist
189
190     return config
191
192
193 def test_sdk_exists(c):
194     if c['sdk_path'] is None:
195         # c['sdk_path'] is set to the value of ANDROID_HOME by default
196         logging.error('No Android SDK found! ANDROID_HOME is not set and sdk_path is not in config.py!')
197         logging.error('You can use ANDROID_HOME to set the path to your SDK, i.e.:')
198         logging.error('\texport ANDROID_HOME=/opt/android-sdk')
199         return False
200     if not os.path.exists(c['sdk_path']):
201         logging.critical('Android SDK path "' + c['sdk_path'] + '" does not exist!')
202         return False
203     if not os.path.isdir(c['sdk_path']):
204         logging.critical('Android SDK path "' + c['sdk_path'] + '" is not a directory!')
205         return False
206     for d in ['build-tools', 'platform-tools', 'tools']:
207         if not os.path.isdir(os.path.join(c['sdk_path'], d)):
208             logging.critical('Android SDK path "%s" does not contain "%s/"!' % (
209                 c['sdk_path'], d))
210             return False
211     return True
212
213
214 def test_build_tools_exists(c):
215     if not test_sdk_exists(c):
216         return False
217     build_tools = os.path.join(c['sdk_path'], 'build-tools')
218     versioned_build_tools = os.path.join(build_tools, c['build_tools'])
219     if not os.path.isdir(versioned_build_tools):
220         logging.critical('Android Build Tools path "'
221                          + versioned_build_tools + '" does not exist!')
222         return False
223     return True
224
225
226 def write_password_file(pwtype, password=None):
227     '''
228     writes out passwords to a protected file instead of passing passwords as
229     command line argments
230     '''
231     filename = '.fdroid.' + pwtype + '.txt'
232     fd = os.open(filename, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0600)
233     if password is None:
234         os.write(fd, config[pwtype])
235     else:
236         os.write(fd, password)
237     os.close(fd)
238     config[pwtype + 'file'] = filename
239
240
241 # Given the arguments in the form of multiple appid:[vc] strings, this returns
242 # a dictionary with the set of vercodes specified for each package.
243 def read_pkg_args(args, allow_vercodes=False):
244
245     vercodes = {}
246     if not args:
247         return vercodes
248
249     for p in args:
250         if allow_vercodes and ':' in p:
251             package, vercode = p.split(':')
252         else:
253             package, vercode = p, None
254         if package not in vercodes:
255             vercodes[package] = [vercode] if vercode else []
256             continue
257         elif vercode and vercode not in vercodes[package]:
258             vercodes[package] += [vercode] if vercode else []
259
260     return vercodes
261
262
263 # On top of what read_pkg_args does, this returns the whole app metadata, but
264 # limiting the builds list to the builds matching the vercodes specified.
265 def read_app_args(args, allapps, allow_vercodes=False):
266
267     vercodes = read_pkg_args(args, allow_vercodes)
268
269     if not vercodes:
270         return allapps
271
272     apps = {}
273     for appid, app in allapps.iteritems():
274         if appid in vercodes:
275             apps[appid] = app
276
277     if len(apps) != len(vercodes):
278         for p in vercodes:
279             if p not in allapps:
280                 logging.critical("No such package: %s" % p)
281         raise FDroidException("Found invalid app ids in arguments")
282     if not apps:
283         raise FDroidException("No packages specified")
284
285     error = False
286     for appid, app in apps.iteritems():
287         vc = vercodes[appid]
288         if not vc:
289             continue
290         app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
291         if len(app['builds']) != len(vercodes[appid]):
292             error = True
293             allvcs = [b['vercode'] for b in app['builds']]
294             for v in vercodes[appid]:
295                 if v not in allvcs:
296                     logging.critical("No such vercode %s for app %s" % (v, appid))
297
298     if error:
299         raise FDroidException("Found invalid vercodes for some apps")
300
301     return apps
302
303
304 def has_extension(filename, extension):
305     name, ext = os.path.splitext(filename)
306     ext = ext.lower()[1:]
307     return ext == extension
308
309 apk_regex = None
310
311
312 def clean_description(description):
313     'Remove unneeded newlines and spaces from a block of description text'
314     returnstring = ''
315     # this is split up by paragraph to make removing the newlines easier
316     for paragraph in re.split(r'\n\n', description):
317         paragraph = re.sub('\r', '', paragraph)
318         paragraph = re.sub('\n', ' ', paragraph)
319         paragraph = re.sub(' {2,}', ' ', paragraph)
320         paragraph = re.sub('^\s*(\w)', r'\1', paragraph)
321         returnstring += paragraph + '\n\n'
322     return returnstring.rstrip('\n')
323
324
325 def apknameinfo(filename):
326     global apk_regex
327     filename = os.path.basename(filename)
328     if apk_regex is None:
329         apk_regex = re.compile(r"^(.+)_([0-9]+)\.apk$")
330     m = apk_regex.match(filename)
331     try:
332         result = (m.group(1), m.group(2))
333     except AttributeError:
334         raise FDroidException("Invalid apk name: %s" % filename)
335     return result
336
337
338 def getapkname(app, build):
339     return "%s_%s.apk" % (app['id'], build['vercode'])
340
341
342 def getsrcname(app, build):
343     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
344
345
346 def getappname(app):
347     if app['Name']:
348         return app['Name']
349     if app['Auto Name']:
350         return app['Auto Name']
351     return app['id']
352
353
354 def getcvname(app):
355     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
356
357
358 def getvcs(vcstype, remote, local):
359     if vcstype == 'git':
360         return vcs_git(remote, local)
361     if vcstype == 'git-svn':
362         return vcs_gitsvn(remote, local)
363     if vcstype == 'hg':
364         return vcs_hg(remote, local)
365     if vcstype == 'bzr':
366         return vcs_bzr(remote, local)
367     if vcstype == 'srclib':
368         if local != os.path.join('build', 'srclib', remote):
369             raise VCSException("Error: srclib paths are hard-coded!")
370         return getsrclib(remote, os.path.join('build', 'srclib'), raw=True)
371     if vcstype == 'svn':
372         raise VCSException("Deprecated vcs type 'svn' - please use 'git-svn' instead")
373     raise VCSException("Invalid vcs type " + vcstype)
374
375
376 def getsrclibvcs(name):
377     if name not in metadata.srclibs:
378         raise VCSException("Missing srclib " + name)
379     return metadata.srclibs[name]['Repo Type']
380
381
382 class vcs:
383     def __init__(self, remote, local):
384
385         # svn, git-svn and bzr may require auth
386         self.username = None
387         if self.repotype() in ('git-svn', 'bzr'):
388             if '@' in remote:
389                 self.username, remote = remote.split('@')
390                 if ':' not in self.username:
391                     raise VCSException("Password required with username")
392                 self.username, self.password = self.username.split(':')
393
394         self.remote = remote
395         self.local = local
396         self.clone_failed = False
397         self.refreshed = False
398         self.srclib = None
399
400     def repotype(self):
401         return None
402
403     # Take the local repository to a clean version of the given revision, which
404     # is specificed in the VCS's native format. Beforehand, the repository can
405     # be dirty, or even non-existent. If the repository does already exist
406     # locally, it will be updated from the origin, but only once in the
407     # lifetime of the vcs object.
408     # None is acceptable for 'rev' if you know you are cloning a clean copy of
409     # the repo - otherwise it must specify a valid revision.
410     def gotorevision(self, rev):
411
412         if self.clone_failed:
413             raise VCSException("Downloading the repository already failed once, not trying again.")
414
415         # The .fdroidvcs-id file for a repo tells us what VCS type
416         # and remote that directory was created from, allowing us to drop it
417         # automatically if either of those things changes.
418         fdpath = os.path.join(self.local, '..',
419                               '.fdroidvcs-' + os.path.basename(self.local))
420         cdata = self.repotype() + ' ' + self.remote
421         writeback = True
422         deleterepo = False
423         if os.path.exists(self.local):
424             if os.path.exists(fdpath):
425                 with open(fdpath, 'r') as f:
426                     fsdata = f.read().strip()
427                 if fsdata == cdata:
428                     writeback = False
429                 else:
430                     deleterepo = True
431                     logging.info(
432                         "Repository details for %s changed - deleting" % (
433                             self.local))
434             else:
435                 deleterepo = True
436                 logging.info("Repository details for %s missing - deleting" % (
437                     self.local))
438         if deleterepo:
439             shutil.rmtree(self.local)
440
441         exc = None
442
443         try:
444             self.gotorevisionx(rev)
445         except FDroidException, e:
446             exc = e
447
448         # If necessary, write the .fdroidvcs file.
449         if writeback and not self.clone_failed:
450             with open(fdpath, 'w') as f:
451                 f.write(cdata)
452
453         if exc is not None:
454             raise exc
455
456     # Derived classes need to implement this. It's called once basic checking
457     # has been performend.
458     def gotorevisionx(self, rev):
459         raise VCSException("This VCS type doesn't define gotorevisionx")
460
461     # Initialise and update submodules
462     def initsubmodules(self):
463         raise VCSException('Submodules not supported for this vcs type')
464
465     # Get a list of all known tags
466     def gettags(self):
467         raise VCSException('gettags not supported for this vcs type')
468
469     # Get a list of latest number tags
470     def latesttags(self, number):
471         raise VCSException('latesttags not supported for this vcs type')
472
473     # Get current commit reference (hash, revision, etc)
474     def getref(self):
475         raise VCSException('getref not supported for this vcs type')
476
477     # Returns the srclib (name, path) used in setting up the current
478     # revision, or None.
479     def getsrclib(self):
480         return self.srclib
481
482
483 class vcs_git(vcs):
484
485     def repotype(self):
486         return 'git'
487
488     # If the local directory exists, but is somehow not a git repository, git
489     # will traverse up the directory tree until it finds one that is (i.e.
490     # fdroidserver) and then we'll proceed to destroy it! This is called as
491     # a safety check.
492     def checkrepo(self):
493         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
494         result = p.output.rstrip()
495         if not result.endswith(self.local):
496             raise VCSException('Repository mismatch')
497
498     def gotorevisionx(self, rev):
499         if not os.path.exists(self.local):
500             # Brand new checkout
501             p = FDroidPopen(['git', 'clone', self.remote, self.local])
502             if p.returncode != 0:
503                 self.clone_failed = True
504                 raise VCSException("Git clone failed", p.output)
505             self.checkrepo()
506         else:
507             self.checkrepo()
508             # Discard any working tree changes
509             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
510             if p.returncode != 0:
511                 raise VCSException("Git reset failed", p.output)
512             # Remove untracked files now, in case they're tracked in the target
513             # revision (it happens!)
514             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
515             if p.returncode != 0:
516                 raise VCSException("Git clean failed", p.output)
517             if not self.refreshed:
518                 # Get latest commits and tags from remote
519                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
520                 if p.returncode != 0:
521                     raise VCSException("Git fetch failed", p.output)
522                 p = SilentPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local)
523                 if p.returncode != 0:
524                     raise VCSException("Git fetch failed", p.output)
525                 # Recreate origin/HEAD as git clone would do it, in case it disappeared
526                 p = SilentPopen(['git', 'remote', 'set-head', 'origin', '--auto'], cwd=self.local)
527                 if p.returncode != 0:
528                     lines = p.output.splitlines()
529                     if 'Multiple remote HEAD branches' not in lines[0]:
530                         raise VCSException("Git remote set-head failed", p.output)
531                     branch = lines[1].split(' ')[-1]
532                     p2 = SilentPopen(['git', 'remote', 'set-head', 'origin', branch], cwd=self.local)
533                     if p2.returncode != 0:
534                         raise VCSException("Git remote set-head failed", p.output + '\n' + p2.output)
535                 self.refreshed = True
536         # origin/HEAD is the HEAD of the remote, e.g. the "default branch" on
537         # a github repo. Most of the time this is the same as origin/master.
538         rev = rev or 'origin/HEAD'
539         p = SilentPopen(['git', 'checkout', '-f', rev], cwd=self.local)
540         if p.returncode != 0:
541             raise VCSException("Git checkout of '%s' failed" % rev, p.output)
542         # Get rid of any uncontrolled files left behind
543         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
544         if p.returncode != 0:
545             raise VCSException("Git clean failed", p.output)
546
547     def initsubmodules(self):
548         self.checkrepo()
549         submfile = os.path.join(self.local, '.gitmodules')
550         if not os.path.isfile(submfile):
551             raise VCSException("No git submodules available")
552
553         # fix submodules not accessible without an account and public key auth
554         with open(submfile, 'r') as f:
555             lines = f.readlines()
556         with open(submfile, 'w') as f:
557             for line in lines:
558                 if 'git@github.com' in line:
559                     line = line.replace('git@github.com:', 'https://github.com/')
560                 f.write(line)
561
562         for cmd in [
563                 ['git', 'reset', '--hard'],
564                 ['git', 'clean', '-dffx'],
565                 ]:
566             p = SilentPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local)
567             if p.returncode != 0:
568                 raise VCSException("Git submodule reset failed", p.output)
569         p = SilentPopen(['git', 'submodule', 'sync'], cwd=self.local)
570         if p.returncode != 0:
571             raise VCSException("Git submodule sync failed", p.output)
572         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
573         if p.returncode != 0:
574             raise VCSException("Git submodule update failed", p.output)
575
576     def gettags(self):
577         self.checkrepo()
578         p = SilentPopen(['git', 'tag'], cwd=self.local)
579         return p.output.splitlines()
580
581     def latesttags(self, alltags, number):
582         self.checkrepo()
583         p = SilentPopen(['echo "' + '\n'.join(alltags) + '" | '
584                         + 'xargs -I@ git log --format=format:"%at @%n" -1 @ | '
585                         + 'sort -n | awk \'{print $2}\''],
586                         cwd=self.local, shell=True)
587         return p.output.splitlines()[-number:]
588
589
590 class vcs_gitsvn(vcs):
591
592     def repotype(self):
593         return 'git-svn'
594
595     # Damn git-svn tries to use a graphical password prompt, so we have to
596     # trick it into taking the password from stdin
597     def userargs(self):
598         if self.username is None:
599             return ('', '')
600         return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
601
602     # If the local directory exists, but is somehow not a git repository, git
603     # will traverse up the directory tree until it finds one that is (i.e.
604     # fdroidserver) and then we'll proceed to destory it! This is called as
605     # a safety check.
606     def checkrepo(self):
607         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
608         result = p.output.rstrip()
609         if not result.endswith(self.local):
610             raise VCSException('Repository mismatch')
611
612     def gotorevisionx(self, rev):
613         if not os.path.exists(self.local):
614             # Brand new checkout
615             gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
616             if ';' in self.remote:
617                 remote_split = self.remote.split(';')
618                 for i in remote_split[1:]:
619                     if i.startswith('trunk='):
620                         gitsvn_cmd += ' -T %s' % i[6:]
621                     elif i.startswith('tags='):
622                         gitsvn_cmd += ' -t %s' % i[5:]
623                     elif i.startswith('branches='):
624                         gitsvn_cmd += ' -b %s' % i[9:]
625                 p = SilentPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True)
626                 if p.returncode != 0:
627                     self.clone_failed = True
628                     raise VCSException("Git svn clone failed", p.output)
629             else:
630                 p = SilentPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True)
631                 if p.returncode != 0:
632                     self.clone_failed = True
633                     raise VCSException("Git svn clone failed", p.output)
634             self.checkrepo()
635         else:
636             self.checkrepo()
637             # Discard any working tree changes
638             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
639             if p.returncode != 0:
640                 raise VCSException("Git reset failed", p.output)
641             # Remove untracked files now, in case they're tracked in the target
642             # revision (it happens!)
643             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
644             if p.returncode != 0:
645                 raise VCSException("Git clean failed", p.output)
646             if not self.refreshed:
647                 # Get new commits, branches and tags from repo
648                 p = SilentPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True)
649                 if p.returncode != 0:
650                     raise VCSException("Git svn fetch failed")
651                 p = SilentPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True)
652                 if p.returncode != 0:
653                     raise VCSException("Git svn rebase failed", p.output)
654                 self.refreshed = True
655
656         rev = rev or 'master'
657         if rev:
658             nospaces_rev = rev.replace(' ', '%20')
659             # Try finding a svn tag
660             for treeish in ['origin/', '']:
661                 p = SilentPopen(['git', 'checkout', treeish + 'tags/' + nospaces_rev],
662                                 cwd=self.local)
663                 if p.returncode == 0:
664                     break
665             if p.returncode != 0:
666                 # No tag found, normal svn rev translation
667                 # Translate svn rev into git format
668                 rev_split = rev.split('/')
669
670                 p = None
671                 for treeish in ['origin/', '']:
672                     if len(rev_split) > 1:
673                         treeish += rev_split[0]
674                         svn_rev = rev_split[1]
675
676                     else:
677                         # if no branch is specified, then assume trunk (i.e. 'master' branch):
678                         treeish += 'master'
679                         svn_rev = rev
680
681                     svn_rev = svn_rev if svn_rev[0] == 'r' else 'r' + svn_rev
682
683                     p = SilentPopen(['git', 'svn', 'find-rev', '--before', svn_rev, treeish],
684                                     cwd=self.local)
685                     git_rev = p.output.rstrip()
686
687                     if p.returncode == 0 and git_rev:
688                         break
689
690                 if p.returncode != 0 or not git_rev:
691                     # Try a plain git checkout as a last resort
692                     p = SilentPopen(['git', 'checkout', rev], cwd=self.local)
693                     if p.returncode != 0:
694                         raise VCSException("No git treeish found and direct git checkout of '%s' failed" % rev, p.output)
695                 else:
696                     # Check out the git rev equivalent to the svn rev
697                     p = SilentPopen(['git', 'checkout', git_rev], cwd=self.local)
698                     if p.returncode != 0:
699                         raise VCSException("Git checkout of '%s' failed" % rev, p.output)
700
701         # Get rid of any uncontrolled files left behind
702         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
703         if p.returncode != 0:
704             raise VCSException("Git clean failed", p.output)
705
706     def gettags(self):
707         self.checkrepo()
708         for treeish in ['origin/', '']:
709             d = os.path.join(self.local, '.git', 'svn', 'refs', 'remotes', treeish, 'tags')
710             if os.path.isdir(d):
711                 return os.listdir(d)
712
713     def getref(self):
714         self.checkrepo()
715         p = SilentPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local)
716         if p.returncode != 0:
717             return None
718         return p.output.strip()
719
720
721 class vcs_hg(vcs):
722
723     def repotype(self):
724         return 'hg'
725
726     def gotorevisionx(self, rev):
727         if not os.path.exists(self.local):
728             p = SilentPopen(['hg', 'clone', self.remote, self.local])
729             if p.returncode != 0:
730                 self.clone_failed = True
731                 raise VCSException("Hg clone failed", p.output)
732         else:
733             p = SilentPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True)
734             if p.returncode != 0:
735                 raise VCSException("Hg clean failed", p.output)
736             if not self.refreshed:
737                 p = SilentPopen(['hg', 'pull'], cwd=self.local)
738                 if p.returncode != 0:
739                     raise VCSException("Hg pull failed", p.output)
740                 self.refreshed = True
741
742         rev = rev or 'default'
743         if not rev:
744             return
745         p = SilentPopen(['hg', 'update', '-C', rev], cwd=self.local)
746         if p.returncode != 0:
747             raise VCSException("Hg checkout of '%s' failed" % rev, p.output)
748         p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
749         # Also delete untracked files, we have to enable purge extension for that:
750         if "'purge' is provided by the following extension" in p.output:
751             with open(os.path.join(self.local, '.hg', 'hgrc'), "a") as myfile:
752                 myfile.write("\n[extensions]\nhgext.purge=\n")
753             p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
754             if p.returncode != 0:
755                 raise VCSException("HG purge failed", p.output)
756         elif p.returncode != 0:
757             raise VCSException("HG purge failed", p.output)
758
759     def gettags(self):
760         p = SilentPopen(['hg', 'tags', '-q'], cwd=self.local)
761         return p.output.splitlines()[1:]
762
763
764 class vcs_bzr(vcs):
765
766     def repotype(self):
767         return 'bzr'
768
769     def gotorevisionx(self, rev):
770         if not os.path.exists(self.local):
771             p = SilentPopen(['bzr', 'branch', self.remote, self.local])
772             if p.returncode != 0:
773                 self.clone_failed = True
774                 raise VCSException("Bzr branch failed", p.output)
775         else:
776             p = SilentPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local)
777             if p.returncode != 0:
778                 raise VCSException("Bzr revert failed", p.output)
779             if not self.refreshed:
780                 p = SilentPopen(['bzr', 'pull'], cwd=self.local)
781                 if p.returncode != 0:
782                     raise VCSException("Bzr update failed", p.output)
783                 self.refreshed = True
784
785         revargs = list(['-r', rev] if rev else [])
786         p = SilentPopen(['bzr', 'revert'] + revargs, cwd=self.local)
787         if p.returncode != 0:
788             raise VCSException("Bzr revert of '%s' failed" % rev, p.output)
789
790     def gettags(self):
791         p = SilentPopen(['bzr', 'tags'], cwd=self.local)
792         return [tag.split('   ')[0].strip() for tag in
793                 p.output.splitlines()]
794
795
796 def retrieve_string(app_dir, string, xmlfiles=None):
797
798     res_dirs = [
799         os.path.join(app_dir, 'res'),
800         os.path.join(app_dir, 'src', 'main'),
801         ]
802
803     if xmlfiles is None:
804         xmlfiles = []
805         for res_dir in res_dirs:
806             for r, d, f in os.walk(res_dir):
807                 if os.path.basename(r) == 'values':
808                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
809
810     string_search = None
811     if string.startswith('@string/'):
812         string_search = re.compile(r'.*name="' + string[8:] + '".*?>"?([^<]+?)"?<.*').search
813     elif string.startswith('&') and string.endswith(';'):
814         string_search = re.compile(r'.*<!ENTITY.*' + string[1:-1] + '.*?"([^"]+?)".*>').search
815
816     if string_search is not None:
817         for xmlfile in xmlfiles:
818             for line in file(xmlfile):
819                 matches = string_search(line)
820                 if matches:
821                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
822         return None
823
824     return string.replace("\\'", "'")
825
826
827 # Return list of existing files that will be used to find the highest vercode
828 def manifest_paths(app_dir, flavours):
829
830     possible_manifests = \
831         [os.path.join(app_dir, 'AndroidManifest.xml'),
832          os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
833          os.path.join(app_dir, 'src', 'AndroidManifest.xml'),
834          os.path.join(app_dir, 'build.gradle')]
835
836     if flavours:
837         for flavour in flavours:
838             possible_manifests.append(
839                 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
840
841     return [path for path in possible_manifests if os.path.isfile(path)]
842
843
844 # Retrieve the package name. Returns the name, or None if not found.
845 def fetch_real_name(app_dir, flavours):
846     app_search = re.compile(r'.*<application.*').search
847     name_search = re.compile(r'.*android:label="([^"]+)".*').search
848     app_found = False
849     for f in manifest_paths(app_dir, flavours):
850         if not has_extension(f, 'xml'):
851             continue
852         logging.debug("fetch_real_name: Checking manifest at " + f)
853         for line in file(f):
854             if not app_found:
855                 if app_search(line):
856                     app_found = True
857             if app_found:
858                 matches = name_search(line)
859                 if matches:
860                     stringname = matches.group(1)
861                     logging.debug("fetch_real_name: using string " + stringname)
862                     result = retrieve_string(app_dir, stringname)
863                     if result:
864                         result = result.strip()
865                     return result
866     return None
867
868
869 # Retrieve the version name
870 def version_name(original, app_dir, flavours):
871     for f in manifest_paths(app_dir, flavours):
872         if not has_extension(f, 'xml'):
873             continue
874         string = retrieve_string(app_dir, original)
875         if string:
876             return string
877     return original
878
879
880 def get_library_references(root_dir):
881     libraries = []
882     proppath = os.path.join(root_dir, 'project.properties')
883     if not os.path.isfile(proppath):
884         return libraries
885     with open(proppath) as f:
886         for line in f.readlines():
887             if not line.startswith('android.library.reference.'):
888                 continue
889             path = line.split('=')[1].strip()
890             relpath = os.path.join(root_dir, path)
891             if not os.path.isdir(relpath):
892                 continue
893             logging.debug("Found subproject at %s" % path)
894             libraries.append(path)
895     return libraries
896
897
898 def ant_subprojects(root_dir):
899     subprojects = get_library_references(root_dir)
900     for subpath in subprojects:
901         subrelpath = os.path.join(root_dir, subpath)
902         for p in get_library_references(subrelpath):
903             relp = os.path.normpath(os.path.join(subpath, p))
904             if relp not in subprojects:
905                 subprojects.insert(0, relp)
906     return subprojects
907
908
909 def remove_debuggable_flags(root_dir):
910     # Remove forced debuggable flags
911     logging.debug("Removing debuggable flags from %s" % root_dir)
912     for root, dirs, files in os.walk(root_dir):
913         if 'AndroidManifest.xml' in files:
914             path = os.path.join(root, 'AndroidManifest.xml')
915             p = SilentPopen(['sed', '-i', 's/android:debuggable="[^"]*"//g', path])
916             if p.returncode != 0:
917                 raise BuildException("Failed to remove debuggable flags of %s" % path)
918
919
920 # Extract some information from the AndroidManifest.xml at the given path.
921 # Returns (version, vercode, package), any or all of which might be None.
922 # All values returned are strings.
923 def parse_androidmanifests(paths, ignoreversions=None):
924
925     if not paths:
926         return (None, None, None)
927
928     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
929     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
930     psearch = re.compile(r'.*package="([^"]+)".*').search
931
932     vcsearch_g = re.compile(r'.*versionCode *=* *["\']*([0-9]+)["\']*').search
933     vnsearch_g = re.compile(r'.*versionName *=* *(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
934     psearch_g = re.compile(r'.*packageName *=* *["\']([^"]+)["\'].*').search
935
936     ignoresearch = re.compile(ignoreversions).search if ignoreversions else None
937
938     max_version = None
939     max_vercode = None
940     max_package = None
941
942     for path in paths:
943
944         gradle = has_extension(path, 'gradle')
945         version = None
946         vercode = None
947         # Remember package name, may be defined separately from version+vercode
948         package = max_package
949
950         for line in file(path):
951             if not package:
952                 if gradle:
953                     matches = psearch_g(line)
954                 else:
955                     matches = psearch(line)
956                 if matches:
957                     package = matches.group(1)
958             if not version:
959                 if gradle:
960                     matches = vnsearch_g(line)
961                 else:
962                     matches = vnsearch(line)
963                 if matches:
964                     version = matches.group(2 if gradle else 1)
965             if not vercode:
966                 if gradle:
967                     matches = vcsearch_g(line)
968                 else:
969                     matches = vcsearch(line)
970                 if matches:
971                     vercode = matches.group(1)
972
973         # Always grab the package name and version name in case they are not
974         # together with the highest version code
975         if max_package is None and package is not None:
976             max_package = package
977         if max_version is None and version is not None:
978             max_version = version
979
980         if max_vercode is None or (vercode is not None and vercode > max_vercode):
981             if not ignoresearch or not ignoresearch(version):
982                 if version is not None:
983                     max_version = version
984                 if vercode is not None:
985                     max_vercode = vercode
986                 if package is not None:
987                     max_package = package
988             else:
989                 max_version = "Ignore"
990
991     if max_version is None:
992         max_version = "Unknown"
993
994     return (max_version, max_vercode, max_package)
995
996
997 class FDroidException(Exception):
998     def __init__(self, value, detail=None):
999         self.value = value
1000         self.detail = detail
1001
1002     def get_wikitext(self):
1003         ret = repr(self.value) + "\n"
1004         if self.detail:
1005             ret += "=detail=\n"
1006             ret += "<pre>\n"
1007             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1008             ret += str(txt)
1009             ret += "</pre>\n"
1010         return ret
1011
1012     def __str__(self):
1013         ret = self.value
1014         if self.detail:
1015             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1016         return ret
1017
1018
1019 class VCSException(FDroidException):
1020     pass
1021
1022
1023 class BuildException(FDroidException):
1024     pass
1025
1026
1027 # Get the specified source library.
1028 # Returns the path to it. Normally this is the path to be used when referencing
1029 # it, which may be a subdirectory of the actual project. If you want the base
1030 # directory of the project, pass 'basepath=True'.
1031 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1032               basepath=False, raw=False, prepare=True, preponly=False):
1033
1034     number = None
1035     subdir = None
1036     if raw:
1037         name = spec
1038         ref = None
1039     else:
1040         name, ref = spec.split('@')
1041         if ':' in name:
1042             number, name = name.split(':', 1)
1043         if '/' in name:
1044             name, subdir = name.split('/', 1)
1045
1046     if name not in metadata.srclibs:
1047         raise VCSException('srclib ' + name + ' not found.')
1048
1049     srclib = metadata.srclibs[name]
1050
1051     sdir = os.path.join(srclib_dir, name)
1052
1053     if not preponly:
1054         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1055         vcs.srclib = (name, number, sdir)
1056         if ref:
1057             vcs.gotorevision(ref)
1058
1059         if raw:
1060             return vcs
1061
1062     libdir = None
1063     if subdir:
1064         libdir = os.path.join(sdir, subdir)
1065     elif srclib["Subdir"]:
1066         for subdir in srclib["Subdir"]:
1067             libdir_candidate = os.path.join(sdir, subdir)
1068             if os.path.exists(libdir_candidate):
1069                 libdir = libdir_candidate
1070                 break
1071
1072     if libdir is None:
1073         libdir = sdir
1074
1075     if srclib["Srclibs"]:
1076         n = 1
1077         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1078             s_tuple = None
1079             for t in srclibpaths:
1080                 if t[0] == lib:
1081                     s_tuple = t
1082                     break
1083             if s_tuple is None:
1084                 raise VCSException('Missing recursive srclib %s for %s' % (
1085                     lib, name))
1086             place_srclib(libdir, n, s_tuple[2])
1087             n += 1
1088
1089     remove_signing_keys(sdir)
1090     remove_debuggable_flags(sdir)
1091
1092     if prepare:
1093
1094         if srclib["Prepare"]:
1095             cmd = replace_config_vars(srclib["Prepare"])
1096
1097             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1098             if p.returncode != 0:
1099                 raise BuildException("Error running prepare command for srclib %s"
1100                                      % name, p.output)
1101
1102     if basepath:
1103         libdir = sdir
1104
1105     return (name, number, libdir)
1106
1107
1108 # Prepare the source code for a particular build
1109 #  'vcs'         - the appropriate vcs object for the application
1110 #  'app'         - the application details from the metadata
1111 #  'build'       - the build details from the metadata
1112 #  'build_dir'   - the path to the build directory, usually
1113 #                   'build/app.id'
1114 #  'srclib_dir'  - the path to the source libraries directory, usually
1115 #                   'build/srclib'
1116 #  'extlib_dir'  - the path to the external libraries directory, usually
1117 #                   'build/extlib'
1118 # Returns the (root, srclibpaths) where:
1119 #   'root' is the root directory, which may be the same as 'build_dir' or may
1120 #          be a subdirectory of it.
1121 #   'srclibpaths' is information on the srclibs being used
1122 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1123
1124     # Optionally, the actual app source can be in a subdirectory
1125     if build['subdir']:
1126         root_dir = os.path.join(build_dir, build['subdir'])
1127     else:
1128         root_dir = build_dir
1129
1130     # Get a working copy of the right revision
1131     logging.info("Getting source for revision " + build['commit'])
1132     vcs.gotorevision(build['commit'])
1133
1134     # Initialise submodules if requred
1135     if build['submodules']:
1136         logging.info("Initialising submodules")
1137         vcs.initsubmodules()
1138
1139     # Check that a subdir (if we're using one) exists. This has to happen
1140     # after the checkout, since it might not exist elsewhere
1141     if not os.path.exists(root_dir):
1142         raise BuildException('Missing subdir ' + root_dir)
1143
1144     # Run an init command if one is required
1145     if build['init']:
1146         cmd = replace_config_vars(build['init'])
1147         logging.info("Running 'init' commands in %s" % root_dir)
1148
1149         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1150         if p.returncode != 0:
1151             raise BuildException("Error running init command for %s:%s" %
1152                                  (app['id'], build['version']), p.output)
1153
1154     # Apply patches if any
1155     if build['patch']:
1156         logging.info("Applying patches")
1157         for patch in build['patch']:
1158             patch = patch.strip()
1159             logging.info("Applying " + patch)
1160             patch_path = os.path.join('metadata', app['id'], patch)
1161             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1162             if p.returncode != 0:
1163                 raise BuildException("Failed to apply patch %s" % patch_path)
1164
1165     # Get required source libraries
1166     srclibpaths = []
1167     if build['srclibs']:
1168         logging.info("Collecting source libraries")
1169         for lib in build['srclibs']:
1170             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1171                                          preponly=onserver))
1172
1173     for name, number, libpath in srclibpaths:
1174         place_srclib(root_dir, int(number) if number else None, libpath)
1175
1176     basesrclib = vcs.getsrclib()
1177     # If one was used for the main source, add that too.
1178     if basesrclib:
1179         srclibpaths.append(basesrclib)
1180
1181     # Update the local.properties file
1182     localprops = [os.path.join(build_dir, 'local.properties')]
1183     if build['subdir']:
1184         localprops += [os.path.join(root_dir, 'local.properties')]
1185     for path in localprops:
1186         if not os.path.isfile(path):
1187             continue
1188         logging.info("Updating properties file at %s" % path)
1189         f = open(path, 'r')
1190         props = f.read()
1191         f.close()
1192         props += '\n'
1193         # Fix old-fashioned 'sdk-location' by copying
1194         # from sdk.dir, if necessary
1195         if build['oldsdkloc']:
1196             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1197                               re.S | re.M).group(1)
1198             props += "sdk-location=%s\n" % sdkloc
1199         else:
1200             props += "sdk.dir=%s\n" % config['sdk_path']
1201             props += "sdk-location=%s\n" % config['sdk_path']
1202         if 'ndk_path' in config:
1203             # Add ndk location
1204             props += "ndk.dir=%s\n" % config['ndk_path']
1205             props += "ndk-location=%s\n" % config['ndk_path']
1206         # Add java.encoding if necessary
1207         if build['encoding']:
1208             props += "java.encoding=%s\n" % build['encoding']
1209         f = open(path, 'w')
1210         f.write(props)
1211         f.close()
1212
1213     flavours = None
1214     if build['type'] == 'gradle':
1215         flavours = build['gradle']
1216         if len(flavours) == 1 and flavours[0] in ['main', 'yes', '']:
1217             flavours = None
1218
1219         version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1220         gradlepluginver = None
1221
1222         gradle_files = [os.path.join(root_dir, 'build.gradle')]
1223
1224         # Parent dir build.gradle
1225         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1226         if parent_dir.startswith(build_dir):
1227             gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1228
1229         for path in gradle_files:
1230             if gradlepluginver:
1231                 break
1232             if not os.path.isfile(path):
1233                 continue
1234             with open(path) as f:
1235                 for line in f:
1236                     match = version_regex.match(line)
1237                     if match:
1238                         gradlepluginver = match.group(1)
1239                         break
1240
1241         if gradlepluginver:
1242             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1243         else:
1244             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1245             build['gradlepluginver'] = LooseVersion('0.11')
1246
1247         if build['target']:
1248             n = build["target"].split('-')[1]
1249             SilentPopen(['sed', '-i',
1250                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1251                          'build.gradle'],
1252                         cwd=root_dir)
1253
1254     # Remove forced debuggable flags
1255     remove_debuggable_flags(root_dir)
1256
1257     # Insert version code and number into the manifest if necessary
1258     if build['forceversion']:
1259         logging.info("Changing the version name")
1260         for path in manifest_paths(root_dir, flavours):
1261             if not os.path.isfile(path):
1262                 continue
1263             if has_extension(path, 'xml'):
1264                 p = SilentPopen(['sed', '-i',
1265                                  's/android:versionName="[^"]*"/android:versionName="'
1266                                  + build['version'] + '"/g',
1267                                  path])
1268                 if p.returncode != 0:
1269                     raise BuildException("Failed to amend manifest")
1270             elif has_extension(path, 'gradle'):
1271                 p = SilentPopen(['sed', '-i',
1272                                  's/versionName *=* *"[^"]*"/versionName = "'
1273                                  + build['version'] + '"/g',
1274                                  path])
1275                 if p.returncode != 0:
1276                     raise BuildException("Failed to amend build.gradle")
1277     if build['forcevercode']:
1278         logging.info("Changing the version code")
1279         for path in manifest_paths(root_dir, flavours):
1280             if not os.path.isfile(path):
1281                 continue
1282             if has_extension(path, 'xml'):
1283                 p = SilentPopen(['sed', '-i',
1284                                  's/android:versionCode="[^"]*"/android:versionCode="'
1285                                  + build['vercode'] + '"/g',
1286                                  path])
1287                 if p.returncode != 0:
1288                     raise BuildException("Failed to amend manifest")
1289             elif has_extension(path, 'gradle'):
1290                 p = SilentPopen(['sed', '-i',
1291                                  's/versionCode *=* *[0-9]*/versionCode = '
1292                                  + build['vercode'] + '/g',
1293                                  path])
1294                 if p.returncode != 0:
1295                     raise BuildException("Failed to amend build.gradle")
1296
1297     # Delete unwanted files
1298     if build['rm']:
1299         logging.info("Removing specified files")
1300         for part in getpaths(build_dir, build, 'rm'):
1301             dest = os.path.join(build_dir, part)
1302             logging.info("Removing {0}".format(part))
1303             if os.path.lexists(dest):
1304                 if os.path.islink(dest):
1305                     SilentPopen(['unlink ' + dest], shell=True)
1306                 else:
1307                     SilentPopen(['rm -rf ' + dest], shell=True)
1308             else:
1309                 logging.info("...but it didn't exist")
1310
1311     remove_signing_keys(build_dir)
1312
1313     # Add required external libraries
1314     if build['extlibs']:
1315         logging.info("Collecting prebuilt libraries")
1316         libsdir = os.path.join(root_dir, 'libs')
1317         if not os.path.exists(libsdir):
1318             os.mkdir(libsdir)
1319         for lib in build['extlibs']:
1320             lib = lib.strip()
1321             logging.info("...installing extlib {0}".format(lib))
1322             libf = os.path.basename(lib)
1323             libsrc = os.path.join(extlib_dir, lib)
1324             if not os.path.exists(libsrc):
1325                 raise BuildException("Missing extlib file {0}".format(libsrc))
1326             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1327
1328     # Run a pre-build command if one is required
1329     if build['prebuild']:
1330         logging.info("Running 'prebuild' commands in %s" % root_dir)
1331
1332         cmd = replace_config_vars(build['prebuild'])
1333
1334         # Substitute source library paths into prebuild commands
1335         for name, number, libpath in srclibpaths:
1336             libpath = os.path.relpath(libpath, root_dir)
1337             cmd = cmd.replace('$$' + name + '$$', libpath)
1338
1339         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1340         if p.returncode != 0:
1341             raise BuildException("Error running prebuild command for %s:%s" %
1342                                  (app['id'], build['version']), p.output)
1343
1344     # Generate (or update) the ant build file, build.xml...
1345     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1346         parms = [config['android'], 'update', 'lib-project']
1347         lparms = [config['android'], 'update', 'project']
1348
1349         if build['target']:
1350             parms += ['-t', build['target']]
1351             lparms += ['-t', build['target']]
1352         if build['update'] == ['auto']:
1353             update_dirs = ant_subprojects(root_dir) + ['.']
1354         else:
1355             update_dirs = build['update']
1356
1357         for d in update_dirs:
1358             subdir = os.path.join(root_dir, d)
1359             if d == '.':
1360                 logging.debug("Updating main project")
1361                 cmd = parms + ['-p', d]
1362             else:
1363                 logging.debug("Updating subproject %s" % d)
1364                 cmd = lparms + ['-p', d]
1365             p = FDroidPopen(cmd, cwd=root_dir)
1366             # Check to see whether an error was returned without a proper exit
1367             # code (this is the case for the 'no target set or target invalid'
1368             # error)
1369             if p.returncode != 0 or p.output.startswith("Error: "):
1370                 raise BuildException("Failed to update project at %s" % d, p.output)
1371             # Clean update dirs via ant
1372             if d != '.':
1373                 logging.info("Cleaning subproject %s" % d)
1374                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1375
1376     return (root_dir, srclibpaths)
1377
1378
1379 # Split and extend via globbing the paths from a field
1380 def getpaths(build_dir, build, field):
1381     paths = []
1382     for p in build[field]:
1383         p = p.strip()
1384         full_path = os.path.join(build_dir, p)
1385         full_path = os.path.normpath(full_path)
1386         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1387     return paths
1388
1389
1390 # Scan the source code in the given directory (and all subdirectories)
1391 # and return the number of fatal problems encountered
1392 def scan_source(build_dir, root_dir, thisbuild):
1393
1394     count = 0
1395
1396     # Common known non-free blobs (always lower case):
1397     usual_suspects = [
1398         re.compile(r'flurryagent', re.IGNORECASE),
1399         re.compile(r'paypal.*mpl', re.IGNORECASE),
1400         re.compile(r'google.*analytics', re.IGNORECASE),
1401         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1402         re.compile(r'google.*ad.*view', re.IGNORECASE),
1403         re.compile(r'google.*admob', re.IGNORECASE),
1404         re.compile(r'google.*play.*services', re.IGNORECASE),
1405         re.compile(r'crittercism', re.IGNORECASE),
1406         re.compile(r'heyzap', re.IGNORECASE),
1407         re.compile(r'jpct.*ae', re.IGNORECASE),
1408         re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1409         re.compile(r'bugsense', re.IGNORECASE),
1410         re.compile(r'crashlytics', re.IGNORECASE),
1411         re.compile(r'ouya.*sdk', re.IGNORECASE),
1412         re.compile(r'libspen23', re.IGNORECASE),
1413         ]
1414
1415     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1416     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1417
1418     try:
1419         ms = magic.open(magic.MIME_TYPE)
1420         ms.load()
1421     except AttributeError:
1422         ms = None
1423
1424     def toignore(fd):
1425         for i in scanignore:
1426             if fd.startswith(i):
1427                 return True
1428         return False
1429
1430     def todelete(fd):
1431         for i in scandelete:
1432             if fd.startswith(i):
1433                 return True
1434         return False
1435
1436     def removeproblem(what, fd, fp):
1437         logging.info('Removing %s at %s' % (what, fd))
1438         os.remove(fp)
1439
1440     def warnproblem(what, fd):
1441         logging.warn('Found %s at %s' % (what, fd))
1442
1443     def handleproblem(what, fd, fp):
1444         if toignore(fd):
1445             logging.info('Ignoring %s at %s' % (what, fd))
1446         elif todelete(fd):
1447             removeproblem(what, fd, fp)
1448         else:
1449             logging.error('Found %s at %s' % (what, fd))
1450             return True
1451         return False
1452
1453     # Iterate through all files in the source code
1454     for r, d, f in os.walk(build_dir, topdown=True):
1455
1456         # It's topdown, so checking the basename is enough
1457         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1458             if ignoredir in d:
1459                 d.remove(ignoredir)
1460
1461         for curfile in f:
1462
1463             # Path (relative) to the file
1464             fp = os.path.join(r, curfile)
1465             fd = fp[len(build_dir) + 1:]
1466
1467             try:
1468                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1469             except UnicodeError:
1470                 warnproblem('malformed magic number', fd)
1471
1472             if mime == 'application/x-sharedlib':
1473                 count += handleproblem('shared library', fd, fp)
1474
1475             elif mime == 'application/x-archive':
1476                 count += handleproblem('static library', fd, fp)
1477
1478             elif mime == 'application/x-executable':
1479                 count += handleproblem('binary executable', fd, fp)
1480
1481             elif mime == 'application/x-java-applet':
1482                 count += handleproblem('Java compiled class', fd, fp)
1483
1484             elif mime in (
1485                     'application/jar',
1486                     'application/zip',
1487                     'application/java-archive',
1488                     'application/octet-stream',
1489                     'binary',
1490                     ):
1491
1492                 if has_extension(fp, 'apk'):
1493                     removeproblem('APK file', fd, fp)
1494
1495                 elif has_extension(fp, 'jar'):
1496
1497                     if any(suspect.match(curfile) for suspect in usual_suspects):
1498                         count += handleproblem('usual supect', fd, fp)
1499                     else:
1500                         warnproblem('JAR file', fd)
1501
1502                 elif has_extension(fp, 'zip'):
1503                     warnproblem('ZIP file', fd)
1504
1505                 else:
1506                     warnproblem('unknown compressed or binary file', fd)
1507
1508             elif has_extension(fp, 'java'):
1509                 for line in file(fp):
1510                     if 'DexClassLoader' in line:
1511                         count += handleproblem('DexClassLoader', fd, fp)
1512                         break
1513     if ms is not None:
1514         ms.close()
1515
1516     # Presence of a jni directory without buildjni=yes might
1517     # indicate a problem (if it's not a problem, explicitly use
1518     # buildjni=no to bypass this check)
1519     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1520             not thisbuild['buildjni']):
1521         logging.error('Found jni directory, but buildjni is not enabled. Set it to \'no\' to ignore.')
1522         count += 1
1523
1524     return count
1525
1526
1527 class KnownApks:
1528
1529     def __init__(self):
1530         self.path = os.path.join('stats', 'known_apks.txt')
1531         self.apks = {}
1532         if os.path.exists(self.path):
1533             for line in file(self.path):
1534                 t = line.rstrip().split(' ')
1535                 if len(t) == 2:
1536                     self.apks[t[0]] = (t[1], None)
1537                 else:
1538                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1539         self.changed = False
1540
1541     def writeifchanged(self):
1542         if self.changed:
1543             if not os.path.exists('stats'):
1544                 os.mkdir('stats')
1545             f = open(self.path, 'w')
1546             lst = []
1547             for apk, app in self.apks.iteritems():
1548                 appid, added = app
1549                 line = apk + ' ' + appid
1550                 if added:
1551                     line += ' ' + time.strftime('%Y-%m-%d', added)
1552                 lst.append(line)
1553             for line in sorted(lst):
1554                 f.write(line + '\n')
1555             f.close()
1556
1557     # Record an apk (if it's new, otherwise does nothing)
1558     # Returns the date it was added.
1559     def recordapk(self, apk, app):
1560         if apk not in self.apks:
1561             self.apks[apk] = (app, time.gmtime(time.time()))
1562             self.changed = True
1563         _, added = self.apks[apk]
1564         return added
1565
1566     # Look up information - given the 'apkname', returns (app id, date added/None).
1567     # Or returns None for an unknown apk.
1568     def getapp(self, apkname):
1569         if apkname in self.apks:
1570             return self.apks[apkname]
1571         return None
1572
1573     # Get the most recent 'num' apps added to the repo, as a list of package ids
1574     # with the most recent first.
1575     def getlatest(self, num):
1576         apps = {}
1577         for apk, app in self.apks.iteritems():
1578             appid, added = app
1579             if added:
1580                 if appid in apps:
1581                     if apps[appid] > added:
1582                         apps[appid] = added
1583                 else:
1584                     apps[appid] = added
1585         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1586         lst = [app for app, _ in sortedapps]
1587         lst.reverse()
1588         return lst
1589
1590
1591 def isApkDebuggable(apkfile, config):
1592     """Returns True if the given apk file is debuggable
1593
1594     :param apkfile: full path to the apk to check"""
1595
1596     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1597                                   config['build_tools'], 'aapt'),
1598                      'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1599     if p.returncode != 0:
1600         logging.critical("Failed to get apk manifest information")
1601         sys.exit(1)
1602     for line in p.output.splitlines():
1603         if 'android:debuggable' in line and not line.endswith('0x0'):
1604             return True
1605     return False
1606
1607
1608 class AsynchronousFileReader(threading.Thread):
1609     '''
1610     Helper class to implement asynchronous reading of a file
1611     in a separate thread. Pushes read lines on a queue to
1612     be consumed in another thread.
1613     '''
1614
1615     def __init__(self, fd, queue):
1616         assert isinstance(queue, Queue.Queue)
1617         assert callable(fd.readline)
1618         threading.Thread.__init__(self)
1619         self._fd = fd
1620         self._queue = queue
1621
1622     def run(self):
1623         '''The body of the tread: read lines and put them on the queue.'''
1624         for line in iter(self._fd.readline, ''):
1625             self._queue.put(line)
1626
1627     def eof(self):
1628         '''Check whether there is no more content to expect.'''
1629         return not self.is_alive() and self._queue.empty()
1630
1631
1632 class PopenResult:
1633     returncode = None
1634     output = ''
1635
1636
1637 def SilentPopen(commands, cwd=None, shell=False):
1638     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1639
1640
1641 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1642     """
1643     Run a command and capture the possibly huge output.
1644
1645     :param commands: command and argument list like in subprocess.Popen
1646     :param cwd: optionally specifies a working directory
1647     :returns: A PopenResult.
1648     """
1649
1650     global env
1651
1652     if cwd:
1653         cwd = os.path.normpath(cwd)
1654         logging.debug("Directory: %s" % cwd)
1655     logging.debug("> %s" % ' '.join(commands))
1656
1657     result = PopenResult()
1658     p = None
1659     try:
1660         p = subprocess.Popen(commands, cwd=cwd, shell=shell, env=env,
1661                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1662     except OSError, e:
1663         raise BuildException("OSError while trying to execute " + ' '.join(commands) + ': ' + str(e))
1664
1665     stdout_queue = Queue.Queue()
1666     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1667     stdout_reader.start()
1668
1669     # Check the queue for output (until there is no more to get)
1670     while not stdout_reader.eof():
1671         while not stdout_queue.empty():
1672             line = stdout_queue.get()
1673             if output and options.verbose:
1674                 # Output directly to console
1675                 sys.stderr.write(line)
1676                 sys.stderr.flush()
1677             result.output += line
1678
1679         time.sleep(0.1)
1680
1681     result.returncode = p.wait()
1682     return result
1683
1684
1685 def remove_signing_keys(build_dir):
1686     comment = re.compile(r'[ ]*//')
1687     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1688     line_matches = [
1689         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1690         re.compile(r'.*android\.signingConfigs\.[^{]*$'),
1691         re.compile(r'.*variant\.outputFile = .*'),
1692         re.compile(r'.*\.readLine\(.*'),
1693         ]
1694     for root, dirs, files in os.walk(build_dir):
1695         if 'build.gradle' in files:
1696             path = os.path.join(root, 'build.gradle')
1697
1698             with open(path, "r") as o:
1699                 lines = o.readlines()
1700
1701             changed = False
1702
1703             opened = 0
1704             with open(path, "w") as o:
1705                 for line in lines:
1706                     if comment.match(line):
1707                         continue
1708
1709                     if opened > 0:
1710                         opened += line.count('{')
1711                         opened -= line.count('}')
1712                         continue
1713
1714                     if signing_configs.match(line):
1715                         changed = True
1716                         opened += 1
1717                         continue
1718
1719                     if any(s.match(line) for s in line_matches):
1720                         changed = True
1721                         continue
1722
1723                     if opened == 0:
1724                         o.write(line)
1725
1726             if changed:
1727                 logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1728
1729         for propfile in [
1730                 'project.properties',
1731                 'build.properties',
1732                 'default.properties',
1733                 'ant.properties',
1734                 ]:
1735             if propfile in files:
1736                 path = os.path.join(root, propfile)
1737
1738                 with open(path, "r") as o:
1739                     lines = o.readlines()
1740
1741                 changed = False
1742
1743                 with open(path, "w") as o:
1744                     for line in lines:
1745                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1746                             changed = True
1747                             continue
1748
1749                         o.write(line)
1750
1751                 if changed:
1752                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1753
1754
1755 def replace_config_vars(cmd):
1756     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1757     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1758     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1759     return cmd
1760
1761
1762 def place_srclib(root_dir, number, libpath):
1763     if not number:
1764         return
1765     relpath = os.path.relpath(libpath, root_dir)
1766     proppath = os.path.join(root_dir, 'project.properties')
1767
1768     lines = []
1769     if os.path.isfile(proppath):
1770         with open(proppath, "r") as o:
1771             lines = o.readlines()
1772
1773     with open(proppath, "w") as o:
1774         placed = False
1775         for line in lines:
1776             if line.startswith('android.library.reference.%d=' % number):
1777                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1778                 placed = True
1779             else:
1780                 o.write(line)
1781         if not placed:
1782             o.write('android.library.reference.%d=%s\n' % (number, relpath))