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