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