chiark / gitweb /
Sanitise tags at point of reading
[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         gradle = has_extension(path, 'gradle')
991         version = None
992         vercode = None
993         # Remember package name, may be defined separately from version+vercode
994         package = max_package
995
996         for line in file(path):
997             if not package:
998                 if gradle:
999                     matches = psearch_g(line)
1000                 else:
1001                     matches = psearch(line)
1002                 if matches:
1003                     package = matches.group(1)
1004             if not version:
1005                 if gradle:
1006                     matches = vnsearch_g(line)
1007                 else:
1008                     matches = vnsearch(line)
1009                 if matches:
1010                     version = matches.group(2 if gradle else 1)
1011             if not vercode:
1012                 if gradle:
1013                     matches = vcsearch_g(line)
1014                 else:
1015                     matches = vcsearch(line)
1016                 if matches:
1017                     vercode = matches.group(1)
1018
1019         # Always grab the package name and version name in case they are not
1020         # together with the highest version code
1021         if max_package is None and package is not None:
1022             max_package = package
1023         if max_version is None and version is not None:
1024             max_version = version
1025
1026         if max_vercode is None or (vercode is not None and vercode > max_vercode):
1027             if not ignoresearch or not ignoresearch(version):
1028                 if version is not None:
1029                     max_version = version
1030                 if vercode is not None:
1031                     max_vercode = vercode
1032                 if package is not None:
1033                     max_package = package
1034             else:
1035                 max_version = "Ignore"
1036
1037     if max_version is None:
1038         max_version = "Unknown"
1039
1040     return (max_version, max_vercode, max_package)
1041
1042
1043 class FDroidException(Exception):
1044
1045     def __init__(self, value, detail=None):
1046         self.value = value
1047         self.detail = detail
1048
1049     def get_wikitext(self):
1050         ret = repr(self.value) + "\n"
1051         if self.detail:
1052             ret += "=detail=\n"
1053             ret += "<pre>\n"
1054             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
1055             ret += str(txt)
1056             ret += "</pre>\n"
1057         return ret
1058
1059     def __str__(self):
1060         ret = self.value
1061         if self.detail:
1062             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
1063         return ret
1064
1065
1066 class VCSException(FDroidException):
1067     pass
1068
1069
1070 class BuildException(FDroidException):
1071     pass
1072
1073
1074 # Get the specified source library.
1075 # Returns the path to it. Normally this is the path to be used when referencing
1076 # it, which may be a subdirectory of the actual project. If you want the base
1077 # directory of the project, pass 'basepath=True'.
1078 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
1079               basepath=False, raw=False, prepare=True, preponly=False):
1080
1081     number = None
1082     subdir = None
1083     if raw:
1084         name = spec
1085         ref = None
1086     else:
1087         name, ref = spec.split('@')
1088         if ':' in name:
1089             number, name = name.split(':', 1)
1090         if '/' in name:
1091             name, subdir = name.split('/', 1)
1092
1093     if name not in metadata.srclibs:
1094         raise VCSException('srclib ' + name + ' not found.')
1095
1096     srclib = metadata.srclibs[name]
1097
1098     sdir = os.path.join(srclib_dir, name)
1099
1100     if not preponly:
1101         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
1102         vcs.srclib = (name, number, sdir)
1103         if ref:
1104             vcs.gotorevision(ref)
1105
1106         if raw:
1107             return vcs
1108
1109     libdir = None
1110     if subdir:
1111         libdir = os.path.join(sdir, subdir)
1112     elif srclib["Subdir"]:
1113         for subdir in srclib["Subdir"]:
1114             libdir_candidate = os.path.join(sdir, subdir)
1115             if os.path.exists(libdir_candidate):
1116                 libdir = libdir_candidate
1117                 break
1118
1119     if libdir is None:
1120         libdir = sdir
1121
1122     if srclib["Srclibs"]:
1123         n = 1
1124         for lib in srclib["Srclibs"].replace(';', ',').split(','):
1125             s_tuple = None
1126             for t in srclibpaths:
1127                 if t[0] == lib:
1128                     s_tuple = t
1129                     break
1130             if s_tuple is None:
1131                 raise VCSException('Missing recursive srclib %s for %s' % (
1132                     lib, name))
1133             place_srclib(libdir, n, s_tuple[2])
1134             n += 1
1135
1136     remove_signing_keys(sdir)
1137     remove_debuggable_flags(sdir)
1138
1139     if prepare:
1140
1141         if srclib["Prepare"]:
1142             cmd = replace_config_vars(srclib["Prepare"])
1143
1144             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
1145             if p.returncode != 0:
1146                 raise BuildException("Error running prepare command for srclib %s"
1147                                      % name, p.output)
1148
1149     if basepath:
1150         libdir = sdir
1151
1152     return (name, number, libdir)
1153
1154
1155 # Prepare the source code for a particular build
1156 #  'vcs'         - the appropriate vcs object for the application
1157 #  'app'         - the application details from the metadata
1158 #  'build'       - the build details from the metadata
1159 #  'build_dir'   - the path to the build directory, usually
1160 #                   'build/app.id'
1161 #  'srclib_dir'  - the path to the source libraries directory, usually
1162 #                   'build/srclib'
1163 #  'extlib_dir'  - the path to the external libraries directory, usually
1164 #                   'build/extlib'
1165 # Returns the (root, srclibpaths) where:
1166 #   'root' is the root directory, which may be the same as 'build_dir' or may
1167 #          be a subdirectory of it.
1168 #   'srclibpaths' is information on the srclibs being used
1169 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1170
1171     # Optionally, the actual app source can be in a subdirectory
1172     if build['subdir']:
1173         root_dir = os.path.join(build_dir, build['subdir'])
1174     else:
1175         root_dir = build_dir
1176
1177     # Get a working copy of the right revision
1178     logging.info("Getting source for revision " + build['commit'])
1179     vcs.gotorevision(build['commit'])
1180
1181     # Initialise submodules if requred
1182     if build['submodules']:
1183         logging.info("Initialising submodules")
1184         vcs.initsubmodules()
1185
1186     # Check that a subdir (if we're using one) exists. This has to happen
1187     # after the checkout, since it might not exist elsewhere
1188     if not os.path.exists(root_dir):
1189         raise BuildException('Missing subdir ' + root_dir)
1190
1191     # Run an init command if one is required
1192     if build['init']:
1193         cmd = replace_config_vars(build['init'])
1194         logging.info("Running 'init' commands in %s" % root_dir)
1195
1196         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1197         if p.returncode != 0:
1198             raise BuildException("Error running init command for %s:%s" %
1199                                  (app['id'], build['version']), p.output)
1200
1201     # Apply patches if any
1202     if build['patch']:
1203         logging.info("Applying patches")
1204         for patch in build['patch']:
1205             patch = patch.strip()
1206             logging.info("Applying " + patch)
1207             patch_path = os.path.join('metadata', app['id'], patch)
1208             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1209             if p.returncode != 0:
1210                 raise BuildException("Failed to apply patch %s" % patch_path)
1211
1212     # Get required source libraries
1213     srclibpaths = []
1214     if build['srclibs']:
1215         logging.info("Collecting source libraries")
1216         for lib in build['srclibs']:
1217             srclibpaths.append(getsrclib(lib, srclib_dir, build, srclibpaths,
1218                                          preponly=onserver))
1219
1220     for name, number, libpath in srclibpaths:
1221         place_srclib(root_dir, int(number) if number else None, libpath)
1222
1223     basesrclib = vcs.getsrclib()
1224     # If one was used for the main source, add that too.
1225     if basesrclib:
1226         srclibpaths.append(basesrclib)
1227
1228     # Update the local.properties file
1229     localprops = [os.path.join(build_dir, 'local.properties')]
1230     if build['subdir']:
1231         localprops += [os.path.join(root_dir, 'local.properties')]
1232     for path in localprops:
1233         props = ""
1234         if os.path.isfile(path):
1235             logging.info("Updating local.properties file at %s" % path)
1236             f = open(path, 'r')
1237             props += f.read()
1238             f.close()
1239             props += '\n'
1240         else:
1241             logging.info("Creating local.properties file at %s" % path)
1242         # Fix old-fashioned 'sdk-location' by copying
1243         # from sdk.dir, if necessary
1244         if build['oldsdkloc']:
1245             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1246                               re.S | re.M).group(1)
1247             props += "sdk-location=%s\n" % sdkloc
1248         else:
1249             props += "sdk.dir=%s\n" % config['sdk_path']
1250             props += "sdk-location=%s\n" % config['sdk_path']
1251         if build['ndk_path']:
1252             # Add ndk location
1253             props += "ndk.dir=%s\n" % build['ndk_path']
1254             props += "ndk-location=%s\n" % build['ndk_path']
1255         # Add java.encoding if necessary
1256         if build['encoding']:
1257             props += "java.encoding=%s\n" % build['encoding']
1258         f = open(path, 'w')
1259         f.write(props)
1260         f.close()
1261
1262     flavours = []
1263     if build['type'] == 'gradle':
1264         flavours = build['gradle']
1265
1266         version_regex = re.compile(r".*'com\.android\.tools\.build:gradle:([^\.]+\.[^\.]+).*'.*")
1267         gradlepluginver = None
1268
1269         gradle_files = [os.path.join(root_dir, 'build.gradle')]
1270
1271         # Parent dir build.gradle
1272         parent_dir = os.path.normpath(os.path.join(root_dir, '..'))
1273         if parent_dir.startswith(build_dir):
1274             gradle_files.append(os.path.join(parent_dir, 'build.gradle'))
1275
1276         for path in gradle_files:
1277             if gradlepluginver:
1278                 break
1279             if not os.path.isfile(path):
1280                 continue
1281             with open(path) as f:
1282                 for line in f:
1283                     match = version_regex.match(line)
1284                     if match:
1285                         gradlepluginver = match.group(1)
1286                         break
1287
1288         if gradlepluginver:
1289             build['gradlepluginver'] = LooseVersion(gradlepluginver)
1290         else:
1291             logging.warn("Could not fetch the gradle plugin version, defaulting to 0.11")
1292             build['gradlepluginver'] = LooseVersion('0.11')
1293
1294         if build['target']:
1295             n = build["target"].split('-')[1]
1296             FDroidPopen(['sed', '-i',
1297                          's@compileSdkVersion *[0-9]*@compileSdkVersion ' + n + '@g',
1298                          'build.gradle'], cwd=root_dir, output=False)
1299
1300     # Remove forced debuggable flags
1301     remove_debuggable_flags(root_dir)
1302
1303     # Insert version code and number into the manifest if necessary
1304     if build['forceversion']:
1305         logging.info("Changing the version name")
1306         for path in manifest_paths(root_dir, flavours):
1307             if not os.path.isfile(path):
1308                 continue
1309             if has_extension(path, 'xml'):
1310                 p = FDroidPopen(['sed', '-i',
1311                                  's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1312                                  path], output=False)
1313                 if p.returncode != 0:
1314                     raise BuildException("Failed to amend manifest")
1315             elif has_extension(path, 'gradle'):
1316                 p = FDroidPopen(['sed', '-i',
1317                                  's/versionName *=* *"[^"]*"/versionName = "' + build['version'] + '"/g',
1318                                  path], output=False)
1319                 if p.returncode != 0:
1320                     raise BuildException("Failed to amend build.gradle")
1321     if build['forcevercode']:
1322         logging.info("Changing the version code")
1323         for path in manifest_paths(root_dir, flavours):
1324             if not os.path.isfile(path):
1325                 continue
1326             if has_extension(path, 'xml'):
1327                 p = FDroidPopen(['sed', '-i',
1328                                  's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1329                                  path], output=False)
1330                 if p.returncode != 0:
1331                     raise BuildException("Failed to amend manifest")
1332             elif has_extension(path, 'gradle'):
1333                 p = FDroidPopen(['sed', '-i',
1334                                  's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1335                                  path], output=False)
1336                 if p.returncode != 0:
1337                     raise BuildException("Failed to amend build.gradle")
1338
1339     # Delete unwanted files
1340     if build['rm']:
1341         logging.info("Removing specified files")
1342         for part in getpaths(build_dir, build, 'rm'):
1343             dest = os.path.join(build_dir, part)
1344             logging.info("Removing {0}".format(part))
1345             if os.path.lexists(dest):
1346                 if os.path.islink(dest):
1347                     FDroidPopen(['unlink ' + dest], shell=True, output=False)
1348                 else:
1349                     FDroidPopen(['rm -rf ' + dest], shell=True, output=False)
1350             else:
1351                 logging.info("...but it didn't exist")
1352
1353     remove_signing_keys(build_dir)
1354
1355     # Add required external libraries
1356     if build['extlibs']:
1357         logging.info("Collecting prebuilt libraries")
1358         libsdir = os.path.join(root_dir, 'libs')
1359         if not os.path.exists(libsdir):
1360             os.mkdir(libsdir)
1361         for lib in build['extlibs']:
1362             lib = lib.strip()
1363             logging.info("...installing extlib {0}".format(lib))
1364             libf = os.path.basename(lib)
1365             libsrc = os.path.join(extlib_dir, lib)
1366             if not os.path.exists(libsrc):
1367                 raise BuildException("Missing extlib file {0}".format(libsrc))
1368             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1369
1370     # Run a pre-build command if one is required
1371     if build['prebuild']:
1372         logging.info("Running 'prebuild' commands in %s" % root_dir)
1373
1374         cmd = replace_config_vars(build['prebuild'])
1375
1376         # Substitute source library paths into prebuild commands
1377         for name, number, libpath in srclibpaths:
1378             libpath = os.path.relpath(libpath, root_dir)
1379             cmd = cmd.replace('$$' + name + '$$', libpath)
1380
1381         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1382         if p.returncode != 0:
1383             raise BuildException("Error running prebuild command for %s:%s" %
1384                                  (app['id'], build['version']), p.output)
1385
1386     # Generate (or update) the ant build file, build.xml...
1387     if build['update'] and build['update'] != ['no'] and build['type'] == 'ant':
1388         parms = ['android', 'update', 'lib-project']
1389         lparms = ['android', 'update', 'project']
1390
1391         if build['target']:
1392             parms += ['-t', build['target']]
1393             lparms += ['-t', build['target']]
1394         if build['update'] == ['auto']:
1395             update_dirs = ant_subprojects(root_dir) + ['.']
1396         else:
1397             update_dirs = build['update']
1398
1399         for d in update_dirs:
1400             subdir = os.path.join(root_dir, d)
1401             if d == '.':
1402                 logging.debug("Updating main project")
1403                 cmd = parms + ['-p', d]
1404             else:
1405                 logging.debug("Updating subproject %s" % d)
1406                 cmd = lparms + ['-p', d]
1407             p = SdkToolsPopen(cmd, cwd=root_dir)
1408             # Check to see whether an error was returned without a proper exit
1409             # code (this is the case for the 'no target set or target invalid'
1410             # error)
1411             if p.returncode != 0 or p.output.startswith("Error: "):
1412                 raise BuildException("Failed to update project at %s" % d, p.output)
1413             # Clean update dirs via ant
1414             if d != '.':
1415                 logging.info("Cleaning subproject %s" % d)
1416                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1417
1418     return (root_dir, srclibpaths)
1419
1420
1421 # Split and extend via globbing the paths from a field
1422 def getpaths(build_dir, build, field):
1423     paths = []
1424     for p in build[field]:
1425         p = p.strip()
1426         full_path = os.path.join(build_dir, p)
1427         full_path = os.path.normpath(full_path)
1428         paths += [r[len(build_dir) + 1:] for r in glob.glob(full_path)]
1429     return paths
1430
1431
1432 # Scan the source code in the given directory (and all subdirectories)
1433 # and return the number of fatal problems encountered
1434 def scan_source(build_dir, root_dir, thisbuild):
1435
1436     count = 0
1437
1438     # Common known non-free blobs (always lower case):
1439     usual_suspects = [
1440         re.compile(r'flurryagent', re.IGNORECASE),
1441         re.compile(r'paypal.*mpl', re.IGNORECASE),
1442         re.compile(r'google.*analytics', re.IGNORECASE),
1443         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1444         re.compile(r'google.*ad.*view', re.IGNORECASE),
1445         re.compile(r'google.*admob', re.IGNORECASE),
1446         re.compile(r'google.*play.*services', re.IGNORECASE),
1447         re.compile(r'crittercism', re.IGNORECASE),
1448         re.compile(r'heyzap', re.IGNORECASE),
1449         re.compile(r'jpct.*ae', re.IGNORECASE),
1450         re.compile(r'youtube.*android.*player.*api', re.IGNORECASE),
1451         re.compile(r'bugsense', re.IGNORECASE),
1452         re.compile(r'crashlytics', re.IGNORECASE),
1453         re.compile(r'ouya.*sdk', re.IGNORECASE),
1454         re.compile(r'libspen23', re.IGNORECASE),
1455     ]
1456
1457     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1458     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1459
1460     scanignore_worked = set()
1461     scandelete_worked = set()
1462
1463     try:
1464         ms = magic.open(magic.MIME_TYPE)
1465         ms.load()
1466     except AttributeError:
1467         ms = None
1468
1469     def toignore(fd):
1470         for p in scanignore:
1471             if fd.startswith(p):
1472                 scanignore_worked.add(p)
1473                 return True
1474         return False
1475
1476     def todelete(fd):
1477         for p in scandelete:
1478             if fd.startswith(p):
1479                 scandelete_worked.add(p)
1480                 return True
1481         return False
1482
1483     def ignoreproblem(what, fd, fp):
1484         logging.info('Ignoring %s at %s' % (what, fd))
1485         return 0
1486
1487     def removeproblem(what, fd, fp):
1488         logging.info('Removing %s at %s' % (what, fd))
1489         os.remove(fp)
1490         return 0
1491
1492     def warnproblem(what, fd):
1493         logging.warn('Found %s at %s' % (what, fd))
1494
1495     def handleproblem(what, fd, fp):
1496         if toignore(fd):
1497             return ignoreproblem(what, fd, fp)
1498         if todelete(fd):
1499             return removeproblem(what, fd, fp)
1500         logging.error('Found %s at %s' % (what, fd))
1501         return 1
1502
1503     # Iterate through all files in the source code
1504     for r, d, f in os.walk(build_dir, topdown=True):
1505
1506         # It's topdown, so checking the basename is enough
1507         for ignoredir in ('.hg', '.git', '.svn', '.bzr'):
1508             if ignoredir in d:
1509                 d.remove(ignoredir)
1510
1511         for curfile in f:
1512
1513             # Path (relative) to the file
1514             fp = os.path.join(r, curfile)
1515             fd = fp[len(build_dir) + 1:]
1516
1517             try:
1518                 mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1519             except UnicodeError:
1520                 warnproblem('malformed magic number', fd)
1521
1522             if mime == 'application/x-sharedlib':
1523                 count += handleproblem('shared library', fd, fp)
1524
1525             elif mime == 'application/x-archive':
1526                 count += handleproblem('static library', fd, fp)
1527
1528             elif mime == 'application/x-executable':
1529                 count += handleproblem('binary executable', fd, fp)
1530
1531             elif mime == 'application/x-java-applet':
1532                 count += handleproblem('Java compiled class', fd, fp)
1533
1534             elif mime in (
1535                     'application/jar',
1536                     'application/zip',
1537                     'application/java-archive',
1538                     'application/octet-stream',
1539                     'binary',
1540             ):
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         ]:
1807             if propfile in files:
1808                 path = os.path.join(root, propfile)
1809
1810                 with open(path, "r") as o:
1811                     lines = o.readlines()
1812
1813                 changed = False
1814
1815                 with open(path, "w") as o:
1816                     for line in lines:
1817                         if any(line.startswith(s) for s in ('key.store', 'key.alias')):
1818                             changed = True
1819                             continue
1820
1821                         o.write(line)
1822
1823                 if changed:
1824                     logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1825
1826
1827 def reset_env_path():
1828     global env, orig_path
1829     env['PATH'] = orig_path
1830
1831
1832 def add_to_env_path(path):
1833     global env
1834     paths = env['PATH'].split(os.pathsep)
1835     if path in paths:
1836         return
1837     paths += path
1838     env['PATH'] = os.pathsep.join(paths)
1839
1840
1841 def replace_config_vars(cmd):
1842     global env
1843     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1844     # env['ANDROID_NDK'] is set in build_local right before prepare_source
1845     cmd = cmd.replace('$$NDK$$', env['ANDROID_NDK'])
1846     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1847     return cmd
1848
1849
1850 def place_srclib(root_dir, number, libpath):
1851     if not number:
1852         return
1853     relpath = os.path.relpath(libpath, root_dir)
1854     proppath = os.path.join(root_dir, 'project.properties')
1855
1856     lines = []
1857     if os.path.isfile(proppath):
1858         with open(proppath, "r") as o:
1859             lines = o.readlines()
1860
1861     with open(proppath, "w") as o:
1862         placed = False
1863         for line in lines:
1864             if line.startswith('android.library.reference.%d=' % number):
1865                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1866                 placed = True
1867             else:
1868                 o.write(line)
1869         if not placed:
1870             o.write('android.library.reference.%d=%s\n' % (number, relpath))
1871
1872
1873 def compare_apks(apk1, apk2, tmp_dir):
1874     """Compare two apks
1875
1876     Returns None if the apk content is the same (apart from the signing key),
1877     otherwise a string describing what's different, or what went wrong when
1878     trying to do the comparison.
1879     """
1880
1881     thisdir = os.path.join(tmp_dir, 'this_apk')
1882     thatdir = os.path.join(tmp_dir, 'that_apk')
1883     for d in [thisdir, thatdir]:
1884         if os.path.exists(d):
1885             shutil.rmtree(d)
1886         os.mkdir(d)
1887
1888     if subprocess.call(['jar', 'xf',
1889                         os.path.abspath(apk1)],
1890                        cwd=thisdir) != 0:
1891         return("Failed to unpack " + apk1)
1892     if subprocess.call(['jar', 'xf',
1893                         os.path.abspath(apk2)],
1894                        cwd=thatdir) != 0:
1895         return("Failed to unpack " + apk2)
1896
1897     p = FDroidPopen(['diff', '-r', 'this_apk', 'that_apk'], cwd=tmp_dir,
1898                     output=False)
1899     lines = p.output.splitlines()
1900     if len(lines) != 1 or 'META-INF' not in lines[0]:
1901         return("Unexpected diff output - " + p.output)
1902
1903     # If we get here, it seems like they're the same!
1904     return None