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