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