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