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