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