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