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