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