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