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