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