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