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