chiark / gitweb /
implement gettext localization
[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 _
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             startroot = os.path.dirname(path)
101             main = os.path.basename(path)
102             ftp.mkdir(main)
103             for root, dirs, files in os.walk(path):
104                 rr = os.path.relpath(root, startroot)
105                 ftp.chdir(rr)
106                 for d in dirs:
107                     ftp.mkdir(d)
108                 for f in files:
109                     lfile = os.path.join(startroot, rr, f)
110                     if not os.path.islink(lfile):
111                         ftp.put(lfile, f)
112                         ftp.chmod(f, 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 _ignored 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.warn('Scanner found %d problems' % count)
527             else:
528                 raise BuildException("Can't build due to %d errors while scanning" % count)
529
530     if not options.notarball:
531         # Build the source tarball right before we build the release...
532         logging.info("Creating source tarball...")
533         tarname = common.getsrcname(app, build)
534         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
535
536         def tarexc(f):
537             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
538         tarball.add(build_dir, tarname, exclude=tarexc)
539         tarball.close()
540
541     # Run a build command if one is required...
542     if build.build:
543         logging.info("Running 'build' commands in %s" % root_dir)
544         cmd = common.replace_config_vars(build.build, build)
545
546         # Substitute source library paths into commands...
547         for name, number, libpath in srclibpaths:
548             libpath = os.path.relpath(libpath, root_dir)
549             cmd = cmd.replace('$$' + name + '$$', libpath)
550
551         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
552
553         if p.returncode != 0:
554             raise BuildException("Error running build command for %s:%s" %
555                                  (app.id, build.versionName), p.output)
556
557     # Build native stuff if required...
558     if build.buildjni and build.buildjni != ['no']:
559         logging.info("Building the native code")
560         jni_components = build.buildjni
561
562         if jni_components == ['yes']:
563             jni_components = ['']
564         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
565         for d in jni_components:
566             if d:
567                 logging.info("Building native code in '%s'" % d)
568             else:
569                 logging.info("Building native code in the main project")
570             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
571             if os.path.exists(manifest):
572                 # Read and write the whole AM.xml to fix newlines and avoid
573                 # the ndk r8c or later 'wordlist' errors. The outcome of this
574                 # under gnu/linux is the same as when using tools like
575                 # dos2unix, but the native python way is faster and will
576                 # work in non-unix systems.
577                 manifest_text = open(manifest, 'U').read()
578                 open(manifest, 'w').write(manifest_text)
579                 # In case the AM.xml read was big, free the memory
580                 del manifest_text
581             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
582             if p.returncode != 0:
583                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.versionName), p.output)
584
585     p = None
586     # Build the release...
587     if bmethod == 'maven':
588         logging.info("Building Maven project...")
589
590         if '@' in build.maven:
591             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
592         else:
593             maven_dir = root_dir
594
595         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
596                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
597                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
598                   'package']
599         if build.target:
600             target = build.target.split('-')[1]
601             common.regsub_file(r'<platform>[0-9]*</platform>',
602                                r'<platform>%s</platform>' % target,
603                                os.path.join(root_dir, 'pom.xml'))
604             if '@' in build.maven:
605                 common.regsub_file(r'<platform>[0-9]*</platform>',
606                                    r'<platform>%s</platform>' % target,
607                                    os.path.join(maven_dir, 'pom.xml'))
608
609         p = FDroidPopen(mvncmd, cwd=maven_dir)
610
611         bindir = os.path.join(root_dir, 'target')
612
613     elif bmethod == 'kivy':
614         logging.info("Building Kivy project...")
615
616         spec = os.path.join(root_dir, 'buildozer.spec')
617         if not os.path.exists(spec):
618             raise BuildException("Expected to find buildozer-compatible spec at {0}"
619                                  .format(spec))
620
621         defaults = {'orientation': 'landscape', 'icon': '',
622                     'permissions': '', 'android.api': "18"}
623         bconfig = ConfigParser(defaults, allow_no_value=True)
624         bconfig.read(spec)
625
626         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
627         if os.path.exists(distdir):
628             shutil.rmtree(distdir)
629
630         modules = bconfig.get('app', 'requirements').split(',')
631
632         cmd = 'ANDROIDSDK=' + config['sdk_path']
633         cmd += ' ANDROIDNDK=' + ndk_path
634         cmd += ' ANDROIDNDKVER=' + build.ndk
635         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
636         cmd += ' VIRTUALENV=virtualenv'
637         cmd += ' ./distribute.sh'
638         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
639         cmd += ' -d fdroid'
640         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
641         if p.returncode != 0:
642             raise BuildException("Distribute build failed")
643
644         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
645         if cid != app.id:
646             raise BuildException("Package ID mismatch between metadata and spec")
647
648         orientation = bconfig.get('app', 'orientation', 'landscape')
649         if orientation == 'all':
650             orientation = 'sensor'
651
652         cmd = ['./build.py'
653                '--dir', root_dir,
654                '--name', bconfig.get('app', 'title'),
655                '--package', app.id,
656                '--version', bconfig.get('app', 'version'),
657                '--orientation', orientation
658                ]
659
660         perms = bconfig.get('app', 'permissions')
661         for perm in perms.split(','):
662             cmd.extend(['--permission', perm])
663
664         if config.get('app', 'fullscreen') == 0:
665             cmd.append('--window')
666
667         icon = bconfig.get('app', 'icon.filename')
668         if icon:
669             cmd.extend(['--icon', os.path.join(root_dir, icon)])
670
671         cmd.append('release')
672         p = FDroidPopen(cmd, cwd=distdir)
673
674     elif bmethod == 'buildozer':
675         logging.info("Building Kivy project using buildozer...")
676
677         # parse buildozer.spez
678         spec = os.path.join(root_dir, 'buildozer.spec')
679         if not os.path.exists(spec):
680             raise BuildException("Expected to find buildozer-compatible spec at {0}"
681                                  .format(spec))
682         defaults = {'orientation': 'landscape', 'icon': '',
683                     'permissions': '', 'android.api': "19"}
684         bconfig = ConfigParser(defaults, allow_no_value=True)
685         bconfig.read(spec)
686
687         # update spec with sdk and ndk locations to prevent buildozer from
688         # downloading.
689         loc_ndk = common.env['ANDROID_NDK']
690         loc_sdk = common.env['ANDROID_SDK']
691         if loc_ndk == '$ANDROID_NDK':
692             loc_ndk = loc_sdk + '/ndk-bundle'
693
694         bc_ndk = None
695         bc_sdk = None
696         try:
697             bc_ndk = bconfig.get('app', 'android.sdk_path')
698         except Exception:
699             pass
700         try:
701             bc_sdk = bconfig.get('app', 'android.ndk_path')
702         except Exception:
703             pass
704
705         if bc_sdk is None:
706             bconfig.set('app', 'android.sdk_path', loc_sdk)
707         if bc_ndk is None:
708             bconfig.set('app', 'android.ndk_path', loc_ndk)
709
710         fspec = open(spec, 'w')
711         bconfig.write(fspec)
712         fspec.close()
713
714         logging.info("sdk_path = %s" % loc_sdk)
715         logging.info("ndk_path = %s" % loc_ndk)
716
717         p = None
718         # execute buildozer
719         cmd = ['buildozer', 'android', 'release']
720         try:
721             p = FDroidPopen(cmd, cwd=root_dir)
722         except Exception:
723             pass
724
725         # buidozer not installed ? clone repo and run
726         if (p is None or p.returncode != 0):
727             cmd = ['git', 'clone', 'https://github.com/kivy/buildozer.git']
728             p = subprocess.Popen(cmd, cwd=root_dir, shell=False)
729             p.wait()
730             if p.returncode != 0:
731                 raise BuildException("Distribute build failed")
732
733             cmd = ['python', 'buildozer/buildozer/scripts/client.py', 'android', 'release']
734             p = FDroidPopen(cmd, cwd=root_dir)
735
736         # expected to fail.
737         # Signing will fail if not set by environnment vars (cf. p4a docs).
738         # But the unsigned apk will be ok.
739         p.returncode = 0
740
741     elif bmethod == 'gradle':
742         logging.info("Building Gradle project...")
743
744         cmd = [config['gradle']]
745         if build.gradleprops:
746             cmd += ['-P' + kv for kv in build.gradleprops]
747
748         cmd += gradletasks
749
750         p = FDroidPopen(cmd, cwd=root_dir)
751
752     elif bmethod == 'ant':
753         logging.info("Building Ant project...")
754         cmd = ['ant']
755         if build.antcommands:
756             cmd += build.antcommands
757         else:
758             cmd += ['release']
759         p = FDroidPopen(cmd, cwd=root_dir)
760
761         bindir = os.path.join(root_dir, 'bin')
762
763     if p is not None and p.returncode != 0:
764         raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
765     logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
766
767     omethod = build.output_method()
768     if omethod == 'maven':
769         stdout_apk = '\n'.join([
770             line for line in p.output.splitlines() if any(
771                 a in line for a in ('.apk', '.ap_', '.jar'))])
772         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
773                      stdout_apk, re.S | re.M)
774         if not m:
775             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
776                          stdout_apk, re.S | re.M)
777         if not m:
778             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
779                          stdout_apk, re.S | re.M)
780
781         if not m:
782             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
783                          stdout_apk, re.S | re.M)
784         if not m:
785             raise BuildException('Failed to find output')
786         src = m.group(1)
787         src = os.path.join(bindir, src) + '.apk'
788     elif omethod == 'kivy':
789         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
790                            '{0}-{1}-release.apk'.format(
791                                bconfig.get('app', 'title'),
792                                bconfig.get('app', 'version')))
793
794     elif omethod == 'buildozer':
795         src = None
796         for apks_dir in [
797                 os.path.join(root_dir, '.buildozer', 'android', 'platform', 'build', 'dists', bconfig.get('app', 'title'), 'bin'),
798                 ]:
799             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
800                 apks = glob.glob(os.path.join(apks_dir, apkglob))
801
802                 if len(apks) > 1:
803                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
804                                          '\n'.join(apks))
805                 if len(apks) == 1:
806                     src = apks[0]
807                     break
808             if src is not None:
809                 break
810
811         if src is None:
812             raise BuildException('Failed to find any output apks')
813
814     elif omethod == 'gradle':
815         src = None
816         apk_dirs = [
817             # gradle plugin >= 3.0
818             os.path.join(root_dir, 'build', 'outputs', 'apk', 'release'),
819             # gradle plugin < 3.0 and >= 0.11
820             os.path.join(root_dir, 'build', 'outputs', 'apk'),
821             # really old path
822             os.path.join(root_dir, 'build', 'apk'),
823             ]
824         # If we build with gradle flavours with gradle plugin >= 3.0 the apk will be in
825         # a subdirectory corresponding to the flavour command used, but with different
826         # capitalization.
827         if flavours_cmd:
828             apk_dirs.append(os.path.join(root_dir, 'build', 'outputs', 'apk', transform_first_char(flavours_cmd, str.lower), 'release'))
829         for apks_dir in apk_dirs:
830             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
831                 apks = glob.glob(os.path.join(apks_dir, apkglob))
832
833                 if len(apks) > 1:
834                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
835                                          '\n'.join(apks))
836                 if len(apks) == 1:
837                     src = apks[0]
838                     break
839             if src is not None:
840                 break
841
842         if src is None:
843             raise BuildException('Failed to find any output apks')
844
845     elif omethod == 'ant':
846         stdout_apk = '\n'.join([
847             line for line in p.output.splitlines() if '.apk' in line])
848         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
849                        re.S | re.M).group(1)
850         src = os.path.join(bindir, src)
851     elif omethod == 'raw':
852         output_path = common.replace_build_vars(build.output, build)
853         globpath = os.path.join(root_dir, output_path)
854         apks = glob.glob(globpath)
855         if len(apks) > 1:
856             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
857         if len(apks) < 1:
858             raise BuildException('No apks match %s' % globpath)
859         src = os.path.normpath(apks[0])
860
861     # Make sure it's not debuggable...
862     if common.isApkAndDebuggable(src):
863         raise BuildException("APK is debuggable")
864
865     # By way of a sanity check, make sure the version and version
866     # code in our new apk match what we expect...
867     logging.debug("Checking " + src)
868     if not os.path.exists(src):
869         raise BuildException("Unsigned apk is not at expected location of " + src)
870
871     if common.get_file_extension(src) == 'apk':
872         vercode, version = get_metadata_from_apk(app, build, src)
873         if (version != build.versionName or vercode != build.versionCode):
874             raise BuildException(("Unexpected version/version code in output;"
875                                   " APK: '%s' / '%s', "
876                                   " Expected: '%s' / '%s'")
877                                  % (version, str(vercode), build.versionName,
878                                     str(build.versionCode)))
879     else:
880         vercode = build.versionCode
881         version = build.versionName
882
883     # Add information for 'fdroid verify' to be able to reproduce the build
884     # environment.
885     if onserver:
886         metadir = os.path.join(tmp_dir, 'META-INF')
887         if not os.path.exists(metadir):
888             os.mkdir(metadir)
889         homedir = os.path.expanduser('~')
890         for fn in ['buildserverid', 'fdroidserverid']:
891             shutil.copyfile(os.path.join(homedir, fn),
892                             os.path.join(metadir, fn))
893             subprocess.call(['jar', 'uf', os.path.abspath(src),
894                              'META-INF/' + fn], cwd=tmp_dir)
895
896     # Copy the unsigned apk to our destination directory for further
897     # processing (by publish.py)...
898     dest = os.path.join(output_dir, common.get_release_filename(app, build))
899     shutil.copyfile(src, dest)
900
901     # Move the source tarball into the output directory...
902     if output_dir != tmp_dir and not options.notarball:
903         shutil.move(os.path.join(tmp_dir, tarname),
904                     os.path.join(output_dir, tarname))
905
906
907 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
908              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
909              server, force, onserver, refresh):
910     """
911     Build a particular version of an application, if it needs building.
912
913     :param output_dir: The directory where the build output will go. Usually
914        this is the 'unsigned' directory.
915     :param repo_dir: The repo directory - used for checking if the build is
916        necessary.
917     :param also_check_dir: An additional location for checking if the build
918        is necessary (usually the archive repo)
919     :param test: True if building in test mode, in which case the build will
920        always happen, even if the output already exists. In test mode, the
921        output directory should be a temporary location, not any of the real
922        ones.
923
924     :returns: True if the build was done, False if it wasn't necessary.
925     """
926
927     dest_file = common.get_release_filename(app, build)
928
929     dest = os.path.join(output_dir, dest_file)
930     dest_repo = os.path.join(repo_dir, dest_file)
931
932     if not test:
933         if os.path.exists(dest) or os.path.exists(dest_repo):
934             return False
935
936         if also_check_dir:
937             dest_also = os.path.join(also_check_dir, dest_file)
938             if os.path.exists(dest_also):
939                 return False
940
941     if build.disable and not options.force:
942         return False
943
944     logging.info("Building version %s (%s) of %s" % (
945         build.versionName, build.versionCode, app.id))
946
947     if server:
948         # When using server mode, still keep a local cache of the repo, by
949         # grabbing the source now.
950         vcs.gotorevision(build.commit)
951
952         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
953     else:
954         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
955     return True
956
957
958 def get_android_tools_versions(ndk_path=None):
959     '''get a list of the versions of all installed Android SDK/NDK components'''
960
961     global config
962     sdk_path = config['sdk_path']
963     if sdk_path[-1] != '/':
964         sdk_path += '/'
965     components = []
966     if ndk_path:
967         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
968         if os.path.isfile(ndk_release_txt):
969             with open(ndk_release_txt, 'r') as fp:
970                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
971
972     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
973     for root, dirs, files in os.walk(sdk_path):
974         if 'source.properties' in files:
975             source_properties = os.path.join(root, 'source.properties')
976             with open(source_properties, 'r') as fp:
977                 m = pattern.search(fp.read())
978                 if m:
979                     components.append((root[len(sdk_path):], m.group(1)))
980
981     return components
982
983
984 def get_android_tools_version_log(ndk_path):
985     '''get a list of the versions of all installed Android SDK/NDK components'''
986     log = '== Installed Android Tools ==\n\n'
987     components = get_android_tools_versions(ndk_path)
988     for name, version in sorted(components):
989         log += '* ' + name + ' (' + version + ')\n'
990
991     return log
992
993
994 def parse_commandline():
995     """Parse the command line. Returns options, parser."""
996
997     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
998     common.setup_global_opts(parser)
999     parser.add_argument("appid", nargs='*', help=_("app-id with optional versionCode in the form APPID[:VERCODE]"))
1000     parser.add_argument("-l", "--latest", action="store_true", default=False,
1001                         help=_("Build only the latest version of each package"))
1002     parser.add_argument("-s", "--stop", action="store_true", default=False,
1003                         help=_("Make the build stop on exceptions"))
1004     parser.add_argument("-t", "--test", action="store_true", default=False,
1005                         help=_("Test mode - put output in the tmp directory only, and always build, even if the output already exists."))
1006     parser.add_argument("--server", action="store_true", default=False,
1007                         help=_("Use build server"))
1008     parser.add_argument("--resetserver", action="store_true", default=False,
1009                         help=_("Reset and create a brand new build server, even if the existing one appears to be ok."))
1010     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
1011                         help=_("Specify that we're running on the build server"))
1012     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
1013                         help=_("Skip scanning the source code for binaries and other problems"))
1014     parser.add_argument("--dscanner", action="store_true", default=False,
1015                         help=_("Setup an emulator, install the apk on it and perform a drozer scan"))
1016     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
1017                         help=_("Don't create a source tarball, useful when testing a build"))
1018     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
1019                         help=_("Don't refresh the repository, useful when testing a build with no internet connection"))
1020     parser.add_argument("-f", "--force", action="store_true", default=False,
1021                         help=_("Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode."))
1022     parser.add_argument("-a", "--all", action="store_true", default=False,
1023                         help=_("Build all applications available"))
1024     parser.add_argument("-w", "--wiki", default=False, action="store_true",
1025                         help=_("Update the wiki"))
1026     metadata.add_metadata_arguments(parser)
1027     options = parser.parse_args()
1028     metadata.warnings_action = options.W
1029
1030     # Force --stop with --on-server to get correct exit code
1031     if options.onserver:
1032         options.stop = True
1033
1034     if options.force and not options.test:
1035         parser.error("option %s: Force is only allowed in test mode" % "force")
1036
1037     return options, parser
1038
1039
1040 options = None
1041 config = None
1042 buildserverid = None
1043
1044
1045 def main():
1046
1047     global options, config, buildserverid
1048
1049     options, parser = parse_commandline()
1050
1051     # The defaults for .fdroid.* metadata that is included in a git repo are
1052     # different than for the standard metadata/ layout because expectations
1053     # are different.  In this case, the most common user will be the app
1054     # developer working on the latest update of the app on their own machine.
1055     local_metadata_files = common.get_local_metadata_files()
1056     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1057         config = dict(common.default_config)
1058         # `fdroid build` should build only the latest version by default since
1059         # most of the time the user will be building the most recent update
1060         if not options.all:
1061             options.latest = True
1062     elif len(local_metadata_files) > 1:
1063         raise FDroidException("Only one local metadata file allowed! Found: "
1064                               + " ".join(local_metadata_files))
1065     else:
1066         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1067             raise FDroidException("No app metadata found, nothing to process!")
1068         if not options.appid and not options.all:
1069             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1070
1071     config = common.read_config(options)
1072
1073     if config['build_server_always']:
1074         options.server = True
1075     if options.resetserver and not options.server:
1076         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1077
1078     log_dir = 'logs'
1079     if not os.path.isdir(log_dir):
1080         logging.info("Creating log directory")
1081         os.makedirs(log_dir)
1082
1083     tmp_dir = 'tmp'
1084     if not os.path.isdir(tmp_dir):
1085         logging.info("Creating temporary directory")
1086         os.makedirs(tmp_dir)
1087
1088     if options.test:
1089         output_dir = tmp_dir
1090     else:
1091         output_dir = 'unsigned'
1092         if not os.path.isdir(output_dir):
1093             logging.info("Creating output directory")
1094             os.makedirs(output_dir)
1095
1096     if config['archive_older'] != 0:
1097         also_check_dir = 'archive'
1098     else:
1099         also_check_dir = None
1100
1101     repo_dir = 'repo'
1102
1103     build_dir = 'build'
1104     if not os.path.isdir(build_dir):
1105         logging.info("Creating build directory")
1106         os.makedirs(build_dir)
1107     srclib_dir = os.path.join(build_dir, 'srclib')
1108     extlib_dir = os.path.join(build_dir, 'extlib')
1109
1110     # Read all app and srclib metadata
1111     pkgs = common.read_pkg_args(options.appid, True)
1112     allapps = metadata.read_metadata(not options.onserver, pkgs)
1113     apps = common.read_app_args(options.appid, allapps, True)
1114
1115     for appid, app in list(apps.items()):
1116         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1117             del apps[appid]
1118
1119     if not apps:
1120         raise FDroidException("No apps to process.")
1121
1122     if options.latest:
1123         for app in apps.values():
1124             for build in reversed(app.builds):
1125                 if build.disable and not options.force:
1126                     continue
1127                 app.builds = [build]
1128                 break
1129
1130     if options.wiki:
1131         import mwclient
1132         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1133                              path=config['wiki_path'])
1134         site.login(config['wiki_user'], config['wiki_password'])
1135
1136     # Build applications...
1137     failed_apps = {}
1138     build_succeeded = []
1139     for appid, app in apps.items():
1140
1141         first = True
1142
1143         for build in app.builds:
1144             wikilog = None
1145             tools_version_log = ''
1146             if not options.onserver:
1147                 tools_version_log = get_android_tools_version_log(build.ndk_path())
1148             try:
1149
1150                 # For the first build of a particular app, we need to set up
1151                 # the source repo. We can reuse it on subsequent builds, if
1152                 # there are any.
1153                 if first:
1154                     vcs, build_dir = common.setup_vcs(app)
1155                     first = False
1156
1157                 logging.debug("Checking " + build.versionName)
1158                 if trybuild(app, build, build_dir, output_dir, log_dir,
1159                             also_check_dir, srclib_dir, extlib_dir,
1160                             tmp_dir, repo_dir, vcs, options.test,
1161                             options.server, options.force,
1162                             options.onserver, options.refresh):
1163                     toolslog = os.path.join(log_dir,
1164                                             common.get_toolsversion_logname(app, build))
1165                     if not options.onserver and os.path.exists(toolslog):
1166                         with open(toolslog, 'r') as f:
1167                             tools_version_log = ''.join(f.readlines())
1168                         os.remove(toolslog)
1169
1170                     if app.Binaries is not None:
1171                         # This is an app where we build from source, and
1172                         # verify the apk contents against a developer's
1173                         # binary. We get that binary now, and save it
1174                         # alongside our built one in the 'unsigend'
1175                         # directory.
1176                         url = app.Binaries
1177                         url = url.replace('%v', build.versionName)
1178                         url = url.replace('%c', str(build.versionCode))
1179                         logging.info("...retrieving " + url)
1180                         of = re.sub(r'.apk$', '.binary.apk', common.get_release_filename(app, build))
1181                         of = os.path.join(output_dir, of)
1182                         try:
1183                             net.download_file(url, local_filename=of)
1184                         except requests.exceptions.HTTPError as e:
1185                             raise FDroidException(
1186                                 'Downloading Binaries from %s failed. %s' % (url, e))
1187
1188                         # Now we check weather the build can be verified to
1189                         # match the supplied binary or not. Should the
1190                         # comparison fail, we mark this build as a failure
1191                         # and remove everything from the unsigend folder.
1192                         with tempfile.TemporaryDirectory() as tmpdir:
1193                             unsigned_apk = \
1194                                 common.get_release_filename(app, build)
1195                             unsigned_apk = \
1196                                 os.path.join(output_dir, unsigned_apk)
1197                             compare_result = \
1198                                 common.verify_apks(of, unsigned_apk, tmpdir)
1199                             if compare_result:
1200                                 logging.debug('removing %s', unsigned_apk)
1201                                 os.remove(unsigned_apk)
1202                                 logging.debug('removing %s', of)
1203                                 os.remove(of)
1204                                 compare_result = compare_result.split('\n')
1205                                 line_count = len(compare_result)
1206                                 compare_result = compare_result[:299]
1207                                 if line_count > len(compare_result):
1208                                     line_difference = \
1209                                         line_count - len(compare_result)
1210                                     compare_result.append('%d more lines ...' %
1211                                                           line_difference)
1212                                 compare_result = '\n'.join(compare_result)
1213                                 raise FDroidException('compared built binary '
1214                                                       'to supplied reference '
1215                                                       'binary but failed',
1216                                                       compare_result)
1217                             else:
1218                                 logging.info('compared built binary to '
1219                                              'supplied reference binary '
1220                                              'successfully')
1221
1222                     build_succeeded.append(app)
1223                     wikilog = "Build succeeded"
1224
1225             except VCSException as vcse:
1226                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1227                 logging.error("VCS error while building app %s: %s" % (
1228                     appid, reason))
1229                 if options.stop:
1230                     sys.exit(1)
1231                 failed_apps[appid] = vcse
1232                 wikilog = str(vcse)
1233             except FDroidException as e:
1234                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1235                     f.write('\n\n============================================================\n')
1236                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1237                             (build.versionCode, build.versionName, build.commit))
1238                     f.write('Build completed at '
1239                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1240                     f.write('\n' + tools_version_log + '\n')
1241                     f.write(str(e))
1242                 logging.error("Could not build app %s: %s" % (appid, e))
1243                 if options.stop:
1244                     sys.exit(1)
1245                 failed_apps[appid] = e
1246                 wikilog = e.get_wikitext()
1247             except Exception as e:
1248                 logging.error("Could not build app %s due to unknown error: %s" % (
1249                     appid, traceback.format_exc()))
1250                 if options.stop:
1251                     sys.exit(1)
1252                 failed_apps[appid] = e
1253                 wikilog = str(e)
1254
1255             if options.wiki and wikilog:
1256                 try:
1257                     # Write a page with the last build log for this version code
1258                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1259                     newpage = site.Pages[lastbuildpage]
1260                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1261                         fdroidserverid = fp.read().rstrip()
1262                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1263                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1264                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1265                     if options.onserver:
1266                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1267                                + buildserverid + ' ' + buildserverid + ']\n\n'
1268                     txt += tools_version_log + '\n\n'
1269                     txt += '== Build Log ==\n\n' + wikilog
1270                     newpage.save(txt, summary='Build log')
1271                     # Redirect from /lastbuild to the most recent build log
1272                     newpage = site.Pages[appid + '/lastbuild']
1273                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1274                 except Exception as e:
1275                     logging.error("Error while attempting to publish build log: %s" % e)
1276
1277     for app in build_succeeded:
1278         logging.info("success: %s" % (app.id))
1279
1280     if not options.verbose:
1281         for fa in failed_apps:
1282             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1283
1284     # perform a drozer scan of all successful builds
1285     if options.dscanner and build_succeeded:
1286         from .dscanner import DockerDriver
1287
1288         docker = DockerDriver()
1289
1290         try:
1291             for app in build_succeeded:
1292
1293                 logging.info("Need to sign the app before we can install it.")
1294                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1295
1296                 apk_path = None
1297
1298                 for f in os.listdir(repo_dir):
1299                     if f.endswith('.apk') and f.startswith(app.id):
1300                         apk_path = os.path.join(repo_dir, f)
1301                         break
1302
1303                 if not apk_path:
1304                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1305
1306                 if not os.path.isdir(repo_dir):
1307                     exit(1)
1308
1309                 logging.info("Performing Drozer scan on {0}.".format(app))
1310                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1311         except Exception as e:
1312             logging.error(str(e))
1313             logging.error("An exception happened. Making sure to clean up")
1314         else:
1315             logging.info("Scan succeeded.")
1316
1317         logging.info("Cleaning up after ourselves.")
1318         docker.clean()
1319
1320     logging.info(_("Finished"))
1321     if len(build_succeeded) > 0:
1322         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1323     if len(failed_apps) > 0:
1324         logging.info(str(len(failed_apps)) + ' builds failed')
1325
1326     sys.exit(0)
1327
1328
1329 if __name__ == "__main__":
1330     main()