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