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