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