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