chiark / gitweb /
47a006b4a1529932a9b99759d3f1d1583c476e0b
[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' : 40,
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[ =]*"([^"]+?)".*').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(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'
922             and build.get('maven', 'no') == 'no'
923             and build.get('kivy', 'no') == 'no'
924             and build.get('gradle', 'no') == 'no'):
925         parms = [os.path.join(config['sdk_path'], 'tools', 'android'),
926                 'update', 'project']
927         if 'target' in build and build['target']:
928             parms += ['-t', build['target']]
929         update_dirs = None
930         if updatemode == 'auto':
931             update_dirs = ['.'] + ant_subprojects(root_dir)
932         else:
933             update_dirs = [d.strip() for d in updatemode.split(';')]
934         # Force build.xml update if necessary...
935         if updatemode == 'force' or 'target' in build:
936             if updatemode == 'force':
937                 update_dirs = ['.']
938             buildxml = os.path.join(root_dir, 'build.xml')
939             if os.path.exists(buildxml):
940                 print 'Force-removing old build.xml'
941                 os.remove(buildxml)
942
943         for d in update_dirs:
944             # Remove gen and bin dirs in libraries
945             # rid of them...
946             for baddir in ['gen', 'bin', 'obj']
947                 badpath = os.path.join(root_dir, d, baddir)
948                 if os.path.exists(badpath):
949                     print "Removing '%s'" % badpath
950                     shutil.rmtree(badpath)
951             dparms = parms + ['-p', d]
952             if options.verbose:
953                 if d == '.':
954                     print "Updating main project..."
955                 else:
956                     print "Updating subproject %s..." % d
957             p = FDroidPopen(dparms, cwd=root_dir)
958             # Check to see whether an error was returned without a proper exit
959             # code (this is the case for the 'no target set or target invalid'
960             # error)
961             if p.returncode != 0 or (p.stderr != "" and
962                     p.stderr.startswith("Error: ")):
963                 raise BuildException("Failed to update project at %s" % d,
964                         p.stdout, p.stderr)
965
966     # Update the local.properties file...
967     localprops = [ os.path.join(build_dir, 'local.properties') ]
968     if 'subdir' in build:
969         localprops += [ os.path.join(root_dir, 'local.properties') ]
970     for path in localprops:
971         if not os.path.isfile(path):
972             continue
973         if options.verbose:
974             print "Updating properties file at %s" % path
975         f = open(path, 'r')
976         props = f.read()
977         f.close()
978         props += '\n'
979         # Fix old-fashioned 'sdk-location' by copying
980         # from sdk.dir, if necessary...
981         if build['oldsdkloc']:
982             sdkloc = re.match(r".*^sdk.dir=(\S+)$.*", props,
983                 re.S|re.M).group(1)
984             props += "sdk-location=%s\n" % sdkloc
985         else:
986             props += "sdk.dir=%s\n" % config['sdk_path']
987             props += "sdk-location=%s\n" % ['sdk_path']
988         # Add ndk location...
989         props += "ndk.dir=%s\n" % config['ndk_path']
990         props += "ndk-location=%s\n" % config['ndk_path']
991         # Add java.encoding if necessary...
992         if 'encoding' in build:
993             props += "java.encoding=%s\n" % build['encoding']
994         f = open(path, 'w')
995         f.write(props)
996         f.close()
997
998     flavour = None
999     if build.get('gradle', 'no') != 'no':
1000         flavour = build['gradle'].split('@')[0]
1001         if flavour in ['main', 'yes', '']:
1002             flavour = None
1003
1004     # Remove forced debuggable flags
1005     print "Removing debuggable flags..."
1006     for path in manifest_paths(root_dir, flavour):
1007         if not os.path.isfile(path):
1008             continue
1009         if subprocess.call(['sed','-i',
1010             's/android:debuggable="[^"]*"//g', path]) != 0:
1011             raise BuildException("Failed to remove debuggable flags")
1012
1013     # Insert version code and number into the manifest if necessary...
1014     if build['forceversion']:
1015         print "Changing the version name..."
1016         for path in manifest_paths(root_dir, flavour):
1017             if not os.path.isfile(path):
1018                 continue
1019             if has_extension(path, 'xml'):
1020                 if subprocess.call(['sed','-i',
1021                     's/android:versionName="[^"]*"/android:versionName="' + build['version'] + '"/g',
1022                     path]) != 0:
1023                     raise BuildException("Failed to amend manifest")
1024             elif has_extension(path, 'gradle'):
1025                 if subprocess.call(['sed','-i',
1026                     's/versionName[ ]*=[ ]*"[^"]*"/versionName = "' + build['version'] + '"/g',
1027                     path]) != 0:
1028                     raise BuildException("Failed to amend build.gradle")
1029     if build['forcevercode']:
1030         print "Changing the version code..."
1031         for path in manifest_paths(root_dir, flavour):
1032             if not os.path.isfile(path):
1033                 continue
1034             if has_extension(path, 'xml'):
1035                 if subprocess.call(['sed','-i',
1036                     's/android:versionCode="[^"]*"/android:versionCode="' + build['vercode'] + '"/g',
1037                     path]) != 0:
1038                     raise BuildException("Failed to amend manifest")
1039             elif has_extension(path, 'gradle'):
1040                 if subprocess.call(['sed','-i',
1041                     's/versionCode[ ]*=[ ]*[0-9]*/versionCode = ' + build['vercode'] + '/g',
1042                     path]) != 0:
1043                     raise BuildException("Failed to amend build.gradle")
1044
1045     # Delete unwanted files...
1046     if 'rm' in build:
1047         for part in build['rm'].split(';'):
1048             dest = os.path.join(build_dir, part.strip())
1049             rdest = os.path.abspath(dest)
1050             if options.verbose:
1051                 print "Removing {0}".format(rdest)
1052             if not rdest.startswith(os.path.abspath(build_dir)):
1053                 raise BuildException("rm for {1} is outside build root {0}".format(
1054                     os.path.abspath(build_dir),os.path.abspath(dest)))
1055             if rdest == os.path.abspath(build_dir):
1056                 raise BuildException("rm removes whole build directory")
1057             if os.path.lexists(rdest):
1058                 if os.path.islink(rdest):
1059                     subprocess.call('unlink ' + rdest, shell=True)
1060                 else:
1061                     subprocess.call('rm -rf ' + rdest, shell=True)
1062             else:
1063                 if options.verbose:
1064                     print "...but it didn't exist"
1065
1066     # Fix apostrophes translation files if necessary...
1067     if build['fixapos']:
1068         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1069             for filename in files:
1070                 if has_extension(filename, 'xml'):
1071                     if subprocess.call(['sed','-i','s@' +
1072                         r"\([^\\]\)'@\1\\'" +
1073                         '@g',
1074                         os.path.join(root, filename)]) != 0:
1075                         raise BuildException("Failed to amend " + filename)
1076
1077     # Fix translation files if necessary...
1078     if build['fixtrans']:
1079         for root, dirs, files in os.walk(os.path.join(root_dir, 'res')):
1080             for filename in files:
1081                 if has_extension(filename, 'xml'):
1082                     f = open(os.path.join(root, filename))
1083                     changed = False
1084                     outlines = []
1085                     for line in f:
1086                         num = 1
1087                         index = 0
1088                         oldline = line
1089                         while True:
1090                             index = line.find("%", index)
1091                             if index == -1:
1092                                 break
1093                             next = line[index+1:index+2]
1094                             if next == "s" or next == "d":
1095                                 line = (line[:index+1] +
1096                                         str(num) + "$" +
1097                                         line[index+1:])
1098                                 num += 1
1099                                 index += 3
1100                             else:
1101                                 index += 1
1102                         # We only want to insert the positional arguments
1103                         # when there is more than one argument...
1104                         if oldline != line:
1105                             if num > 2:
1106                                 changed = True
1107                             else:
1108                                 line = oldline
1109                         outlines.append(line)
1110                     f.close()
1111                     if changed:
1112                         f = open(os.path.join(root, filename), 'w')
1113                         f.writelines(outlines)
1114                         f.close()
1115
1116     remove_signing_keys(build_dir)
1117
1118     # Add required external libraries...
1119     if 'extlibs' in build:
1120         print "Collecting prebuilt libraries..."
1121         libsdir = os.path.join(root_dir, 'libs')
1122         if not os.path.exists(libsdir):
1123             os.mkdir(libsdir)
1124         for lib in build['extlibs'].split(';'):
1125             lib = lib.strip()
1126             if options.verbose:
1127                 print "...installing extlib {0}".format(lib)
1128             libf = os.path.basename(lib)
1129             libsrc = os.path.join(extlib_dir, lib)
1130             if not os.path.exists(libsrc):
1131                 raise BuildException("Missing extlib file {0}".format(libsrc))
1132             shutil.copyfile(libsrc, os.path.join(libsdir, libf))
1133
1134     # Get required source libraries...
1135     srclibpaths = []
1136     if 'srclibs' in build:
1137         target=build['target'] if 'target' in build else None
1138         print "Collecting source libraries..."
1139         for lib in build['srclibs'].split(';'):
1140             srclibpaths.append(getsrclib(lib, srclib_dir, srclibpaths,
1141                 target=target, preponly=onserver))
1142
1143     # Apply patches if any
1144     if 'patch' in build:
1145         for patch in build['patch'].split(';'):
1146             patch = patch.strip()
1147             print "Applying " + patch
1148             patch_path = os.path.join('metadata', app['id'], patch)
1149             if subprocess.call(['patch', '-p1',
1150                             '-i', os.path.abspath(patch_path)], cwd=build_dir) != 0:
1151                 raise BuildException("Failed to apply patch %s" % patch_path)
1152
1153     for name, number, libpath in srclibpaths:
1154         place_srclib(root_dir, int(number) if number else None, libpath)
1155
1156     basesrclib = vcs.getsrclib()
1157     # If one was used for the main source, add that too.
1158     if basesrclib:
1159         srclibpaths.append(basesrclib)
1160
1161     # Run a pre-build command if one is required...
1162     if 'prebuild' in build:
1163         cmd = replace_config_vars(build['prebuild'])
1164
1165         # Substitute source library paths into prebuild commands...
1166         for name, number, libpath in srclibpaths:
1167             libpath = os.path.relpath(libpath, root_dir)
1168             cmd = cmd.replace('$$' + name + '$$', libpath)
1169
1170         if options.verbose:
1171             print "Running 'prebuild' commands in %s" % root_dir
1172
1173         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
1174         if p.returncode != 0:
1175             raise BuildException("Error running prebuild command for %s:%s" %
1176                     (app['id'], build['version']), p.stdout, p.stderr)
1177
1178     return (root_dir, srclibpaths)
1179
1180 # Scan the source code in the given directory (and all subdirectories)
1181 # and return a list of potential problems.
1182 def scan_source(build_dir, root_dir, thisbuild):
1183
1184     problems = []
1185
1186     # Common known non-free blobs (always lower case):
1187     usual_suspects = ['flurryagent',
1188                       'paypal_mpl',
1189                       'libgoogleanalytics',
1190                       'admob-sdk-android',
1191                       'googleadview',
1192                       'googleadmobadssdk',
1193                       'google-play-services',
1194                       'crittercism',
1195                       'heyzap',
1196                       'jpct-ae',
1197                       'youtubeandroidplayerapi',
1198                       'bugsense',
1199                       'crashlytics',
1200                       'ouya-sdk']
1201
1202     def getpaths(field):
1203         paths = []
1204         if field not in thisbuild:
1205             return paths
1206         for p in thisbuild[field].split(';'):
1207             p = p.strip()
1208             if p == '.':
1209                 p = '/'
1210             elif p.startswith('./'):
1211                 p = p[1:]
1212             elif not p.startswith('/'):
1213                 p = '/' + p;
1214             if p not in paths:
1215                 paths.append(p)
1216         return paths
1217
1218     scanignore = getpaths('scanignore')
1219     scandelete = getpaths('scandelete')
1220
1221     ms = magic.open(magic.MIME_TYPE)
1222     ms.load()
1223
1224     def toignore(fd):
1225         for i in scanignore:
1226             if fd.startswith(i):
1227                 return True
1228         return False
1229
1230     def todelete(fd):
1231         for i in scandelete:
1232             if fd.startswith(i):
1233                 return True
1234         return False
1235
1236     def removeproblem(what, fd, fp):
1237         print 'Removing %s at %s' % (what, fd)
1238         os.remove(fp)
1239
1240     def handleproblem(what, fd, fp):
1241         if todelete(fd):
1242             removeproblem(what, fd, fp)
1243         else:
1244             problems.append('Found %s at %s' % (what, fd))
1245
1246     def warnproblem(what, fd, fp):
1247         print 'Warning: Found %s at %s' % (what, fd)
1248
1249     # Iterate through all files in the source code...
1250     for r,d,f in os.walk(build_dir):
1251         for curfile in f:
1252
1253             if '/.hg' in r or '/.git' in r or '/.svn' in r:
1254                 continue
1255
1256             # Path (relative) to the file...
1257             fp = os.path.join(r, curfile)
1258             fd = fp[len(build_dir):]
1259
1260             # Check if this file has been explicitly excluded from scanning...
1261             if toignore(fd):
1262                 continue
1263
1264             for suspect in usual_suspects:
1265                 if suspect in curfile.lower():
1266                     handleproblem('usual supect', fd, fp)
1267
1268             mime = ms.file(fp)
1269             if mime == 'application/x-sharedlib':
1270                 handleproblem('shared library', fd, fp)
1271             elif mime == 'application/x-archive':
1272                 handleproblem('static library', fd, fp)
1273             elif mime == 'application/x-executable':
1274                 handleproblem('binary executable', fd, fp)
1275             elif mime == 'application/jar' and has_extension(fp, 'apk'):
1276                 removeproblem('APK file', fd, fp)
1277             elif mime == 'application/jar' and has_extension(fp, 'jar'):
1278                 warnproblem('JAR file', fd, fp)
1279
1280             elif has_extension(fp, 'java'):
1281                 for line in file(fp):
1282                     if 'DexClassLoader' in line:
1283                         handleproblem('DexClassLoader', fd, fp)
1284                         break
1285     ms.close()
1286
1287     # Presence of a jni directory without buildjni=yes might
1288     # indicate a problem... (if it's not a problem, explicitly use
1289     # buildjni=no to bypass this check)
1290     if (os.path.exists(os.path.join(root_dir, 'jni')) and
1291             thisbuild.get('buildjni') is None):
1292         msg = 'Found jni directory, but buildjni is not enabled'
1293         problems.append(msg)
1294
1295     return problems
1296
1297
1298 class KnownApks:
1299
1300     def __init__(self):
1301         self.path = os.path.join('stats', 'known_apks.txt')
1302         self.apks = {}
1303         if os.path.exists(self.path):
1304             for line in file( self.path):
1305                 t = line.rstrip().split(' ')
1306                 if len(t) == 2:
1307                     self.apks[t[0]] = (t[1], None)
1308                 else:
1309                     self.apks[t[0]] = (t[1], time.strptime(t[2], '%Y-%m-%d'))
1310         self.changed = False
1311
1312     def writeifchanged(self):
1313         if self.changed:
1314             if not os.path.exists('stats'):
1315                 os.mkdir('stats')
1316             f = open(self.path, 'w')
1317             lst = []
1318             for apk, app in self.apks.iteritems():
1319                 appid, added = app
1320                 line = apk + ' ' + appid
1321                 if added:
1322                     line += ' ' + time.strftime('%Y-%m-%d', added)
1323                 lst.append(line)
1324             for line in sorted(lst):
1325                 f.write(line + '\n')
1326             f.close()
1327
1328     # Record an apk (if it's new, otherwise does nothing)
1329     # Returns the date it was added.
1330     def recordapk(self, apk, app):
1331         if not apk in self.apks:
1332             self.apks[apk] = (app, time.gmtime(time.time()))
1333             self.changed = True
1334         _, added = self.apks[apk]
1335         return added
1336
1337     # Look up information - given the 'apkname', returns (app id, date added/None).
1338     # Or returns None for an unknown apk.
1339     def getapp(self, apkname):
1340         if apkname in self.apks:
1341             return self.apks[apkname]
1342         return None
1343
1344     # Get the most recent 'num' apps added to the repo, as a list of package ids
1345     # with the most recent first.
1346     def getlatest(self, num):
1347         apps = {}
1348         for apk, app in self.apks.iteritems():
1349             appid, added = app
1350             if added:
1351                 if appid in apps:
1352                     if apps[appid] > added:
1353                         apps[appid] = added
1354                 else:
1355                     apps[appid] = added
1356         sortedapps = sorted(apps.iteritems(), key=operator.itemgetter(1))[-num:]
1357         lst = [app for app,added in sortedapps]
1358         lst.reverse()
1359         return lst
1360
1361 def isApkDebuggable(apkfile, config):
1362     """Returns True if the given apk file is debuggable
1363
1364     :param apkfile: full path to the apk to check"""
1365
1366     p = subprocess.Popen([os.path.join(config['sdk_path'],
1367         'build-tools', config['build_tools'], 'aapt'),
1368         'dump', 'xmltree', apkfile, 'AndroidManifest.xml'],
1369         stdout=subprocess.PIPE)
1370     output = p.communicate()[0]
1371     if p.returncode != 0:
1372         print "ERROR: Failed to get apk manifest information"
1373         sys.exit(1)
1374     for line in output.splitlines():
1375         if 'android:debuggable' in line and not line.endswith('0x0'):
1376             return True
1377     return False
1378
1379
1380 class AsynchronousFileReader(threading.Thread):
1381     '''
1382     Helper class to implement asynchronous reading of a file
1383     in a separate thread. Pushes read lines on a queue to
1384     be consumed in another thread.
1385     '''
1386
1387     def __init__(self, fd, queue):
1388         assert isinstance(queue, Queue.Queue)
1389         assert callable(fd.readline)
1390         threading.Thread.__init__(self)
1391         self._fd = fd
1392         self._queue = queue
1393
1394     def run(self):
1395         '''The body of the tread: read lines and put them on the queue.'''
1396         for line in iter(self._fd.readline, ''):
1397             self._queue.put(line)
1398
1399     def eof(self):
1400         '''Check whether there is no more content to expect.'''
1401         return not self.is_alive() and self._queue.empty()
1402
1403 class PopenResult:
1404     returncode = None
1405     stdout = ''
1406     stderr = ''
1407     stdout_apk = ''
1408
1409 def FDroidPopen(commands, cwd=None):
1410     """
1411     Runs a command the FDroid way and returns return code and output
1412
1413     :param commands, cwd: like subprocess.Popen
1414     """
1415
1416     if options.verbose:
1417         if cwd:
1418             print "Directory: %s" % cwd
1419         print " > %s" % ' '.join(commands)
1420
1421     result = PopenResult()
1422     p = subprocess.Popen(commands, cwd=cwd,
1423             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
1424
1425     stdout_queue = Queue.Queue()
1426     stdout_reader = AsynchronousFileReader(p.stdout, stdout_queue)
1427     stdout_reader.start()
1428     stderr_queue = Queue.Queue()
1429     stderr_reader = AsynchronousFileReader(p.stderr, stderr_queue)
1430     stderr_reader.start()
1431
1432     # Check the queues for output (until there is no more to get)
1433     while not stdout_reader.eof() or not stderr_reader.eof():
1434         # Show what we received from standard output
1435         while not stdout_queue.empty():
1436             line = stdout_queue.get()
1437             if options.verbose:
1438                 # Output directly to console
1439                 sys.stdout.write(line)
1440                 sys.stdout.flush()
1441             result.stdout += line
1442
1443         # Show what we received from standard error
1444         while not stderr_queue.empty():
1445             line = stderr_queue.get()
1446             if options.verbose:
1447                 # Output directly to console
1448                 sys.stderr.write(line)
1449                 sys.stderr.flush()
1450             result.stderr += line
1451         time.sleep(0.1)
1452
1453     p.communicate()
1454     result.returncode = p.returncode
1455     return result
1456
1457 def remove_signing_keys(build_dir):
1458     for root, dirs, files in os.walk(build_dir):
1459         if 'build.gradle' in files:
1460             path = os.path.join(root, 'build.gradle')
1461             changed = False
1462
1463             with open(path, "r") as o:
1464                 lines = o.readlines()
1465
1466             opened = 0
1467             with open(path, "w") as o:
1468                 for line in lines:
1469                     if 'signingConfigs ' in line:
1470                         opened = 1
1471                         changed = True
1472                     elif opened > 0:
1473                         if '{' in line:
1474                             opened += 1
1475                         elif '}' in line:
1476                             opened -=1
1477                     elif any(s in line for s in (
1478                             ' signingConfig ',
1479                             'android.signingConfigs.',
1480                             'variant.outputFile = ',
1481                             '.readLine(')):
1482                         changed = True
1483                     else:
1484                         o.write(line)
1485
1486             if changed and options.verbose:
1487                 print "Cleaned build.gradle of keysigning configs at %s" % path
1488
1489         for propfile in ('build.properties', 'default.properties', 'ant.properties'):
1490             if propfile in files:
1491                 path = os.path.join(root, propfile)
1492                 changed = False
1493
1494                 with open(path, "r") as o:
1495                     lines = o.readlines()
1496
1497                 with open(path, "w") as o:
1498                     for line in lines:
1499                         if line.startswith('key.store'):
1500                             changed = True
1501                         else:
1502                             o.write(line)
1503
1504                 if changed and options.verbose:
1505                     print "Cleaned %s of keysigning configs at %s" % (propfile,path)
1506
1507 def replace_config_vars(cmd):
1508     cmd = cmd.replace('$$SDK$$', config['sdk_path'])
1509     cmd = cmd.replace('$$NDK$$', config['ndk_path'])
1510     cmd = cmd.replace('$$MVN3$$', config['mvn3'])
1511     return cmd
1512
1513 def place_srclib(root_dir, number, libpath):
1514     if not number:
1515         return
1516     relpath = os.path.relpath(libpath, root_dir)
1517     proppath = os.path.join(root_dir, 'project.properties')
1518
1519     with open(proppath, "r") as o:
1520         lines = o.readlines()
1521
1522     with open(proppath, "w") as o:
1523         placed = False
1524         for line in lines:
1525             if line.startswith('android.library.reference.%d=' % number):
1526                 o.write('android.library.reference.%d=%s\n' % (number,relpath))
1527                 placed = True
1528             else:
1529                 o.write(line)
1530         if not placed:
1531             o.write('android.library.reference.%d=%s\n' % (number,relpath))
1532