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