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