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