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