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