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