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