chiark / gitweb /
Add proprietary samsung jar to usual suspects
[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         re.compile(r'libspen23', re.IGNORECASE),
1288         ]
1289
1290     scanignore = getpaths(build_dir, thisbuild, 'scanignore')
1291     scandelete = getpaths(build_dir, thisbuild, 'scandelete')
1292
1293     try:
1294         ms = magic.open(magic.MIME_TYPE)
1295         ms.load()
1296     except AttributeError:
1297         ms = None
1298
1299     def toignore(fd):
1300         for i in scanignore:
1301             if fd.startswith(i):
1302                 return True
1303         return False
1304
1305     def todelete(fd):
1306         for i in scandelete:
1307             if fd.startswith(i):
1308                 return True
1309         return False
1310
1311     def removeproblem(what, fd, fp):
1312         logging.info('Removing %s at %s' % (what, fd))
1313         os.remove(fp)
1314
1315     def warnproblem(what, fd):
1316         logging.warn('Found %s at %s' % (what, fd))
1317
1318     def handleproblem(what, fd, fp):
1319         if todelete(fd):
1320             removeproblem(what, fd, fp)
1321         else:
1322             logging.error('Found %s at %s' % (what, fd))
1323             return True
1324         return False
1325
1326     def insidedir(path, dirname):
1327         return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1328
1329     # Iterate through all files in the source code
1330     for r, d, f in os.walk(build_dir):
1331
1332         if any(insidedir(r, d) for d in ('.hg', '.git', '.svn', '.bzr')):
1333             continue
1334
1335         for curfile in f:
1336
1337             # Path (relative) to the file
1338             fp = os.path.join(r, curfile)
1339             fd = fp[len(build_dir) + 1:]
1340
1341             # Check if this file has been explicitly excluded from scanning
1342             if toignore(fd):
1343                 continue
1344
1345             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1346
1347             if mime == 'application/x-sharedlib':
1348                 count += handleproblem('shared library', fd, fp)
1349
1350             elif mime == 'application/x-archive':
1351                 count += handleproblem('static library', fd, fp)
1352
1353             elif mime == 'application/x-executable':
1354                 count += handleproblem('binary executable', fd, fp)
1355
1356             elif mime == 'application/x-java-applet':
1357                 count += handleproblem('Java compiled class', fd, fp)
1358
1359             elif mime in (
1360                     'application/jar',
1361                     'application/zip',
1362                     'application/java-archive',
1363                     'application/octet-stream',
1364                     'binary',
1365                     ):
1366
1367                 if has_extension(fp, 'apk'):
1368                     removeproblem('APK file', fd, fp)
1369
1370                 elif has_extension(fp, 'jar'):
1371
1372                     if any(suspect.match(curfile) for suspect in usual_suspects):
1373                         count += handleproblem('usual supect', fd, fp)
1374                     else:
1375                         warnproblem('JAR file', fd)
1376
1377                 elif has_extension(fp, 'zip'):
1378                     warnproblem('ZIP file', fd)
1379
1380                 else:
1381                     warnproblem('unknown compressed or binary file', fd)
1382
1383             elif has_extension(fp, 'java'):
1384                 for line in file(fp):
1385                     if 'DexClassLoader' in line:
1386                         count += handleproblem('DexClassLoader', fd, fp)
1387                         break
1388     if ms is not None:
1389         ms.close()
1390
1391     # Presence of a jni directory without buildjni=yes might
1392     # indicate a problem (if it's not a problem, explicitly use
1393     # buildjni=no to bypass this check)
1394     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1395             thisbuild.get('buildjni') is None):
1396         logging.warn('Found jni directory, but buildjni is not enabled')
1397         count += 1
1398
1399     return count
1400
1401
1402 class KnownApks:
1403
1404     def __init__(self):
1405         self.path = os.path.join('stats', 'known_apks.txt')
1406         self.apks = {}
1407         if os.path.exists(self.path):
1408             for line in file(self.path):
1409                 t = line.rstrip().split(' ')
1410                 if len(t) == 2:
1411                     self.apks[t[0]] = (t[1], None)
1412                 else:
1413                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1414         self.changed = False
1415
1416     def writeifchanged(self):
1417         if self.changed:
1418             if not os.path.exists('stats'):
1419                 os.mkdir('stats')
1420             f = open(self.path, 'w')
1421             lst = []
1422             for apk, app in self.apks.iteritems():
1423                 appid, added = app
1424                 line = apk + ' ' + appid
1425                 if added:
1426                     line += ' ' + time.strftime('%Y-%m-%d', added)
1427                 lst.append(line)
1428             for line in sorted(lst):
1429                 f.write(line + '\n')
1430             f.close()
1431
1432     # Record an apk (if it's new, otherwise does nothing)
1433     # Returns the date it was added.
1434     def recordapk(self, apk, app):
1435         if not apk in self.apks:
1436             self.apks[apk] = (app, time.gmtime(time.time()))
1437             self.changed = True
1438         _, added = self.apks[apk]
1439         return added
1440
1441     # Look up information - given the 'apkname', returns (app id, date added/None).
1442     # Or returns None for an unknown apk.
1443     def getapp(self, apkname):
1444         if apkname in self.apks:
1445             return self.apks[apkname]
1446         return None
1447
1448     # Get the most recent 'num' apps added to the repo, as a list of package ids
1449     # with the most recent first.
1450     def getlatest(self, num):
1451         apps = {}
1452         for apk, app in self.apks.iteritems():
1453             appid, added = app
1454             if added:
1455                 if appid in apps:
1456                     if apps[appid] > added:
1457                         apps[appid] = added
1458                 else:
1459                     apps[appid] = added
1460         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1461         lst = [app for app, _ in sortedapps]
1462         lst.reverse()
1463         return lst
1464
1465
1466 def isApkDebuggable(apkfile, config):
1467     """Returns True if the given apk file is debuggable
1468
1469     :param apkfile: full path to the apk to check"""
1470
1471     p = SilentPopen([os.path.join(config['sdk_path'], 'build-tools',
1472                                   config['build_tools'], 'aapt'),
1473                      'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1474     if p.returncode != 0:
1475         logging.critical("Failed to get apk manifest information")
1476         sys.exit(1)
1477     for line in p.stdout.splitlines():
1478         if 'android:debuggable' in line and not line.endswith('0x0'):
1479             return True
1480     return False
1481
1482
1483 class AsynchronousFileReader(threading.Thread):
1484     '''
1485     Helper class to implement asynchronous reading of a file
1486     in a separate thread. Pushes read lines on a queue to
1487     be consumed in another thread.
1488     '''
1489
1490     def __init__(self, fd, queue):
1491         assert isinstance(queue, Queue.Queue)
1492         assert callable(fd.readline)
1493         threading.Thread.__init__(self)
1494         self._fd = fd
1495         self._queue = queue
1496
1497     def run(self):
1498         '''The body of the tread: read lines and put them on the queue.'''
1499         for line in iter(self._fd.readline, ''):
1500             self._queue.put(line)
1501
1502     def eof(self):
1503         '''Check whether there is no more content to expect.'''
1504         return not self.is_alive() and self._queue.empty()
1505
1506
1507 class PopenResult:
1508     returncode = None
1509     stdout = ''
1510
1511
1512 def SilentPopen(commands, cwd=None, shell=False):
1513     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1514
1515
1516 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1517     """
1518     Run a command and capture the possibly huge output.
1519
1520     :param commands: command and argument list like in subprocess.Popen
1521     :param cwd: optionally specifies a working directory
1522     :returns: A PopenResult.
1523     """
1524
1525     if output:
1526         if cwd:
1527             cwd = os.path.normpath(cwd)
1528             logging.info("Directory: %s" % cwd)
1529         logging.info("> %s" % ' '.join(commands))
1530
1531     result = PopenResult()
1532     p = subprocess.Popen(commands, cwd=cwd, shell=shell,
1533                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1534
1535     stdout_queue = Queue.Queue()
1536     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1537     stdout_reader.start()
1538
1539     # Check the queue for output (until there is no more to get)
1540     while not stdout_reader.eof():
1541         while not stdout_queue.empty():
1542             line = stdout_queue.get()
1543             if output and options.verbose:
1544                 # Output directly to console
1545                 sys.stdout.write(line)
1546                 sys.stdout.flush()
1547             result.stdout += line
1548
1549         time.sleep(0.1)
1550
1551     p.communicate()
1552     result.returncode = p.returncode
1553     return result
1554
1555
1556 def remove_signing_keys(build_dir):
1557     comment = re.compile(r'[ ]*//')
1558     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1559     line_matches = [
1560         re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1561         re.compile(r'.*android\.signingConfigs\..*'),
1562         re.compile(r'.*variant\.outputFile = .*'),
1563         re.compile(r'.*\.readLine\(.*'),
1564         ]
1565     for root, dirs, files in os.walk(build_dir):
1566         if 'build.gradle' in files:
1567             path = os.path.join(root, 'build.gradle')
1568
1569             with open(path, "r") as o:
1570                 lines = o.readlines()
1571
1572             opened = 0
1573             with open(path, "w") as o:
1574                 for line in lines:
1575                     if comment.match(line):
1576                         continue
1577
1578                     if opened > 0:
1579                         opened += line.count('{')
1580                         opened -= line.count('}')
1581                         continue
1582
1583                     if signing_configs.match(line):
1584                         opened += 1
1585                         continue
1586
1587                     if any(s.match(line) for s in line_matches):
1588                         continue
1589
1590                     if opened == 0:
1591                         o.write(line)
1592
1593             logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1594
1595         for propfile in [
1596                 'project.properties',
1597                 'build.properties',
1598                 'default.properties',
1599                 'ant.properties',
1600                 ]:
1601             if propfile in files:
1602                 path = os.path.join(root, propfile)
1603
1604                 with open(path, "r") as o:
1605                     lines = o.readlines()
1606
1607                 with open(path, "w") as o:
1608                     for line in lines:
1609                         if line.startswith('key.store'):
1610                             continue
1611                         if line.startswith('key.alias'):
1612                             continue
1613                         o.write(line)
1614
1615                 logging.info("Cleaned %s of keysigning configs at %s" % (propfile, path))
1616
1617
1618 def replace_config_vars(cmd):
1619     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1620     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1621     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1622     return cmd
1623
1624
1625 def place_srclib(root_dir, number, libpath):
1626     if not number:
1627         return
1628     relpath = os.path.relpath(libpath, root_dir)
1629     proppath = os.path.join(root_dir, 'project.properties')
1630
1631     lines = []
1632     if os.path.isfile(proppath):
1633         with open(proppath, "r") as o:
1634             lines = o.readlines()
1635
1636     with open(proppath, "w") as o:
1637         placed = False
1638         for line in lines:
1639             if line.startswith('android.library.reference.%d=' % number):
1640                 o.write('android.library.reference.%d=%s\n' % (number, relpath))
1641                 placed = True
1642             else:
1643                 o.write(line)
1644         if not placed:
1645             o.write('android.library.reference.%d=%s\n' % (number, relpath))