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