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