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