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