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