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