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