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