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