chiark / gitweb /
1151b75e2ed7772682fe1653e4c4d51bd9d994aa
[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):
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     max_version = None
840     max_vercode = None
841     max_package = None
842
843     for path in paths:
844
845         gradle = has_extension(path, 'gradle')
846         version = None
847         vercode = None
848         # Remember package name, may be defined separately from version+vercode
849         package = max_package
850
851         for line in file(path):
852             if not package:
853                 if gradle:
854                     matches = psearch_g(line)
855                 else:
856                     matches = psearch(line)
857                 if matches:
858                     package = matches.group(1)
859             if not version:
860                 if gradle:
861                     matches = vnsearch_g(line)
862                 else:
863                     matches = vnsearch(line)
864                 if matches:
865                     version = matches.group(2 if gradle else 1)
866             if not vercode:
867                 if gradle:
868                     matches = vcsearch_g(line)
869                 else:
870                     matches = vcsearch(line)
871                 if matches:
872                     vercode = matches.group(1)
873
874         # Better some package name than nothing
875         if max_package is None:
876             max_package = package
877
878         if max_vercode is None or (vercode is not None and vercode > max_vercode):
879             max_version = version
880             max_vercode = vercode
881             max_package = package
882
883     if max_version is None:
884         max_version = "Unknown"
885
886     return (max_version, max_vercode, max_package)
887
888
889 class BuildException(Exception):
890     def __init__(self, value, detail=None):
891         self.value = value
892         self.detail = detail
893
894     def get_wikitext(self):
895         ret = repr(self.value) + "\n"
896         if self.detail:
897             ret += "=detail=\n"
898             ret += "<pre>\n"
899             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
900             ret += str(txt)
901             ret += "</pre>\n"
902         return ret
903
904     def __str__(self):
905         ret = repr(self.value)
906         if self.detail:
907             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
908         return ret
909
910
911 class VCSException(Exception):
912     def __init__(self, value):
913         self.value = value
914
915     def __str__(self):
916         return repr(self.value)
917
918
919 # Get the specified source library.
920 # Returns the path to it. Normally this is the path to be used when referencing
921 # it, which may be a subdirectory of the actual project. If you want the base
922 # directory of the project, pass 'basepath=True'.
923 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
924               basepath=False, raw=False, prepare=True, preponly=False):
925
926     number = None
927     subdir = None
928     if raw:
929         name = spec
930         ref = None
931     else:
932         name, ref = spec.split('@')
933         if ':' in name:
934             number, name = name.split(':', 1)
935         if '/' in name:
936             name, subdir = name.split('/', 1)
937
938     srclib_path = os.path.join('srclibs', name + ".txt")
939
940     if not os.path.exists(srclib_path):
941         raise BuildException('srclib ' + name + ' not found.')
942
943     srclib = metadata.parse_srclib(srclib_path)
944
945     sdir = os.path.join(srclib_dir, name)
946
947     if not preponly:
948         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
949         vcs.srclib = (name, number, sdir)
950         if ref:
951             vcs.gotorevision(ref)
952
953         if raw:
954             return vcs
955
956     libdir = None
957     if subdir:
958         libdir = os.path.join(sdir, subdir)
959     elif srclib["Subdir"]:
960         for subdir in srclib["Subdir"]:
961             libdir_candidate = os.path.join(sdir, subdir)
962             if os.path.exists(libdir_candidate):
963                 libdir = libdir_candidate
964                 break
965
966     if libdir is None:
967         libdir = sdir
968
969     if srclib["Srclibs"]:
970         n = 1
971         for lib in srclib["Srclibs"].replace(';', ',').split(','):
972             s_tuple = None
973             for t in srclibpaths:
974                 if t[0] == lib:
975                     s_tuple = t
976                     break
977             if s_tuple is None:
978                 raise BuildException('Missing recursive srclib %s for %s' % (
979                     lib, name))
980             place_srclib(libdir, n, s_tuple[2])
981             n += 1
982
983     remove_signing_keys(sdir)
984     remove_debuggable_flags(sdir)
985
986     if prepare:
987
988         if srclib["Prepare"]:
989             cmd = replace_config_vars(srclib["Prepare"])
990
991             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
992             if p.returncode != 0:
993                 raise BuildException("Error running prepare command for srclib %s"
994                                      % name, p.stdout)
995
996     if basepath:
997         libdir = sdir
998
999     return (name, number, libdir)
1000
1001
1002 # Prepare the source code for a particular build
1003 #  'vcs'         - the appropriate vcs object for the application
1004 #  'app'         - the application details from the metadata
1005 #  'build'       - the build details from the metadata
1006 #  'build_dir'   - the path to the build directory, usually
1007 #                   'build/app.id'
1008 #  'srclib_dir'  - the path to the source libraries directory, usually
1009 #                   'build/srclib'
1010 #  'extlib_dir'  - the path to the external libraries directory, usually
1011 #                   'build/extlib'
1012 # Returns the (root, srclibpaths) where:
1013 #   'root' is the root directory, which may be the same as 'build_dir' or may
1014 #          be a subdirectory of it.
1015 #   'srclibpaths' is information on the srclibs being used
1016 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
1017
1018     # Optionally, the actual app source can be in a subdirectory
1019     if 'subdir' in build:
1020         root_dir = os.path.join(build_dir, build['subdir'])
1021     else:
1022         root_dir = build_dir
1023
1024     # Get a working copy of the right revision
1025     logging.info("Getting source for revision " + build['commit'])
1026     vcs.gotorevision(build['commit'])
1027
1028     # Initialise submodules if requred
1029     if build['submodules']:
1030         logging.info("Initialising submodules")
1031         vcs.initsubmodules()
1032
1033     # Check that a subdir (if we're using one) exists. This has to happen
1034     # after the checkout, since it might not exist elsewhere
1035     if not os.path.exists(root_dir):
1036         raise BuildException('Missing subdir ' + root_dir)
1037
1038     # Run an init command if one is required
1039     if 'init' in build:
1040         cmd = replace_config_vars(build['init'])
1041         logging.info("Running 'init' commands in %s" % root_dir)
1042
1043         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1044         if p.returncode != 0:
1045             raise BuildException("Error running init command for %s:%s" %
1046                                  (app['id'], build['version']), p.stdout)
1047
1048     # Apply patches if any
1049     if 'patch' in build:
1050         for patch in build['patch']:
1051             patch = patch.strip()
1052             logging.info("Applying " + patch)
1053             patch_path = os.path.join('metadata', app['id'], patch)
1054             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
1055             if p.returncode != 0:
1056                 raise BuildException("Failed to apply patch %s" % patch_path)
1057
1058     # Get required source libraries
1059     srclibpaths = []
1060     if 'srclibs' in build:
1061         logging.info("Collecting source libraries")
1062         for lib in build['srclibs']:
1063             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1064                                          preponly=onserver))
1065
1066     for name, number, libpath in srclibpaths:
1067         place_srclib(root_dir, int(number) if number else None, libpath)
1068
1069     basesrclib = vcs.getsrclib()
1070     # If one was used for the main source, add that too.
1071     if basesrclib:
1072         srclibpaths.append(basesrclib)
1073
1074     # Update the local.properties file
1075     localprops = [os.path.join(build_dir, 'local.properties')]
1076     if 'subdir' in build:
1077         localprops += [os.path.join(root_dir, 'local.properties')]
1078     for path in localprops:
1079         if not os.path.isfile(path):
1080             continue
1081         logging.info("Updating properties file at %s" % path)
1082         f = open(path, 'r')
1083         props = f.read()
1084         f.close()
1085         props += '\n'
1086         # Fix old-fashioned 'sdk-location' by copying
1087         # from sdk.dir, if necessary
1088         if build['oldsdkloc']:
1089             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
1090                               re.S | re.M).group(1)
1091             props += "sdk-location=%s\n" % sdkloc
1092         else:
1093             props += "sdk.dir=%s\n" % config['sdk_path']
1094             props += "sdk-location=%s\n" % config['sdk_path']
1095         if 'ndk_path' in config:
1096             # Add ndk location
1097             props += "ndk.dir=%s\n" % config['ndk_path']
1098             props += "ndk-location=%s\n" % config['ndk_path']
1099         # Add java.encoding if necessary
1100         if 'encoding' in build:
1101             props += "java.encoding=%s\n" % build['encoding']
1102         f = open(path, 'w')
1103         f.write(props)
1104         f.close()
1105
1106     flavour = None
1107     if build['type'] == 'gradle':
1108         flavour = build['gradle'].split('@')[0]
1109         if flavour in ['main', 'yes', '']:
1110             flavour = None
1111
1112         if 'target' in build:
1113             n = build["target"].split('-')[1]
1114             FDroidPopen(['sed', '-i',
1115                          's@compileSdkVersion *[0-9]*@compileSdkVersion '+n+'@g',
1116                          'build.gradle'],
1117                         cwd=root_dir)
1118             if '@' in build['gradle']:
1119                 gradle_dir = os.path.join(root_dir, build['gradle'].split('@', 1)[1])
1120                 gradle_dir = os.path.normpath(gradle_dir)
1121                 FDroidPopen(['sed', '-i',
1122                              's@compileSdkVersion *[0-9]*@compileSdkVersion '+n+'@g',
1123                              'build.gradle'],
1124                             cwd=gradle_dir)
1125
1126     # Remove forced debuggable flags
1127     remove_debuggable_flags(root_dir)
1128
1129     # Insert version code and number into the manifest if necessary
1130     if build['forceversion']:
1131         logging.info("Changing the version name")
1132         for path in manifest_paths(root_dir, flavour):
1133             if not os.path.isfile(path):
1134                 continue
1135             if has_extension(path, 'xml'):
1136                 p = SilentPopen(['sed', '-i',
1137                                  's/android:versionName="[^"]*"/android:versionName="'
1138                                  + build['version'] + '"/g',
1139                                  path])
1140                 if p.returncode != 0:
1141                     raise BuildException("Failed to amend manifest")
1142             elif has_extension(path, 'gradle'):
1143                 p = SilentPopen(['sed', '-i',
1144                                  's/versionName *=* *"[^"]*"/versionName = "'
1145                                  + build['version'] + '"/g',
1146                                  path])
1147                 if p.returncode != 0:
1148                     raise BuildException("Failed to amend build.gradle")
1149     if build['forcevercode']:
1150         logging.info("Changing the version code")
1151         for path in manifest_paths(root_dir, flavour):
1152             if not os.path.isfile(path):
1153                 continue
1154             if has_extension(path, 'xml'):
1155                 p = SilentPopen(['sed', '-i',
1156                                  's/android:versionCode="[^"]*"/android:versionCode="'
1157                                  + build['vercode'] + '"/g',
1158                                  path])
1159                 if p.returncode != 0:
1160                     raise BuildException("Failed to amend manifest")
1161             elif has_extension(path, 'gradle'):
1162                 p = SilentPopen(['sed', '-i',
1163                                  's/versionCode *=* *[0-9]*/versionCode = '
1164                                  + build['vercode'] + '/g',
1165                                  path])
1166                 if p.returncode != 0:
1167                     raise BuildException("Failed to amend build.gradle")
1168
1169     # Delete unwanted files
1170     if 'rm' in build:
1171         for part in getpaths(build_dir, build, 'rm'):
1172             dest = os.path.join(build_dir, part)
1173             logging.info("Removing {0}".format(part))
1174             if os.path.lexists(dest):
1175                 if os.path.islink(dest):
1176                     SilentPopen(['unlink ' + dest], shell=True)
1177                 else:
1178                     SilentPopen(['rm -rf ' + dest], shell=True)
1179             else:
1180                 logging.info("...but it didn't exist")
1181
1182     remove_signing_keys(build_dir)
1183
1184     # Add required external libraries
1185     if 'extlibs' in build:
1186         logging.info("Collecting prebuilt libraries")
1187         libsdir = os.path.join(root_dir, 'libs')
1188         if not os.path.exists(libsdir):
1189             os.mkdir(libsdir)
1190         for lib in build['extlibs']:
1191             lib = lib.strip()
1192             logging.info("...installing extlib {0}".format(lib))
1193             libf = os.path.basename(lib)
1194             libsrc = os.path.join(extlib_dir, lib)
1195             if not os.path.exists(libsrc):
1196                 raise BuildException("Missing extlib file {0}".format(libsrc))
1197             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1198
1199     # Run a pre-build command if one is required
1200     if 'prebuild' in build:
1201         cmd = replace_config_vars(build['prebuild'])
1202
1203         # Substitute source library paths into prebuild commands
1204         for name, number, libpath in srclibpaths:
1205             libpath = os.path.relpath(libpath, root_dir)
1206             cmd = cmd.replace('$$' + name + '$$', libpath)
1207
1208         logging.info("Running 'prebuild' commands in %s" % root_dir)
1209
1210         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1211         if p.returncode != 0:
1212             raise BuildException("Error running prebuild command for %s:%s" %
1213                                  (app['id'], build['version']), p.stdout)
1214
1215     updatemode = build.get('update', ['auto'])
1216     # Generate (or update) the ant build file, build.xml...
1217     if updatemode != ['no'] and build['type'] == 'ant':
1218         parms = [os.path.join(config['sdk_path'], 'tools', 'android'), 'update']
1219         lparms = parms + ['lib-project']
1220         parms = parms + ['project']
1221
1222         if 'target' in build and build['target']:
1223             parms += ['-t', build['target']]
1224             lparms += ['-t', build['target']]
1225         if updatemode == ['auto']:
1226             update_dirs = ant_subprojects(root_dir) + ['.']
1227         else:
1228             update_dirs = updatemode
1229
1230         for d in update_dirs:
1231             subdir = os.path.join(root_dir, d)
1232             if d == '.':
1233                 print("Updating main project")
1234                 cmd = parms + ['-p', d]
1235             else:
1236                 print("Updating subproject %s" % d)
1237                 cmd = lparms + ['-p', d]
1238             p = FDroidPopen(cmd, cwd=root_dir)
1239             # Check to see whether an error was returned without a proper exit
1240             # code (this is the case for the 'no target set or target invalid'
1241             # error)
1242             if p.returncode != 0 or p.stdout.startswith("Error: "):
1243                 raise BuildException("Failed to update project at %s" % d, p.stdout)
1244             # Clean update dirs via ant
1245             if d != '.':
1246                 logging.info("Cleaning subproject %s" % d)
1247                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1248
1249     return (root_dir, srclibpaths)
1250
1251
1252 # Split and extend via globbing the paths from a field
1253 def getpaths(build_dir, build, field):
1254     paths = []
1255     if field not in build:
1256         return paths
1257     for p in build[field]:
1258         p = p.strip()
1259         full_path = os.path.join(build_dir, p)
1260         full_path = os.path.normpath(full_path)
1261         paths += [r[len(build_dir)+1:] for r in glob.glob(full_path)]
1262     return paths
1263
1264
1265 # Scan the source code in the given directory (and all subdirectories)
1266 # and return the number of fatal problems encountered
1267 def scan_source(build_dir, root_dir, thisbuild):
1268
1269     count = 0
1270
1271     # Common known non-free blobs (always lower case):
1272     usual_suspects = [
1273         re.compile(r'flurryagent', re.IGNORECASE),
1274         re.compile(r'paypal.*mpl', re.IGNORECASE),
1275         re.compile(r'libgoogleanalytics', re.IGNORECASE),
1276         re.compile(r'admob.*sdk.*android', re.IGNORECASE),
1277         re.compile(r'googleadview', re.IGNORECASE),
1278         re.compile(r'googleadmobadssdk', re.IGNORECASE),
1279         re.compile(r'google.*play.*services', re.IGNORECASE),
1280         re.compile(r'crittercism', re.IGNORECASE),
1281         re.compile(r'heyzap', re.IGNORECASE),
1282         re.compile(r'jpct.*ae', re.IGNORECASE),
1283         re.compile(r'youtubeandroidplayerapi', re.IGNORECASE),
1284         re.compile(r'bugsense', re.IGNORECASE),
1285         re.compile(r'crashlytics', re.IGNORECASE),
1286         re.compile(r'ouya.*sdk', re.IGNORECASE),
1287         ]
1288
1289     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1290     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1291
1292     try:
1293         ms = magic.open(magic.MIME_TYPE)
1294         ms.load()
1295     except AttributeError:
1296         ms = None
1297
1298     def toignore(fd):
1299         for i in scanignore:
1300             if fd.startswith(i):
1301                 return True
1302         return False
1303
1304     def todelete(fd):
1305         for i in scandelete:
1306             if fd.startswith(i):
1307                 return True
1308         return False
1309
1310     def removeproblem(what, fd, fp):
1311         logging.info('Removing %s at %s' % (what, fd))
1312         os.remove(fp)
1313
1314     def warnproblem(what, fd):
1315         logging.warn('Found %s at %s' % (what, fd))
1316
1317     def handleproblem(what, fd, fp):
1318         if todelete(fd):
1319             removeproblem(what, fd, fp)
1320         else:
1321             logging.error('Found %s at %s' % (what, fd))
1322             return True
1323         return False
1324
1325     def insidedir(path, dirname):
1326         return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1327
1328     # Iterate through all files in the source code
1329     for r, d, f in os.walk(build_dir):
1330
1331         if any(insidedir(r, d) for d in ('.hg', '.git', '.svn', '.bzr')):
1332             continue
1333
1334         for curfile in f:
1335
1336             # Path (relative) to the file
1337             fp = os.path.join(r, curfile)
1338             fd = fp[len(build_dir)+1:]
1339
1340             # Check if this file has been explicitly excluded from scanning
1341             if toignore(fd):
1342                 continue
1343
1344             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1345
1346             if mime == 'application/x-sharedlib':
1347                 count += handleproblem('shared library', fd, fp)
1348
1349             elif mime == 'application/x-archive':
1350                 count += handleproblem('static library', fd, fp)
1351
1352             elif mime == 'application/x-executable':
1353                 count += handleproblem('binary executable', fd, fp)
1354
1355             elif mime == 'application/x-java-applet':
1356                 count += handleproblem('Java compiled class', fd, fp)
1357
1358             elif mime in (
1359                     'application/jar',
1360                     'application/zip',
1361                     'application/java-archive',
1362                     'application/octet-stream',
1363                     'binary',
1364                     ):
1365
1366                 if has_extension(fp, 'apk'):
1367                     removeproblem('APK file', fd, fp)
1368
1369                 elif has_extension(fp, 'jar'):
1370
1371                     if any(suspect.match(curfile) for suspect in usual_suspects):
1372                         count += handleproblem('usual supect', fd, fp)
1373                     else:
1374                         warnproblem('JAR file', fd)
1375
1376                 elif has_extension(fp, 'zip'):
1377                     warnproblem('ZIP file', fd)
1378
1379                 else:
1380                     warnproblem('unknown compressed or binary file', fd)
1381
1382             elif has_extension(fp, 'java'):
1383                 for line in file(fp):
1384                     if 'DexClassLoader' in line:
1385                         count += handleproblem('DexClassLoader', fd, fp)
1386                         break
1387     if ms is not None:
1388         ms.close()
1389
1390     # Presence of a jni directory without buildjni=yes might
1391     # indicate a problem (if it's not a problem, explicitly use
1392     # buildjni=no to bypass this check)
1393     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1394             thisbuild.get('buildjni') is None):
1395         logging.warn('Found jni directory, but buildjni is not enabled')
1396         count += 1
1397
1398     return count
1399
1400
1401 class KnownApks:
1402
1403     def __init__(self):
1404         self.path = os.path.join('stats', 'known_apks.txt')
1405         self.apks = {}
1406         if os.path.exists(self.path):
1407             for line in file(self.path):
1408                 t = line.rstrip().split(' ')
1409                 if len(t) == 2:
1410                     self.apks[t[0]] = (t[1], None)
1411                 else:
1412                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1413         self.changed = False
1414
1415     def writeifchanged(self):
1416         if self.changed:
1417             if not os.path.exists('stats'):
1418                 os.mkdir('stats')
1419             f = open(self.path, 'w')
1420             lst = []
1421             for apk, app in self.apks.iteritems():
1422                 appid, added = app
1423                 line = apk + ' ' + appid
1424                 if added:
1425                     line += ' ' + time.strftime('%Y-%m-%d', added)
1426                 lst.append(line)
1427             for line in sorted(lst):
1428                 f.write(line + '\n')
1429             f.close()
1430
1431     # Record an apk (if it's new, otherwise does nothing)
1432     # Returns the date it was added.
1433     def recordapk(self, apk, app):
1434         if not apk in self.apks:
1435             self.apks[apk] = (app, time.gmtime(time.time()))
1436             self.changed = True
1437         _, added = self.apks[apk]
1438         return added
1439
1440     # Look up information - given the 'apkname', returns (app id, date added/None).
1441     # Or returns None for an unknown apk.
1442     def getapp(self, apkname):
1443         if apkname in self.apks:
1444             return self.apks[apkname]
1445         return None
1446
1447     # Get the most recent 'num' apps added to the repo, as a list of package ids
1448     # with the most recent first.
1449     def getlatest(self, num):
1450         apps = {}
1451         for apk, app in self.apks.iteritems():
1452             appid, added = app
1453             if added:
1454                 if appid in apps:
1455                     if apps[appid] > added:
1456                         apps[appid] = added
1457                 else:
1458                     apps[appid] = added
1459         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1460         lst = [app for app, _ in sortedapps]
1461         lst.reverse()
1462         return lst
1463
1464
1465 def isApkDebuggable(apkfile, config):
1466     """Returns True if the given apk file is debuggable
1467
1468     :param apkfile: full path to the apk to check"""
1469
1470     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1471                                   config['build_tools'], 'aapt'),
1472                      'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1473     if p.returncode != 0:
1474         logging.critical("Failed to get apk manifest information")
1475         sys.exit(1)
1476     for line in p.stdout.splitlines():
1477         if 'android:debuggable' in line and not line.endswith('0x0'):
1478             return True
1479     return False
1480
1481
1482 class AsynchronousFileReader(threading.Thread):
1483     '''
1484     Helper class to implement asynchronous reading of a file
1485     in a separate thread. Pushes read lines on a queue to
1486     be consumed in another thread.
1487     '''
1488
1489     def __init__(self, fd, queue):
1490         assert isinstance(queue, Queue.Queue)
1491         assert callable(fd.readline)
1492         threading.Thread.__init__(self)
1493         self._fd = fd
1494         self._queue = queue
1495
1496     def run(self):
1497         '''The body of the tread: read lines and put them on the queue.'''
1498         for line in iter(self._fd.readline, ''):
1499             self._queue.put(line)
1500
1501     def eof(self):
1502         '''Check whether there is no more content to expect.'''
1503         return not self.is_alive() and self._queue.empty()
1504
1505
1506 class PopenResult:
1507     returncode = None
1508     stdout = ''
1509
1510
1511 def SilentPopen(commands, cwd=None, shell=False):
1512     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1513
1514
1515 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1516     """
1517     Run a command and capture the possibly huge output.
1518
1519     :param commands: command and argument list like in subprocess.Popen
1520     :param cwd: optionally specifies a working directory
1521     :returns: A PopenResult.
1522     """
1523
1524     if output:
1525         if cwd:
1526             cwd = os.path.normpath(cwd)
1527             logging.info("Directory: %s" % cwd)
1528         logging.info("> %s" % ' '.join(commands))
1529
1530     result = PopenResult()
1531     p = subprocess.Popen(commands, cwd=cwd, shell=shell,
1532                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1533
1534     stdout_queue = Queue.Queue()
1535     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1536     stdout_reader.start()
1537
1538     # Check the queue for output (until there is no more to get)
1539     while not stdout_reader.eof():
1540         while not stdout_queue.empty():
1541             line = stdout_queue.get()
1542             if output and options.verbose:
1543                 # Output directly to console
1544                 sys.stdout.write(line)
1545                 sys.stdout.flush()
1546             result.stdout += line
1547
1548         time.sleep(0.1)
1549
1550     p.communicate()
1551     result.returncode = p.returncode
1552     return result
1553
1554
1555 def remove_signing_keys(build_dir):
1556     comment = re.compile(r'[ ]*//')
1557     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1558     line_matches = [
1559         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1560         re.compile(r'.*android\.signingConfigs\..*'),
1561         re.compile(r'.*variant\.outputFile = .*'),
1562         re.compile(r'.*\.readLine\(.*'),
1563         ]
1564     for root, dirs, files in os.walk(build_dir):
1565         if 'build.gradle' in files:
1566             path = os.path.join(root, 'build.gradle')
1567
1568             with open(path, "r") as o:
1569                 lines = o.readlines()
1570
1571             opened = 0
1572             with open(path, "w") as o:
1573                 for line in lines:
1574                     if comment.match(line):
1575                         continue
1576
1577                     if opened > 0:
1578                         opened += line.count('{')
1579                         opened -= line.count('}')
1580                         continue
1581
1582                     if signing_configs.match(line):
1583                         opened += 1
1584                         continue
1585
1586                     if any(s.match(line) for s in line_matches):
1587                         continue
1588
1589                     if opened == 0:
1590                         o.write(line)
1591
1592             logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1593
1594         for propfile in [
1595                 'project.properties',
1596                 'build.properties',
1597                 'default.properties',
1598                 'ant.properties',
1599                 ]:
1600             if propfile in files:
1601                 path = os.path.join(root, propfile)
1602
1603                 with open(path, "r") as o:
1604                     lines = o.readlines()
1605
1606                 with open(path, "w") as o:
1607                     for line in lines:
1608                         if line.startswith('key.store'):
1609                             continue
1610                         if line.startswith('key.alias'):
1611                             continue
1612                         o.write(line)
1613
1614                 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1615
1616
1617 def replace_config_vars(cmd):
1618     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1619     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1620     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1621     return cmd
1622
1623
1624 def place_srclib(root_dir, number, libpath):
1625     if not number:
1626         return
1627     relpath = os.path.relpath(libpath, root_dir)
1628     proppath = os.path.join(root_dir, 'project.properties')
1629
1630     lines = []
1631     if os.path.isfile(proppath):
1632         with open(proppath, "r") as o:
1633             lines = o.readlines()
1634
1635     with open(proppath, "w") as o:
1636         placed = False
1637         for line in lines:
1638             if line.startswith('android.library.reference.%d=' % number):
1639                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1640                 placed = True
1641             else:
1642                 o.write(line)
1643         if not placed:
1644             o.write('android.library.reference.%d=%s\n' % (number, relpath))