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