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