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