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