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