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