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