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