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