chiark / gitweb /
d83342d50e3b72a0ebaefa5f6dff9cf0be120401
[fdroidserver.git] / fdroidserver / build.py
1 #!/usr/bin/env python3
2 #
3 # build.py - part of the FDroid server tools
4 # Copyright (C) 2010-2014, 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 sys
21 import os
22 import shutil
23 import glob
24 import subprocess
25 import re
26 import resource
27 import tarfile
28 import traceback
29 import time
30 import requests
31 import tempfile
32 from configparser import ConfigParser
33 from argparse import ArgumentParser
34 import logging
35 from gettext import ngettext
36
37 from . import _
38 from . import common
39 from . import net
40 from . import metadata
41 from . import scanner
42 from . import vmtools
43 from .common import FDroidPopen, SdkToolsPopen
44 from .exception import FDroidException, BuildException, VCSException
45
46 try:
47     import paramiko
48 except ImportError:
49     pass
50
51
52 # Note that 'force' here also implies test mode.
53 def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
54     """Do a build on the builder vm.
55
56     :param app: app metadata dict
57     :param build:
58     :param vcs: version control system controller object
59     :param build_dir: local source-code checkout of app
60     :param output_dir: target folder for the build result
61     :param force:
62     """
63
64     global buildserverid
65
66     try:
67         paramiko
68     except NameError:
69         raise BuildException("Paramiko is required to use the buildserver")
70     if options.verbose:
71         logging.getLogger("paramiko").setLevel(logging.INFO)
72     else:
73         logging.getLogger("paramiko").setLevel(logging.WARN)
74
75     sshinfo = vmtools.get_clean_builder('builder')
76
77     try:
78         if not buildserverid:
79             buildserverid = subprocess.check_output(['vagrant', 'ssh', '-c',
80                                                      'cat /home/vagrant/buildserverid'],
81                                                     cwd='builder').rstrip()
82
83         # Open SSH connection...
84         logging.info("Connecting to virtual machine...")
85         sshs = paramiko.SSHClient()
86         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
87         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
88                      port=sshinfo['port'], timeout=300,
89                      look_for_keys=False, key_filename=sshinfo['idfile'])
90
91         homedir = '/home/' + sshinfo['user']
92
93         # Get an SFTP connection...
94         ftp = sshs.open_sftp()
95         ftp.get_channel().settimeout(60)
96
97         # Put all the necessary files in place...
98         ftp.chdir(homedir)
99
100         # Helper to copy the contents of a directory to the server...
101         def send_dir(path):
102             logging.debug("rsyncing " + path + " to " + ftp.getcwd())
103             # TODO this should move to `vagrant rsync` from >= v1.5
104             try:
105                 subprocess.check_output(['rsync', '--recursive', '--perms', '--links', '--quiet', '--rsh=' +
106                                          'ssh -o StrictHostKeyChecking=no' +
107                                          ' -o UserKnownHostsFile=/dev/null' +
108                                          ' -o LogLevel=FATAL' +
109                                          ' -o IdentitiesOnly=yes' +
110                                          ' -o PasswordAuthentication=no' +
111                                          ' -p ' + str(sshinfo['port']) +
112                                          ' -i ' + sshinfo['idfile'],
113                                          path,
114                                          sshinfo['user'] + "@" + sshinfo['hostname'] + ":" + ftp.getcwd()],
115                                         stderr=subprocess.STDOUT)
116             except subprocess.CalledProcessError as e:
117                 raise FDroidException(str(e), e.output.decode())
118
119         logging.info("Preparing server for build...")
120         serverpath = os.path.abspath(os.path.dirname(__file__))
121         ftp.mkdir('fdroidserver')
122         ftp.chdir('fdroidserver')
123         ftp.put(os.path.join(serverpath, '..', 'fdroid'), 'fdroid')
124         ftp.chmod('fdroid', 0o755)
125         send_dir(os.path.join(serverpath))
126         ftp.chdir(homedir)
127
128         ftp.put(os.path.join(serverpath, '..', 'buildserver',
129                              'config.buildserver.py'), 'config.py')
130         ftp.chmod('config.py', 0o600)
131
132         # Copy over the ID (head commit hash) of the fdroidserver in use...
133         subprocess.call('git rev-parse HEAD >' +
134                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
135                         shell=True, cwd=serverpath)
136         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
137
138         # Copy the metadata - just the file for this app...
139         ftp.mkdir('metadata')
140         ftp.mkdir('srclibs')
141         ftp.chdir('metadata')
142         ftp.put(app.metadatapath, os.path.basename(app.metadatapath))
143
144         # And patches if there are any...
145         if os.path.exists(os.path.join('metadata', app.id)):
146             send_dir(os.path.join('metadata', app.id))
147
148         ftp.chdir(homedir)
149         # Create the build directory...
150         ftp.mkdir('build')
151         ftp.chdir('build')
152         ftp.mkdir('extlib')
153         ftp.mkdir('srclib')
154         # Copy any extlibs that are required...
155         if build.extlibs:
156             ftp.chdir(homedir + '/build/extlib')
157             for lib in build.extlibs:
158                 lib = lib.strip()
159                 libsrc = os.path.join('build/extlib', lib)
160                 if not os.path.exists(libsrc):
161                     raise BuildException("Missing extlib {0}".format(libsrc))
162                 lp = lib.split('/')
163                 for d in lp[:-1]:
164                     if d not in ftp.listdir():
165                         ftp.mkdir(d)
166                     ftp.chdir(d)
167                 ftp.put(libsrc, lp[-1])
168                 for _ignored in lp[:-1]:
169                     ftp.chdir('..')
170         # Copy any srclibs that are required...
171         srclibpaths = []
172         if build.srclibs:
173             for lib in build.srclibs:
174                 srclibpaths.append(
175                     common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
176
177         # If one was used for the main source, add that too.
178         basesrclib = vcs.getsrclib()
179         if basesrclib:
180             srclibpaths.append(basesrclib)
181         for name, number, lib in srclibpaths:
182             logging.info("Sending srclib '%s'" % lib)
183             ftp.chdir(homedir + '/build/srclib')
184             if not os.path.exists(lib):
185                 raise BuildException("Missing srclib directory '" + lib + "'")
186             fv = '.fdroidvcs-' + name
187             ftp.put(os.path.join('build/srclib', fv), fv)
188             send_dir(lib)
189             # Copy the metadata file too...
190             ftp.chdir(homedir + '/srclibs')
191             ftp.put(os.path.join('srclibs', name + '.txt'),
192                     name + '.txt')
193         # Copy the main app source code
194         # (no need if it's a srclib)
195         if (not basesrclib) and os.path.exists(build_dir):
196             ftp.chdir(homedir + '/build')
197             fv = '.fdroidvcs-' + app.id
198             ftp.put(os.path.join('build', fv), fv)
199             send_dir(build_dir)
200
201         # Execute the build script...
202         logging.info("Starting build...")
203         chan = sshs.get_transport().open_session()
204         chan.get_pty()
205         cmdline = os.path.join(homedir, 'fdroidserver', 'fdroid')
206         cmdline += ' build --on-server'
207         if force:
208             cmdline += ' --force --test'
209         if options.verbose:
210             cmdline += ' --verbose'
211         if options.skipscan:
212             cmdline += ' --skip-scan'
213         cmdline += " %s:%s" % (app.id, build.versionCode)
214         chan.exec_command('bash --login -c "' + cmdline + '"')
215
216         # Fetch build process output ...
217         try:
218             cmd_stdout = chan.makefile('rb', 1024)
219             output = bytes()
220             output += get_android_tools_version_log(build.ndk_path()).encode()
221             while not chan.exit_status_ready():
222                 line = cmd_stdout.readline()
223                 if line:
224                     if options.verbose:
225                         logging.debug("buildserver > " + str(line, 'utf-8').rstrip())
226                     output += line
227                 else:
228                     time.sleep(0.05)
229             for line in cmd_stdout.readlines():
230                 if options.verbose:
231                     logging.debug("buildserver > " + str(line, 'utf-8').rstrip())
232                 output += line
233         finally:
234             cmd_stdout.close()
235
236         # Check build process exit status ...
237         logging.info("...getting exit status")
238         returncode = chan.recv_exit_status()
239         if returncode != 0:
240             raise BuildException(
241                 "Build.py failed on server for {0}:{1}".format(
242                     app.id, build.versionName), None if options.verbose else str(output, 'utf-8'))
243
244         # Retreive logs...
245         toolsversion_log = common.get_toolsversion_logname(app, build)
246         try:
247             ftp.chdir(os.path.join(homedir, log_dir))
248             ftp.get(toolsversion_log, os.path.join(log_dir, toolsversion_log))
249             logging.debug('retrieved %s', toolsversion_log)
250         except Exception as e:
251             logging.warn('could not get %s from builder vm: %s' % (toolsversion_log, e))
252
253         # Retrieve the built files...
254         logging.info("Retrieving build output...")
255         if force:
256             ftp.chdir(homedir + '/tmp')
257         else:
258             ftp.chdir(homedir + '/unsigned')
259         apkfile = common.get_release_filename(app, build)
260         tarball = common.getsrcname(app, build)
261         try:
262             ftp.get(apkfile, os.path.join(output_dir, apkfile))
263             if not options.notarball:
264                 ftp.get(tarball, os.path.join(output_dir, tarball))
265         except Exception:
266             raise BuildException(
267                 "Build failed for {0}:{1} - missing output files".format(
268                     app.id, build.versionName), None if options.verbose else str(output, 'utf-8'))
269         ftp.close()
270
271     finally:
272         # Suspend the build server.
273         vm = vmtools.get_build_vm('builder')
274         vm.suspend()
275
276
277 def force_gradle_build_tools(build_dir, build_tools):
278     for root, dirs, files in os.walk(build_dir):
279         for filename in files:
280             if not filename.endswith('.gradle'):
281                 continue
282             path = os.path.join(root, filename)
283             if not os.path.isfile(path):
284                 continue
285             logging.debug("Forcing build-tools %s in %s" % (build_tools, path))
286             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
287                                r"""\1buildToolsVersion\2'%s'""" % build_tools,
288                                path)
289
290
291 def transform_first_char(string, method):
292     """Uses method() on the first character of string."""
293     if len(string) == 0:
294         return string
295     if len(string) == 1:
296         return method(string)
297     return method(string[0]) + string[1:]
298
299
300 def has_native_code(apkobj):
301     """aapt checks if there are architecture folders under the lib/ folder
302     so we are simulating the same behaviour"""
303     arch_re = re.compile("^lib/(.*)/.*$")
304     arch = [file for file in apkobj.get_files() if arch_re.match(file)]
305     return False if not arch else True
306
307
308 def get_apk_metadata_aapt(apkfile):
309     """aapt function to extract versionCode, versionName, packageName and nativecode"""
310     vercode = None
311     version = None
312     foundid = None
313     nativecode = None
314
315     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
316
317     for line in p.output.splitlines():
318         if line.startswith("package:"):
319             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
320             m = pat.match(line)
321             if m:
322                 foundid = m.group(1)
323             pat = re.compile(".*versionCode='([0-9]*)'.*")
324             m = pat.match(line)
325             if m:
326                 vercode = m.group(1)
327             pat = re.compile(".*versionName='([^']*)'.*")
328             m = pat.match(line)
329             if m:
330                 version = m.group(1)
331         elif line.startswith("native-code:"):
332             nativecode = line[12:]
333
334     return vercode, version, foundid, nativecode
335
336
337 def get_apk_metadata_androguard(apkfile):
338     """androguard function to extract versionCode, versionName, packageName and nativecode"""
339     try:
340         from androguard.core.bytecodes.apk import APK
341         apkobject = APK(apkfile)
342     except ImportError:
343         raise BuildException("androguard library is not installed and aapt binary not found")
344     except FileNotFoundError:
345         raise BuildException("Could not open apk file for metadata analysis")
346
347     if not apkobject.is_valid_APK():
348         raise BuildException("Invalid APK provided")
349
350     foundid = apkobject.get_package()
351     vercode = apkobject.get_androidversion_code()
352     version = apkobject.get_androidversion_name()
353     nativecode = has_native_code(apkobject)
354
355     return vercode, version, foundid, nativecode
356
357
358 def get_metadata_from_apk(app, build, apkfile):
359     """get the required metadata from the built APK"""
360
361     if common.SdkToolsPopen(['aapt', 'version'], output=False):
362         vercode, version, foundid, nativecode = get_apk_metadata_aapt(apkfile)
363     else:
364         vercode, version, foundid, nativecode = get_apk_metadata_androguard(apkfile)
365
366     # Ignore empty strings or any kind of space/newline chars that we don't
367     # care about
368     if nativecode is not None:
369         nativecode = nativecode.strip()
370         nativecode = None if not nativecode else nativecode
371
372     if build.buildjni and build.buildjni != ['no']:
373         if nativecode is None:
374             raise BuildException("Native code should have been built but none was packaged")
375     if build.novcheck:
376         vercode = build.versionCode
377         version = build.versionName
378     if not version or not vercode:
379         raise BuildException("Could not find version information in build in output")
380     if not foundid:
381         raise BuildException("Could not find package ID in output")
382     if foundid != app.id:
383         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
384
385     return vercode, version
386
387
388 def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
389     """Do a build locally."""
390     ndk_path = build.ndk_path()
391     if build.ndk or (build.buildjni and build.buildjni != ['no']):
392         if not ndk_path:
393             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r12b')
394             logging.critical("Configured versions:")
395             for k, v in config['ndk_paths'].items():
396                 if k.endswith("_orig"):
397                     continue
398                 logging.critical("  %s: %s" % (k, v))
399             raise FDroidException()
400         elif not os.path.isdir(ndk_path):
401             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
402             raise FDroidException()
403
404     common.set_FDroidPopen_env(build)
405
406     # create ..._toolsversion.log when running in builder vm
407     if onserver:
408         # before doing anything, run the sudo commands to setup the VM
409         if build.sudo:
410             logging.info("Running 'sudo' commands in %s" % os.getcwd())
411
412             p = FDroidPopen(['sudo', 'bash', '-x', '-c', build.sudo])
413             if p.returncode != 0:
414                 raise BuildException("Error running sudo command for %s:%s" %
415                                      (app.id, build.versionName), p.output)
416
417         log_path = os.path.join(log_dir,
418                                 common.get_toolsversion_logname(app, build))
419         with open(log_path, 'w') as f:
420             f.write(get_android_tools_version_log(build.ndk_path()))
421     else:
422         if build.sudo:
423             logging.warning('%s:%s runs this on the buildserver with sudo:\n\t%s'
424                             % (app.id, build.versionName, build.sudo))
425
426     # Prepare the source code...
427     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
428                                                   build_dir, srclib_dir,
429                                                   extlib_dir, onserver, refresh)
430
431     # We need to clean via the build tool in case the binary dirs are
432     # different from the default ones
433     p = None
434     gradletasks = []
435     bmethod = build.build_method()
436     if bmethod == 'maven':
437         logging.info("Cleaning Maven project...")
438         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
439
440         if '@' in build.maven:
441             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
442             maven_dir = os.path.normpath(maven_dir)
443         else:
444             maven_dir = root_dir
445
446         p = FDroidPopen(cmd, cwd=maven_dir)
447
448     elif bmethod == 'gradle':
449
450         logging.info("Cleaning Gradle project...")
451
452         if build.preassemble:
453             gradletasks += build.preassemble
454
455         flavours = build.gradle
456         if flavours == ['yes']:
457             flavours = []
458
459         flavours_cmd = ''.join([transform_first_char(flav, str.upper) for flav in flavours])
460
461         gradletasks += ['assemble' + flavours_cmd + 'Release']
462
463         if config['force_build_tools']:
464             force_gradle_build_tools(build_dir, config['build_tools'])
465             for name, number, libpath in srclibpaths:
466                 force_gradle_build_tools(libpath, config['build_tools'])
467
468         cmd = [config['gradle']]
469         if build.gradleprops:
470             cmd += ['-P' + kv for kv in build.gradleprops]
471
472         cmd += ['clean']
473
474         p = FDroidPopen(cmd, cwd=root_dir)
475
476     elif bmethod == 'kivy':
477         pass
478
479     elif bmethod == 'buildozer':
480         pass
481
482     elif bmethod == 'ant':
483         logging.info("Cleaning Ant project...")
484         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
485
486     if p is not None and p.returncode != 0:
487         raise BuildException("Error cleaning %s:%s" %
488                              (app.id, build.versionName), p.output)
489
490     for root, dirs, files in os.walk(build_dir):
491
492         def del_dirs(dl):
493             for d in dl:
494                 if d in dirs:
495                     shutil.rmtree(os.path.join(root, d))
496
497         def del_files(fl):
498             for f in fl:
499                 if f in files:
500                     os.remove(os.path.join(root, f))
501
502         if any(f in files for f in ['build.gradle', 'settings.gradle']):
503             # Even when running clean, gradle stores task/artifact caches in
504             # .gradle/ as binary files. To avoid overcomplicating the scanner,
505             # manually delete them, just like `gradle clean` should have removed
506             # the build/ dirs.
507             del_dirs(['build', '.gradle'])
508             del_files(['gradlew', 'gradlew.bat'])
509
510         if 'pom.xml' in files:
511             del_dirs(['target'])
512
513         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
514             del_dirs(['bin', 'gen'])
515
516         if 'jni' in dirs:
517             del_dirs(['obj'])
518
519     if options.skipscan:
520         if build.scandelete:
521             raise BuildException("Refusing to skip source scan since scandelete is present")
522     else:
523         # Scan before building...
524         logging.info("Scanning source for common problems...")
525         count = scanner.scan_source(build_dir, build)
526         if count > 0:
527             if force:
528                 logging.warning(ngettext('Scanner found {} problem',
529                                          'Scanner found {} problems', count).format(count))
530             else:
531                 raise BuildException(ngettext(
532                     "Can't build due to {} error while scanning",
533                     "Can't build due to {} errors while scanning", count).format(count))
534
535     if not options.notarball:
536         # Build the source tarball right before we build the release...
537         logging.info("Creating source tarball...")
538         tarname = common.getsrcname(app, build)
539         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
540
541         def tarexc(f):
542             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
543         tarball.add(build_dir, tarname, exclude=tarexc)
544         tarball.close()
545
546     # Run a build command if one is required...
547     if build.build:
548         logging.info("Running 'build' commands in %s" % root_dir)
549         cmd = common.replace_config_vars(build.build, build)
550
551         # Substitute source library paths into commands...
552         for name, number, libpath in srclibpaths:
553             libpath = os.path.relpath(libpath, root_dir)
554             cmd = cmd.replace('$$' + name + '$$', libpath)
555
556         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
557
558         if p.returncode != 0:
559             raise BuildException("Error running build command for %s:%s" %
560                                  (app.id, build.versionName), p.output)
561
562     # Build native stuff if required...
563     if build.buildjni and build.buildjni != ['no']:
564         logging.info("Building the native code")
565         jni_components = build.buildjni
566
567         if jni_components == ['yes']:
568             jni_components = ['']
569         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
570         for d in jni_components:
571             if d:
572                 logging.info("Building native code in '%s'" % d)
573             else:
574                 logging.info("Building native code in the main project")
575             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
576             if os.path.exists(manifest):
577                 # Read and write the whole AM.xml to fix newlines and avoid
578                 # the ndk r8c or later 'wordlist' errors. The outcome of this
579                 # under gnu/linux is the same as when using tools like
580                 # dos2unix, but the native python way is faster and will
581                 # work in non-unix systems.
582                 manifest_text = open(manifest, 'U').read()
583                 open(manifest, 'w').write(manifest_text)
584                 # In case the AM.xml read was big, free the memory
585                 del manifest_text
586             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
587             if p.returncode != 0:
588                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.versionName), p.output)
589
590     p = None
591     # Build the release...
592     if bmethod == 'maven':
593         logging.info("Building Maven project...")
594
595         if '@' in build.maven:
596             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
597         else:
598             maven_dir = root_dir
599
600         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
601                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
602                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
603                   'package']
604         if build.target:
605             target = build.target.split('-')[1]
606             common.regsub_file(r'<platform>[0-9]*</platform>',
607                                r'<platform>%s</platform>' % target,
608                                os.path.join(root_dir, 'pom.xml'))
609             if '@' in build.maven:
610                 common.regsub_file(r'<platform>[0-9]*</platform>',
611                                    r'<platform>%s</platform>' % target,
612                                    os.path.join(maven_dir, 'pom.xml'))
613
614         p = FDroidPopen(mvncmd, cwd=maven_dir)
615
616         bindir = os.path.join(root_dir, 'target')
617
618     elif bmethod == 'kivy':
619         logging.info("Building Kivy project...")
620
621         spec = os.path.join(root_dir, 'buildozer.spec')
622         if not os.path.exists(spec):
623             raise BuildException("Expected to find buildozer-compatible spec at {0}"
624                                  .format(spec))
625
626         defaults = {'orientation': 'landscape', 'icon': '',
627                     'permissions': '', 'android.api': "18"}
628         bconfig = ConfigParser(defaults, allow_no_value=True)
629         bconfig.read(spec)
630
631         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
632         if os.path.exists(distdir):
633             shutil.rmtree(distdir)
634
635         modules = bconfig.get('app', 'requirements').split(',')
636
637         cmd = 'ANDROIDSDK=' + config['sdk_path']
638         cmd += ' ANDROIDNDK=' + ndk_path
639         cmd += ' ANDROIDNDKVER=' + build.ndk
640         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
641         cmd += ' VIRTUALENV=virtualenv'
642         cmd += ' ./distribute.sh'
643         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
644         cmd += ' -d fdroid'
645         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
646         if p.returncode != 0:
647             raise BuildException("Distribute build failed")
648
649         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
650         if cid != app.id:
651             raise BuildException("Package ID mismatch between metadata and spec")
652
653         orientation = bconfig.get('app', 'orientation', 'landscape')
654         if orientation == 'all':
655             orientation = 'sensor'
656
657         cmd = ['./build.py'
658                '--dir', root_dir,
659                '--name', bconfig.get('app', 'title'),
660                '--package', app.id,
661                '--version', bconfig.get('app', 'version'),
662                '--orientation', orientation
663                ]
664
665         perms = bconfig.get('app', 'permissions')
666         for perm in perms.split(','):
667             cmd.extend(['--permission', perm])
668
669         if config.get('app', 'fullscreen') == 0:
670             cmd.append('--window')
671
672         icon = bconfig.get('app', 'icon.filename')
673         if icon:
674             cmd.extend(['--icon', os.path.join(root_dir, icon)])
675
676         cmd.append('release')
677         p = FDroidPopen(cmd, cwd=distdir)
678
679     elif bmethod == 'buildozer':
680         logging.info("Building Kivy project using buildozer...")
681
682         # parse buildozer.spez
683         spec = os.path.join(root_dir, 'buildozer.spec')
684         if not os.path.exists(spec):
685             raise BuildException("Expected to find buildozer-compatible spec at {0}"
686                                  .format(spec))
687         defaults = {'orientation': 'landscape', 'icon': '',
688                     'permissions': '', 'android.api': "19"}
689         bconfig = ConfigParser(defaults, allow_no_value=True)
690         bconfig.read(spec)
691
692         # update spec with sdk and ndk locations to prevent buildozer from
693         # downloading.
694         loc_ndk = common.env['ANDROID_NDK']
695         loc_sdk = common.env['ANDROID_SDK']
696         if loc_ndk == '$ANDROID_NDK':
697             loc_ndk = loc_sdk + '/ndk-bundle'
698
699         bc_ndk = None
700         bc_sdk = None
701         try:
702             bc_ndk = bconfig.get('app', 'android.sdk_path')
703         except Exception:
704             pass
705         try:
706             bc_sdk = bconfig.get('app', 'android.ndk_path')
707         except Exception:
708             pass
709
710         if bc_sdk is None:
711             bconfig.set('app', 'android.sdk_path', loc_sdk)
712         if bc_ndk is None:
713             bconfig.set('app', 'android.ndk_path', loc_ndk)
714
715         fspec = open(spec, 'w')
716         bconfig.write(fspec)
717         fspec.close()
718
719         logging.info("sdk_path = %s" % loc_sdk)
720         logging.info("ndk_path = %s" % loc_ndk)
721
722         p = None
723         # execute buildozer
724         cmd = ['buildozer', 'android', 'release']
725         try:
726             p = FDroidPopen(cmd, cwd=root_dir)
727         except Exception:
728             pass
729
730         # buidozer not installed ? clone repo and run
731         if (p is None or p.returncode != 0):
732             cmd = ['git', 'clone', 'https://github.com/kivy/buildozer.git']
733             p = subprocess.Popen(cmd, cwd=root_dir, shell=False)
734             p.wait()
735             if p.returncode != 0:
736                 raise BuildException("Distribute build failed")
737
738             cmd = ['python', 'buildozer/buildozer/scripts/client.py', 'android', 'release']
739             p = FDroidPopen(cmd, cwd=root_dir)
740
741         # expected to fail.
742         # Signing will fail if not set by environnment vars (cf. p4a docs).
743         # But the unsigned apk will be ok.
744         p.returncode = 0
745
746     elif bmethod == 'gradle':
747         logging.info("Building Gradle project...")
748
749         cmd = [config['gradle']]
750         if build.gradleprops:
751             cmd += ['-P' + kv for kv in build.gradleprops]
752
753         cmd += gradletasks
754
755         p = FDroidPopen(cmd, cwd=root_dir)
756
757     elif bmethod == 'ant':
758         logging.info("Building Ant project...")
759         cmd = ['ant']
760         if build.antcommands:
761             cmd += build.antcommands
762         else:
763             cmd += ['release']
764         p = FDroidPopen(cmd, cwd=root_dir)
765
766         bindir = os.path.join(root_dir, 'bin')
767
768     if p is not None and p.returncode != 0:
769         raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
770     logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
771
772     omethod = build.output_method()
773     if omethod == 'maven':
774         stdout_apk = '\n'.join([
775             line for line in p.output.splitlines() if any(
776                 a in line for a in ('.apk', '.ap_', '.jar'))])
777         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
778                      stdout_apk, re.S | re.M)
779         if not m:
780             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
781                          stdout_apk, re.S | re.M)
782         if not m:
783             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
784                          stdout_apk, re.S | re.M)
785
786         if not m:
787             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
788                          stdout_apk, re.S | re.M)
789         if not m:
790             raise BuildException('Failed to find output')
791         src = m.group(1)
792         src = os.path.join(bindir, src) + '.apk'
793     elif omethod == 'kivy':
794         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
795                            '{0}-{1}-release.apk'.format(
796                                bconfig.get('app', 'title'),
797                                bconfig.get('app', 'version')))
798
799     elif omethod == 'buildozer':
800         src = None
801         for apks_dir in [
802                 os.path.join(root_dir, '.buildozer', 'android', 'platform', 'build', 'dists', bconfig.get('app', 'title'), 'bin'),
803                 ]:
804             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
805                 apks = glob.glob(os.path.join(apks_dir, apkglob))
806
807                 if len(apks) > 1:
808                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
809                                          '\n'.join(apks))
810                 if len(apks) == 1:
811                     src = apks[0]
812                     break
813             if src is not None:
814                 break
815
816         if src is None:
817             raise BuildException('Failed to find any output apks')
818
819     elif omethod == 'gradle':
820         src = None
821         apk_dirs = [
822             # gradle plugin >= 3.0
823             os.path.join(root_dir, 'build', 'outputs', 'apk', 'release'),
824             # gradle plugin < 3.0 and >= 0.11
825             os.path.join(root_dir, 'build', 'outputs', 'apk'),
826             # really old path
827             os.path.join(root_dir, 'build', 'apk'),
828             ]
829         # If we build with gradle flavours with gradle plugin >= 3.0 the apk will be in
830         # a subdirectory corresponding to the flavour command used, but with different
831         # capitalization.
832         if flavours_cmd:
833             apk_dirs.append(os.path.join(root_dir, 'build', 'outputs', 'apk', transform_first_char(flavours_cmd, str.lower), 'release'))
834         for apks_dir in apk_dirs:
835             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
836                 apks = glob.glob(os.path.join(apks_dir, apkglob))
837
838                 if len(apks) > 1:
839                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
840                                          '\n'.join(apks))
841                 if len(apks) == 1:
842                     src = apks[0]
843                     break
844             if src is not None:
845                 break
846
847         if src is None:
848             raise BuildException('Failed to find any output apks')
849
850     elif omethod == 'ant':
851         stdout_apk = '\n'.join([
852             line for line in p.output.splitlines() if '.apk' in line])
853         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
854                        re.S | re.M).group(1)
855         src = os.path.join(bindir, src)
856     elif omethod == 'raw':
857         output_path = common.replace_build_vars(build.output, build)
858         globpath = os.path.join(root_dir, output_path)
859         apks = glob.glob(globpath)
860         if len(apks) > 1:
861             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
862         if len(apks) < 1:
863             raise BuildException('No apks match %s' % globpath)
864         src = os.path.normpath(apks[0])
865
866     # Make sure it's not debuggable...
867     if common.isApkAndDebuggable(src):
868         raise BuildException("APK is debuggable")
869
870     # By way of a sanity check, make sure the version and version
871     # code in our new apk match what we expect...
872     logging.debug("Checking " + src)
873     if not os.path.exists(src):
874         raise BuildException("Unsigned apk is not at expected location of " + src)
875
876     if common.get_file_extension(src) == 'apk':
877         vercode, version = get_metadata_from_apk(app, build, src)
878         if (version != build.versionName or vercode != build.versionCode):
879             raise BuildException(("Unexpected version/version code in output;"
880                                   " APK: '%s' / '%s', "
881                                   " Expected: '%s' / '%s'")
882                                  % (version, str(vercode), build.versionName,
883                                     str(build.versionCode)))
884     else:
885         vercode = build.versionCode
886         version = build.versionName
887
888     # Add information for 'fdroid verify' to be able to reproduce the build
889     # environment.
890     if onserver:
891         metadir = os.path.join(tmp_dir, 'META-INF')
892         if not os.path.exists(metadir):
893             os.mkdir(metadir)
894         homedir = os.path.expanduser('~')
895         for fn in ['buildserverid', 'fdroidserverid']:
896             shutil.copyfile(os.path.join(homedir, fn),
897                             os.path.join(metadir, fn))
898             subprocess.call(['jar', 'uf', os.path.abspath(src),
899                              'META-INF/' + fn], cwd=tmp_dir)
900
901     # Copy the unsigned apk to our destination directory for further
902     # processing (by publish.py)...
903     dest = os.path.join(output_dir, common.get_release_filename(app, build))
904     shutil.copyfile(src, dest)
905
906     # Move the source tarball into the output directory...
907     if output_dir != tmp_dir and not options.notarball:
908         shutil.move(os.path.join(tmp_dir, tarname),
909                     os.path.join(output_dir, tarname))
910
911
912 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
913              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
914              server, force, onserver, refresh):
915     """
916     Build a particular version of an application, if it needs building.
917
918     :param output_dir: The directory where the build output will go. Usually
919        this is the 'unsigned' directory.
920     :param repo_dir: The repo directory - used for checking if the build is
921        necessary.
922     :param also_check_dir: An additional location for checking if the build
923        is necessary (usually the archive repo)
924     :param test: True if building in test mode, in which case the build will
925        always happen, even if the output already exists. In test mode, the
926        output directory should be a temporary location, not any of the real
927        ones.
928
929     :returns: True if the build was done, False if it wasn't necessary.
930     """
931
932     dest_file = common.get_release_filename(app, build)
933
934     dest = os.path.join(output_dir, dest_file)
935     dest_repo = os.path.join(repo_dir, dest_file)
936
937     if not test:
938         if os.path.exists(dest) or os.path.exists(dest_repo):
939             return False
940
941         if also_check_dir:
942             dest_also = os.path.join(also_check_dir, dest_file)
943             if os.path.exists(dest_also):
944                 return False
945
946     if build.disable and not options.force:
947         return False
948
949     logging.info("Building version %s (%s) of %s" % (
950         build.versionName, build.versionCode, app.id))
951
952     if server:
953         # When using server mode, still keep a local cache of the repo, by
954         # grabbing the source now.
955         vcs.gotorevision(build.commit)
956
957         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
958     else:
959         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
960     return True
961
962
963 def get_android_tools_versions(ndk_path=None):
964     '''get a list of the versions of all installed Android SDK/NDK components'''
965
966     global config
967     sdk_path = config['sdk_path']
968     if sdk_path[-1] != '/':
969         sdk_path += '/'
970     components = []
971     if ndk_path:
972         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
973         if os.path.isfile(ndk_release_txt):
974             with open(ndk_release_txt, 'r') as fp:
975                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
976
977     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
978     for root, dirs, files in os.walk(sdk_path):
979         if 'source.properties' in files:
980             source_properties = os.path.join(root, 'source.properties')
981             with open(source_properties, 'r') as fp:
982                 m = pattern.search(fp.read())
983                 if m:
984                     components.append((root[len(sdk_path):], m.group(1)))
985
986     return components
987
988
989 def get_android_tools_version_log(ndk_path):
990     '''get a list of the versions of all installed Android SDK/NDK components'''
991     log = '== Installed Android Tools ==\n\n'
992     components = get_android_tools_versions(ndk_path)
993     for name, version in sorted(components):
994         log += '* ' + name + ' (' + version + ')\n'
995
996     return log
997
998
999 def parse_commandline():
1000     """Parse the command line. Returns options, parser."""
1001
1002     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
1003     common.setup_global_opts(parser)
1004     parser.add_argument("appid", nargs='*', help=_("applicationId with optional versionCode in the form APPID[:VERCODE]"))
1005     parser.add_argument("-l", "--latest", action="store_true", default=False,
1006                         help=_("Build only the latest version of each package"))
1007     parser.add_argument("-s", "--stop", action="store_true", default=False,
1008                         help=_("Make the build stop on exceptions"))
1009     parser.add_argument("-t", "--test", action="store_true", default=False,
1010                         help=_("Test mode - put output in the tmp directory only, and always build, even if the output already exists."))
1011     parser.add_argument("--server", action="store_true", default=False,
1012                         help=_("Use build server"))
1013     parser.add_argument("--resetserver", action="store_true", default=False,
1014                         help=_("Reset and create a brand new build server, even if the existing one appears to be ok."))
1015     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
1016                         help=_("Specify that we're running on the build server"))
1017     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
1018                         help=_("Skip scanning the source code for binaries and other problems"))
1019     parser.add_argument("--dscanner", action="store_true", default=False,
1020                         help=_("Setup an emulator, install the APK on it and perform a Drozer scan"))
1021     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
1022                         help=_("Don't create a source tarball, useful when testing a build"))
1023     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
1024                         help=_("Don't refresh the repository, useful when testing a build with no internet connection"))
1025     parser.add_argument("-f", "--force", action="store_true", default=False,
1026                         help=_("Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode."))
1027     parser.add_argument("-a", "--all", action="store_true", default=False,
1028                         help=_("Build all applications available"))
1029     parser.add_argument("-w", "--wiki", default=False, action="store_true",
1030                         help=_("Update the wiki"))
1031     metadata.add_metadata_arguments(parser)
1032     options = parser.parse_args()
1033     metadata.warnings_action = options.W
1034
1035     # Force --stop with --on-server to get correct exit code
1036     if options.onserver:
1037         options.stop = True
1038
1039     if options.force and not options.test:
1040         parser.error("option %s: Force is only allowed in test mode" % "force")
1041
1042     return options, parser
1043
1044
1045 options = None
1046 config = None
1047 buildserverid = None
1048
1049
1050 def main():
1051
1052     global options, config, buildserverid
1053
1054     options, parser = parse_commandline()
1055
1056     # The defaults for .fdroid.* metadata that is included in a git repo are
1057     # different than for the standard metadata/ layout because expectations
1058     # are different.  In this case, the most common user will be the app
1059     # developer working on the latest update of the app on their own machine.
1060     local_metadata_files = common.get_local_metadata_files()
1061     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1062         config = dict(common.default_config)
1063         # `fdroid build` should build only the latest version by default since
1064         # most of the time the user will be building the most recent update
1065         if not options.all:
1066             options.latest = True
1067     elif len(local_metadata_files) > 1:
1068         raise FDroidException("Only one local metadata file allowed! Found: "
1069                               + " ".join(local_metadata_files))
1070     else:
1071         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1072             raise FDroidException("No app metadata found, nothing to process!")
1073         if not options.appid and not options.all:
1074             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1075
1076     config = common.read_config(options)
1077
1078     if config['build_server_always']:
1079         options.server = True
1080     if options.resetserver and not options.server:
1081         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1082
1083     log_dir = 'logs'
1084     if not os.path.isdir(log_dir):
1085         logging.info("Creating log directory")
1086         os.makedirs(log_dir)
1087
1088     tmp_dir = 'tmp'
1089     if not os.path.isdir(tmp_dir):
1090         logging.info("Creating temporary directory")
1091         os.makedirs(tmp_dir)
1092
1093     if options.test:
1094         output_dir = tmp_dir
1095     else:
1096         output_dir = 'unsigned'
1097         if not os.path.isdir(output_dir):
1098             logging.info("Creating output directory")
1099             os.makedirs(output_dir)
1100
1101     if config['archive_older'] != 0:
1102         also_check_dir = 'archive'
1103     else:
1104         also_check_dir = None
1105
1106     repo_dir = 'repo'
1107
1108     build_dir = 'build'
1109     if not os.path.isdir(build_dir):
1110         logging.info("Creating build directory")
1111         os.makedirs(build_dir)
1112     srclib_dir = os.path.join(build_dir, 'srclib')
1113     extlib_dir = os.path.join(build_dir, 'extlib')
1114
1115     # Read all app and srclib metadata
1116     pkgs = common.read_pkg_args(options.appid, True)
1117     allapps = metadata.read_metadata(not options.onserver, pkgs, sort_by_time=True)
1118     apps = common.read_app_args(options.appid, allapps, True)
1119
1120     for appid, app in list(apps.items()):
1121         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1122             del apps[appid]
1123
1124     if not apps:
1125         raise FDroidException("No apps to process.")
1126
1127     # make sure enough open files are allowed to process everything
1128     soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
1129     if len(apps) > soft:
1130         try:
1131             soft = len(apps) * 2
1132             if soft > hard:
1133                 soft = hard
1134             resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
1135             logging.debug(_('Set open file limit to {integer}')
1136                           .format(integer=soft))
1137         except (OSError, ValueError) as e:
1138             logging.warning(_('Setting open file limit failed: ') + str(e))
1139
1140     if options.latest:
1141         for app in apps.values():
1142             for build in reversed(app.builds):
1143                 if build.disable and not options.force:
1144                     continue
1145                 app.builds = [build]
1146                 break
1147
1148     if options.wiki:
1149         import mwclient
1150         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1151                              path=config['wiki_path'])
1152         site.login(config['wiki_user'], config['wiki_password'])
1153
1154     # Build applications...
1155     failed_apps = {}
1156     build_succeeded = []
1157     for appid, app in apps.items():
1158
1159         first = True
1160
1161         for build in app.builds:
1162             wikilog = None
1163             tools_version_log = ''
1164             if not options.onserver:
1165                 tools_version_log = get_android_tools_version_log(build.ndk_path())
1166             try:
1167
1168                 # For the first build of a particular app, we need to set up
1169                 # the source repo. We can reuse it on subsequent builds, if
1170                 # there are any.
1171                 if first:
1172                     vcs, build_dir = common.setup_vcs(app)
1173                     first = False
1174
1175                 logging.info("Using %s" % vcs.clientversion())
1176                 logging.debug("Checking " + build.versionName)
1177                 if trybuild(app, build, build_dir, output_dir, log_dir,
1178                             also_check_dir, srclib_dir, extlib_dir,
1179                             tmp_dir, repo_dir, vcs, options.test,
1180                             options.server, options.force,
1181                             options.onserver, options.refresh):
1182                     toolslog = os.path.join(log_dir,
1183                                             common.get_toolsversion_logname(app, build))
1184                     if not options.onserver and os.path.exists(toolslog):
1185                         with open(toolslog, 'r') as f:
1186                             tools_version_log = ''.join(f.readlines())
1187                         os.remove(toolslog)
1188
1189                     if app.Binaries is not None:
1190                         # This is an app where we build from source, and
1191                         # verify the apk contents against a developer's
1192                         # binary. We get that binary now, and save it
1193                         # alongside our built one in the 'unsigend'
1194                         # directory.
1195                         url = app.Binaries
1196                         url = url.replace('%v', build.versionName)
1197                         url = url.replace('%c', str(build.versionCode))
1198                         logging.info("...retrieving " + url)
1199                         of = re.sub(r'.apk$', '.binary.apk', common.get_release_filename(app, build))
1200                         of = os.path.join(output_dir, of)
1201                         try:
1202                             net.download_file(url, local_filename=of)
1203                         except requests.exceptions.HTTPError as e:
1204                             raise FDroidException(
1205                                 'Downloading Binaries from %s failed. %s' % (url, e))
1206
1207                         # Now we check weather the build can be verified to
1208                         # match the supplied binary or not. Should the
1209                         # comparison fail, we mark this build as a failure
1210                         # and remove everything from the unsigend folder.
1211                         with tempfile.TemporaryDirectory() as tmpdir:
1212                             unsigned_apk = \
1213                                 common.get_release_filename(app, build)
1214                             unsigned_apk = \
1215                                 os.path.join(output_dir, unsigned_apk)
1216                             compare_result = \
1217                                 common.verify_apks(of, unsigned_apk, tmpdir)
1218                             if compare_result:
1219                                 logging.debug('removing %s', unsigned_apk)
1220                                 os.remove(unsigned_apk)
1221                                 logging.debug('removing %s', of)
1222                                 os.remove(of)
1223                                 compare_result = compare_result.split('\n')
1224                                 line_count = len(compare_result)
1225                                 compare_result = compare_result[:299]
1226                                 if line_count > len(compare_result):
1227                                     line_difference = \
1228                                         line_count - len(compare_result)
1229                                     compare_result.append('%d more lines ...' %
1230                                                           line_difference)
1231                                 compare_result = '\n'.join(compare_result)
1232                                 raise FDroidException('compared built binary '
1233                                                       'to supplied reference '
1234                                                       'binary but failed',
1235                                                       compare_result)
1236                             else:
1237                                 logging.info('compared built binary to '
1238                                              'supplied reference binary '
1239                                              'successfully')
1240
1241                     build_succeeded.append(app)
1242                     wikilog = "Build succeeded"
1243
1244             except VCSException as vcse:
1245                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1246                 logging.error("VCS error while building app %s: %s" % (
1247                     appid, reason))
1248                 if options.stop:
1249                     logging.debug("Error encoutered, stopping by user request.")
1250                     sys.exit(1)
1251                 failed_apps[appid] = vcse
1252                 wikilog = str(vcse)
1253             except FDroidException as e:
1254                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1255                     f.write('\n\n============================================================\n')
1256                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1257                             (build.versionCode, build.versionName, build.commit))
1258                     f.write('Build completed at '
1259                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1260                     f.write('\n' + tools_version_log + '\n')
1261                     f.write(str(e))
1262                 logging.error("Could not build app %s: %s" % (appid, e))
1263                 if options.stop:
1264                     logging.debug("Error encoutered, stopping by user request.")
1265                     sys.exit(1)
1266                 failed_apps[appid] = e
1267                 wikilog = e.get_wikitext()
1268             except Exception as e:
1269                 logging.error("Could not build app %s due to unknown error: %s" % (
1270                     appid, traceback.format_exc()))
1271                 if options.stop:
1272                     logging.debug("Error encoutered, stopping by user request.")
1273                     sys.exit(1)
1274                 failed_apps[appid] = e
1275                 wikilog = str(e)
1276
1277             if options.wiki and wikilog:
1278                 try:
1279                     # Write a page with the last build log for this version code
1280                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1281                     newpage = site.Pages[lastbuildpage]
1282                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1283                         fdroidserverid = fp.read().rstrip()
1284                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1285                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1286                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1287                     if options.onserver:
1288                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1289                                + buildserverid + ' ' + buildserverid + ']\n\n'
1290                     txt += tools_version_log + '\n\n'
1291                     txt += '== Build Log ==\n\n' + wikilog
1292                     newpage.save(txt, summary='Build log')
1293                     # Redirect from /lastbuild to the most recent build log
1294                     newpage = site.Pages[appid + '/lastbuild']
1295                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1296                 except Exception as e:
1297                     logging.error("Error while attempting to publish build log: %s" % e)
1298
1299     for app in build_succeeded:
1300         logging.info("success: %s" % (app.id))
1301
1302     if not options.verbose:
1303         for fa in failed_apps:
1304             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1305
1306     # perform a drozer scan of all successful builds
1307     if options.dscanner and build_succeeded:
1308         from .dscanner import DockerDriver
1309
1310         docker = DockerDriver()
1311
1312         try:
1313             for app in build_succeeded:
1314
1315                 logging.info("Need to sign the app before we can install it.")
1316                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1317
1318                 apk_path = None
1319
1320                 for f in os.listdir(repo_dir):
1321                     if f.endswith('.apk') and f.startswith(app.id):
1322                         apk_path = os.path.join(repo_dir, f)
1323                         break
1324
1325                 if not apk_path:
1326                     raise Exception("No signed APK found at path: {path}".format(path=apk_path))
1327
1328                 if not os.path.isdir(repo_dir):
1329                     logging.critical("directory does not exists '{path}'".format(path=repo_dir))
1330                     sys.exit(1)
1331
1332                 logging.info("Performing Drozer scan on {0}.".format(app))
1333                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1334         except Exception as e:
1335             logging.error(str(e))
1336             logging.error("An exception happened. Making sure to clean up")
1337         else:
1338             logging.info("Scan succeeded.")
1339
1340         logging.info("Cleaning up after ourselves.")
1341         docker.clean()
1342
1343     logging.info(_("Finished"))
1344     if len(build_succeeded) > 0:
1345         logging.info(ngettext("{} build succeeded",
1346                               "{} builds succeeded", len(build_succeeded)).format(len(build_succeeded)))
1347     if len(failed_apps) > 0:
1348         logging.info(ngettext("{} build failed",
1349                               "{} builds failed", len(failed_apps)).format(len(failed_apps)))
1350
1351     # hack to ensure this exits, even is some threads are still running
1352     sys.stdout.flush()
1353     sys.stderr.flush()
1354     os._exit(0)
1355
1356
1357 if __name__ == "__main__":
1358     main()