chiark / gitweb /
Merge branch 'master' into 'master'
[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     srclib_path = os.path.join('srclibs', name + ".txt")
284     if not os.path.exists(srclib_path):
285         raise VCSException("Missing srclib " + name)
286     return metadata.parse_srclib(srclib_path)['Repo Type']
287
288
289 class vcs:
290     def __init__(self, remote, local):
291
292         # svn, git-svn and bzr may require auth
293         self.username = None
294         if self.repotype() in ('svn', 'git-svn', 'bzr'):
295             if '@' in remote:
296                 self.username, remote = remote.split('@')
297                 if ':' not in self.username:
298                     raise VCSException("Password required with username")
299                 self.username, self.password = self.username.split(':')
300
301         self.remote = remote
302         self.local = local
303         self.refreshed = False
304         self.srclib = None
305
306     def repotype(self):
307         return None
308
309     # Take the local repository to a clean version of the given revision, which
310     # is specificed in the VCS's native format. Beforehand, the repository can
311     # be dirty, or even non-existent. If the repository does already exist
312     # locally, it will be updated from the origin, but only once in the
313     # lifetime of the vcs object.
314     # None is acceptable for 'rev' if you know you are cloning a clean copy of
315     # the repo - otherwise it must specify a valid revision.
316     def gotorevision(self, rev):
317
318         # The .fdroidvcs-id file for a repo tells us what VCS type
319         # and remote that directory was created from, allowing us to drop it
320         # automatically if either of those things changes.
321         fdpath = os.path.join(self.local, '..',
322                 '.fdroidvcs-' + os.path.basename(self.local))
323         cdata = self.repotype() + ' ' + self.remote
324         writeback = True
325         deleterepo = False
326         if os.path.exists(self.local):
327             if os.path.exists(fdpath):
328                 with open(fdpath, 'r') as f:
329                     fsdata = f.read().strip()
330                 if fsdata == cdata:
331                     writeback = False
332                 else:
333                     deleterepo = True
334                     logging.info("Repository details changed - deleting")
335             else:
336                 deleterepo = True
337                 logging.info("Repository details missing - deleting")
338         if deleterepo:
339             shutil.rmtree(self.local)
340
341         self.gotorevisionx(rev)
342
343         # If necessary, write the .fdroidvcs file.
344         if writeback:
345             with open(fdpath, 'w') as f:
346                 f.write(cdata)
347
348     # Derived classes need to implement this. It's called once basic checking
349     # has been performend.
350     def gotorevisionx(self, rev):
351         raise VCSException("This VCS type doesn't define gotorevisionx")
352
353     # Initialise and update submodules
354     def initsubmodules(self):
355         raise VCSException('Submodules not supported for this vcs type')
356
357     # Get a list of all known tags
358     def gettags(self):
359         raise VCSException('gettags not supported for this vcs type')
360
361     # Get a list of latest number tags
362     def latesttags(self, number):
363         raise VCSException('latesttags not supported for this vcs type')
364
365     # Get current commit reference (hash, revision, etc)
366     def getref(self):
367         raise VCSException('getref not supported for this vcs type')
368
369     # Returns the srclib (name, path) used in setting up the current
370     # revision, or None.
371     def getsrclib(self):
372         return self.srclib
373
374
375 class vcs_git(vcs):
376
377     def repotype(self):
378         return 'git'
379
380     # If the local directory exists, but is somehow not a git repository, git
381     # will traverse up the directory tree until it finds one that is (i.e.
382     # fdroidserver) and then we'll proceed to destroy it! This is called as
383     # a safety check.
384     def checkrepo(self):
385         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
386         result = p.stdout.rstrip()
387         if not result.endswith(self.local):
388             raise VCSException('Repository mismatch')
389
390     def gotorevisionx(self, rev):
391         if not os.path.exists(self.local):
392             # Brand new checkout
393             p = FDroidPopen(['git', 'clone', self.remote, self.local])
394             if p.returncode != 0:
395                 raise VCSException("Git clone failed")
396             self.checkrepo()
397         else:
398             self.checkrepo()
399             # Discard any working tree changes
400             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
401             if p.returncode != 0:
402                 raise VCSException("Git reset failed")
403             # Remove untracked files now, in case they're tracked in the target
404             # revision (it happens!)
405             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
406             if p.returncode != 0:
407                 raise VCSException("Git clean failed")
408             if not self.refreshed:
409                 # Get latest commits and tags from remote
410                 p = FDroidPopen(['git', 'fetch', 'origin'], cwd=self.local)
411                 if p.returncode != 0:
412                     raise VCSException("Git fetch failed")
413                 p = SilentPopen(['git', 'fetch', '--prune', '--tags', 'origin'], cwd=self.local)
414                 if p.returncode != 0:
415                     raise VCSException("Git fetch failed")
416                 self.refreshed = True
417         # Check out the appropriate revision
418         rev = str(rev if rev else 'origin/master')
419         p = SilentPopen(['git', 'checkout', '-f', rev], cwd=self.local)
420         if p.returncode != 0:
421             raise VCSException("Git checkout failed")
422         # Get rid of any uncontrolled files left behind
423         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
424         if p.returncode != 0:
425             raise VCSException("Git clean failed")
426
427     def initsubmodules(self):
428         self.checkrepo()
429         submfile = os.path.join(self.local, '.gitmodules')
430         if not os.path.isfile(submfile):
431             raise VCSException("No git submodules available")
432
433         # fix submodules not accessible without an account and public key auth
434         with open(submfile, 'r') as f:
435             lines = f.readlines()
436         with open(submfile, 'w') as f:
437             for line in lines:
438                 if 'git@github.com' in line:
439                     line = line.replace('git@github.com:', 'https://github.com/')
440                 f.write(line)
441
442         for cmd in [
443                 ['git', 'reset', '--hard'],
444                 ['git', 'clean', '-dffx'],
445                 ]:
446             p = SilentPopen(['git', 'submodule', 'foreach', '--recursive'] + cmd, cwd=self.local)
447             if p.returncode != 0:
448                 raise VCSException("Git submodule reset failed")
449         p = FDroidPopen(['git', 'submodule', 'sync'], cwd=self.local)
450         if p.returncode != 0:
451             raise VCSException("Git submodule sync failed")
452         p = FDroidPopen(['git', 'submodule', 'update', '--init', '--force', '--recursive'], cwd=self.local)
453         if p.returncode != 0:
454             raise VCSException("Git submodule update failed")
455
456     def gettags(self):
457         self.checkrepo()
458         p = SilentPopen(['git', 'tag'], cwd=self.local)
459         return p.stdout.splitlines()
460
461     def latesttags(self, alltags, number):
462         self.checkrepo()
463         p = SilentPopen(['echo "'+'\n'.join(alltags)+'" | \
464                 xargs -I@ git log --format=format:"%at @%n" -1 @ | \
465                 sort -n | awk \'{print $2}\''],
466                 cwd=self.local, shell=True)
467         return p.stdout.splitlines()[-number:]
468
469
470 class vcs_gitsvn(vcs):
471
472     def repotype(self):
473         return 'git-svn'
474
475     # Damn git-svn tries to use a graphical password prompt, so we have to
476     # trick it into taking the password from stdin
477     def userargs(self):
478         if self.username is None:
479             return ('', '')
480         return ('echo "%s" | DISPLAY="" ' % self.password, ' --username "%s"' % self.username)
481
482     # If the local directory exists, but is somehow not a git repository, git
483     # will traverse up the directory tree until it finds one that is (i.e.
484     # fdroidserver) and then we'll proceed to destory it! This is called as
485     # a safety check.
486     def checkrepo(self):
487         p = SilentPopen(['git', 'rev-parse', '--show-toplevel'], cwd=self.local)
488         result = p.stdout.rstrip()
489         if not result.endswith(self.local):
490             raise VCSException('Repository mismatch')
491
492     def gotorevisionx(self, rev):
493         if not os.path.exists(self.local):
494             # Brand new checkout
495             gitsvn_cmd = '%sgit svn clone%s' % self.userargs()
496             if ';' in self.remote:
497                 remote_split = self.remote.split(';')
498                 for i in remote_split[1:]:
499                     if i.startswith('trunk='):
500                         gitsvn_cmd += ' -T %s' % i[6:]
501                     elif i.startswith('tags='):
502                         gitsvn_cmd += ' -t %s' % i[5:]
503                     elif i.startswith('branches='):
504                         gitsvn_cmd += ' -b %s' % i[9:]
505                 p = SilentPopen([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)], shell=True)
506                 if p.returncode != 0:
507                     raise VCSException("Git clone failed")
508             else:
509                 p = SilentPopen([gitsvn_cmd + " %s %s" % (self.remote, self.local)], shell=True)
510                 if p.returncode != 0:
511                     raise VCSException("Git clone failed")
512             self.checkrepo()
513         else:
514             self.checkrepo()
515             # Discard any working tree changes
516             p = SilentPopen(['git', 'reset', '--hard'], cwd=self.local)
517             if p.returncode != 0:
518                 raise VCSException("Git reset failed")
519             # Remove untracked files now, in case they're tracked in the target
520             # revision (it happens!)
521             p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
522             if p.returncode != 0:
523                 raise VCSException("Git clean failed")
524             if not self.refreshed:
525                 # Get new commits, branches and tags from repo
526                 p = SilentPopen(['%sgit svn fetch %s' % self.userargs()], cwd=self.local, shell=True)
527                 if p.returncode != 0:
528                     raise VCSException("Git svn fetch failed")
529                 p = SilentPopen(['%sgit svn rebase %s' % self.userargs()], cwd=self.local, shell=True)
530                 if p.returncode != 0:
531                     raise VCSException("Git svn rebase failed")
532                 self.refreshed = True
533
534         rev = str(rev if rev else 'master')
535         if rev:
536             nospaces_rev = rev.replace(' ', '%20')
537             # Try finding a svn tag
538             p = SilentPopen(['git', 'checkout', 'tags/' + nospaces_rev], cwd=self.local)
539             if p.returncode != 0:
540                 # No tag found, normal svn rev translation
541                 # Translate svn rev into git format
542                 rev_split = rev.split('/')
543                 if len(rev_split) > 1:
544                     treeish = rev_split[0]
545                     svn_rev = rev_split[1]
546
547                 else:
548                     # if no branch is specified, then assume trunk (ie. 'master'
549                     # branch):
550                     treeish = 'master'
551                     svn_rev = rev
552
553                 p = SilentPopen(['git', 'svn', 'find-rev', 'r' + svn_rev, treeish], cwd=self.local)
554                 git_rev = p.stdout.rstrip()
555
556                 if p.returncode != 0 or not git_rev:
557                     # Try a plain git checkout as a last resort
558                     p = SilentPopen(['git', 'checkout', rev], cwd=self.local)
559                     if p.returncode != 0:
560                         raise VCSException("No git treeish found and direct git checkout failed")
561                 else:
562                     # Check out the git rev equivalent to the svn rev
563                     p = SilentPopen(['git', 'checkout', git_rev], cwd=self.local)
564                     if p.returncode != 0:
565                         raise VCSException("Git svn checkout failed")
566
567         # Get rid of any uncontrolled files left behind
568         p = SilentPopen(['git', 'clean', '-dffx'], cwd=self.local)
569         if p.returncode != 0:
570             raise VCSException("Git clean failed")
571
572     def gettags(self):
573         self.checkrepo()
574         return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
575
576     def getref(self):
577         self.checkrepo()
578         p = SilentPopen(['git', 'svn', 'find-rev', 'HEAD'], cwd=self.local)
579         if p.returncode != 0:
580             return None
581         return p.stdout.strip()
582
583
584 class vcs_svn(vcs):
585
586     def repotype(self):
587         return 'svn'
588
589     def userargs(self):
590         if self.username is None:
591             return ['--non-interactive']
592         return ['--username', self.username,
593                 '--password', self.password,
594                 '--non-interactive']
595
596     def gotorevisionx(self, rev):
597         if not os.path.exists(self.local):
598             p = SilentPopen(['svn', 'checkout', self.remote, self.local] + self.userargs())
599             if p.returncode != 0:
600                 raise VCSException("Svn checkout failed")
601         else:
602             for svncommand in (
603                     'svn revert -R .',
604                     r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
605                 p = SilentPopen([svncommand], cwd=self.local, shell=True)
606                 if p.returncode != 0:
607                     raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local))
608             if not self.refreshed:
609                 p = SilentPopen(['svn', 'update'] + self.userargs(), cwd=self.local)
610                 if p.returncode != 0:
611                     raise VCSException("Svn update failed")
612                 self.refreshed = True
613
614         revargs = list(['-r', rev] if rev else [])
615         p = SilentPopen(['svn', 'update', '--force'] + revargs + self.userargs(), cwd=self.local)
616         if p.returncode != 0:
617             raise VCSException("Svn update failed")
618
619     def getref(self):
620         p = SilentPopen(['svn', 'info'], cwd=self.local)
621         for line in p.stdout.splitlines():
622             if line and line.startswith('Last Changed Rev: '):
623                 return line[18:]
624         return None
625
626
627 class vcs_hg(vcs):
628
629     def repotype(self):
630         return 'hg'
631
632     def gotorevisionx(self, rev):
633         if not os.path.exists(self.local):
634             p = SilentPopen(['hg', 'clone', self.remote, self.local])
635             if p.returncode != 0:
636                 raise VCSException("Hg clone failed")
637         else:
638             p = SilentPopen(['hg status -uS | xargs rm -rf'], cwd=self.local, shell=True)
639             if p.returncode != 0:
640                 raise VCSException("Hg clean failed")
641             if not self.refreshed:
642                 p = SilentPopen(['hg', 'pull'], cwd=self.local)
643                 if p.returncode != 0:
644                     raise VCSException("Hg pull failed")
645                 self.refreshed = True
646
647         rev = str(rev if rev else 'default')
648         if not rev:
649             return
650         p = SilentPopen(['hg', 'update', '-C', rev], cwd=self.local)
651         if p.returncode != 0:
652             raise VCSException("Hg checkout failed")
653         p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
654         # Also delete untracked files, we have to enable purge extension for that:
655         if "'purge' is provided by the following extension" in p.stdout:
656             with open(self.local+"/.hg/hgrc", "a") as myfile:
657                 myfile.write("\n[extensions]\nhgext.purge=\n")
658             p = SilentPopen(['hg', 'purge', '--all'], cwd=self.local)
659             if p.returncode != 0:
660                 raise VCSException("HG purge failed")
661         elif p.returncode != 0:
662             raise VCSException("HG purge failed")
663
664     def gettags(self):
665         p = SilentPopen(['hg', 'tags', '-q'], cwd=self.local)
666         return p.stdout.splitlines()[1:]
667
668
669 class vcs_bzr(vcs):
670
671     def repotype(self):
672         return 'bzr'
673
674     def gotorevisionx(self, rev):
675         if not os.path.exists(self.local):
676             p = SilentPopen(['bzr', 'branch', self.remote, self.local])
677             if p.returncode != 0:
678                 raise VCSException("Bzr branch failed")
679         else:
680             p = SilentPopen(['bzr', 'clean-tree', '--force', '--unknown', '--ignored'], cwd=self.local)
681             if p.returncode != 0:
682                 raise VCSException("Bzr revert failed")
683             if not self.refreshed:
684                 p = SilentPopen(['bzr', 'pull'], cwd=self.local)
685                 if p.returncode != 0:
686                     raise VCSException("Bzr update failed")
687                 self.refreshed = True
688
689         revargs = list(['-r', rev] if rev else [])
690         p = SilentPopen(['bzr', 'revert'] + revargs, cwd=self.local)
691         if p.returncode != 0:
692             raise VCSException("Bzr revert failed")
693
694     def gettags(self):
695         p = SilentPopen(['bzr', 'tags'], cwd=self.local)
696         return [tag.split('   ')[0].strip() for tag in
697                 p.stdout.splitlines()]
698
699
700 def retrieve_string(app_dir, string, xmlfiles=None):
701
702     res_dirs = [
703             os.path.join(app_dir, 'res'),
704             os.path.join(app_dir, 'src/main'),
705             ]
706
707     if xmlfiles is None:
708         xmlfiles = []
709         for res_dir in res_dirs:
710             for r, d, f in os.walk(res_dir):
711                 if r.endswith('/values'):
712                     xmlfiles += [os.path.join(r, x) for x in f if x.endswith('.xml')]
713
714     string_search = None
715     if string.startswith('@string/'):
716         string_search = re.compile(r'.*"'+string[8:]+'".*?>([^<]+?)<.*').search
717     elif string.startswith('&') and string.endswith(';'):
718         string_search = re.compile(r'.*<!ENTITY.*'+string[1:-1]+'.*?"([^"]+?)".*>').search
719
720     if string_search is not None:
721         for xmlfile in xmlfiles:
722             for line in file(xmlfile):
723                 matches = string_search(line)
724                 if matches:
725                     return retrieve_string(app_dir, matches.group(1), xmlfiles)
726         return None
727
728     return string.replace("\\'", "'")
729
730
731 # Return list of existing files that will be used to find the highest vercode
732 def manifest_paths(app_dir, flavour):
733
734     possible_manifests = [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):
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     max_version = None
839     max_vercode = None
840     max_package = None
841
842     for path in paths:
843
844         gradle = has_extension(path, 'gradle')
845         version = None
846         vercode = None
847         # Remember package name, may be defined separately from version+vercode
848         package = max_package
849
850         for line in file(path):
851             if not package:
852                 if gradle:
853                     matches = psearch_g(line)
854                 else:
855                     matches = psearch(line)
856                 if matches:
857                     package = matches.group(1)
858             if not version:
859                 if gradle:
860                     matches = vnsearch_g(line)
861                 else:
862                     matches = vnsearch(line)
863                 if matches:
864                     version = matches.group(2 if gradle else 1)
865             if not vercode:
866                 if gradle:
867                     matches = vcsearch_g(line)
868                 else:
869                     matches = vcsearch(line)
870                 if matches:
871                     vercode = matches.group(1)
872
873         # Better some package name than nothing
874         if max_package is None:
875             max_package = package
876
877         if max_vercode is None or (vercode is not None and vercode > max_vercode):
878             max_version = version
879             max_vercode = vercode
880             max_package = package
881
882     if max_version is None:
883         max_version = "Unknown"
884
885     return (max_version, max_vercode, max_package)
886
887
888 class BuildException(Exception):
889     def __init__(self, value, detail=None):
890         self.value = value
891         self.detail = detail
892
893     def get_wikitext(self):
894         ret = repr(self.value) + "\n"
895         if self.detail:
896             ret += "=detail=\n"
897             ret += "<pre>\n"
898             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
899             ret += str(txt)
900             ret += "</pre>\n"
901         return ret
902
903     def __str__(self):
904         ret = repr(self.value)
905         if self.detail:
906             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
907         return ret
908
909
910 class VCSException(Exception):
911     def __init__(self, value):
912         self.value = value
913
914     def __str__(self):
915         return repr(self.value)
916
917
918 # Get the specified source library.
919 # Returns the path to it. Normally this is the path to be used when referencing
920 # it, which may be a subdirectory of the actual project. If you want the base
921 # directory of the project, pass 'basepath=True'.
922 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
923         basepath=False, raw=False, prepare=True, preponly=False):
924
925     number = None
926     subdir = None
927     if raw:
928         name = spec
929         ref = None
930     else:
931         name, ref = spec.split('@')
932         if ':' in name:
933             number, name = name.split(':', 1)
934         if '/' in name:
935             name, subdir = name.split('/', 1)
936
937     srclib_path = os.path.join('srclibs', name + ".txt")
938
939     if not os.path.exists(srclib_path):
940         raise BuildException('srclib ' + name + ' not found.')
941
942     srclib = metadata.parse_srclib(srclib_path)
943
944     sdir = os.path.join(srclib_dir, name)
945
946     if not preponly:
947         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
948         vcs.srclib = (name, number, sdir)
949         if ref:
950             vcs.gotorevision(ref)
951
952         if raw:
953             return vcs
954
955     libdir = None
956     if subdir:
957         libdir = os.path.join(sdir, subdir)
958     elif srclib["Subdir"]:
959         for subdir in srclib["Subdir"]:
960             libdir_candidate = os.path.join(sdir, subdir)
961             if os.path.exists(libdir_candidate):
962                 libdir = libdir_candidate
963                 break
964
965     if libdir is None:
966         libdir = sdir
967
968     if srclib["Srclibs"]:
969         n = 1
970         for lib in srclib["Srclibs"].replace(';', ',').split(','):
971             s_tuple = None
972             for t in srclibpaths:
973                 if t[0] == lib:
974                     s_tuple = t
975                     break
976             if s_tuple is None:
977                 raise BuildException('Missing recursive srclib %s for %s' % (
978                     lib, name))
979             place_srclib(libdir, n, s_tuple[2])
980             n += 1
981
982     remove_signing_keys(sdir)
983     remove_debuggable_flags(sdir)
984
985     if prepare:
986
987         if srclib["Prepare"]:
988             cmd = replace_config_vars(srclib["Prepare"])
989
990             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
991             if p.returncode != 0:
992                 raise BuildException("Error running prepare command for srclib %s"
993                         % name, p.stdout)
994
995     if basepath:
996         libdir = sdir
997
998     return (name, number, libdir)
999
1000
1001 # Prepare the source code for a particular build
1002 #  'vcs'         - the appropriate vcs object for the application
1003 #  'app'         - the application details from the metadata
1004 #  'build'       - the build details from the metadata
1005 #  'build_dir'   - the path to the build directory, usually
1006 #                   'build/app.id'
1007 #  'srclib_dir'  - the path to the source libraries directory, usually
1008 #                   'build/srclib'
1009 #  'extlib_dir'  - the path to the external libraries directory, usually
1010 #                   'build/extlib'
1011 # Returns the (root, srclibpaths) where:
1012 #   'root' is the root directory, which may be the same as 'build_dir' or may
1013 #          be a subdirectory of it.
1014 #   'srclibpaths' is information on the srclibs being used
1015 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1016
1017     # Optionally, the actual app source can be in a subdirectory
1018     if 'subdir' in build:
1019         root_dir = os.path.join(build_dir, build['subdir'])
1020     else:
1021         root_dir = build_dir
1022
1023     # Get a working copy of the right revision
1024     logging.info("Getting source for revision " + build['commit'])
1025     vcs.gotorevision(build['commit'])
1026
1027     # Initialise submodules if requred
1028     if build['submodules']:
1029         logging.info("Initialising submodules")
1030         vcs.initsubmodules()
1031
1032     # Check that a subdir (if we're using one) exists. This has to happen
1033     # after the checkout, since it might not exist elsewhere
1034     if not os.path.exists(root_dir):
1035         raise BuildException('Missing subdir ' + root_dir)
1036
1037     # Run an init command if one is required
1038     if 'init' in build:
1039         cmd = replace_config_vars(build['init'])
1040         logging.info("Running 'init' commands in %s" % root_dir)
1041
1042         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1043         if p.returncode != 0:
1044             raise BuildException("Error running init command for %s:%s" %
1045                     (app['id'], build['version']), p.stdout)
1046
1047     # Apply patches if any
1048     if 'patch' in build:
1049         for patch in build['patch']:
1050             patch = patch.strip()
1051             logging.info("Applying " + patch)
1052             patch_path = os.path.join('metadata', app['id'], patch)
1053             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1054             if p.returncode != 0:
1055                 raise BuildException("Failed to apply patch %s" % patch_path)
1056
1057     # Get required source libraries
1058     srclibpaths = []
1059     if 'srclibs' in build:
1060         logging.info("Collecting source libraries")
1061         for lib in build['srclibs']:
1062             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1063                 preponly=onserver))
1064
1065     for name, number, libpath in srclibpaths:
1066         place_srclib(root_dir, int(number) if number else None, libpath)
1067
1068     basesrclib = vcs.getsrclib()
1069     # If one was used for the main source, add that too.
1070     if basesrclib:
1071         srclibpaths.append(basesrclib)
1072
1073     # Update the local.properties file
1074     localprops = [os.path.join(build_dir, 'local.properties')]
1075     if 'subdir' in build:
1076         localprops += [os.path.join(root_dir, 'local.properties')]
1077     for path in localprops:
1078         if not os.path.isfile(path):
1079             continue
1080         logging.info("Updating properties file at %s" % path)
1081         f = open(path, 'r')
1082         props = f.read()
1083         f.close()
1084         props += '\n'
1085         # Fix old-fashioned 'sdk-location' by copying
1086         # from sdk.dir, if necessary
1087         if build['oldsdkloc']:
1088             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1089                 re.S | re.M).group(1)
1090             props += "sdk-location=%s\n" % sdkloc
1091         else:
1092             props += "sdk.dir=%s\n" % config['sdk_path']
1093             props += "sdk-location=%s\n" % config['sdk_path']
1094         if 'ndk_path' in config:
1095             # Add ndk location
1096             props += "ndk.dir=%s\n" % config['ndk_path']
1097             props += "ndk-location=%s\n" % config['ndk_path']
1098         # Add java.encoding if necessary
1099         if 'encoding' in build:
1100             props += "java.encoding=%s\n" % build['encoding']
1101         f = open(path, 'w')
1102         f.write(props)
1103         f.close()
1104
1105     flavour = None
1106     if build['type'] == 'gradle':
1107         flavour = build['gradle'].split('@')[0]
1108         if flavour in ['main', 'yes', '']:
1109             flavour = None
1110
1111         if 'target' in build:
1112             n = build["target"].split('-')[1]
1113             FDroidPopen(['sed', '-i',
1114                 's@compileSdkVersion *[0-9]*@compileSdkVersion '+n+'@g',
1115                 'build.gradle'], cwd=root_dir)
1116             if '@' in build['gradle']:
1117                 gradle_dir = os.path.join(root_dir, build['gradle'].split('@', 1)[1])
1118                 gradle_dir = os.path.normpath(gradle_dir)
1119                 FDroidPopen(['sed', '-i',
1120                     's@compileSdkVersion *[0-9]*@compileSdkVersion '+n+'@g',
1121                     'build.gradle'], cwd=gradle_dir)
1122
1123     # Remove forced debuggable flags
1124     remove_debuggable_flags(root_dir)
1125
1126     # Insert version code and number into the manifest if necessary
1127     if build['forceversion']:
1128         logging.info("Changing the version name")
1129         for path in manifest_paths(root_dir, flavour):
1130             if not os.path.isfile(path):
1131                 continue
1132             if has_extension(path, 'xml'):
1133                 p = SilentPopen(['sed', '-i',
1134                     's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1135                     path])
1136                 if p.returncode != 0:
1137                     raise BuildException("Failed to amend manifest")
1138             elif has_extension(path, 'gradle'):
1139                 p = SilentPopen(['sed', '-i',
1140                     's/versionName *=* *"[^"]*"/versionName = "' + build['version'] + '"/g',
1141                     path])
1142                 if p.returncode != 0:
1143                     raise BuildException("Failed to amend build.gradle")
1144     if build['forcevercode']:
1145         logging.info("Changing the version code")
1146         for path in manifest_paths(root_dir, flavour):
1147             if not os.path.isfile(path):
1148                 continue
1149             if has_extension(path, 'xml'):
1150                 p = SilentPopen(['sed', '-i',
1151                     's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1152                     path])
1153                 if p.returncode != 0:
1154                     raise BuildException("Failed to amend manifest")
1155             elif has_extension(path, 'gradle'):
1156                 p = SilentPopen(['sed', '-i',
1157                     's/versionCode *=* *[0-9]*/versionCode = ' + build['vercode'] + '/g',
1158                     path])
1159                 if p.returncode != 0:
1160                     raise BuildException("Failed to amend build.gradle")
1161
1162     # Delete unwanted files
1163     if 'rm' in build:
1164         for part in getpaths(build_dir, build, 'rm'):
1165             dest = os.path.join(build_dir, part)
1166             logging.info("Removing {0}".format(part))
1167             if os.path.lexists(dest):
1168                 if os.path.islink(dest):
1169                     SilentPopen(['unlink ' + dest], shell=True)
1170                 else:
1171                     SilentPopen(['rm -rf ' + dest], shell=True)
1172             else:
1173                 logging.info("...but it didn't exist")
1174
1175     remove_signing_keys(build_dir)
1176
1177     # Add required external libraries
1178     if 'extlibs' in build:
1179         logging.info("Collecting prebuilt libraries")
1180         libsdir = os.path.join(root_dir, 'libs')
1181         if not os.path.exists(libsdir):
1182             os.mkdir(libsdir)
1183         for lib in build['extlibs']:
1184             lib = lib.strip()
1185             logging.info("...installing extlib {0}".format(lib))
1186             libf = os.path.basename(lib)
1187             libsrc = os.path.join(extlib_dir, lib)
1188             if not os.path.exists(libsrc):
1189                 raise BuildException("Missing extlib file {0}".format(libsrc))
1190             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1191
1192     # Run a pre-build command if one is required
1193     if 'prebuild' in build:
1194         cmd = replace_config_vars(build['prebuild'])
1195
1196         # Substitute source library paths into prebuild commands
1197         for name, number, libpath in srclibpaths:
1198             libpath = os.path.relpath(libpath, root_dir)
1199             cmd = cmd.replace('$$' + name + '$$', libpath)
1200
1201         logging.info("Running 'prebuild' commands in %s" % root_dir)
1202
1203         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1204         if p.returncode != 0:
1205             raise BuildException("Error running prebuild command for %s:%s" %
1206                     (app['id'], build['version']), p.stdout)
1207
1208     updatemode = build.get('update', ['auto'])
1209     # Generate (or update) the ant build file, build.xml...
1210     if updatemode != ['no'] and build['type'] == 'ant':
1211         parms = [os.path.join(config['sdk_path'], 'tools', 'android'), 'update']
1212         lparms = parms + ['lib-project']
1213         parms = parms + ['project']
1214
1215         if 'target' in build and build['target']:
1216             parms += ['-t', build['target']]
1217             lparms += ['-t', build['target']]
1218         if updatemode == ['auto']:
1219             update_dirs = ant_subprojects(root_dir) + ['.']
1220         else:
1221             update_dirs = updatemode
1222
1223         for d in update_dirs:
1224             subdir = os.path.join(root_dir, d)
1225             if d == '.':
1226                 print("Updating main project")
1227                 cmd = parms + ['-p', d]
1228             else:
1229                 print("Updating subproject %s" % d)
1230                 cmd = lparms + ['-p', d]
1231             p = FDroidPopen(cmd, cwd=root_dir)
1232             # Check to see whether an error was returned without a proper exit
1233             # code (this is the case for the 'no target set or target invalid'
1234             # error)
1235             if p.returncode != 0 or p.stdout.startswith("Error: "):
1236                 raise BuildException("Failed to update project at %s" % d, p.stdout)
1237             # Clean update dirs via ant
1238             if d != '.':
1239                 logging.info("Cleaning subproject %s" % d)
1240                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1241
1242     return (root_dir, srclibpaths)
1243
1244
1245 # Split and extend via globbing the paths from a field
1246 def getpaths(build_dir, build, field):
1247     paths = []
1248     if field not in build:
1249         return paths
1250     for p in build[field]:
1251         p = p.strip()
1252         full_path = os.path.join(build_dir, p)
1253         full_path = os.path.normpath(full_path)
1254         paths += [r[len(build_dir)+1:] for r in glob.glob(full_path)]
1255     return paths
1256
1257
1258 # Scan the source code in the given directory (and all subdirectories)
1259 # and return the number of fatal problems encountered
1260 def scan_source(build_dir, root_dir, thisbuild):
1261
1262     count = 0
1263
1264     # Common known non-free blobs (always lower case):
1265     usual_suspects = [
1266             re.compile(r'flurryagent', re.IGNORECASE),
1267             re.compile(r'paypal.*mpl', re.IGNORECASE),
1268             re.compile(r'libgoogleanalytics', re.IGNORECASE),
1269             re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1270             re.compile(r'googleadview', re.IGNORECASE),
1271             re.compile(r'googleadmobadssdk', re.IGNORECASE),
1272             re.compile(r'google.*play.*services', re.IGNORECASE),
1273             re.compile(r'crittercism', re.IGNORECASE),
1274             re.compile(r'heyzap', re.IGNORECASE),
1275             re.compile(r'jpct.*ae', re.IGNORECASE),
1276             re.compile(r'youtubeandroidplayerapi', re.IGNORECASE),
1277             re.compile(r'bugsense', re.IGNORECASE),
1278             re.compile(r'crashlytics', re.IGNORECASE),
1279             re.compile(r'ouya.*sdk', re.IGNORECASE),
1280             ]
1281
1282     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1283     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1284
1285     try:
1286         ms = magic.open(magic.MIME_TYPE)
1287         ms.load()
1288     except AttributeError:
1289         ms = None
1290
1291     def toignore(fd):
1292         for i in scanignore:
1293             if fd.startswith(i):
1294                 return True
1295         return False
1296
1297     def todelete(fd):
1298         for i in scandelete:
1299             if fd.startswith(i):
1300                 return True
1301         return False
1302
1303     def removeproblem(what, fd, fp):
1304         logging.info('Removing %s at %s' % (what, fd))
1305         os.remove(fp)
1306
1307     def warnproblem(what, fd):
1308         logging.warn('Found %s at %s' % (what, fd))
1309
1310     def handleproblem(what, fd, fp):
1311         if todelete(fd):
1312             removeproblem(what, fd, fp)
1313         else:
1314             logging.error('Found %s at %s' % (what, fd))
1315             return True
1316         return False
1317
1318     def insidedir(path, dirname):
1319         return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1320
1321     # Iterate through all files in the source code
1322     for r, d, f in os.walk(build_dir):
1323
1324         if any(insidedir(r, d) for d in ('.hg', '.git', '.svn', '.bzr')):
1325             continue
1326
1327         for curfile in f:
1328
1329             # Path (relative) to the file
1330             fp = os.path.join(r, curfile)
1331             fd = fp[len(build_dir)+1:]
1332
1333             # Check if this file has been explicitly excluded from scanning
1334             if toignore(fd):
1335                 continue
1336
1337             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1338
1339             if mime == 'application/x-sharedlib':
1340                 count += handleproblem('shared library', fd, fp)
1341
1342             elif mime == 'application/x-archive':
1343                 count += handleproblem('static library', fd, fp)
1344
1345             elif mime == 'application/x-executable':
1346                 count += handleproblem('binary executable', fd, fp)
1347
1348             elif mime == 'application/x-java-applet':
1349                 count += handleproblem('Java compiled class', fd, fp)
1350
1351             elif mime in (
1352                     'application/jar',
1353                     'application/zip',
1354                     'application/java-archive',
1355                     'application/octet-stream',
1356                     'binary',
1357                     ):
1358
1359                 if has_extension(fp, 'apk'):
1360                     removeproblem('APK file', fd, fp)
1361
1362                 elif has_extension(fp, 'jar'):
1363
1364                     if any(suspect.match(curfile) for suspect in usual_suspects):
1365                         count += handleproblem('usual supect', fd, fp)
1366                     else:
1367                         warnproblem('JAR file', fd)
1368
1369                 elif has_extension(fp, 'zip'):
1370                     warnproblem('ZIP file', fd)
1371
1372                 else:
1373                     warnproblem('unknown compressed or binary file', fd)
1374
1375             elif has_extension(fp, 'java'):
1376                 for line in file(fp):
1377                     if 'DexClassLoader' in line:
1378                         count += handleproblem('DexClassLoader', fd, fp)
1379                         break
1380     if ms is not None:
1381         ms.close()
1382
1383     # Presence of a jni directory without buildjni=yes might
1384     # indicate a problem (if it's not a problem, explicitly use
1385     # buildjni=no to bypass this check)
1386     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1387             thisbuild.get('buildjni') is None):
1388         logging.warn('Found jni directory, but buildjni is not enabled')
1389         count += 1
1390
1391     return count
1392
1393
1394 class KnownApks:
1395
1396     def __init__(self):
1397         self.path = os.path.join('stats', 'known_apks.txt')
1398         self.apks = {}
1399         if os.path.exists(self.path):
1400             for line in file(self.path):
1401                 t = line.rstrip().split(' ')
1402                 if len(t) == 2:
1403                     self.apks[t[0]] = (t[1], None)
1404                 else:
1405                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1406         self.changed = False
1407
1408     def writeifchanged(self):
1409         if self.changed:
1410             if not os.path.exists('stats'):
1411                 os.mkdir('stats')
1412             f = open(self.path, 'w')
1413             lst = []
1414             for apk, app in self.apks.iteritems():
1415                 appid, added = app
1416                 line = apk + ' ' + appid
1417                 if added:
1418                     line += ' ' + time.strftime('%Y-%m-%d', added)
1419                 lst.append(line)
1420             for line in sorted(lst):
1421                 f.write(line + '\n')
1422             f.close()
1423
1424     # Record an apk (if it's new, otherwise does nothing)
1425     # Returns the date it was added.
1426     def recordapk(self, apk, app):
1427         if not apk in self.apks:
1428             self.apks[apk] = (app, time.gmtime(time.time()))
1429             self.changed = True
1430         _, added = self.apks[apk]
1431         return added
1432
1433     # Look up information - given the 'apkname', returns (app id, date added/None).
1434     # Or returns None for an unknown apk.
1435     def getapp(self, apkname):
1436         if apkname in self.apks:
1437             return self.apks[apkname]
1438         return None
1439
1440     # Get the most recent 'num' apps added to the repo, as a list of package ids
1441     # with the most recent first.
1442     def getlatest(self, num):
1443         apps = {}
1444         for apk, app in self.apks.iteritems():
1445             appid, added = app
1446             if added:
1447                 if appid in apps:
1448                     if apps[appid] > added:
1449                         apps[appid] = added
1450                 else:
1451                     apps[appid] = added
1452         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1453         lst = [app for app, _ in sortedapps]
1454         lst.reverse()
1455         return lst
1456
1457
1458 def isApkDebuggable(apkfile, config):
1459     """Returns True if the given apk file is debuggable
1460
1461     :param apkfile: full path to the apk to check"""
1462
1463     p = SilentPopen([os.path.join(config['sdk_path'],
1464         'build-tools', config['build_tools'], 'aapt'),
1465         'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1466     if p.returncode != 0:
1467         logging.critical("Failed to get apk manifest information")
1468         sys.exit(1)
1469     for line in p.stdout.splitlines():
1470         if 'android:debuggable' in line and not line.endswith('0x0'):
1471             return True
1472     return False
1473
1474
1475 class AsynchronousFileReader(threading.Thread):
1476     '''
1477     Helper class to implement asynchronous reading of a file
1478     in a separate thread. Pushes read lines on a queue to
1479     be consumed in another thread.
1480     '''
1481
1482     def __init__(self, fd, queue):
1483         assert isinstance(queue, Queue.Queue)
1484         assert callable(fd.readline)
1485         threading.Thread.__init__(self)
1486         self._fd = fd
1487         self._queue = queue
1488
1489     def run(self):
1490         '''The body of the tread: read lines and put them on the queue.'''
1491         for line in iter(self._fd.readline, ''):
1492             self._queue.put(line)
1493
1494     def eof(self):
1495         '''Check whether there is no more content to expect.'''
1496         return not self.is_alive() and self._queue.empty()
1497
1498
1499 class PopenResult:
1500     returncode = None
1501     stdout = ''
1502
1503
1504 def SilentPopen(commands, cwd=None, shell=False):
1505     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1506
1507
1508 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1509     """
1510     Run a command and capture the possibly huge output.
1511
1512     :param commands: command and argument list like in subprocess.Popen
1513     :param cwd: optionally specifies a working directory
1514     :returns: A PopenResult.
1515     """
1516
1517     if output:
1518         if cwd:
1519             cwd = os.path.normpath(cwd)
1520             logging.info("Directory: %s" % cwd)
1521         logging.info("> %s" % ' '.join(commands))
1522
1523     result = PopenResult()
1524     p = subprocess.Popen(commands, cwd=cwd, shell=shell,
1525             stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1526
1527     stdout_queue = Queue.Queue()
1528     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1529     stdout_reader.start()
1530
1531     # Check the queue for output (until there is no more to get)
1532     while not stdout_reader.eof():
1533         while not stdout_queue.empty():
1534             line = stdout_queue.get()
1535             if output and options.verbose:
1536                 # Output directly to console
1537                 sys.stdout.write(line)
1538                 sys.stdout.flush()
1539             result.stdout += line
1540
1541         time.sleep(0.1)
1542
1543     p.communicate()
1544     result.returncode = p.returncode
1545     return result
1546
1547
1548 def remove_signing_keys(build_dir):
1549     comment = re.compile(r'[ ]*//')
1550     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1551     line_matches = [
1552             re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1553             re.compile(r'.*android\.signingConfigs\..*'),
1554             re.compile(r'.*variant\.outputFile = .*'),
1555             re.compile(r'.*\.readLine\(.*'),
1556     ]
1557     for root, dirs, files in os.walk(build_dir):
1558         if 'build.gradle' in files:
1559             path = os.path.join(root, 'build.gradle')
1560
1561             with open(path, "r") as o:
1562                 lines = o.readlines()
1563
1564             opened = 0
1565             with open(path, "w") as o:
1566                 for line in lines:
1567                     if comment.match(line):
1568                         continue
1569
1570                     if opened > 0:
1571                         opened += line.count('{')
1572                         opened -= line.count('}')
1573                         continue
1574
1575                     if signing_configs.match(line):
1576                         opened += 1
1577                         continue
1578
1579                     if any(s.match(line) for s in line_matches):
1580                         continue
1581
1582                     if opened == 0:
1583                         o.write(line)
1584
1585             logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1586
1587         for propfile in [
1588                 'project.properties',
1589                 'build.properties',
1590                 'default.properties',
1591                 'ant.properties',
1592                 ]:
1593             if propfile in files:
1594                 path = os.path.join(root, propfile)
1595
1596                 with open(path, "r") as o:
1597                     lines = o.readlines()
1598
1599                 with open(path, "w") as o:
1600                     for line in lines:
1601                         if line.startswith('key.store'):
1602                             continue
1603                         if line.startswith('key.alias'):
1604                             continue
1605                         o.write(line)
1606
1607                 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1608
1609
1610 def replace_config_vars(cmd):
1611     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1612     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1613     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1614     return cmd
1615
1616
1617 def place_srclib(root_dir, number, libpath):
1618     if not number:
1619         return
1620     relpath = os.path.relpath(libpath, root_dir)
1621     proppath = os.path.join(root_dir, 'project.properties')
1622
1623     lines = []
1624     if os.path.isfile(proppath):
1625         with open(proppath, "r") as o:
1626             lines = o.readlines()
1627
1628     with open(proppath, "w") as o:
1629         placed = False
1630         for line in lines:
1631             if line.startswith('android.library.reference.%d=' % number):
1632                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1633                 placed = True
1634             else:
1635                 o.write(line)
1636         if not placed:
1637             o.write('android.library.reference.%d=%s\n' % (number, relpath))