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