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