chiark / gitweb /
fc23074901de98598419c93e6f258c071c11466b
[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 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 from distutils.spawn import find_executable
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         print "Missing config file - is this a repo directory?"
48         sys.exit(2)
49
50     options = opts
51     if not hasattr(options, 'verbose'):
52         options.verbose = False
53
54     defconfig = {
55         'build_server_always': False,
56         'mvn3': "mvn3",
57         'archive_older': 0,
58         'gradle': 'gradle',
59         'update_stats': False,
60         'archive_older': 0,
61         'max_icon_size': 72,
62         'stats_to_carbon': False,
63         'repo_maxage': 0
64     }
65     config = {}
66
67     if options.verbose:
68         print "Reading %s..." % config_file
69     execfile(config_file, config)
70
71     if any(k in config for k in ["keystore", "keystorepass", "keypass"]):
72         st = os.stat(config_file)
73         if st.st_mode & stat.S_IRWXG or st.st_mode & stat.S_IRWXO:
74             print "WARNING: unsafe permissions on {0} (should be 0600)!".format(config_file)
75
76     # Expand environment variables
77     for k, v in config.items():
78         if type(v) != str:
79             continue
80         v = os.path.expanduser(v)
81         config[k] = os.path.expandvars(v)
82
83     # Check that commands and binaries do exist
84     for key in ('mvn3', 'gradle'):
85         if key not in config:
86             continue
87         val = config[key]
88         executable = find_executable(val)
89         if not executable:
90             print "ERROR: No such command or binary for %s: %s" % (key, val)
91             sys.exit(3)
92
93     # Check that directories exist
94     for key in ('sdk_path', 'ndk_path', 'build_tools'):
95         if key not in config:
96             continue
97         val = config[key]
98         if key == 'build_tools':
99             if 'sdk_path' not in config:
100                 print "ERROR: sdk_path needs to be set for build_tools"
101                 sys.exit(3)
102             val = os.path.join(config['sdk_path'], 'build-tools', val)
103         if not os.path.isdir(val):
104             print "ERROR: No such directory found for %s: %s" % (key, val)
105             sys.exit(3)
106
107     for k, v in defconfig.items():
108         if k not in config:
109             config[k] = v
110
111     return config
112
113 def read_app_args(args, options, allapps):
114     if args:
115         vercodes = {}
116         for p in args:
117             if ':' in p:
118                 package, vercode = p.split(':')
119             else:
120                 package, vercode = p, None
121             if package not in vercodes:
122                 vercodes[package] = [vercode] if vercode else []
123                 continue
124             elif vercode not in vercodes[package]:
125                 vercodes[package] += [vercode] if vercode else []
126         packages = vercodes.keys()
127         apps = [app for app in allapps if app['id'] in packages]
128         if len(apps) != len(packages):
129             allids = [app["id"] for app in allapps]
130             for p in packages:
131                 if p not in allids:
132                     print "No such package: %s" % p
133             raise Exception("Found invalid app ids in arguments")
134
135     apps = [app for app in apps if (options.force or not app['Disabled']) and
136             app['builds'] and len(app['Repo Type']) > 0 and len(app['builds']) > 0]
137     if len(apps) == 0:
138         raise Exception("No apps to process.")
139
140     error = False
141     for app in apps:
142         vc = vercodes[app['id']]
143         if vc:
144             app['builds'] = [b for b in app['builds'] if b['vercode'] in vc]
145             if len(app['builds']) != len(vercodes[app['id']]):
146                 error = True
147                 allvcs = [b['vercode'] for b in app['builds']]
148                 for v in vercodes[app['id']]:
149                     if v not in allvcs:
150                         print "No such vercode %s for app %s" % (v, app['id'])
151         elif options.latest:
152             app['builds'] = app['builds'][-1:]
153
154     if error:
155         raise Exception("Found invalid vercodes for some apps")
156
157     return apps
158
159
160 def getapkname(app, build):
161     return "%s_%s.apk" % (app['id'], build['vercode'])
162
163 def getsrcname(app, build):
164     return "%s_%s_src.tar.gz" % (app['id'], build['vercode'])
165
166 def getappname(app):
167     if app['Name']:
168         return '%s (%s)' % (app['Name'], app['id'])
169     if app['Auto Name']:
170         return '%s (%s)' % (app['Auto Name'], app['id'])
171     return app['id']
172
173 def getcvname(app):
174     return '%s (%s)' % (app['Current Version'], app['Current Version Code'])
175
176 def getvcs(vcstype, remote, local):
177     if vcstype == 'git':
178         return vcs_git(remote, local)
179     if vcstype == 'svn':
180         return vcs_svn(remote, local)
181     if vcstype == 'git-svn':
182         return vcs_gitsvn(remote, local)
183     if vcstype == 'hg':
184         return vcs_hg(remote, local)
185     if vcstype == 'bzr':
186         return vcs_bzr(remote, local)
187     if vcstype == 'srclib':
188         if local != 'build/srclib/' + remote:
189             raise VCSException("Error: srclib paths are hard-coded!")
190         return getsrclib(remote, 'build/srclib', raw=True)
191     raise VCSException("Invalid vcs type " + vcstype)
192
193 def getsrclibvcs(name):
194     srclib_path = os.path.join('srclibs', name + ".txt")
195     if not os.path.exists(srclib_path):
196         raise VCSException("Missing srclib " + name)
197     return metadata.parse_srclib(srclib_path)['Repo Type']
198
199 class vcs:
200     def __init__(self, remote, local):
201
202         # svn, git-svn and bzr may require auth
203         self.username = None
204         if self.repotype() in ('svn', 'git-svn', 'bzr'):
205             if '@' in remote:
206                 self.username, remote = remote.split('@')
207                 if ':' not in self.username:
208                     raise VCSException("Password required with username")
209                 self.username, self.password = self.username.split(':')
210
211         self.remote = remote
212         self.local = local
213         self.refreshed = False
214         self.srclib = None
215
216     # Take the local repository to a clean version of the given revision, which
217     # is specificed in the VCS's native format. Beforehand, the repository can
218     # be dirty, or even non-existent. If the repository does already exist
219     # locally, it will be updated from the origin, but only once in the
220     # lifetime of the vcs object.
221     # None is acceptable for 'rev' if you know you are cloning a clean copy of
222     # the repo - otherwise it must specify a valid revision.
223     def gotorevision(self, rev):
224
225         # The .fdroidvcs-id file for a repo tells us what VCS type
226         # and remote that directory was created from, allowing us to drop it
227         # automatically if either of those things changes.
228         fdpath = os.path.join(self.local, '..',
229                 '.fdroidvcs-' + os.path.basename(self.local))
230         cdata = self.repotype() + ' ' + self.remote
231         writeback = True
232         deleterepo = False
233         if os.path.exists(self.local):
234             if os.path.exists(fdpath):
235                 with open(fdpath, 'r') as f:
236                     fsdata = f.read()
237                 if fsdata == cdata:
238                     writeback = False
239                 else:
240                     deleterepo = True
241                     print "*** Repository details changed - deleting ***"
242             else:
243                 deleterepo = True
244                 print "*** Repository details missing - deleting ***"
245         if deleterepo:
246             shutil.rmtree(self.local)
247
248         self.gotorevisionx(rev)
249
250         # If necessary, write the .fdroidvcs file.
251         if writeback:
252             with open(fdpath, 'w') as f:
253                 f.write(cdata)
254
255     # Derived classes need to implement this. It's called once basic checking
256     # has been performend.
257     def gotorevisionx(self, rev):
258         raise VCSException("This VCS type doesn't define gotorevisionx")
259
260     # Initialise and update submodules
261     def initsubmodules(self):
262         raise VCSException('Submodules not supported for this vcs type')
263
264     # Get a list of all known tags
265     def gettags(self):
266         raise VCSException('gettags not supported for this vcs type')
267
268     # Get current commit reference (hash, revision, etc)
269     def getref(self):
270         raise VCSException('getref not supported for this vcs type')
271
272     # Returns the srclib (name, path) used in setting up the current
273     # revision, or None.
274     def getsrclib(self):
275         return self.srclib
276
277 class vcs_git(vcs):
278
279     def repotype(self):
280         return 'git'
281
282     # If the local directory exists, but is somehow not a git repository, git
283     # will traverse up the directory tree until it finds one that is (i.e.
284     # fdroidserver) and then we'll proceed to destroy it! This is called as
285     # a safety check.
286     def checkrepo(self):
287         p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
288                 stdout=subprocess.PIPE, cwd=self.local)
289         result = p.communicate()[0].rstrip()
290         if not result.endswith(self.local):
291             raise VCSException('Repository mismatch')
292
293     def gotorevisionx(self, rev):
294         if not os.path.exists(self.local):
295             # Brand new checkout...
296             if subprocess.call(['git', 'clone', self.remote, self.local]) != 0:
297                 raise VCSException("Git clone failed")
298             self.checkrepo()
299         else:
300             self.checkrepo()
301             # Discard any working tree changes...
302             if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
303                 raise VCSException("Git reset failed")
304             # Remove untracked files now, in case they're tracked in the target
305             # revision (it happens!)...
306             if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
307                 raise VCSException("Git clean failed")
308             if not self.refreshed:
309                 # Get latest commits and tags from remote...
310                 if subprocess.call(['git', 'fetch', 'origin'],
311                         cwd=self.local) != 0:
312                     raise VCSException("Git fetch failed")
313                 if subprocess.call(['git', 'fetch', '--tags', 'origin'],
314                         cwd=self.local) != 0:
315                     raise VCSException("Git fetch failed")
316                 self.refreshed = True
317         # Check out the appropriate revision...
318         rev = str(rev if rev else 'origin/master')
319         if subprocess.call(['git', 'checkout', '-f', rev], cwd=self.local) != 0:
320             raise VCSException("Git checkout failed")
321         # Get rid of any uncontrolled files left behind...
322         if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
323             raise VCSException("Git clean failed")
324
325     def initsubmodules(self):
326         self.checkrepo()
327         if subprocess.call(['git', 'submodule', 'init'],
328                 cwd=self.local) != 0:
329             raise VCSException("Git submodule init failed")
330         if subprocess.call(['git', 'submodule', 'update'],
331                 cwd=self.local) != 0:
332             raise VCSException("Git submodule update failed")
333         if subprocess.call(['git', 'submodule', 'foreach',
334             'git', 'reset', '--hard'],
335                 cwd=self.local) != 0:
336             raise VCSException("Git submodule reset failed")
337         if subprocess.call(['git', 'submodule', 'foreach',
338             'git', 'clean', '-dffx'],
339                 cwd=self.local) != 0:
340             raise VCSException("Git submodule clean failed")
341
342     def gettags(self):
343         self.checkrepo()
344         p = subprocess.Popen(['git', 'tag'],
345                 stdout=subprocess.PIPE, cwd=self.local)
346         return p.communicate()[0].splitlines()
347
348
349 class vcs_gitsvn(vcs):
350
351     def repotype(self):
352         return 'git-svn'
353
354     # Damn git-svn tries to use a graphical password prompt, so we have to
355     # trick it into taking the password from stdin
356     def userargs(self):
357         if self.username is None:
358             return ('', '')
359         return ('echo "%s" | DISPLAY="" ' % self.password, '--username "%s"' % self.username)
360
361     # If the local directory exists, but is somehow not a git repository, git
362     # will traverse up the directory tree until it finds one that is (i.e.
363     # fdroidserver) and then we'll proceed to destory it! This is called as
364     # a safety check.
365     def checkrepo(self):
366         p = subprocess.Popen(['git', 'rev-parse', '--show-toplevel'],
367                 stdout=subprocess.PIPE, cwd=self.local)
368         result = p.communicate()[0].rstrip()
369         if not result.endswith(self.local):
370             raise VCSException('Repository mismatch')
371
372     def gotorevisionx(self, rev):
373         if not os.path.exists(self.local):
374             # Brand new checkout...
375             gitsvn_cmd = '%sgit svn clone %s' % self.userargs()
376             if ';' in self.remote:
377                 remote_split = self.remote.split(';')
378                 for i in remote_split[1:]:
379                     if i.startswith('trunk='):
380                         gitsvn_cmd += ' -T %s' % i[6:]
381                     elif i.startswith('tags='):
382                         gitsvn_cmd += ' -t %s' % i[5:]
383                     elif i.startswith('branches='):
384                         gitsvn_cmd += ' -b %s' % i[9:]
385                 if subprocess.call([gitsvn_cmd + " %s %s" % (remote_split[0], self.local)],
386                         shell=True) != 0:
387                     raise VCSException("Git clone failed")
388             else:
389                 if subprocess.call([gitsvn_cmd + " %s %s" % (self.remote, self.local)],
390                         shell=True) != 0:
391                     raise VCSException("Git clone failed")
392             self.checkrepo()
393         else:
394             self.checkrepo()
395             # Discard any working tree changes...
396             if subprocess.call(['git', 'reset', '--hard'], cwd=self.local) != 0:
397                 raise VCSException("Git reset failed")
398             # Remove untracked files now, in case they're tracked in the target
399             # revision (it happens!)...
400             if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
401                 raise VCSException("Git clean failed")
402             if not self.refreshed:
403                 # Get new commits and tags from repo...
404                 if subprocess.call(['%sgit svn rebase %s' % self.userargs()],
405                         cwd=self.local, shell=True) != 0:
406                     raise VCSException("Git svn rebase failed")
407                 self.refreshed = True
408
409         rev = str(rev if rev else 'master')
410         if rev:
411             nospaces_rev = rev.replace(' ', '%20')
412             # Try finding a svn tag
413             p = subprocess.Popen(['git', 'checkout', 'tags/' + nospaces_rev],
414                     cwd=self.local, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
415             out, err = p.communicate()
416             if p.returncode == 0:
417                 print out
418             else:
419                 # No tag found, normal svn rev translation
420                 # Translate svn rev into git format
421                 p = subprocess.Popen(['git', 'svn', 'find-rev', 'r' + rev],
422                     cwd=self.local, stdout=subprocess.PIPE)
423                 git_rev = p.communicate()[0].rstrip()
424                 if p.returncode != 0 or not git_rev:
425                     # Try a plain git checkout as a last resort
426                     p = subprocess.Popen(['git', 'checkout', rev], cwd=self.local,
427                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
428                     out, err = p.communicate()
429                     if p.returncode == 0:
430                         print out
431                     else:
432                         raise VCSException("No git treeish found and direct git checkout failed")
433                 else:
434                     # Check out the git rev equivalent to the svn rev
435                     p = subprocess.Popen(['git', 'checkout', git_rev], cwd=self.local,
436                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
437                     out, err = p.communicate()
438                     if p.returncode == 0:
439                         print out
440                     else:
441                         raise VCSException("Git svn checkout failed")
442         # Get rid of any uncontrolled files left behind...
443         if subprocess.call(['git', 'clean', '-dffx'], cwd=self.local) != 0:
444             raise VCSException("Git clean failed")
445
446     def gettags(self):
447         self.checkrepo()
448         return os.listdir(os.path.join(self.local, '.git/svn/refs/remotes/tags'))
449
450     def getref(self):
451         self.checkrepo()
452         p = subprocess.Popen(['git', 'svn', 'find-rev', 'HEAD'],
453                 stdout=subprocess.PIPE, cwd=self.local)
454         return p.communicate()[0].strip()
455
456 class vcs_svn(vcs):
457
458     def repotype(self):
459         return 'svn'
460
461     def userargs(self):
462         if self.username is None:
463             return ['--non-interactive']
464         return ['--username', self.username, 
465                 '--password', self.password,
466                 '--non-interactive']
467
468     def gotorevisionx(self, rev):
469         if not os.path.exists(self.local):
470             if subprocess.call(['svn', 'checkout', self.remote, self.local] +
471                     self.userargs()) != 0:
472                 raise VCSException("Svn checkout failed")
473         else:
474             for svncommand in (
475                     'svn revert -R .',
476                     r"svn status | awk '/\?/ {print $2}' | xargs rm -rf"):
477                 if subprocess.call(svncommand, cwd=self.local, shell=True) != 0:
478                     raise VCSException("Svn reset ({0}) failed in {1}".format(svncommand, self.local))
479             if not self.refreshed:
480                 if subprocess.call(['svn', 'update'] +
481                         self.userargs(), cwd=self.local) != 0:
482                     raise VCSException("Svn update failed")
483                 self.refreshed = True
484
485         revargs = list(['-r', rev] if rev else [])
486         if subprocess.call(['svn', 'update', '--force'] + revargs +
487                 self.userargs(), cwd=self.local) != 0:
488             raise VCSException("Svn update failed")
489
490     def getref(self):
491         p = subprocess.Popen(['svn', 'info'],
492                 stdout=subprocess.PIPE, cwd=self.local)
493         for line in p.communicate()[0].splitlines():
494             if line and line.startswith('Last Changed Rev: '):
495                 return line[18:]
496
497 class vcs_hg(vcs):
498
499     def repotype(self):
500         return 'hg'
501
502     def gotorevisionx(self, rev):
503         if not os.path.exists(self.local):
504             if subprocess.call(['hg', 'clone', self.remote, self.local]) !=0:
505                 raise VCSException("Hg clone failed")
506         else:
507             if subprocess.call('hg status -uS | xargs rm -rf',
508                     cwd=self.local, shell=True) != 0:
509                 raise VCSException("Hg clean failed")
510             if not self.refreshed:
511                 if subprocess.call(['hg', 'pull'],
512                         cwd=self.local) != 0:
513                     raise VCSException("Hg pull failed")
514                 self.refreshed = True
515
516         rev = str(rev if rev else 'default')
517         if not rev:
518             return
519         if subprocess.call(['hg', 'update', '-C', rev],
520                 cwd=self.local) != 0:
521             raise VCSException("Hg checkout failed")
522
523     def gettags(self):
524         p = subprocess.Popen(['hg', 'tags', '-q'],
525                 stdout=subprocess.PIPE, cwd=self.local)
526         return p.communicate()[0].splitlines()[1:]
527
528
529 class vcs_bzr(vcs):
530
531     def repotype(self):
532         return 'bzr'
533
534     def gotorevisionx(self, rev):
535         if not os.path.exists(self.local):
536             if subprocess.call(['bzr', 'branch', self.remote, self.local]) != 0:
537                 raise VCSException("Bzr branch failed")
538         else:
539             if subprocess.call(['bzr', 'clean-tree', '--force',
540                     '--unknown', '--ignored'], cwd=self.local) != 0:
541                 raise VCSException("Bzr revert failed")
542             if not self.refreshed:
543                 if subprocess.call(['bzr', 'pull'],
544                         cwd=self.local) != 0:
545                     raise VCSException("Bzr update failed")
546                 self.refreshed = True
547
548         revargs = list(['-r', rev] if rev else [])
549         if subprocess.call(['bzr', 'revert'] + revargs,
550                 cwd=self.local) != 0:
551             raise VCSException("Bzr revert failed")
552
553     def gettags(self):
554         p = subprocess.Popen(['bzr', 'tags'],
555                 stdout=subprocess.PIPE, cwd=self.local)
556         return [tag.split('   ')[0].strip() for tag in
557                 p.communicate()[0].splitlines()]
558
559 def retrieve_string(xml_dir, string):
560     if string.startswith('@string/'):
561         string_search = re.compile(r'.*"'+string[8:]+'".*?>([^<]+?)<.*').search
562         for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
563             for line in file(xmlfile):
564                 matches = string_search(line)
565                 if matches:
566                     return retrieve_string(xml_dir, matches.group(1))
567     elif string.startswith('&') and string.endswith(';'):
568         string_search = re.compile(r'.*<!ENTITY.*'+string[1:-1]+'.*?"([^"]+?)".*>').search
569         for xmlfile in glob.glob(os.path.join(xml_dir, '*.xml')):
570             for line in file(xmlfile):
571                 matches = string_search(line)
572                 if matches:
573                     return retrieve_string(xml_dir, matches.group(1))
574
575     return string.replace("\\'","'")
576
577 # Return list of existing files that will be used to find the highest vercode
578 def manifest_paths(app_dir, flavour):
579
580     possible_manifests = [ os.path.join(app_dir, 'AndroidManifest.xml'),
581             os.path.join(app_dir, 'src', 'main', 'AndroidManifest.xml'),
582             os.path.join(app_dir, 'build.gradle') ]
583
584     if flavour:
585         possible_manifests.append(
586                 os.path.join(app_dir, 'src', flavour, 'AndroidManifest.xml'))
587     
588     return [path for path in possible_manifests if os.path.isfile(path)]
589
590 # Retrieve the package name
591 def fetch_real_name(app_dir, flavour):
592     app_search = re.compile(r'.*<application.*').search
593     name_search = re.compile(r'.*android:label="([^"]+)".*').search
594     app_found = False
595     for f in manifest_paths(app_dir, flavour):
596         if not f.endswith(".xml"):
597             continue
598         xml_dir = os.path.join(f[:-19], 'res', 'values')
599         for line in file(f):
600             if not app_found:
601                 if app_search(line):
602                     app_found = True
603             if app_found:
604                 matches = name_search(line)
605                 if matches:
606                     return retrieve_string(xml_dir, matches.group(1))
607     return ''
608
609 # Retrieve the version name
610 def version_name(original, app_dir, flavour):
611     for f in manifest_paths(app_dir, flavour):
612         if not f.endswith(".xml"):
613             continue
614         xml_dir = os.path.join(f[:-19], 'res', 'values')
615         string = retrieve_string(xml_dir, original)
616         if string:
617             return string
618     return original
619
620 def ant_subprojects(root_dir):
621     subprojects = []
622     proppath = os.path.join(root_dir, 'project.properties')
623     if not os.path.isfile(proppath):
624         return subprojects
625     with open(proppath) as f:
626         for line in f.readlines():
627             if not line.startswith('android.library.reference.'):
628                 continue
629             path = line.split('=')[1].strip()
630             relpath = os.path.join(root_dir, path)
631             if not os.path.isdir(relpath):
632                 continue
633             if options.verbose:
634                 print "Found subproject %s..." % path
635             subprojects.append(path)
636     return subprojects
637
638 # Extract some information from the AndroidManifest.xml at the given path.
639 # Returns (version, vercode, package), any or all of which might be None.
640 # All values returned are strings.
641 def parse_androidmanifests(paths):
642
643     if not paths:
644         return (None, None, None)
645
646     vcsearch = re.compile(r'.*android:versionCode="([0-9]+?)".*').search
647     vnsearch = re.compile(r'.*android:versionName="([^"]+?)".*').search
648     psearch = re.compile(r'.*package="([^"]+)".*').search
649
650     vcsearch_g = re.compile(r'.*versionCode[ =]*([0-9]+?)[^\d].*').search
651     vnsearch_g = re.compile(r'.*versionName[ =]*"([^"]+?)".*').search
652     psearch_g = re.compile(r'.*packageName[ =]*"([^"]+)".*').search
653
654     max_version = None
655     max_vercode = None
656     max_package = None
657
658     for path in paths:
659
660         gradle = path.endswith("gradle")
661         version = None
662         vercode = None
663         # Remember package name, may be defined separately from version+vercode
664         package = max_package
665
666         for line in file(path):
667             if not package:
668                 if gradle:
669                     matches = psearch_g(line)
670                 else:
671                     matches = psearch(line)
672                 if matches:
673                     package = matches.group(1)
674             if not version:
675                 if gradle:
676                     matches = vnsearch_g(line)
677                 else:
678                     matches = vnsearch(line)
679                 if matches:
680                     version = matches.group(1)
681             if not vercode:
682                 if gradle:
683                     matches = vcsearch_g(line)
684                 else:
685                     matches = vcsearch(line)
686                 if matches:
687                     vercode = matches.group(1)
688
689         # Better some package name than nothing
690         if max_package is None:
691             max_package = package
692
693         if max_vercode is None or (vercode is not None and vercode > max_vercode):
694             max_version = version
695             max_vercode = vercode
696             max_package = package
697
698     if max_version is None:
699         max_version = "Unknown"
700
701     return (max_version, max_vercode, max_package)
702
703 class BuildException(Exception):
704     def __init__(self, value, stdout = None, stderr = None):
705         self.value = value
706         self.stdout = stdout
707         self.stderr = stderr
708
709     def get_wikitext(self):
710         ret = repr(self.value) + "\n"
711         if self.stdout:
712             ret += "=stdout=\n"
713             ret += "<pre>\n"
714             ret += str(self.stdout)
715             ret += "</pre>\n"
716         if self.stderr:
717             ret += "=stderr=\n"
718             ret += "<pre>\n"
719             ret += str(self.stderr)
720             ret += "</pre>\n"
721         return ret
722
723     def __str__(self):
724         ret = repr(self.value)
725         if self.stdout:
726             ret += "\n==== stdout begin ====\n%s\n==== stdout end ====" % self.stdout.strip()
727         if self.stderr:
728             ret += "\n==== stderr begin ====\n%s\n==== stderr end ====" % self.stderr.strip()
729         return ret
730
731 class VCSException(Exception):
732     def __init__(self, value):
733         self.value = value
734
735     def __str__(self):
736         return repr(self.value)
737
738 # Get the specified source library.
739 # Returns the path to it. Normally this is the path to be used when referencing
740 # it, which may be a subdirectory of the actual project. If you want the base
741 # directory of the project, pass 'basepath=True'.
742 def getsrclib(spec, srclib_dir, srclibpaths=[], subdir=None, target=None,
743         basepath=False, raw=False, prepare=True, preponly=False):
744
745     number = None
746     subdir = None
747     if raw:
748         name = spec
749         ref = None
750     else:
751         name, ref = spec.split('@')
752         if ':' in name:
753             number, name = name.split(':', 1)
754         if '/' in name:
755             name, subdir = name.split('/',1)
756
757     srclib_path = os.path.join('srclibs', name + ".txt")
758
759     if not os.path.exists(srclib_path):
760         raise BuildException('srclib ' + name + ' not found.')
761
762     srclib = metadata.parse_srclib(srclib_path)
763
764     sdir = os.path.join(srclib_dir, name)
765
766     if not preponly:
767         vcs = getvcs(srclib["Repo Type"], srclib["Repo"], sdir)
768         vcs.srclib = (name, number, sdir)
769         if ref:
770             vcs.gotorevision(ref)
771
772         if raw:
773             return vcs
774
775     libdir = None
776     if subdir:
777         libdir = os.path.join(sdir, subdir)
778     elif srclib["Subdir"]:
779         for subdir in srclib["Subdir"]:
780             libdir_candidate = os.path.join(sdir, subdir)
781             if os.path.exists(libdir_candidate):
782                 libdir = libdir_candidate
783                 break
784
785     if libdir is None:
786         libdir = sdir
787
788     if srclib["Srclibs"]:
789         n=1
790         for lib in srclib["Srclibs"].split(','):
791             s_tuple = None
792             for t in srclibpaths:
793                 if t[0] == lib:
794                     s_tuple = t
795                     break
796             if s_tuple is None:
797                 raise BuildException('Missing recursive srclib %s for %s' % (
798                     lib, name))
799             place_srclib(libdir, n, s_tuple[2])
800             n+=1
801
802     if prepare:
803
804         if srclib["Prepare"]:
805             cmd = replace_config_vars(srclib["Prepare"])
806
807             p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=libdir)
808             if p.returncode != 0:
809                 raise BuildException("Error running prepare command for srclib %s"
810                         % name, p.stdout, p.stderr)
811         
812         if srclib["Update Project"] == "Yes":
813             print "Updating srclib %s at path %s" % (name, libdir)
814             cmd = [os.path.join(config['sdk_path'], 'tools', 'android'),
815                 'update', 'project', '-p', libdir]
816             if target:
817                 cmd += ['-t', target]
818             p = FDroidPopen(cmd)
819             # Check to see whether an error was returned without a proper exit
820             # code (this is the case for the 'no target set or target invalid'
821             # error)
822             if p.returncode != 0 or (p.stderr != "" and
823                     p.stderr.startswith("Error: ")):
824                 raise BuildException("Failed to update srclib project {0}"
825                         .format(name), p.stdout, p.stderr)
826
827             remove_signing_keys(libdir)
828
829     if basepath:
830         libdir = sdir
831
832     return (name, number, libdir)
833
834
835 # Prepare the source code for a particular build
836 #  'vcs'         - the appropriate vcs object for the application
837 #  'app'         - the application details from the metadata
838 #  'build'       - the build details from the metadata
839 #  'build_dir'   - the path to the build directory, usually
840 #                   'build/app.id'
841 #  'srclib_dir'  - the path to the source libraries directory, usually
842 #                   'build/srclib'
843 #  'extlib_dir'  - the path to the external libraries directory, usually
844 #                   'build/extlib'
845 # Returns the (root, srclibpaths) where:
846 #   'root' is the root directory, which may be the same as 'build_dir' or may
847 #          be a subdirectory of it.
848 #   'srclibpaths' is information on the srclibs being used
849 def prepare_source(vcs, app, build, build_dir, srclib_dir, extlib_dir, onserver=False):
850
851     # Optionally, the actual app source can be in a subdirectory...
852     if 'subdir' in build:
853         root_dir = os.path.join(build_dir, build['subdir'])
854     else:
855         root_dir = build_dir
856
857     # Get a working copy of the right revision...
858     print "Getting source for revision " + build['commit']
859     vcs.gotorevision(build['commit'])
860
861     # Check that a subdir (if we're using one) exists. This has to happen
862     # after the checkout, since it might not exist elsewhere...
863     if not os.path.exists(root_dir):
864         raise BuildException('Missing subdir ' + root_dir)
865
866     # Initialise submodules if requred...
867     if build['submodules']:
868         if options.verbose:
869             print "Initialising submodules..."
870         vcs.initsubmodules()
871
872     # Run an init command if one is required...
873     if 'init' in build:
874         cmd = replace_config_vars(build['init'])
875         if options.verbose:
876             print "Running 'init' commands in %s" % root_dir
877
878         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
879         if p.returncode != 0:
880             raise BuildException("Error running init command for %s:%s" %
881                     (app['id'], build['version']), p.stdout, p.stderr)
882
883     # Generate (or update) the ant build file, build.xml...
884     updatemode = build.get('update', 'auto')
885     if (updatemode != 'no'
886             and build.get('maven', 'no') == 'no'
887             and build.get('kivy', 'no') == 'no'
888             and build.get('gradle', 'no') == 'no'):
889         parms = [os.path.join(config['sdk_path'], 'tools', 'android'),
890                 'update', 'project']
891         if 'target' in build and build['target']:
892             parms += ['-t', build['target']]
893         update_dirs = None
894         if updatemode == 'auto':
895             update_dirs = ['.'] + ant_subprojects(root_dir)
896         else:
897             update_dirs = [d.strip() for d in updatemode.split(';')]
898         # Force build.xml update if necessary...
899         if updatemode == 'force' or 'target' in build:
900             if updatemode == 'force':
901                 update_dirs = ['.']
902             buildxml = os.path.join(root_dir, 'build.xml')
903             if os.path.exists(buildxml):
904                 print 'Force-removing old build.xml'
905                 os.remove(buildxml)
906
907         for d in update_dirs:
908             # Remove gen and bin dirs in libraries
909             # rid of them...
910             for baddir in [
911                     'gen', 'bin', 'obj', # ant
912                     'libs/armeabi-v7a', 'libs/armeabi', # jni
913                     'libs/mips', 'libs/x86']:
914                 badpath = os.path.join(root_dir, d, baddir)
915                 if os.path.exists(badpath):
916                     print "Removing '%s'" % badpath
917                     shutil.rmtree(badpath)
918             dparms = parms + ['-p', d]
919             if options.verbose:
920                 if d == '.':
921                     print "Updating main project..."
922                 else:
923                     print "Updating subproject %s..." % d
924             p = FDroidPopen(dparms, cwd=root_dir)
925             # Check to see whether an error was returned without a proper exit
926             # code (this is the case for the 'no target set or target invalid'
927             # error)
928             if p.returncode != 0 or (p.stderr != "" and
929                     p.stderr.startswith("Error: ")):
930                 raise BuildException("Failed to update project at %s" % d,
931                         p.stdout, p.stderr)
932
933     # Update the local.properties file...
934     localprops = [ os.path.join(build_dir, 'local.properties') ]
935     if 'subdir' in build:
936         localprops += [ os.path.join(root_dir, 'local.properties') ]
937     for path in localprops:
938         if not os.path.isfile(path):
939             continue
940         if options.verbose:
941             print "Updating properties file at %s" % path
942         f = open(path, 'r')
943         props = f.read()
944         f.close()
945         props += '\n'
946         # Fix old-fashioned 'sdk-location' by copying
947         # from sdk.dir, if necessary...
948         if build['oldsdkloc']:
949             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
950                 re.S|re.M).group(1)
951             props += "sdk-location=%s\n" % sdkloc
952         else:
953             props += "sdk.dir=%s\n" % config['sdk_path']
954             props += "sdk-location=%s\n" % ['sdk_path']
955         # Add ndk location...
956         props += "ndk.dir=%s\n" % config['ndk_path']
957         props += "ndk-location=%s\n" % config['ndk_path']
958         # Add java.encoding if necessary...
959         if 'encoding' in build:
960             props += "java.encoding=%s\n" % build['encoding']
961         f = open(path, 'w')
962         f.write(props)
963         f.close()
964
965     flavour = None
966     if build.get('gradle', 'no') != 'no':
967         flavour = build['gradle'].split('@')[0]
968         if flavour in ['main', 'yes', '']:
969             flavour = None
970
971     # Remove forced debuggable flags
972     print "Removing debuggable flags..."
973     for path in manifest_paths(root_dir, flavour):
974         if not os.path.isfile(path):
975             continue
976         if subprocess.call(['sed','-i',
977             's/android:debuggable="[^"]*"//g', path]) != 0:
978             raise BuildException("Failed to remove debuggable flags")
979
980     # Insert version code and number into the manifest if necessary...
981     if build['forceversion']:
982         print "Changing the version name..."
983         for path in manifest_paths(root_dir, flavour):
984             if not os.path.isfile(path):
985                 continue
986             if path.endswith('.xml'):
987                 if subprocess.call(['sed','-i',
988                     's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
989                     path]) != 0:
990                     raise BuildException("Failed to amend manifest")
991             elif path.endswith('.gradle'):
992                 if subprocess.call(['sed','-i',
993                     's/versionName[ ]*=[ ]*"[^"]*"/versionName = "' + build['version'] + '"/g',
994                     path]) != 0:
995                     raise BuildException("Failed to amend build.gradle")
996     if build['forcevercode']:
997         print "Changing the version code..."
998         for path in manifest_paths(root_dir, flavour):
999             if not os.path.isfile(path):
1000                 continue
1001             if path.endswith('.xml'):
1002                 if subprocess.call(['sed','-i',
1003                     's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1004                     path]) != 0:
1005                     raise BuildException("Failed to amend manifest")
1006             elif path.endswith('.gradle'):
1007                 if subprocess.call(['sed','-i',
1008                     's/versionCode[ ]*=[ ]*[0-9]*/versionCode = ' + build['vercode'] + '/g',
1009                     path]) != 0:
1010                     raise BuildException("Failed to amend build.gradle")
1011
1012     # Delete unwanted files...
1013     if 'rm' in build:
1014         for part in build['rm'].split(';'):
1015             dest = os.path.join(build_dir, part.strip())
1016             rdest = os.path.abspath(dest)
1017             if options.verbose:
1018                 print "Removing {0}".format(rdest)
1019             if not rdest.startswith(os.path.abspath(build_dir)):
1020                 raise BuildException("rm for {1} is outside build root {0}".format(
1021                     os.path.abspath(build_dir),os.path.abspath(dest)))
1022             if rdest == os.path.abspath(build_dir):
1023                 raise BuildException("rm removes whole build directory")
1024             if os.path.lexists(rdest):
1025                 if os.path.islink(rdest):
1026                     subprocess.call('unlink ' + rdest, shell=True)
1027                 else:
1028                     subprocess.call('rm -rf ' + rdest, shell=True)
1029             else:
1030                 if options.verbose:
1031                     print "...but it didn't exist"
1032
1033     # Fix apostrophes translation files if necessary...
1034     if build['fixapos']:
1035         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1036             for filename in files:
1037                 if filename.endswith('.xml'):
1038                     if subprocess.call(['sed','-i','s@' +
1039                         r"\([^\\]\)'@\1\\'" +
1040                         '@g',
1041                         os.path.join(root, filename)]) != 0:
1042                         raise BuildException("Failed to amend " + filename)
1043
1044     # Fix translation files if necessary...
1045     if build['fixtrans']:
1046         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1047             for filename in files:
1048                 if filename.endswith('.xml'):
1049                     f = open(os.path.join(root, filename))
1050                     changed = False
1051                     outlines = []
1052                     for line in f:
1053                         num = 1
1054                         index = 0
1055                         oldline = line
1056                         while True:
1057                             index = line.find("%", index)
1058                             if index == -1:
1059                                 break
1060                             next = line[index+1:index+2]
1061                             if next == "s" or next == "d":
1062                                 line = (line[:index+1] +
1063                                         str(num) + "$" +
1064                                         line[index+1:])
1065                                 num += 1
1066                                 index += 3
1067                             else:
1068                                 index += 1
1069                         # We only want to insert the positional arguments
1070                         # when there is more than one argument...
1071                         if oldline != line:
1072                             if num > 2:
1073                                 changed = True
1074                             else:
1075                                 line = oldline
1076                         outlines.append(line)
1077                     f.close()
1078                     if changed:
1079                         f = open(os.path.join(root, filename), 'w')
1080                         f.writelines(outlines)
1081                         f.close()
1082
1083     remove_signing_keys(build_dir)
1084
1085     # Add required external libraries...
1086     if 'extlibs' in build:
1087         print "Collecting prebuilt libraries..."
1088         libsdir = os.path.join(root_dir, 'libs')
1089         if not os.path.exists(libsdir):
1090             os.mkdir(libsdir)
1091         for lib in build['extlibs'].split(';'):
1092             lib = lib.strip()
1093             if options.verbose:
1094                 print "...installing extlib {0}".format(lib)
1095             libf = os.path.basename(lib)
1096             libsrc = os.path.join(extlib_dir, lib)
1097             if not os.path.exists(libsrc):
1098                 raise BuildException("Missing extlib file {0}".format(libsrc))
1099             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1100
1101     # Get required source libraries...
1102     srclibpaths = []
1103     if 'srclibs' in build:
1104         target=build['target'] if 'target' in build else None
1105         print "Collecting source libraries..."
1106         for lib in build['srclibs'].split(';'):
1107             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1108                 target=target, preponly=onserver))
1109
1110     # Apply patches if any
1111     if 'patch' in build:
1112         for patch in build['patch'].split(';'):
1113             patch = patch.strip()
1114             print "Applying " + patch
1115             patch_path = os.path.join('metadata', app['id'], patch)
1116             if subprocess.call(['patch', '-p1',
1117                             '-i', os.path.abspath(patch_path)], cwd=build_dir) != 0:
1118                 raise BuildException("Failed to apply patch %s" % patch_path)
1119
1120     for name, number, libpath in srclibpaths:
1121         place_srclib(root_dir, int(number) if number else None, libpath)
1122
1123     basesrclib = vcs.getsrclib()
1124     # If one was used for the main source, add that too.
1125     if basesrclib:
1126         srclibpaths.append(basesrclib)
1127
1128     # Run a pre-build command if one is required...
1129     if 'prebuild' in build:
1130         cmd = replace_config_vars(build['prebuild'])
1131
1132         # Substitute source library paths into prebuild commands...
1133         for name, number, libpath in srclibpaths:
1134             libpath = os.path.relpath(libpath, root_dir)
1135             cmd = cmd.replace('$$' + name + '$$', libpath)
1136
1137         if options.verbose:
1138             print "Running 'prebuild' commands in %s" % root_dir
1139
1140         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1141         if p.returncode != 0:
1142             raise BuildException("Error running prebuild command for %s:%s" %
1143                     (app['id'], build['version']), p.stdout, p.stderr)
1144
1145     return (root_dir, srclibpaths)
1146
1147
1148 # Scan the source code in the given directory (and all subdirectories)
1149 # and return a list of potential problems.
1150 def scan_source(build_dir, root_dir, thisbuild):
1151
1152     problems = []
1153
1154     # Common known non-free blobs (always lower case):
1155     usual_suspects = ['flurryagent',
1156                       'paypal_mpl',
1157                       'libgoogleanalytics',
1158                       'admob-sdk-android',
1159                       'googleadview',
1160                       'googleadmobadssdk',
1161                       'google-play-services',
1162                       'crittercism',
1163                       'heyzap',
1164                       'jpct-ae',
1165                       'youtubeandroidplayerapi',
1166                       'bugsense',
1167                       'crashlytics',
1168                       'ouya-sdk']
1169
1170     def getpaths(field):
1171         paths = []
1172         if field not in thisbuild:
1173             return paths
1174         for p in thisbuild[field].split(';'):
1175             p = p.strip()
1176             if p == '.':
1177                 p = '/'
1178             elif p.startswith('./'):
1179                 p = p[1:]
1180             elif not p.startswith('/'):
1181                 p = '/' + p;
1182             if p not in paths:
1183                 paths.append(p)
1184         return paths
1185
1186     scanignore = getpaths('scanignore')
1187     scandelete = getpaths('scandelete')
1188
1189     ms = magic.open(magic.MIME_TYPE)
1190     ms.load()
1191
1192     def toignore(fd):
1193         for i in scanignore:
1194             if fd.startswith(i):
1195                 return True
1196         return False
1197
1198     def todelete(fd):
1199         for i in scandelete:
1200             if fd.startswith(i):
1201                 return True
1202         return False
1203
1204     def removeproblem(what, fd, fp):
1205         print 'Removing %s at %s' % (what, fd)
1206         os.remove(fp)
1207     
1208     def handleproblem(what, fd, fp):
1209         if todelete(fd):
1210             removeproblem(what, fd, fp)
1211         else:
1212             problems.append('Found %s at %s' % (what, fd))
1213
1214     # Iterate through all files in the source code...
1215     for r,d,f in os.walk(build_dir):
1216         for curfile in f:
1217
1218             if '/.hg' in r or '/.git' in r or '/.svn' in r:
1219                 continue
1220
1221             # Path (relative) to the file...
1222             fp = os.path.join(r, curfile)
1223             fd = fp[len(build_dir):]
1224
1225             # Check if this file has been explicitly excluded from scanning...
1226             if toignore(fd):
1227                 continue
1228
1229             for suspect in usual_suspects:
1230                 if suspect in curfile.lower():
1231                     handleproblem('usual supect', fd, fp)
1232
1233             mime = ms.file(fp)
1234             if mime == 'application/x-sharedlib':
1235                 handleproblem('shared library', fd, fp)
1236             elif mime == 'application/x-archive':
1237                 handleproblem('static library', fd, fp)
1238             elif mime == 'application/x-executable':
1239                 handleproblem('binary executable', fd, fp)
1240             elif mime == 'application/jar' and fp.endswith('.apk'):
1241                 removeproblem('APK file', fd, fp)
1242
1243             elif curfile.endswith('.java'):
1244                 for line in file(fp):
1245                     if 'DexClassLoader' in line:
1246                         handleproblem('DexClassLoader', fd, fp)
1247                         break
1248     ms.close()
1249
1250     # Presence of a jni directory without buildjni=yes might
1251     # indicate a problem... (if it's not a problem, explicitly use
1252     # buildjni=no to bypass this check)
1253     if (os.path.exists(os.path.join(root_dir, 'jni')) and 
1254             thisbuild.get('buildjni') is None):
1255         msg = 'Found jni directory, but buildjni is not enabled'
1256         problems.append(msg)
1257
1258     return problems
1259
1260
1261 class KnownApks:
1262
1263     def __init__(self):
1264         self.path = os.path.join('stats', 'known_apks.txt')
1265         self.apks = {}
1266         if os.path.exists(self.path):
1267             for line in file( self.path):
1268                 t = line.rstrip().split(' ')
1269                 if len(t) == 2:
1270                     self.apks[t[0]] = (t[1], None)
1271                 else:
1272                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1273         self.changed = False
1274
1275     def writeifchanged(self):
1276         if self.changed:
1277             if not os.path.exists('stats'):
1278                 os.mkdir('stats')
1279             f = open(self.path, 'w')
1280             lst = []
1281             for apk, app in self.apks.iteritems():
1282                 appid, added = app
1283                 line = apk + ' ' + appid
1284                 if added:
1285                     line += ' ' + time.strftime('%Y-%m-%d', added)
1286                 lst.append(line)
1287             for line in sorted(lst):
1288                 f.write(line + '\n')
1289             f.close()
1290
1291     # Record an apk (if it's new, otherwise does nothing)
1292     # Returns the date it was added.
1293     def recordapk(self, apk, app):
1294         if not apk in self.apks:
1295             self.apks[apk] = (app, time.gmtime(time.time()))
1296             self.changed = True
1297         _, added = self.apks[apk]
1298         return added
1299
1300     # Look up information - given the 'apkname', returns (app id, date added/None).
1301     # Or returns None for an unknown apk.
1302     def getapp(self, apkname):
1303         if apkname in self.apks:
1304             return self.apks[apkname]
1305         return None
1306
1307     # Get the most recent 'num' apps added to the repo, as a list of package ids
1308     # with the most recent first.
1309     def getlatest(self, num):
1310         apps = {}
1311         for apk, app in self.apks.iteritems():
1312             appid, added = app
1313             if added:
1314                 if appid in apps:
1315                     if apps[appid] > added:
1316                         apps[appid] = added
1317                 else:
1318                     apps[appid] = added
1319         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1320         lst = [app for app,added in sortedapps]
1321         lst.reverse()
1322         return lst
1323
1324 def isApkDebuggable(apkfile, config):
1325     """Returns True if the given apk file is debuggable
1326
1327     :param apkfile: full path to the apk to check"""
1328
1329     p = subprocess.Popen([os.path.join(config['sdk_path'],
1330         'build-tools', config['build_tools'], 'aapt'),
1331         'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1332         stdout=subprocess.PIPE)
1333     output = p.communicate()[0]
1334     if p.returncode != 0:
1335         print "ERROR: Failed to get apk manifest information"
1336         sys.exit(1)
1337     for line in output.splitlines():
1338         if line.find('android:debuggable') != -1 and not line.endswith('0x0'):
1339             return True
1340     return False
1341
1342
1343 class AsynchronousFileReader(threading.Thread):
1344     '''
1345     Helper class to implement asynchronous reading of a file
1346     in a separate thread. Pushes read lines on a queue to
1347     be consumed in another thread.
1348     '''
1349  
1350     def __init__(self, fd, queue):
1351         assert isinstance(queue, Queue.Queue)
1352         assert callable(fd.readline)
1353         threading.Thread.__init__(self)
1354         self._fd = fd
1355         self._queue = queue
1356  
1357     def run(self):
1358         '''The body of the tread: read lines and put them on the queue.'''
1359         for line in iter(self._fd.readline, ''):
1360             self._queue.put(line)
1361  
1362     def eof(self):
1363         '''Check whether there is no more content to expect.'''
1364         return not self.is_alive() and self._queue.empty()
1365
1366 class PopenResult:
1367     returncode = None
1368     stdout = ''
1369     stderr = ''
1370     stdout_apk = ''
1371
1372 def FDroidPopen(commands, cwd=None):
1373     """
1374     Runs a command the FDroid way and returns return code and output
1375
1376     :param commands, cwd: like subprocess.Popen
1377     """
1378
1379     if options.verbose:
1380         if cwd:
1381             print "Directory: %s" % cwd
1382         print " > %s" % ' '.join(commands)
1383
1384     result = PopenResult()
1385     p = subprocess.Popen(commands, cwd=cwd,
1386             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1387     
1388     stdout_queue = Queue.Queue()
1389     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1390     stdout_reader.start()
1391     stderr_queue = Queue.Queue()
1392     stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1393     stderr_reader.start()
1394     
1395     # Check the queues for output (until there is no more to get)
1396     while not stdout_reader.eof() or not stderr_reader.eof():
1397         # Show what we received from standard output
1398         while not stdout_queue.empty():
1399             line = stdout_queue.get()
1400             if options.verbose:
1401                 # Output directly to console
1402                 sys.stdout.write(line)
1403                 sys.stdout.flush()
1404             result.stdout += line
1405
1406         # Show what we received from standard error
1407         while not stderr_queue.empty():
1408             line = stderr_queue.get()
1409             if options.verbose:
1410                 # Output directly to console
1411                 sys.stderr.write(line)
1412                 sys.stderr.flush()
1413             result.stderr += line
1414         time.sleep(0.2)
1415
1416     p.communicate()
1417     result.returncode = p.returncode
1418     return result
1419
1420 def remove_signing_keys(build_dir):
1421     for root, dirs, files in os.walk(build_dir):
1422         if 'build.gradle' in files:
1423             path = os.path.join(root, 'build.gradle')
1424             changed = False
1425
1426             with open(path, "r") as o:
1427                 lines = o.readlines()
1428             
1429             opened = 0
1430             with open(path, "w") as o:
1431                 for line in lines:
1432                     if 'signingConfigs ' in line:
1433                         opened = 1
1434                         changed = True
1435                     elif opened > 0:
1436                         if '{' in line:
1437                             opened += 1
1438                         elif '}' in line:
1439                             opened -=1
1440                     elif any(s in line for s in (
1441                             ' signingConfig ',
1442                             'android.signingConfigs.',
1443                             'variant.outputFile = ',
1444                             '.readLine(')):
1445                         changed = True
1446                     else:
1447                         o.write(line)
1448
1449             if changed and options.verbose:
1450                 print "Cleaned build.gradle of keysigning configs at %s" % path
1451
1452         for propfile in ('build.properties', 'default.properties', 'ant.properties'):
1453             if propfile in files:
1454                 path = os.path.join(root, propfile)
1455                 changed = False
1456
1457                 with open(path, "r") as o:
1458                     lines = o.readlines()
1459
1460                 with open(path, "w") as o:
1461                     for line in lines:
1462                         if line.startswith('key.store'):
1463                             changed = True
1464                         else:
1465                             o.write(line)
1466
1467                 if changed and options.verbose:
1468                     print "Cleaned %s of keysigning configs at %s" % (propfile,path)
1469
1470 def replace_config_vars(cmd):
1471     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1472     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1473     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1474     return cmd
1475
1476 def place_srclib(root_dir, number, libpath):
1477     if not number:
1478         return
1479     relpath = os.path.relpath(libpath, root_dir)
1480     proppath = os.path.join(root_dir, 'project.properties')
1481
1482     with open(proppath, "r") as o:
1483         lines = o.readlines()
1484
1485     with open(proppath, "w") as o:
1486         placed = False
1487         for line in lines:
1488             if line.startswith('android.library.reference.%d=' % number):
1489                 o.write('android.library.reference.%d=%s\n' % (number,relpath))
1490                 placed = True
1491             else:
1492                 o.write(line)
1493         if not placed:
1494             o.write('android.library.reference.%d=%s\n' % (number,relpath))
1495