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