chiark / gitweb /
Merge remote-tracking branch 'fdroid/master' into build_tools_log
[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 += b'== Installed Android Tools ==\n\n'
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('== Installed Android Tools ==\n\n')
558             f.write(get_android_tools_version_log(build.ndk_path()))
559
560     # Prepare the source code...
561     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
562                                                   build_dir, srclib_dir,
563                                                   extlib_dir, onserver, refresh)
564
565     # We need to clean via the build tool in case the binary dirs are
566     # different from the default ones
567     p = None
568     gradletasks = []
569     bmethod = build.build_method()
570     if bmethod == 'maven':
571         logging.info("Cleaning Maven project...")
572         cmd = [config['mvn3'], 'clean', '-Dandroid.sdk.path=' + config['sdk_path']]
573
574         if '@' in build.maven:
575             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
576             maven_dir = os.path.normpath(maven_dir)
577         else:
578             maven_dir = root_dir
579
580         p = FDroidPopen(cmd, cwd=maven_dir)
581
582     elif bmethod == 'gradle':
583
584         logging.info("Cleaning Gradle project...")
585
586         if build.preassemble:
587             gradletasks += build.preassemble
588
589         flavours = build.gradle
590         if flavours == ['yes']:
591             flavours = []
592
593         flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
594
595         gradletasks += ['assemble' + flavours_cmd + 'Release']
596
597         if config['force_build_tools']:
598             force_gradle_build_tools(build_dir, config['build_tools'])
599             for name, number, libpath in srclibpaths:
600                 force_gradle_build_tools(libpath, config['build_tools'])
601
602         cmd = [config['gradle']]
603         if build.gradleprops:
604             cmd += ['-P' + kv for kv in build.gradleprops]
605
606         cmd += ['clean']
607
608         p = FDroidPopen(cmd, cwd=root_dir)
609
610     elif bmethod == 'kivy':
611         pass
612
613     elif bmethod == 'ant':
614         logging.info("Cleaning Ant project...")
615         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
616
617     if p is not None and p.returncode != 0:
618         raise BuildException("Error cleaning %s:%s" %
619                              (app.id, build.versionName), p.output)
620
621     for root, dirs, files in os.walk(build_dir):
622
623         def del_dirs(dl):
624             for d in dl:
625                 if d in dirs:
626                     shutil.rmtree(os.path.join(root, d))
627
628         def del_files(fl):
629             for f in fl:
630                 if f in files:
631                     os.remove(os.path.join(root, f))
632
633         if 'build.gradle' in files:
634             # Even when running clean, gradle stores task/artifact caches in
635             # .gradle/ as binary files. To avoid overcomplicating the scanner,
636             # manually delete them, just like `gradle clean` should have removed
637             # the build/ dirs.
638             del_dirs(['build', '.gradle'])
639             del_files(['gradlew', 'gradlew.bat'])
640
641         if 'pom.xml' in files:
642             del_dirs(['target'])
643
644         if any(f in files for f in ['ant.properties', 'project.properties', 'build.xml']):
645             del_dirs(['bin', 'gen'])
646
647         if 'jni' in dirs:
648             del_dirs(['obj'])
649
650     if options.skipscan:
651         if build.scandelete:
652             raise BuildException("Refusing to skip source scan since scandelete is present")
653     else:
654         # Scan before building...
655         logging.info("Scanning source for common problems...")
656         count = scanner.scan_source(build_dir, root_dir, build)
657         if count > 0:
658             if force:
659                 logging.warn('Scanner found %d problems' % count)
660             else:
661                 raise BuildException("Can't build due to %d errors while scanning" % count)
662
663     if not options.notarball:
664         # Build the source tarball right before we build the release...
665         logging.info("Creating source tarball...")
666         tarname = common.getsrcname(app, build)
667         tarball = tarfile.open(os.path.join(tmp_dir, tarname), "w:gz")
668
669         def tarexc(f):
670             return any(f.endswith(s) for s in ['.svn', '.git', '.hg', '.bzr'])
671         tarball.add(build_dir, tarname, exclude=tarexc)
672         tarball.close()
673
674     # Run a build command if one is required...
675     if build.build:
676         logging.info("Running 'build' commands in %s" % root_dir)
677         cmd = common.replace_config_vars(build.build, build)
678
679         # Substitute source library paths into commands...
680         for name, number, libpath in srclibpaths:
681             libpath = os.path.relpath(libpath, root_dir)
682             cmd = cmd.replace('$$' + name + '$$', libpath)
683
684         p = FDroidPopen(['bash', '-x', '-c', cmd], cwd=root_dir)
685
686         if p.returncode != 0:
687             raise BuildException("Error running build command for %s:%s" %
688                                  (app.id, build.versionName), p.output)
689
690     # Build native stuff if required...
691     if build.buildjni and build.buildjni != ['no']:
692         logging.info("Building the native code")
693         jni_components = build.buildjni
694
695         if jni_components == ['yes']:
696             jni_components = ['']
697         cmd = [os.path.join(ndk_path, "ndk-build"), "-j1"]
698         for d in jni_components:
699             if d:
700                 logging.info("Building native code in '%s'" % d)
701             else:
702                 logging.info("Building native code in the main project")
703             manifest = os.path.join(root_dir, d, 'AndroidManifest.xml')
704             if os.path.exists(manifest):
705                 # Read and write the whole AM.xml to fix newlines and avoid
706                 # the ndk r8c or later 'wordlist' errors. The outcome of this
707                 # under gnu/linux is the same as when using tools like
708                 # dos2unix, but the native python way is faster and will
709                 # work in non-unix systems.
710                 manifest_text = open(manifest, 'U').read()
711                 open(manifest, 'w').write(manifest_text)
712                 # In case the AM.xml read was big, free the memory
713                 del manifest_text
714             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
715             if p.returncode != 0:
716                 raise BuildException("NDK build failed for %s:%s" % (app.id, build.versionName), p.output)
717
718     p = None
719     # Build the release...
720     if bmethod == 'maven':
721         logging.info("Building Maven project...")
722
723         if '@' in build.maven:
724             maven_dir = os.path.join(root_dir, build.maven.split('@', 1)[1])
725         else:
726             maven_dir = root_dir
727
728         mvncmd = [config['mvn3'], '-Dandroid.sdk.path=' + config['sdk_path'],
729                   '-Dmaven.jar.sign.skip=true', '-Dmaven.test.skip=true',
730                   '-Dandroid.sign.debug=false', '-Dandroid.release=true',
731                   'package']
732         if build.target:
733             target = build.target.split('-')[1]
734             common.regsub_file(r'<platform>[0-9]*</platform>',
735                                r'<platform>%s</platform>' % target,
736                                os.path.join(root_dir, 'pom.xml'))
737             if '@' in build.maven:
738                 common.regsub_file(r'<platform>[0-9]*</platform>',
739                                    r'<platform>%s</platform>' % target,
740                                    os.path.join(maven_dir, 'pom.xml'))
741
742         p = FDroidPopen(mvncmd, cwd=maven_dir)
743
744         bindir = os.path.join(root_dir, 'target')
745
746     elif bmethod == 'kivy':
747         logging.info("Building Kivy project...")
748
749         spec = os.path.join(root_dir, 'buildozer.spec')
750         if not os.path.exists(spec):
751             raise BuildException("Expected to find buildozer-compatible spec at {0}"
752                                  .format(spec))
753
754         defaults = {'orientation': 'landscape', 'icon': '',
755                     'permissions': '', 'android.api': "18"}
756         bconfig = ConfigParser(defaults, allow_no_value=True)
757         bconfig.read(spec)
758
759         distdir = os.path.join('python-for-android', 'dist', 'fdroid')
760         if os.path.exists(distdir):
761             shutil.rmtree(distdir)
762
763         modules = bconfig.get('app', 'requirements').split(',')
764
765         cmd = 'ANDROIDSDK=' + config['sdk_path']
766         cmd += ' ANDROIDNDK=' + ndk_path
767         cmd += ' ANDROIDNDKVER=' + build.ndk
768         cmd += ' ANDROIDAPI=' + str(bconfig.get('app', 'android.api'))
769         cmd += ' VIRTUALENV=virtualenv'
770         cmd += ' ./distribute.sh'
771         cmd += ' -m ' + "'" + ' '.join(modules) + "'"
772         cmd += ' -d fdroid'
773         p = subprocess.Popen(cmd, cwd='python-for-android', shell=True)
774         if p.returncode != 0:
775             raise BuildException("Distribute build failed")
776
777         cid = bconfig.get('app', 'package.domain') + '.' + bconfig.get('app', 'package.name')
778         if cid != app.id:
779             raise BuildException("Package ID mismatch between metadata and spec")
780
781         orientation = bconfig.get('app', 'orientation', 'landscape')
782         if orientation == 'all':
783             orientation = 'sensor'
784
785         cmd = ['./build.py'
786                '--dir', root_dir,
787                '--name', bconfig.get('app', 'title'),
788                '--package', app.id,
789                '--version', bconfig.get('app', 'version'),
790                '--orientation', orientation
791                ]
792
793         perms = bconfig.get('app', 'permissions')
794         for perm in perms.split(','):
795             cmd.extend(['--permission', perm])
796
797         if config.get('app', 'fullscreen') == 0:
798             cmd.append('--window')
799
800         icon = bconfig.get('app', 'icon.filename')
801         if icon:
802             cmd.extend(['--icon', os.path.join(root_dir, icon)])
803
804         cmd.append('release')
805         p = FDroidPopen(cmd, cwd=distdir)
806
807     elif bmethod == 'gradle':
808         logging.info("Building Gradle project...")
809
810         cmd = [config['gradle']]
811         if build.gradleprops:
812             cmd += ['-P' + kv for kv in build.gradleprops]
813
814         cmd += gradletasks
815
816         p = FDroidPopen(cmd, cwd=root_dir)
817
818     elif bmethod == 'ant':
819         logging.info("Building Ant project...")
820         cmd = ['ant']
821         if build.antcommands:
822             cmd += build.antcommands
823         else:
824             cmd += ['release']
825         p = FDroidPopen(cmd, cwd=root_dir)
826
827         bindir = os.path.join(root_dir, 'bin')
828
829     if p is not None and p.returncode != 0:
830         raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
831     logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
832
833     omethod = build.output_method()
834     if omethod == 'maven':
835         stdout_apk = '\n'.join([
836             line for line in p.output.splitlines() if any(
837                 a in line for a in ('.apk', '.ap_', '.jar'))])
838         m = re.match(r".*^\[INFO\] .*apkbuilder.*/([^/]*)\.apk",
839                      stdout_apk, re.S | re.M)
840         if not m:
841             m = re.match(r".*^\[INFO\] Creating additional unsigned apk file .*/([^/]+)\.apk[^l]",
842                          stdout_apk, re.S | re.M)
843         if not m:
844             m = re.match(r'.*^\[INFO\] [^$]*aapt \[package,[^$]*' + bindir + r'/([^/]+)\.ap[_k][,\]]',
845                          stdout_apk, re.S | re.M)
846
847         if not m:
848             m = re.match(r".*^\[INFO\] Building jar: .*/" + bindir + r"/(.+)\.jar",
849                          stdout_apk, re.S | re.M)
850         if not m:
851             raise BuildException('Failed to find output')
852         src = m.group(1)
853         src = os.path.join(bindir, src) + '.apk'
854     elif omethod == 'kivy':
855         src = os.path.join('python-for-android', 'dist', 'default', 'bin',
856                            '{0}-{1}-release.apk'.format(
857                                bconfig.get('app', 'title'),
858                                bconfig.get('app', 'version')))
859     elif omethod == 'gradle':
860         src = None
861         for apks_dir in [
862                 os.path.join(root_dir, 'build', 'outputs', 'apk'),
863                 os.path.join(root_dir, 'build', 'apk'),
864                 ]:
865             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
866                 apks = glob.glob(os.path.join(apks_dir, apkglob))
867
868                 if len(apks) > 1:
869                     raise BuildException('More than one resulting apks found in %s' % apks_dir,
870                                          '\n'.join(apks))
871                 if len(apks) == 1:
872                     src = apks[0]
873                     break
874             if src is not None:
875                 break
876
877         if src is None:
878             raise BuildException('Failed to find any output apks')
879
880     elif omethod == 'ant':
881         stdout_apk = '\n'.join([
882             line for line in p.output.splitlines() if '.apk' in line])
883         src = re.match(r".*^.*Creating (.+) for release.*$.*", stdout_apk,
884                        re.S | re.M).group(1)
885         src = os.path.join(bindir, src)
886     elif omethod == 'raw':
887         output_path = common.replace_build_vars(build.output, build)
888         globpath = os.path.join(root_dir, output_path)
889         apks = glob.glob(globpath)
890         if len(apks) > 1:
891             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
892         if len(apks) < 1:
893             raise BuildException('No apks match %s' % globpath)
894         src = os.path.normpath(apks[0])
895
896     # Make sure it's not debuggable...
897     if common.isApkAndDebuggable(src, config):
898         raise BuildException("APK is debuggable")
899
900     # By way of a sanity check, make sure the version and version
901     # code in our new apk match what we expect...
902     logging.debug("Checking " + src)
903     if not os.path.exists(src):
904         raise BuildException("Unsigned apk is not at expected location of " + src)
905
906     if common.get_file_extension(src) == 'apk':
907         vercode, version = get_metadata_from_apk(app, build, src)
908         if (version != build.versionName or vercode != build.versionCode):
909             raise BuildException(("Unexpected version/version code in output;"
910                                   " APK: '%s' / '%s', "
911                                   " Expected: '%s' / '%s'")
912                                  % (version, str(vercode), build.versionName,
913                                     str(build.versionCode)))
914     else:
915         vercode = build.versionCode
916         version = build.versionName
917
918     # Add information for 'fdroid verify' to be able to reproduce the build
919     # environment.
920     if onserver:
921         metadir = os.path.join(tmp_dir, 'META-INF')
922         if not os.path.exists(metadir):
923             os.mkdir(metadir)
924         homedir = os.path.expanduser('~')
925         for fn in ['buildserverid', 'fdroidserverid']:
926             shutil.copyfile(os.path.join(homedir, fn),
927                             os.path.join(metadir, fn))
928             subprocess.call(['jar', 'uf', os.path.abspath(src),
929                              'META-INF/' + fn], cwd=tmp_dir)
930
931     # Copy the unsigned apk to our destination directory for further
932     # processing (by publish.py)...
933     dest = os.path.join(output_dir, common.get_release_filename(app, build))
934     shutil.copyfile(src, dest)
935
936     # Move the source tarball into the output directory...
937     if output_dir != tmp_dir and not options.notarball:
938         shutil.move(os.path.join(tmp_dir, tarname),
939                     os.path.join(output_dir, tarname))
940
941
942 def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
943              srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
944              server, force, onserver, refresh):
945     """
946     Build a particular version of an application, if it needs building.
947
948     :param output_dir: The directory where the build output will go. Usually
949        this is the 'unsigned' directory.
950     :param repo_dir: The repo directory - used for checking if the build is
951        necessary.
952     :paaram also_check_dir: An additional location for checking if the build
953        is necessary (usually the archive repo)
954     :param test: True if building in test mode, in which case the build will
955        always happen, even if the output already exists. In test mode, the
956        output directory should be a temporary location, not any of the real
957        ones.
958
959     :returns: True if the build was done, False if it wasn't necessary.
960     """
961
962     dest_file = common.get_release_filename(app, build)
963
964     dest = os.path.join(output_dir, dest_file)
965     dest_repo = os.path.join(repo_dir, dest_file)
966
967     if not test:
968         if os.path.exists(dest) or os.path.exists(dest_repo):
969             return False
970
971         if also_check_dir:
972             dest_also = os.path.join(also_check_dir, dest_file)
973             if os.path.exists(dest_also):
974                 return False
975
976     if build.disable and not options.force:
977         return False
978
979     logging.info("Building version %s (%s) of %s" % (
980         build.versionName, build.versionCode, app.id))
981
982     if server:
983         # When using server mode, still keep a local cache of the repo, by
984         # grabbing the source now.
985         vcs.gotorevision(build.commit)
986
987         build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
988     else:
989         build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
990     return True
991
992
993 def get_android_tools_versions(ndk_path=None):
994     '''get a list of the versions of all installed Android SDK/NDK components'''
995
996     global config
997     sdk_path = config['sdk_path']
998     if sdk_path[-1] != '/':
999         sdk_path += '/'
1000     components = []
1001     if ndk_path:
1002         ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
1003         if os.path.isfile(ndk_release_txt):
1004             with open(ndk_release_txt, 'r') as fp:
1005                 components.append((os.path.basename(ndk_path), fp.read()[:-1]))
1006
1007     pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
1008     for root, dirs, files in os.walk(sdk_path):
1009         if 'source.properties' in files:
1010             source_properties = os.path.join(root, 'source.properties')
1011             with open(source_properties, 'r') as fp:
1012                 m = pattern.search(fp.read())
1013                 if m:
1014                     components.append((root[len(sdk_path):], m.group(1)))
1015
1016     return components
1017
1018
1019 def get_android_tools_version_log(ndk_path):
1020     '''get a list of the versions of all installed Android SDK/NDK components'''
1021     log = ''
1022     components = get_android_tools_versions(ndk_path)
1023     for name, version in sorted(components):
1024         log += '* ' + name + ' (' + version + ')\n'
1025
1026     return log
1027
1028
1029 def parse_commandline():
1030     """Parse the command line. Returns options, parser."""
1031
1032     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
1033     common.setup_global_opts(parser)
1034     parser.add_argument("appid", nargs='*', help="app-id with optional versionCode in the form APPID[:VERCODE]")
1035     parser.add_argument("-l", "--latest", action="store_true", default=False,
1036                         help="Build only the latest version of each package")
1037     parser.add_argument("-s", "--stop", action="store_true", default=False,
1038                         help="Make the build stop on exceptions")
1039     parser.add_argument("-t", "--test", action="store_true", default=False,
1040                         help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
1041     parser.add_argument("--server", action="store_true", default=False,
1042                         help="Use build server")
1043     parser.add_argument("--resetserver", action="store_true", default=False,
1044                         help="Reset and create a brand new build server, even if the existing one appears to be ok.")
1045     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
1046                         help="Specify that we're running on the build server")
1047     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
1048                         help="Skip scanning the source code for binaries and other problems")
1049     parser.add_argument("--dscanner", action="store_true", default=False,
1050                         help="Setup an emulator, install the apk on it and perform a drozer scan")
1051     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
1052                         help="Don't create a source tarball, useful when testing a build")
1053     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
1054                         help="Don't refresh the repository, useful when testing a build with no internet connection")
1055     parser.add_argument("-f", "--force", action="store_true", default=False,
1056                         help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
1057     parser.add_argument("-a", "--all", action="store_true", default=False,
1058                         help="Build all applications available")
1059     parser.add_argument("-w", "--wiki", default=False, action="store_true",
1060                         help="Update the wiki")
1061     metadata.add_metadata_arguments(parser)
1062     options = parser.parse_args()
1063     metadata.warnings_action = options.W
1064
1065     # Force --stop with --on-server to get correct exit code
1066     if options.onserver:
1067         options.stop = True
1068
1069     if options.force and not options.test:
1070         parser.error("option %s: Force is only allowed in test mode" % "force")
1071
1072     return options, parser
1073
1074
1075 options = None
1076 config = None
1077 buildserverid = None
1078
1079
1080 def main():
1081
1082     global options, config, buildserverid
1083
1084     options, parser = parse_commandline()
1085
1086     # The defaults for .fdroid.* metadata that is included in a git repo are
1087     # different than for the standard metadata/ layout because expectations
1088     # are different.  In this case, the most common user will be the app
1089     # developer working on the latest update of the app on their own machine.
1090     local_metadata_files = common.get_local_metadata_files()
1091     if len(local_metadata_files) == 1:  # there is local metadata in an app's source
1092         config = dict(common.default_config)
1093         # `fdroid build` should build only the latest version by default since
1094         # most of the time the user will be building the most recent update
1095         if not options.all:
1096             options.latest = True
1097     elif len(local_metadata_files) > 1:
1098         raise FDroidException("Only one local metadata file allowed! Found: "
1099                               + " ".join(local_metadata_files))
1100     else:
1101         if not os.path.isdir('metadata') and len(local_metadata_files) == 0:
1102             raise FDroidException("No app metadata found, nothing to process!")
1103         if not options.appid and not options.all:
1104             parser.error("option %s: If you really want to build all the apps, use --all" % "all")
1105
1106     config = common.read_config(options)
1107
1108     if config['build_server_always']:
1109         options.server = True
1110     if options.resetserver and not options.server:
1111         parser.error("option %s: Using --resetserver without --server makes no sense" % "resetserver")
1112
1113     log_dir = 'logs'
1114     if not os.path.isdir(log_dir):
1115         logging.info("Creating log directory")
1116         os.makedirs(log_dir)
1117
1118     tmp_dir = 'tmp'
1119     if not os.path.isdir(tmp_dir):
1120         logging.info("Creating temporary directory")
1121         os.makedirs(tmp_dir)
1122
1123     if options.test:
1124         output_dir = tmp_dir
1125     else:
1126         output_dir = 'unsigned'
1127         if not os.path.isdir(output_dir):
1128             logging.info("Creating output directory")
1129             os.makedirs(output_dir)
1130
1131     if config['archive_older'] != 0:
1132         also_check_dir = 'archive'
1133     else:
1134         also_check_dir = None
1135
1136     repo_dir = 'repo'
1137
1138     build_dir = 'build'
1139     if not os.path.isdir(build_dir):
1140         logging.info("Creating build directory")
1141         os.makedirs(build_dir)
1142     srclib_dir = os.path.join(build_dir, 'srclib')
1143     extlib_dir = os.path.join(build_dir, 'extlib')
1144
1145     # Read all app and srclib metadata
1146     pkgs = common.read_pkg_args(options.appid, True)
1147     allapps = metadata.read_metadata(not options.onserver, pkgs)
1148     apps = common.read_app_args(options.appid, allapps, True)
1149
1150     for appid, app in list(apps.items()):
1151         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
1152             del apps[appid]
1153
1154     if not apps:
1155         raise FDroidException("No apps to process.")
1156
1157     if options.latest:
1158         for app in apps.values():
1159             for build in reversed(app.builds):
1160                 if build.disable and not options.force:
1161                     continue
1162                 app.builds = [build]
1163                 break
1164
1165     if options.wiki:
1166         import mwclient
1167         site = mwclient.Site((config['wiki_protocol'], config['wiki_server']),
1168                              path=config['wiki_path'])
1169         site.login(config['wiki_user'], config['wiki_password'])
1170
1171     # Build applications...
1172     failed_apps = {}
1173     build_succeeded = []
1174     for appid, app in apps.items():
1175
1176         first = True
1177
1178         for build in app.builds:
1179             wikilog = None
1180             tools_version_log = ''
1181             if not options.onserver:
1182                 tools_version_log = '== Installed Android Tools ==\n\n'
1183                 tools_version_log += get_android_tools_version_log(build.ndk_path())
1184             try:
1185
1186                 # For the first build of a particular app, we need to set up
1187                 # the source repo. We can reuse it on subsequent builds, if
1188                 # there are any.
1189                 if first:
1190                     vcs, build_dir = common.setup_vcs(app)
1191                     first = False
1192
1193                 logging.debug("Checking " + build.versionName)
1194                 if trybuild(app, build, build_dir, output_dir, log_dir,
1195                             also_check_dir, srclib_dir, extlib_dir,
1196                             tmp_dir, repo_dir, vcs, options.test,
1197                             options.server, options.force,
1198                             options.onserver, options.refresh):
1199                     toolslog = os.path.join(log_dir,
1200                                             common.get_toolsversion_logname(app, build))
1201                     if not options.onserver and os.path.exists(toolslog):
1202                         with open(toolslog, 'r') as f:
1203                             tools_version_log = ''.join(f.readlines())
1204                         os.remove(toolslog)
1205
1206                     if app.Binaries is not None:
1207                         # This is an app where we build from source, and
1208                         # verify the apk contents against a developer's
1209                         # binary. We get that binary now, and save it
1210                         # alongside our built one in the 'unsigend'
1211                         # directory.
1212                         url = app.Binaries
1213                         url = url.replace('%v', build.versionName)
1214                         url = url.replace('%c', str(build.versionCode))
1215                         logging.info("...retrieving " + url)
1216                         of = "{0}_{1}.apk.binary".format(app.id, build.versionCode)
1217                         of = os.path.join(output_dir, of)
1218                         net.download_file(url, local_filename=of)
1219
1220                     build_succeeded.append(app)
1221                     wikilog = "Build succeeded"
1222             except VCSException as vcse:
1223                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1224                 logging.error("VCS error while building app %s: %s" % (
1225                     appid, reason))
1226                 if options.stop:
1227                     sys.exit(1)
1228                 failed_apps[appid] = vcse
1229                 wikilog = str(vcse)
1230             except FDroidException as e:
1231                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1232                     f.write('\n\n============================================================\n')
1233                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1234                             (build.versionCode, build.versionName, build.commit))
1235                     f.write('Build completed at '
1236                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1237                     f.write('\n' + tools_version_log + '\n')
1238                     f.write(str(e))
1239                 logging.error("Could not build app %s: %s" % (appid, e))
1240                 if options.stop:
1241                     sys.exit(1)
1242                 failed_apps[appid] = e
1243                 wikilog = e.get_wikitext()
1244             except Exception as e:
1245                 logging.error("Could not build app %s due to unknown error: %s" % (
1246                     appid, traceback.format_exc()))
1247                 if options.stop:
1248                     sys.exit(1)
1249                 failed_apps[appid] = e
1250                 wikilog = str(e)
1251
1252             if options.wiki and wikilog:
1253                 try:
1254                     # Write a page with the last build log for this version code
1255                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1256                     newpage = site.Pages[lastbuildpage]
1257                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1258                         fdroidserverid = fp.read().rstrip()
1259                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1260                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1261                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1262                     if options.onserver:
1263                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1264                                + buildserverid + ' ' + buildserverid + ']\n\n'
1265                     txt += tools_version_log + '\n\n'
1266                     txt += '== Build Log ==\n\n' + wikilog
1267                     newpage.save(txt, summary='Build log')
1268                     # Redirect from /lastbuild to the most recent build log
1269                     newpage = site.Pages[appid + '/lastbuild']
1270                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1271                 except Exception as e:
1272                     logging.error("Error while attempting to publish build log: %s" % e)
1273
1274     for app in build_succeeded:
1275         logging.info("success: %s" % (app.id))
1276
1277     if not options.verbose:
1278         for fa in failed_apps:
1279             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1280
1281     # perform a drozer scan of all successful builds
1282     if options.dscanner and build_succeeded:
1283         from .dscanner import DockerDriver
1284
1285         docker = DockerDriver()
1286
1287         try:
1288             for app in build_succeeded:
1289
1290                 logging.info("Need to sign the app before we can install it.")
1291                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1292
1293                 apk_path = None
1294
1295                 for f in os.listdir(repo_dir):
1296                     if f.endswith('.apk') and f.startswith(app.id):
1297                         apk_path = os.path.join(repo_dir, f)
1298                         break
1299
1300                 if not apk_path:
1301                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1302
1303                 if not os.path.isdir(repo_dir):
1304                     exit(1)
1305
1306                 logging.info("Performing Drozer scan on {0}.".format(app))
1307                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1308         except Exception as e:
1309             logging.error(str(e))
1310             logging.error("An exception happened. Making sure to clean up")
1311         else:
1312             logging.info("Scan succeeded.")
1313
1314         logging.info("Cleaning up after ourselves.")
1315         docker.clean()
1316
1317     logging.info("Finished.")
1318     if len(build_succeeded) > 0:
1319         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1320     if len(failed_apps) > 0:
1321         logging.info(str(len(failed_apps)) + ' builds failed')
1322
1323     sys.exit(0)
1324
1325
1326 if __name__ == "__main__":
1327     main()