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