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