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