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