chiark / gitweb /
Support auto names on gradle projects
[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, 'build.gradle') ]
628
629     if flavour:
630         possible_manifests.append(
631                 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
632
633     return [path for path in possible_manifests if os.path.isfile(path)]
634
635 # Retrieve the package name. Returns the name, or None if not found.
636 def fetch_real_name(app_dir, flavour):
637     app_search = re.compile(r'.*<application.*').search
638     name_search = re.compile(r'.*android:label="([^"]+)".*').search
639     app_found = False
640     for f in manifest_paths(app_dir, flavour):
641         if not has_extension(f, 'xml'):
642             continue
643         logging.debug("fetch_real_name: Checking manifest at " + f)
644         for line in file(f):
645             if not app_found:
646                 if app_search(line):
647                     app_found = True
648             if app_found:
649                 matches = name_search(line)
650                 if matches:
651                     stringname = matches.group(1)
652                     logging.debug("fetch_real_name: using string " + stringname)
653                     result = retrieve_string(app_dir, stringname)
654                     if result:
655                         result = result.strip()
656                     return result
657     return None
658
659 # Retrieve the version name
660 def version_name(original, app_dir, flavour):
661     for f in manifest_paths(app_dir, flavour):
662         if not has_extension(f, 'xml'):
663             continue
664         string = retrieve_string(app_dir, original)
665         if string:
666             return string
667     return original
668
669 def get_library_references(root_dir):
670     libraries = []
671     proppath = os.path.join(root_dir, 'project.properties')
672     if not os.path.isfile(proppath):
673         return libraries
674     with open(proppath) as f:
675         for line in f.readlines():
676             if not line.startswith('android.library.reference.'):
677                 continue
678             path = line.split('=')[1].strip()
679             relpath = os.path.join(root_dir, path)
680             if not os.path.isdir(relpath):
681                 continue
682             logging.info("Found subproject at %s" % path)
683             libraries.append(path)
684     return libraries
685
686 def ant_subprojects(root_dir):
687     subprojects = get_library_references(root_dir)
688     for subpath in subprojects:
689         subrelpath = os.path.join(root_dir, subpath)
690         for p in get_library_references(subrelpath):
691             relp = os.path.normpath(os.path.join(subpath,p))
692             if relp not in subprojects:
693                 subprojects.insert(0, relp)
694     return subprojects
695
696 def remove_debuggable_flags(root_dir):
697     # Remove forced debuggable flags
698     logging.info("Removing debuggable flags")
699     for root, dirs, files in os.walk(root_dir):
700         if 'AndroidManifest.xml' in files:
701             path = os.path.join(root, 'AndroidManifest.xml')
702             p = FDroidPopen(['sed','-i', 's/android:debuggable="[^"]*"//g', path])
703             if p.returncode != 0:
704                 raise BuildException("Failed to remove debuggable flags of %s" % path)
705
706 # Extract some information from the AndroidManifest.xml at the given path.
707 # Returns (version, vercode, package), any or all of which might be None.
708 # All values returned are strings.
709 def parse_androidmanifests(paths):
710
711     if not paths:
712         return (None, None, None)
713
714     vcsearch = re.compile(r'.*:versionCode="([0-9]+?)".*').search
715     vnsearch = re.compile(r'.*:versionName="([^"]+?)".*').search
716     psearch = re.compile(r'.*package="([^"]+)".*').search
717
718     vcsearch_g = re.compile(r'.*versionCode[ ]*[=]*[ ]*["\']*([0-9]+)["\']*').search
719     vnsearch_g = re.compile(r'.*versionName[ ]*[=]*[ ]*(["\'])((?:(?=(\\?))\3.)*?)\1.*').search
720     psearch_g = re.compile(r'.*packageName[ ]*[=]*[ ]*["\']([^"]+)["\'].*').search
721
722     max_version = None
723     max_vercode = None
724     max_package = None
725
726     for path in paths:
727
728         gradle = has_extension(path, 'gradle')
729         version = None
730         vercode = None
731         # Remember package name, may be defined separately from version+vercode
732         package = max_package
733
734         for line in file(path):
735             if not package:
736                 if gradle:
737                     matches = psearch_g(line)
738                 else:
739                     matches = psearch(line)
740                 if matches:
741                     package = matches.group(1)
742             if not version:
743                 if gradle:
744                     matches = vnsearch_g(line)
745                 else:
746                     matches = vnsearch(line)
747                 if matches:
748                     version = matches.group(2 if gradle else 1)
749             if not vercode:
750                 if gradle:
751                     matches = vcsearch_g(line)
752                 else:
753                     matches = vcsearch(line)
754                 if matches:
755                     vercode = matches.group(1)
756
757         # Better some package name than nothing
758         if max_package is None:
759             max_package = package
760
761         if max_vercode is None or (vercode is not None and vercode > max_vercode):
762             max_version = version
763             max_vercode = vercode
764             max_package = package
765
766     if max_version is None:
767         max_version = "Unknown"
768
769     return (max_version, max_vercode, max_package)
770
771 class BuildException(Exception):
772     def __init__(self, value, detail = None):
773         self.value = value
774         self.detail = detail
775
776     def get_wikitext(self):
777         ret = repr(self.value) + "\n"
778         if self.detail:
779             ret += "=detail=\n"
780             ret += "<pre>\n"
781             txt = self.detail[-8192:] if len(self.detail) > 8192 else self.detail
782             ret += str(txt)
783             ret += "</pre>\n"
784         return ret
785
786     def __str__(self):
787         ret = repr(self.value)
788         if self.detail:
789             ret += "\n==== detail begin ====\n%s\n==== detail end ====" % self.detail.strip()
790         return ret
791
792 class VCSException(Exception):
793     def __init__(self, value):
794         self.value = value
795
796     def __str__(self):
797         return repr(self.value)
798
799 # Get the specified source library.
800 # Returns the path to it. Normally this is the path to be used when referencing
801 # it, which may be a subdirectory of the actual project. If you want the base
802 # directory of the project, pass 'basepath=True'.
803 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None,
804         basepath=False, raw=False, prepare=True, preponly=False):
805
806     number = None
807     subdir = None
808     if raw:
809         name = spec
810         ref = None
811     else:
812         name, ref = spec.split('@')
813         if ':' in name:
814             number, name = name.split(':', 1)
815         if '/' in name:
816             name, subdir = name.split('/',1)
817
818     srclib_path = os.path.join('srclibs', name + ".txt")
819
820     if not os.path.exists(srclib_path):
821         raise BuildException('srclib ' + name + ' not found.')
822
823     srclib = metadata.parse_srclib(srclib_path)
824
825     sdir = os.path.join(srclib_dir, name)
826
827     if not preponly:
828         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
829         vcs.srclib = (name, number, sdir)
830         if ref:
831             vcs.gotorevision(ref)
832
833         if raw:
834             return vcs
835
836     libdir = None
837     if subdir:
838         libdir = os.path.join(sdir, subdir)
839     elif srclib["Subdir"]:
840         for subdir in srclib["Subdir"]:
841             libdir_candidate = os.path.join(sdir, subdir)
842             if os.path.exists(libdir_candidate):
843                 libdir = libdir_candidate
844                 break
845
846     if libdir is None:
847         libdir = sdir
848
849     if srclib["Srclibs"]:
850         for n,lib in enumerate(srclib["Srclibs"].replace(';',',').split(',')):
851             s_tuple = None
852             for t in srclibpaths:
853                 if t[0] == lib:
854                     s_tuple = t
855                     break
856             if s_tuple is None:
857                 raise BuildException('Missing recursive srclib %s for %s' % (
858                     lib, name))
859             place_srclib(libdir, n, s_tuple[2])
860
861     remove_signing_keys(sdir)
862     remove_debuggable_flags(sdir)
863
864     if prepare:
865
866         if srclib["Prepare"]:
867             cmd = replace_config_vars(srclib["Prepare"])
868
869             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
870             if p.returncode != 0:
871                 raise BuildException("Error running prepare command for srclib %s"
872                         % name, p.stdout)
873
874     if basepath:
875         libdir = sdir
876
877     return (name, number, libdir)
878
879
880 # Prepare the source code for a particular build
881 #  'vcs'         - the appropriate vcs object for the application
882 #  'app'         - the application details from the metadata
883 #  'build'       - the build details from the metadata
884 #  'build_dir'   - the path to the build directory, usually
885 #                   'build/app.id'
886 #  'srclib_dir'  - the path to the source libraries directory, usually
887 #                   'build/srclib'
888 #  'extlib_dir'  - the path to the external libraries directory, usually
889 #                   'build/extlib'
890 # Returns the (root, srclibpaths) where:
891 #   'root' is the root directory, which may be the same as 'build_dir' or may
892 #          be a subdirectory of it.
893 #   'srclibpaths' is information on the srclibs being used
894 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
895
896     # Optionally, the actual app source can be in a subdirectory
897     if 'subdir' in build:
898         root_dir = os.path.join(build_dir, build['subdir'])
899     else:
900         root_dir = build_dir
901
902     # Get a working copy of the right revision
903     logging.info("Getting source for revision " + build['commit'])
904     vcs.gotorevision(build['commit'])
905
906     # Initialise submodules if requred
907     if build['submodules']:
908         logging.info("Initialising submodules")
909         vcs.initsubmodules()
910
911     # Check that a subdir (if we're using one) exists. This has to happen
912     # after the checkout, since it might not exist elsewhere
913     if not os.path.exists(root_dir):
914         raise BuildException('Missing subdir ' + root_dir)
915
916     # Run an init command if one is required
917     if 'init' in build:
918         cmd = replace_config_vars(build['init'])
919         logging.info("Running 'init' commands in %s" % root_dir)
920
921         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
922         if p.returncode != 0:
923             raise BuildException("Error running init command for %s:%s" %
924                     (app['id'], build['version']), p.stdout)
925
926     # Apply patches if any
927     if 'patch' in build:
928         for patch in build['patch']:
929             patch = patch.strip()
930             logging.info("Applying " + patch)
931             patch_path = os.path.join('metadata', app['id'], patch)
932             p = FDroidPopen(['patch', '-p1', '-i', os.path.abspath(patch_path)], cwd=build_dir)
933             if p.returncode != 0:
934                 raise BuildException("Failed to apply patch %s" % patch_path)
935
936     # Get required source libraries
937     srclibpaths = []
938     if 'srclibs' in build:
939         logging.info("Collecting source libraries")
940         for lib in build['srclibs']:
941             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
942                 preponly=onserver))
943
944     for name, number, libpath in srclibpaths:
945         place_srclib(root_dir, int(number) if number else None, libpath)
946
947     basesrclib = vcs.getsrclib()
948     # If one was used for the main source, add that too.
949     if basesrclib:
950         srclibpaths.append(basesrclib)
951
952     # Update the local.properties file
953     localprops = [ os.path.join(build_dir, 'local.properties') ]
954     if 'subdir' in build:
955         localprops += [ os.path.join(root_dir, 'local.properties') ]
956     for path in localprops:
957         if not os.path.isfile(path):
958             continue
959         logging.info("Updating properties file at %s" % path)
960         f = open(path, 'r')
961         props = f.read()
962         f.close()
963         props += '\n'
964         # Fix old-fashioned 'sdk-location' by copying
965         # from sdk.dir, if necessary
966         if build['oldsdkloc']:
967             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
968                 re.S|re.M).group(1)
969             props += "sdk-location=%s\n" % sdkloc
970         else:
971             props += "sdk.dir=%s\n" % config['sdk_path']
972             props += "sdk-location=%s\n" % config['sdk_path']
973         if 'ndk_path' in config:
974             # Add ndk location
975             props += "ndk.dir=%s\n" % config['ndk_path']
976             props += "ndk-location=%s\n" % config['ndk_path']
977         # Add java.encoding if necessary
978         if 'encoding' in build:
979             props += "java.encoding=%s\n" % build['encoding']
980         f = open(path, 'w')
981         f.write(props)
982         f.close()
983
984     flavour = None
985     if build['type'] == 'gradle':
986         flavour = build['gradle'].split('@')[0]
987         if flavour in ['main', 'yes', '']:
988             flavour = None
989
990         if 'target' in build:
991             n = build["target"].split('-')[1]
992             FDroidPopen(['sed', '-i',
993                 's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+n+'@g',
994                 'build.gradle'], cwd=root_dir)
995             if '@' in build['gradle']:
996                 gradle_dir = os.path.join(root_dir, build['gradle'].split('@',1)[1])
997                 gradle_dir = os.path.normpath(gradle_dir)
998                 FDroidPopen(['sed', '-i',
999                     's@compileSdkVersion[ ]*[0-9]*@compileSdkVersion '+n+'@g',
1000                     'build.gradle'], cwd=gradle_dir)
1001
1002     # Remove forced debuggable flags
1003     remove_debuggable_flags(root_dir)
1004
1005     # Insert version code and number into the manifest if necessary
1006     if build['forceversion']:
1007         logging.info("Changing the version name")
1008         for path in manifest_paths(root_dir, flavour):
1009             if not os.path.isfile(path):
1010                 continue
1011             if has_extension(path, 'xml'):
1012                 p = SilentPopen(['sed', '-i',
1013                     's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1014                     path])
1015                 if p.returncode != 0:
1016                     raise BuildException("Failed to amend manifest")
1017             elif has_extension(path, 'gradle'):
1018                 p = SilentPopen(['sed', '-i',
1019                     's/versionName[ ]*=[ ]*"[^"]*"/versionName = "' + build['version'] + '"/g',
1020                     path])
1021                 if p.returncode != 0:
1022                     raise BuildException("Failed to amend build.gradle")
1023     if build['forcevercode']:
1024         logging.info("Changing the version code")
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:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/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/versionCode[ ]*=[ ]*[0-9]*/versionCode = ' + build['vercode'] + '/g',
1037                     path])
1038                 if p.returncode != 0:
1039                     raise BuildException("Failed to amend build.gradle")
1040
1041     # Delete unwanted files
1042     if 'rm' in build:
1043         for part in build['rm']:
1044             dest = os.path.join(build_dir, part)
1045             logging.info("Removing {0}".format(part))
1046             if os.path.lexists(dest):
1047                 if os.path.islink(dest):
1048                     SilentPopen(['unlink ' + dest], shell=True)
1049                 else:
1050                     SilentPopen(['rm -rf ' + dest], shell=True)
1051             else:
1052                 logging.info("...but it didn't exist")
1053
1054     remove_signing_keys(build_dir)
1055
1056     # Add required external libraries
1057     if 'extlibs' in build:
1058         logging.info("Collecting prebuilt libraries")
1059         libsdir = os.path.join(root_dir, 'libs')
1060         if not os.path.exists(libsdir):
1061             os.mkdir(libsdir)
1062         for lib in build['extlibs']:
1063             lib = lib.strip()
1064             logging.info("...installing extlib {0}".format(lib))
1065             libf = os.path.basename(lib)
1066             libsrc = os.path.join(extlib_dir, lib)
1067             if not os.path.exists(libsrc):
1068                 raise BuildException("Missing extlib file {0}".format(libsrc))
1069             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1070
1071     # Run a pre-build command if one is required
1072     if 'prebuild' in build:
1073         cmd = replace_config_vars(build['prebuild'])
1074
1075         # Substitute source library paths into prebuild commands
1076         for name, number, libpath in srclibpaths:
1077             libpath = os.path.relpath(libpath, root_dir)
1078             cmd = cmd.replace('$$' + name + '$$', libpath)
1079
1080         logging.info("Running 'prebuild' commands in %s" % root_dir)
1081
1082         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1083         if p.returncode != 0:
1084             raise BuildException("Error running prebuild command for %s:%s" %
1085                     (app['id'], build['version']), p.stdout)
1086
1087     updatemode = build.get('update', ['auto'])
1088     # Generate (or update) the ant build file, build.xml...
1089     if updatemode != ['no'] and build['type'] == 'ant':
1090         parms = [os.path.join(config['sdk_path'], 'tools', 'android'), 'update']
1091         lparms = parms + ['lib-project']
1092         parms = parms + ['project']
1093
1094         if 'target' in build and build['target']:
1095             parms += ['-t', build['target']]
1096             lparms += ['-t', build['target']]
1097         if updatemode == ['auto']:
1098             update_dirs = ant_subprojects(root_dir) + ['.']
1099         else:
1100             update_dirs = updatemode
1101
1102         for d in update_dirs:
1103             subdir = os.path.join(root_dir, d)
1104             if d == '.':
1105                 print("Updating main project")
1106                 cmd = parms + ['-p', d]
1107             else:
1108                 print("Updating subproject %s" % d)
1109                 cmd = lparms + ['-p', d]
1110             p = FDroidPopen(cmd, cwd=root_dir)
1111             # Check to see whether an error was returned without a proper exit
1112             # code (this is the case for the 'no target set or target invalid'
1113             # error)
1114             if p.returncode != 0 or p.stdout.startswith("Error: "):
1115                 raise BuildException("Failed to update project at %s" % d, p.stdout)
1116             # Clean update dirs via ant
1117             if d != '.':
1118                 logging.info("Cleaning subproject %s" % d)
1119                 p = FDroidPopen(['ant', 'clean'], cwd=subdir)
1120
1121     return (root_dir, srclibpaths)
1122
1123 # Scan the source code in the given directory (and all subdirectories)
1124 # and return a list of potential problems.
1125 def scan_source(build_dir, root_dir, thisbuild):
1126
1127     problems = []
1128
1129     # Common known non-free blobs (always lower case):
1130     usual_suspects = ['flurryagent',
1131                       'paypal_mpl',
1132                       'libgoogleanalytics',
1133                       'admob-sdk-android',
1134                       'googleadview',
1135                       'googleadmobadssdk',
1136                       'google-play-services',
1137                       'crittercism',
1138                       'heyzap',
1139                       'jpct-ae',
1140                       'youtubeandroidplayerapi',
1141                       'bugsense',
1142                       'crashlytics',
1143                       'ouya-sdk']
1144
1145     def getpaths(field):
1146         paths = []
1147         if field not in thisbuild:
1148             return paths
1149         for p in thisbuild[field]:
1150             p = p.strip()
1151             if p == '.':
1152                 p = '/'
1153             elif p.startswith('./'):
1154                 p = p[1:]
1155             elif not p.startswith('/'):
1156                 p = '/' + p;
1157             if p not in paths:
1158                 paths.append(p)
1159         return paths
1160
1161     scanignore = getpaths('scanignore')
1162     scandelete = getpaths('scandelete')
1163
1164     try:
1165         ms = magic.open(magic.MIME_TYPE)
1166         ms.load()
1167     except AttributeError:
1168         ms = None
1169
1170     def toignore(fd):
1171         for i in scanignore:
1172             if fd.startswith(i):
1173                 return True
1174         return False
1175
1176     def todelete(fd):
1177         for i in scandelete:
1178             if fd.startswith(i):
1179                 return True
1180         return False
1181
1182     def removeproblem(what, fd, fp):
1183         logging.info('Removing %s at %s' % (what, fd))
1184         os.remove(fp)
1185
1186     def handleproblem(what, fd, fp):
1187         if todelete(fd):
1188             removeproblem(what, fd, fp)
1189         else:
1190             problems.append('Found %s at %s' % (what, fd))
1191
1192     def warnproblem(what, fd, fp):
1193         logging.warn('Found %s at %s' % (what, fd))
1194
1195     def insidedir(path, dirname):
1196         return path.endswith('/%s' % dirname) or '/%s/' % dirname in path
1197
1198     # Iterate through all files in the source code
1199     for r,d,f in os.walk(build_dir):
1200
1201         if any(insidedir(r, igndir) for igndir in ('.hg', '.git', '.svn')):
1202             continue
1203
1204         for curfile in f:
1205
1206             # Path (relative) to the file
1207             fp = os.path.join(r, curfile)
1208             fd = fp[len(build_dir):]
1209
1210             # Check if this file has been explicitly excluded from scanning
1211             if toignore(fd):
1212                 continue
1213
1214             for suspect in usual_suspects:
1215                 if suspect in curfile.lower():
1216                     handleproblem('usual supect', fd, fp)
1217
1218             mime = magic.from_file(fp, mime=True) if ms is None else ms.file(fp)
1219             if mime == 'application/x-sharedlib':
1220                 handleproblem('shared library', fd, fp)
1221             elif mime == 'application/x-archive':
1222                 handleproblem('static library', fd, fp)
1223             elif mime == 'application/x-executable':
1224                 handleproblem('binary executable', fd, fp)
1225             elif mime == 'application/x-java-applet':
1226                 handleproblem('Java compiled class', fd, fp)
1227             elif mime == 'application/jar' and has_extension(fp, 'apk'):
1228                 removeproblem('APK file', fd, fp)
1229             elif has_extension(fp, 'jar') and mime in [
1230                     'application/zip',
1231                     'application/java-archive',
1232                     'binary',
1233                     ]:
1234                 warnproblem('JAR file', fd, fp)
1235             elif mime == 'application/zip':
1236                 warnproblem('ZIP file', fd, fp)
1237
1238             elif has_extension(fp, 'java'):
1239                 for line in file(fp):
1240                     if 'DexClassLoader' in line:
1241                         handleproblem('DexClassLoader', fd, fp)
1242                         break
1243     if ms is not None:
1244         ms.close()
1245
1246     # Presence of a jni directory without buildjni=yes might
1247     # indicate a problem (if it's not a problem, explicitly use
1248     # buildjni=no to bypass this check)
1249     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1250             thisbuild.get('buildjni') is None):
1251         msg = 'Found jni directory, but buildjni is not enabled'
1252         problems.append(msg)
1253
1254     return problems
1255
1256
1257 class KnownApks:
1258
1259     def __init__(self):
1260         self.path = os.path.join('stats', 'known_apks.txt')
1261         self.apks = {}
1262         if os.path.exists(self.path):
1263             for line in file( self.path):
1264                 t = line.rstrip().split(' ')
1265                 if len(t) == 2:
1266                     self.apks[t[0]] = (t[1], None)
1267                 else:
1268                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1269         self.changed = False
1270
1271     def writeifchanged(self):
1272         if self.changed:
1273             if not os.path.exists('stats'):
1274                 os.mkdir('stats')
1275             f = open(self.path, 'w')
1276             lst = []
1277             for apk, app in self.apks.iteritems():
1278                 appid, added = app
1279                 line = apk + ' ' + appid
1280                 if added:
1281                     line += ' ' + time.strftime('%Y-%m-%d', added)
1282                 lst.append(line)
1283             for line in sorted(lst):
1284                 f.write(line + '\n')
1285             f.close()
1286
1287     # Record an apk (if it's new, otherwise does nothing)
1288     # Returns the date it was added.
1289     def recordapk(self, apk, app):
1290         if not apk in self.apks:
1291             self.apks[apk] = (app, time.gmtime(time.time()))
1292             self.changed = True
1293         _, added = self.apks[apk]
1294         return added
1295
1296     # Look up information - given the 'apkname', returns (app id, date added/None).
1297     # Or returns None for an unknown apk.
1298     def getapp(self, apkname):
1299         if apkname in self.apks:
1300             return self.apks[apkname]
1301         return None
1302
1303     # Get the most recent 'num' apps added to the repo, as a list of package ids
1304     # with the most recent first.
1305     def getlatest(self, num):
1306         apps = {}
1307         for apk, app in self.apks.iteritems():
1308             appid, added = app
1309             if added:
1310                 if appid in apps:
1311                     if apps[appid] > added:
1312                         apps[appid] = added
1313                 else:
1314                     apps[appid] = added
1315         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1316         lst = [app for app,added in sortedapps]
1317         lst.reverse()
1318         return lst
1319
1320 def isApkDebuggable(apkfile, config):
1321     """Returns True if the given apk file is debuggable
1322
1323     :param apkfile: full path to the apk to check"""
1324
1325     p = SilentPopen([os.path.join(config['sdk_path'],
1326         'build-tools', config['build_tools'], 'aapt'),
1327         'dump', 'xmltree', apkfile, 'AndroidManifest.xml'])
1328     if p.returncode != 0:
1329         logging.critical("Failed to get apk manifest information")
1330         sys.exit(1)
1331     for line in p.stdout.splitlines():
1332         if 'android:debuggable' in line and not line.endswith('0x0'):
1333             return True
1334     return False
1335
1336
1337 class AsynchronousFileReader(threading.Thread):
1338     '''
1339     Helper class to implement asynchronous reading of a file
1340     in a separate thread. Pushes read lines on a queue to
1341     be consumed in another thread.
1342     '''
1343
1344     def __init__(self, fd, queue):
1345         assert isinstance(queue, Queue.Queue)
1346         assert callable(fd.readline)
1347         threading.Thread.__init__(self)
1348         self._fd = fd
1349         self._queue = queue
1350
1351     def run(self):
1352         '''The body of the tread: read lines and put them on the queue.'''
1353         for line in iter(self._fd.readline, ''):
1354             self._queue.put(line)
1355
1356     def eof(self):
1357         '''Check whether there is no more content to expect.'''
1358         return not self.is_alive() and self._queue.empty()
1359
1360 class PopenResult:
1361     returncode = None
1362     stdout = ''
1363
1364 def SilentPopen(commands, cwd=None, shell=False):
1365     return FDroidPopen(commands, cwd=cwd, shell=shell, output=False)
1366
1367 def FDroidPopen(commands, cwd=None, shell=False, output=True):
1368     """
1369     Run a command and capture the possibly huge output.
1370
1371     :param commands: command and argument list like in subprocess.Popen
1372     :param cwd: optionally specifies a working directory
1373     :returns: A PopenResult.
1374     """
1375
1376     if cwd:
1377         cwd = os.path.normpath(cwd)
1378         logging.info("Directory: %s" % cwd)
1379     logging.info("> %s" % ' '.join(commands))
1380
1381     result = PopenResult()
1382     p = subprocess.Popen(commands, cwd=cwd, shell=shell,
1383             universal_newlines=True,
1384             stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
1385
1386     stdout_queue = Queue.Queue()
1387     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1388     stdout_reader.start()
1389
1390     # Check the queue for output (until there is no more to get)
1391     while not stdout_reader.eof():
1392         while not stdout_queue.empty():
1393             line = stdout_queue.get()
1394             if output and options.verbose:
1395                 # Output directly to console
1396                 sys.stdout.write(line)
1397                 sys.stdout.flush()
1398             result.stdout += line
1399
1400         time.sleep(0.1)
1401
1402     p.communicate()
1403     result.returncode = p.returncode
1404     return result
1405
1406 def remove_signing_keys(build_dir):
1407     comment = re.compile(r'[ ]*//')
1408     signing_configs = re.compile(r'^[\t ]*signingConfigs[ \t]*{[ \t]*$')
1409     line_matches = [
1410             re.compile(r'^[\t ]*signingConfig [^ ]*$'),
1411             re.compile(r'android.signingConfigs.'),
1412             re.compile(r'variant.outputFile = '),
1413             re.compile(r'.readLine\('),
1414     ]
1415     for root, dirs, files in os.walk(build_dir):
1416         if 'build.gradle' in files:
1417             path = os.path.join(root, 'build.gradle')
1418
1419             with open(path, "r") as o:
1420                 lines = o.readlines()
1421
1422             opened = 0
1423             with open(path, "w") as o:
1424                 for line in lines:
1425                     if comment.match(line):
1426                         continue
1427
1428                     if opened > 0:
1429                         opened += line.count('{')
1430                         opened -= line.count('}')
1431                         continue
1432
1433                     if signing_configs.match(line):
1434                         opened += 1
1435                         continue
1436
1437                     if any(s.match(line) for s in line_matches):
1438                         continue
1439
1440                     if opened == 0:
1441                         o.write(line)
1442
1443             logging.info("Cleaned build.gradle of keysigning configs at %s" % path)
1444
1445         for propfile in [
1446                 'project.properties',
1447                 'build.properties',
1448                 'default.properties',
1449                 'ant.properties',
1450                 ]:
1451             if propfile in files:
1452                 path = os.path.join(root, propfile)
1453
1454                 with open(path, "r") as o:
1455                     lines = o.readlines()
1456
1457                 with open(path, "w") as o:
1458                     for line in lines:
1459                         if line.startswith('key.store'):
1460                             continue
1461                         if line.startswith('key.alias'):
1462                             continue
1463                         o.write(line)
1464
1465                 logging.info("Cleaned %s of keysigning configs at %s" % (propfile,path))
1466
1467 def replace_config_vars(cmd):
1468     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1469     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1470     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1471     return cmd
1472
1473 def place_srclib(root_dir, number, libpath):
1474     if not number:
1475         return
1476     relpath = os.path.relpath(libpath, root_dir)
1477     proppath = os.path.join(root_dir, 'project.properties')
1478
1479     lines = []
1480     if os.path.isfile(proppath):
1481         with open(proppath, "r") as o:
1482             lines = o.readlines()
1483
1484     with open(proppath, "w") as o:
1485         placed = False
1486         for line in lines:
1487             if line.startswith('android.library.reference.%d=' % number):
1488                 o.write('android.library.reference.%d=%s\n' % (number,relpath))
1489                 placed = True
1490             else:
1491                 o.write(line)
1492         if not placed:
1493             o.write('android.library.reference.%d=%s\n' % (number,relpath))
1494