chiark / gitweb /
check meld config for manual apk diffs
[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 FDroidException, BuildException, VCSException, FDroidPopen, SdkToolsPopen
41
42 try:
43     import paramiko
44 except ImportError:
45     pass
46
47
48 def get_builder_vm_id():
49     vd = os.path.join('builder', '.vagrant')
50     if os.path.isdir(vd):
51         # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
52         with open(os.path.join(vd, 'machines', 'default',
53                                'virtualbox', 'id')) as vf:
54             id = vf.read()
55         return id
56     else:
57         # Vagrant 1.0 - it's a json file...
58         with open(os.path.join('builder', '.vagrant')) as vf:
59             v = json.load(vf)
60         return v['active']['default']
61
62
63 def got_valid_builder_vm():
64     """Returns True if we have a valid-looking builder vm
65     """
66     if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
67         return False
68     vd = os.path.join('builder', '.vagrant')
69     if not os.path.exists(vd):
70         return False
71     if not os.path.isdir(vd):
72         # Vagrant 1.0 - if the directory is there, it's valid...
73         return True
74     # Vagrant 1.2 - the directory can exist, but the id can be missing...
75     if not os.path.exists(os.path.join(vd, 'machines', 'default',
76                                        'virtualbox', 'id')):
77         return False
78     return True
79
80
81 def vagrant(params, cwd=None, printout=False):
82     """Run a vagrant command.
83
84     :param: list of parameters to pass to vagrant
85     :cwd: directory to run in, or None for current directory
86     :returns: (ret, out) where ret is the return code, and out
87                is the stdout (and stderr) from vagrant
88     """
89     p = FDroidPopen(['vagrant'] + params, cwd=cwd)
90     return (p.returncode, p.output)
91
92
93 def get_vagrant_sshinfo():
94     """Get ssh connection info for a vagrant VM
95
96     :returns: A dictionary containing 'hostname', 'port', 'user'
97         and 'idfile'
98     """
99     if subprocess.call('vagrant ssh-config >sshconfig',
100                        cwd='builder', shell=True) != 0:
101         raise BuildException("Error getting ssh config")
102     vagranthost = 'default'  # Host in ssh config file
103     sshconfig = paramiko.SSHConfig()
104     sshf = open(os.path.join('builder', 'sshconfig'), 'r')
105     sshconfig.parse(sshf)
106     sshf.close()
107     sshconfig = sshconfig.lookup(vagranthost)
108     idfile = sshconfig['identityfile']
109     if isinstance(idfile, list):
110         idfile = idfile[0]
111     elif idfile.startswith('"') and idfile.endswith('"'):
112         idfile = idfile[1:-1]
113     return {'hostname': sshconfig['hostname'],
114             'port': int(sshconfig['port']),
115             'user': sshconfig['user'],
116             'idfile': idfile}
117
118
119 def get_clean_vm(reset=False):
120     """Get a clean VM ready to do a buildserver build.
121
122     This might involve creating and starting a new virtual machine from
123     scratch, or it might be as simple (unless overridden by the reset
124     parameter) as re-using a snapshot created previously.
125
126     A BuildException will be raised if anything goes wrong.
127
128     :reset: True to force creating from scratch.
129     :returns: A dictionary containing 'hostname', 'port', 'user'
130         and 'idfile'
131     """
132     # Reset existing builder machine to a clean state if possible.
133     vm_ok = False
134     if not reset:
135         logging.info("Checking for valid existing build server")
136
137         if got_valid_builder_vm():
138             logging.info("...VM is present")
139             p = FDroidPopen(['VBoxManage', 'snapshot',
140                              get_builder_vm_id(), 'list',
141                              '--details'], cwd='builder')
142             if 'fdroidclean' in p.output:
143                 logging.info("...snapshot exists - resetting build server to "
144                              "clean state")
145                 retcode, output = vagrant(['status'], cwd='builder')
146
147                 if 'running' in output:
148                     logging.info("...suspending")
149                     vagrant(['suspend'], cwd='builder')
150                     logging.info("...waiting a sec...")
151                     time.sleep(10)
152                 p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
153                                  'restore', 'fdroidclean'],
154                                 cwd='builder')
155
156                 if p.returncode == 0:
157                     logging.info("...reset to snapshot - server is valid")
158                     retcode, output = vagrant(['up'], cwd='builder')
159                     if retcode != 0:
160                         raise BuildException("Failed to start build server")
161                     logging.info("...waiting a sec...")
162                     time.sleep(10)
163                     sshinfo = get_vagrant_sshinfo()
164                     vm_ok = True
165                 else:
166                     logging.info("...failed to reset to snapshot")
167             else:
168                 logging.info("...snapshot doesn't exist - "
169                              "VBoxManage snapshot list:\n" + p.output)
170
171     # If we can't use the existing machine for any reason, make a
172     # new one from scratch.
173     if not vm_ok:
174         if os.path.exists('builder'):
175             logging.info("Removing broken/incomplete/unwanted build server")
176             vagrant(['destroy', '-f'], cwd='builder')
177             shutil.rmtree('builder')
178         os.mkdir('builder')
179
180         p = subprocess.Popen(['vagrant', '--version'],
181                              universal_newlines=True,
182                              stdout=subprocess.PIPE)
183         vver = p.communicate()[0].strip().split(' ')[1]
184         if vver.split('.')[0] != '1' or int(vver.split('.')[1]) < 4:
185             raise BuildException("Unsupported vagrant version {0}".format(vver))
186
187         with open(os.path.join('builder', 'Vagrantfile'), 'w') as vf:
188             vf.write('Vagrant.configure("2") do |config|\n')
189             vf.write('config.vm.box = "buildserver"\n')
190             vf.write('config.vm.synced_folder ".", "/vagrant", disabled: true\n')
191             vf.write('end\n')
192
193         logging.info("Starting new build server")
194         retcode, _ = vagrant(['up'], cwd='builder')
195         if retcode != 0:
196             raise BuildException("Failed to start build server")
197
198         # Open SSH connection to make sure it's working and ready...
199         logging.info("Connecting to virtual machine...")
200         sshinfo = get_vagrant_sshinfo()
201         sshs = paramiko.SSHClient()
202         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
203         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
204                      port=sshinfo['port'], timeout=300,
205                      look_for_keys=False,
206                      key_filename=sshinfo['idfile'])
207         sshs.close()
208
209         logging.info("Saving clean state of new build server")
210         retcode, _ = vagrant(['suspend'], cwd='builder')
211         if retcode != 0:
212             raise BuildException("Failed to suspend build server")
213         logging.info("...waiting a sec...")
214         time.sleep(10)
215         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
216                          'take', 'fdroidclean'],
217                         cwd='builder')
218         if p.returncode != 0:
219             raise BuildException("Failed to take snapshot")
220         logging.info("...waiting a sec...")
221         time.sleep(10)
222         logging.info("Restarting new build server")
223         retcode, _ = vagrant(['up'], cwd='builder')
224         if retcode != 0:
225             raise BuildException("Failed to start build server")
226         logging.info("...waiting a sec...")
227         time.sleep(10)
228         # Make sure it worked...
229         p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
230                          'list', '--details'],
231                         cwd='builder')
232         if 'fdroidclean' not in p.output:
233             raise BuildException("Failed to take snapshot.")
234
235     return sshinfo
236
237
238 def release_vm():
239     """Release the VM previously started with get_clean_vm().
240
241     This should always be called.
242     """
243     logging.info("Suspending build server")
244     subprocess.call(['vagrant', 'suspend'], cwd='builder')
245
246
247 # Note that 'force' here also implies test mode.
248 def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
249     """Do a build on the builder vm.
250
251     :param app: app metadata dict
252     :param build:
253     :param vcs: version control system controller object
254     :param build_dir: local source-code checkout of app
255     :param output_dir: target folder for the build result
256     :param force:
257     """
258
259     global buildserverid
260
261     try:
262         paramiko
263     except NameError:
264         raise BuildException("Paramiko is required to use the buildserver")
265     if options.verbose:
266         logging.getLogger("paramiko").setLevel(logging.INFO)
267     else:
268         logging.getLogger("paramiko").setLevel(logging.WARN)
269
270     sshinfo = get_clean_vm()
271
272     try:
273         if not buildserverid:
274             buildserverid = subprocess.check_output(['vagrant', 'ssh', '-c',
275                                                      'cat /home/vagrant/buildserverid'],
276                                                     cwd='builder').rstrip()
277
278         # Open SSH connection...
279         logging.info("Connecting to virtual machine...")
280         sshs = paramiko.SSHClient()
281         sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
282         sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
283                      port=sshinfo['port'], timeout=300,
284                      look_for_keys=False, key_filename=sshinfo['idfile'])
285
286         homedir = '/home/' + sshinfo['user']
287
288         # Get an SFTP connection...
289         ftp = sshs.open_sftp()
290         ftp.get_channel().settimeout(60)
291
292         # Put all the necessary files in place...
293         ftp.chdir(homedir)
294
295         # Helper to copy the contents of a directory to the server...
296         def send_dir(path):
297             root = os.path.dirname(path)
298             main = os.path.basename(path)
299             ftp.mkdir(main)
300             for r, d, f in os.walk(path):
301                 rr = os.path.relpath(r, root)
302                 ftp.chdir(rr)
303                 for dd in d:
304                     ftp.mkdir(dd)
305                 for ff in f:
306                     lfile = os.path.join(root, rr, ff)
307                     if not os.path.islink(lfile):
308                         ftp.put(lfile, ff)
309                         ftp.chmod(ff, os.stat(lfile).st_mode)
310                 for i in range(len(rr.split('/'))):
311                     ftp.chdir('..')
312             ftp.chdir('..')
313
314         logging.info("Preparing server for build...")
315         serverpath = os.path.abspath(os.path.dirname(__file__))
316         ftp.mkdir('fdroidserver')
317         ftp.chdir('fdroidserver')
318         ftp.put(os.path.join(serverpath, '..', 'fdroid'), 'fdroid')
319         ftp.chmod('fdroid', 0o755)
320         send_dir(os.path.join(serverpath))
321         ftp.chdir(homedir)
322
323         ftp.put(os.path.join(serverpath, '..', 'buildserver',
324                              'config.buildserver.py'), 'config.py')
325         ftp.chmod('config.py', 0o600)
326
327         # Copy over the ID (head commit hash) of the fdroidserver in use...
328         subprocess.call('git rev-parse HEAD >' +
329                         os.path.join(os.getcwd(), 'tmp', 'fdroidserverid'),
330                         shell=True, cwd=serverpath)
331         ftp.put('tmp/fdroidserverid', 'fdroidserverid')
332
333         # Copy the metadata - just the file for this app...
334         ftp.mkdir('metadata')
335         ftp.mkdir('srclibs')
336         ftp.chdir('metadata')
337         ftp.put(os.path.join('metadata', app.id + '.txt'),
338                 app.id + '.txt')
339         # And patches if there are any...
340         if os.path.exists(os.path.join('metadata', app.id)):
341             send_dir(os.path.join('metadata', app.id))
342
343         ftp.chdir(homedir)
344         # Create the build directory...
345         ftp.mkdir('build')
346         ftp.chdir('build')
347         ftp.mkdir('extlib')
348         ftp.mkdir('srclib')
349         # Copy any extlibs that are required...
350         if build.extlibs:
351             ftp.chdir(homedir + '/build/extlib')
352             for lib in build.extlibs:
353                 lib = lib.strip()
354                 libsrc = os.path.join('build/extlib', lib)
355                 if not os.path.exists(libsrc):
356                     raise BuildException("Missing extlib {0}".format(libsrc))
357                 lp = lib.split('/')
358                 for d in lp[:-1]:
359                     if d not in ftp.listdir():
360                         ftp.mkdir(d)
361                     ftp.chdir(d)
362                 ftp.put(libsrc, lp[-1])
363                 for _ in lp[:-1]:
364                     ftp.chdir('..')
365         # Copy any srclibs that are required...
366         srclibpaths = []
367         if build.srclibs:
368             for lib in build.srclibs:
369                 srclibpaths.append(
370                     common.getsrclib(lib, 'build/srclib', basepath=True, prepare=False))
371
372         # If one was used for the main source, add that too.
373         basesrclib = vcs.getsrclib()
374         if basesrclib:
375             srclibpaths.append(basesrclib)
376         for name, number, lib in srclibpaths:
377             logging.info("Sending srclib '%s'" % lib)
378             ftp.chdir(homedir + '/build/srclib')
379             if not os.path.exists(lib):
380                 raise BuildException("Missing srclib directory '" + lib + "'")
381             fv = '.fdroidvcs-' + name
382             ftp.put(os.path.join('build/srclib', fv), fv)
383             send_dir(lib)
384             # Copy the metadata file too...
385             ftp.chdir(homedir + '/srclibs')
386             ftp.put(os.path.join('srclibs', name + '.txt'),
387                     name + '.txt')
388         # Copy the main app source code
389         # (no need if it's a srclib)
390         if (not basesrclib) and os.path.exists(build_dir):
391             ftp.chdir(homedir + '/build')
392             fv = '.fdroidvcs-' + app.id
393             ftp.put(os.path.join('build', fv), fv)
394             send_dir(build_dir)
395
396         # Execute the build script...
397         logging.info("Starting build...")
398         chan = sshs.get_transport().open_session()
399         chan.get_pty()
400         cmdline = os.path.join(homedir, 'fdroidserver', 'fdroid')
401         cmdline += ' build --on-server'
402         if force:
403             cmdline += ' --force --test'
404         if options.verbose:
405             cmdline += ' --verbose'
406         if options.skipscan:
407             cmdline += ' --skip-scan'
408         cmdline += " %s:%s" % (app.id, build.versionCode)
409         chan.exec_command('bash --login -c "' + cmdline + '"')
410
411         output = bytes()
412         output += get_android_tools_version_log(build.ndk_path()).encode()
413         while not chan.exit_status_ready():
414             while chan.recv_ready():
415                 output += chan.recv(1024)
416             time.sleep(0.1)
417         logging.info("...getting exit status")
418         returncode = chan.recv_exit_status()
419         while True:
420             get = chan.recv(1024)
421             if len(get) == 0:
422                 break
423             output += get
424         if returncode != 0:
425             raise BuildException(
426                 "Build.py failed on server for {0}:{1}".format(
427                     app.id, build.versionName), str(output, 'utf-8'))
428
429         # Retreive logs...
430         toolsversion_log = common.get_toolsversion_logname(app, build)
431         try:
432             ftp.chdir(os.path.join(homedir, log_dir))
433             ftp.get(toolsversion_log, os.path.join(log_dir, toolsversion_log))
434             logging.debug('retrieved %s', toolsversion_log)
435         except Exception as e:
436             logging.warn('could not get %s from builder vm: %s' % (toolsversion_log, e))
437
438         # Retrieve the built files...
439         logging.info("Retrieving build output...")
440         if force:
441             ftp.chdir(homedir + '/tmp')
442         else:
443             ftp.chdir(homedir + '/unsigned')
444         apkfile = common.get_release_filename(app, build)
445         tarball = common.getsrcname(app, build)
446         try:
447             ftp.get(apkfile, os.path.join(output_dir, apkfile))
448             if not options.notarball:
449                 ftp.get(tarball, os.path.join(output_dir, tarball))
450         except Exception:
451             raise BuildException(
452                 "Build failed for %s:%s - missing output files".format(
453                     app.id, build.versionName), output)
454         ftp.close()
455
456     finally:
457
458         # Suspend the build server.
459         release_vm()
460
461
462 def force_gradle_build_tools(build_dir, build_tools):
463     for root, dirs, files in os.walk(build_dir):
464         for filename in files:
465             if not filename.endswith('.gradle'):
466                 continue
467             path = os.path.join(root, filename)
468             if not os.path.isfile(path):
469                 continue
470             logging.debug("Forcing build-tools %s in %s" % (build_tools, path))
471             common.regsub_file(r"""(\s*)buildToolsVersion([\s=]+).*""",
472                                r"""\1buildToolsVersion\2'%s'""" % build_tools,
473                                path)
474
475
476 def capitalize_intact(string):
477     """Like str.capitalize(), but leave the rest of the string intact without
478     switching it to lowercase."""
479     if len(string) == 0:
480         return string
481     if len(string) == 1:
482         return string.upper()
483     return string[0].upper() + string[1:]
484
485
486 def get_metadata_from_apk(app, build, apkfile):
487     """get the required metadata from the built APK"""
488
489     p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
490
491     vercode = None
492     version = None
493     foundid = None
494     nativecode = None
495     for line in p.output.splitlines():
496         if line.startswith("package:"):
497             pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
498             m = pat.match(line)
499             if m:
500                 foundid = m.group(1)
501             pat = re.compile(".*versionCode='([0-9]*)'.*")
502             m = pat.match(line)
503             if m:
504                 vercode = m.group(1)
505             pat = re.compile(".*versionName='([^']*)'.*")
506             m = pat.match(line)
507             if m:
508                 version = m.group(1)
509         elif line.startswith("native-code:"):
510             nativecode = line[12:]
511
512     # Ignore empty strings or any kind of space/newline chars that we don't
513     # care about
514     if nativecode is not None:
515         nativecode = nativecode.strip()
516         nativecode = None if not nativecode else nativecode
517
518     if build.buildjni and build.buildjni != ['no']:
519         if nativecode is None:
520             raise BuildException("Native code should have been built but none was packaged")
521     if build.novcheck:
522         vercode = build.versionCode
523         version = build.versionName
524     if not version or not vercode:
525         raise BuildException("Could not find version information in build in output")
526     if not foundid:
527         raise BuildException("Could not find package ID in output")
528     if foundid != app.id:
529         raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
530
531     return vercode, version
532
533
534 def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
535     """Do a build locally."""
536
537     ndk_path = build.ndk_path()
538     if build.ndk or (build.buildjni and build.buildjni != ['no']):
539         if not ndk_path:
540             logging.critical("Android NDK version '%s' could not be found!" % build.ndk or 'r12b')
541             logging.critical("Configured versions:")
542             for k, v in config['ndk_paths'].items():
543                 if k.endswith("_orig"):
544                     continue
545                 logging.critical("  %s: %s" % (k, v))
546             sys.exit(3)
547         elif not os.path.isdir(ndk_path):
548             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
549             sys.exit(3)
550
551     common.set_FDroidPopen_env(build)
552
553     # create ..._toolsversion.log when running in builder vm
554     if onserver:
555         log_path = os.path.join(log_dir,
556                                 common.get_toolsversion_logname(app, build))
557         with open(log_path, 'w') as f:
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(flav) for flav 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 = '== Installed Android Tools ==\n\n'
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 = get_android_tools_version_log(build.ndk_path())
1183             try:
1184
1185                 # For the first build of a particular app, we need to set up
1186                 # the source repo. We can reuse it on subsequent builds, if
1187                 # there are any.
1188                 if first:
1189                     vcs, build_dir = common.setup_vcs(app)
1190                     first = False
1191
1192                 logging.debug("Checking " + build.versionName)
1193                 if trybuild(app, build, build_dir, output_dir, log_dir,
1194                             also_check_dir, srclib_dir, extlib_dir,
1195                             tmp_dir, repo_dir, vcs, options.test,
1196                             options.server, options.force,
1197                             options.onserver, options.refresh):
1198                     toolslog = os.path.join(log_dir,
1199                                             common.get_toolsversion_logname(app, build))
1200                     if not options.onserver and os.path.exists(toolslog):
1201                         with open(toolslog, 'r') as f:
1202                             tools_version_log = ''.join(f.readlines())
1203                         os.remove(toolslog)
1204
1205                     if app.Binaries is not None:
1206                         # This is an app where we build from source, and
1207                         # verify the apk contents against a developer's
1208                         # binary. We get that binary now, and save it
1209                         # alongside our built one in the 'unsigend'
1210                         # directory.
1211                         url = app.Binaries
1212                         url = url.replace('%v', build.versionName)
1213                         url = url.replace('%c', str(build.versionCode))
1214                         logging.info("...retrieving " + url)
1215                         of = "{0}_{1}.apk.binary".format(app.id, build.versionCode)
1216                         of = os.path.join(output_dir, of)
1217                         try:
1218                             net.download_file(url, local_filename=of)
1219                         except requests.exceptions.HTTPError as e:
1220                             raise FDroidException('downloading Binaries from %s failed' % url) from e
1221
1222                         # Now we check weather the build can be verified to
1223                         # match the supplied binary or not. Should the
1224                         # comparison fail, we mark this build as a failure
1225                         # and remove everything from the unsigend folder.
1226                         with tempfile.TemporaryDirectory() as tmpdir:
1227                             unsigned_apk = \
1228                                 '{0}_{1}.apk'.format(appid,
1229                                                      build.versionCode)
1230                             unsigned_apk = os.path.join(output_dir,
1231                                                         unsigned_apk)
1232                             compare_result = \
1233                                 common.verify_apks(of, unsigned_apk, tmpdir)
1234                             if compare_result:
1235                                 compare_result = compare_result.split('\n')
1236                                 line_count = len(compare_result)
1237                                 compare_result = compare_result[:299]
1238                                 if line_count > len(compare_result):
1239                                     line_difference = \
1240                                         line_count - len(compare_result)
1241                                     compare_result.append('%d more lines ...' %
1242                                                           line_difference)
1243                                 compare_result = '\n'.join(compare_result)
1244                                 raise FDroidException('compared built binary '
1245                                                       'to supplied reference '
1246                                                       'binary but failed',
1247                                                       compare_result)
1248                             else:
1249                                 logging.info('compared built binary to '
1250                                              'supplied reference binary '
1251                                              'successfully')
1252
1253                     build_succeeded.append(app)
1254                     wikilog = "Build succeeded"
1255
1256             except VCSException as vcse:
1257                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
1258                 logging.error("VCS error while building app %s: %s" % (
1259                     appid, reason))
1260                 if options.stop:
1261                     sys.exit(1)
1262                 failed_apps[appid] = vcse
1263                 wikilog = str(vcse)
1264             except FDroidException as e:
1265                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
1266                     f.write('\n\n============================================================\n')
1267                     f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
1268                             (build.versionCode, build.versionName, build.commit))
1269                     f.write('Build completed at '
1270                             + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
1271                     f.write('\n' + tools_version_log + '\n')
1272                     f.write(str(e))
1273                 logging.error("Could not build app %s: %s" % (appid, e))
1274                 if options.stop:
1275                     sys.exit(1)
1276                 failed_apps[appid] = e
1277                 wikilog = e.get_wikitext()
1278             except Exception as e:
1279                 logging.error("Could not build app %s due to unknown error: %s" % (
1280                     appid, traceback.format_exc()))
1281                 if options.stop:
1282                     sys.exit(1)
1283                 failed_apps[appid] = e
1284                 wikilog = str(e)
1285
1286             if options.wiki and wikilog:
1287                 try:
1288                     # Write a page with the last build log for this version code
1289                     lastbuildpage = appid + '/lastbuild_' + build.versionCode
1290                     newpage = site.Pages[lastbuildpage]
1291                     with open(os.path.join('tmp', 'fdroidserverid')) as fp:
1292                         fdroidserverid = fp.read().rstrip()
1293                     txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
1294                           + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1295                           + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
1296                     if options.onserver:
1297                         txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
1298                                + buildserverid + ' ' + buildserverid + ']\n\n'
1299                     txt += tools_version_log + '\n\n'
1300                     txt += '== Build Log ==\n\n' + wikilog
1301                     newpage.save(txt, summary='Build log')
1302                     # Redirect from /lastbuild to the most recent build log
1303                     newpage = site.Pages[appid + '/lastbuild']
1304                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
1305                 except Exception as e:
1306                     logging.error("Error while attempting to publish build log: %s" % e)
1307
1308     for app in build_succeeded:
1309         logging.info("success: %s" % (app.id))
1310
1311     if not options.verbose:
1312         for fa in failed_apps:
1313             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
1314
1315     # perform a drozer scan of all successful builds
1316     if options.dscanner and build_succeeded:
1317         from .dscanner import DockerDriver
1318
1319         docker = DockerDriver()
1320
1321         try:
1322             for app in build_succeeded:
1323
1324                 logging.info("Need to sign the app before we can install it.")
1325                 subprocess.call("fdroid publish {0}".format(app.id), shell=True)
1326
1327                 apk_path = None
1328
1329                 for f in os.listdir(repo_dir):
1330                     if f.endswith('.apk') and f.startswith(app.id):
1331                         apk_path = os.path.join(repo_dir, f)
1332                         break
1333
1334                 if not apk_path:
1335                     raise Exception("No signed APK found at path: {0}".format(apk_path))
1336
1337                 if not os.path.isdir(repo_dir):
1338                     exit(1)
1339
1340                 logging.info("Performing Drozer scan on {0}.".format(app))
1341                 docker.perform_drozer_scan(apk_path, app.id, repo_dir)
1342         except Exception as e:
1343             logging.error(str(e))
1344             logging.error("An exception happened. Making sure to clean up")
1345         else:
1346             logging.info("Scan succeeded.")
1347
1348         logging.info("Cleaning up after ourselves.")
1349         docker.clean()
1350
1351     logging.info("Finished.")
1352     if len(build_succeeded) > 0:
1353         logging.info(str(len(build_succeeded)) + ' builds succeeded')
1354     if len(failed_apps) > 0:
1355         logging.info(str(len(failed_apps)) + ' builds failed')
1356
1357     sys.exit(0)
1358
1359
1360 if __name__ == "__main__":
1361     main()