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