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