chiark / gitweb /
Merge branch 'plural' into 'master'
[fdroidserver.git] / fdroidserver / build.py
index 021c5da722167b0918aacadeddd511e41b9027a4..6410226ccb4603315afb4bd75dc818b0693b3850 100644 (file)
@@ -26,16 +26,21 @@ import re
 import tarfile
 import traceback
 import time
-import json
+import requests
+import tempfile
 from configparser import ConfigParser
 from argparse import ArgumentParser
 import logging
+from gettext import ngettext
 
+from . import _
 from . import common
 from . import net
 from . import metadata
 from . import scanner
-from .common import FDroidException, BuildException, VCSException, FDroidPopen, SdkToolsPopen
+from . import vmtools
+from .common import FDroidPopen, SdkToolsPopen
+from .exception import FDroidException, BuildException, VCSException
 
 try:
     import paramiko
@@ -43,208 +48,19 @@ except ImportError:
     pass
 
 
-def get_builder_vm_id():
-    vd = os.path.join('builder', '.vagrant')
-    if os.path.isdir(vd):
-        # Vagrant 1.2 (and maybe 1.1?) it's a directory tree...
-        with open(os.path.join(vd, 'machines', 'default',
-                               'virtualbox', 'id')) as vf:
-            id = vf.read()
-        return id
-    else:
-        # Vagrant 1.0 - it's a json file...
-        with open(os.path.join('builder', '.vagrant')) as vf:
-            v = json.load(vf)
-        return v['active']['default']
-
-
-def got_valid_builder_vm():
-    """Returns True if we have a valid-looking builder vm
-    """
-    if not os.path.exists(os.path.join('builder', 'Vagrantfile')):
-        return False
-    vd = os.path.join('builder', '.vagrant')
-    if not os.path.exists(vd):
-        return False
-    if not os.path.isdir(vd):
-        # Vagrant 1.0 - if the directory is there, it's valid...
-        return True
-    # Vagrant 1.2 - the directory can exist, but the id can be missing...
-    if not os.path.exists(os.path.join(vd, 'machines', 'default',
-                                       'virtualbox', 'id')):
-        return False
-    return True
-
-
-def vagrant(params, cwd=None, printout=False):
-    """Run a vagrant command.
-
-    :param: list of parameters to pass to vagrant
-    :cwd: directory to run in, or None for current directory
-    :returns: (ret, out) where ret is the return code, and out
-               is the stdout (and stderr) from vagrant
-    """
-    p = FDroidPopen(['vagrant'] + params, cwd=cwd)
-    return (p.returncode, p.output)
-
-
-def get_vagrant_sshinfo():
-    """Get ssh connection info for a vagrant VM
-
-    :returns: A dictionary containing 'hostname', 'port', 'user'
-        and 'idfile'
-    """
-    if subprocess.call('vagrant ssh-config >sshconfig',
-                       cwd='builder', shell=True) != 0:
-        raise BuildException("Error getting ssh config")
-    vagranthost = 'default'  # Host in ssh config file
-    sshconfig = paramiko.SSHConfig()
-    sshf = open(os.path.join('builder', 'sshconfig'), 'r')
-    sshconfig.parse(sshf)
-    sshf.close()
-    sshconfig = sshconfig.lookup(vagranthost)
-    idfile = sshconfig['identityfile']
-    if isinstance(idfile, list):
-        idfile = idfile[0]
-    elif idfile.startswith('"') and idfile.endswith('"'):
-        idfile = idfile[1:-1]
-    return {'hostname': sshconfig['hostname'],
-            'port': int(sshconfig['port']),
-            'user': sshconfig['user'],
-            'idfile': idfile}
-
-
-def get_clean_vm(reset=False):
-    """Get a clean VM ready to do a buildserver build.
-
-    This might involve creating and starting a new virtual machine from
-    scratch, or it might be as simple (unless overridden by the reset
-    parameter) as re-using a snapshot created previously.
-
-    A BuildException will be raised if anything goes wrong.
-
-    :reset: True to force creating from scratch.
-    :returns: A dictionary containing 'hostname', 'port', 'user'
-        and 'idfile'
-    """
-    # Reset existing builder machine to a clean state if possible.
-    vm_ok = False
-    if not reset:
-        logging.info("Checking for valid existing build server")
-
-        if got_valid_builder_vm():
-            logging.info("...VM is present")
-            p = FDroidPopen(['VBoxManage', 'snapshot',
-                             get_builder_vm_id(), 'list',
-                             '--details'], cwd='builder')
-            if 'fdroidclean' in p.output:
-                logging.info("...snapshot exists - resetting build server to "
-                             "clean state")
-                retcode, output = vagrant(['status'], cwd='builder')
-
-                if 'running' in output:
-                    logging.info("...suspending")
-                    vagrant(['suspend'], cwd='builder')
-                    logging.info("...waiting a sec...")
-                    time.sleep(10)
-                p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
-                                 'restore', 'fdroidclean'],
-                                cwd='builder')
-
-                if p.returncode == 0:
-                    logging.info("...reset to snapshot - server is valid")
-                    retcode, output = vagrant(['up'], cwd='builder')
-                    if retcode != 0:
-                        raise BuildException("Failed to start build server")
-                    logging.info("...waiting a sec...")
-                    time.sleep(10)
-                    sshinfo = get_vagrant_sshinfo()
-                    vm_ok = True
-                else:
-                    logging.info("...failed to reset to snapshot")
-            else:
-                logging.info("...snapshot doesn't exist - "
-                             "VBoxManage snapshot list:\n" + p.output)
-
-    # If we can't use the existing machine for any reason, make a
-    # new one from scratch.
-    if not vm_ok:
-        if os.path.exists('builder'):
-            logging.info("Removing broken/incomplete/unwanted build server")
-            vagrant(['destroy', '-f'], cwd='builder')
-            shutil.rmtree('builder')
-        os.mkdir('builder')
-
-        p = subprocess.Popen(['vagrant', '--version'],
-                             universal_newlines=True,
-                             stdout=subprocess.PIPE)
-        vver = p.communicate()[0].strip().split(' ')[1]
-        if vver.split('.')[0] != '1' or int(vver.split('.')[1]) < 4:
-            raise BuildException("Unsupported vagrant version {0}".format(vver))
-
-        with open(os.path.join('builder', 'Vagrantfile'), 'w') as vf:
-            vf.write('Vagrant.configure("2") do |config|\n')
-            vf.write('config.vm.box = "buildserver"\n')
-            vf.write('config.vm.synced_folder ".", "/vagrant", disabled: true\n')
-            vf.write('end\n')
-
-        logging.info("Starting new build server")
-        retcode, _ = vagrant(['up'], cwd='builder')
-        if retcode != 0:
-            raise BuildException("Failed to start build server")
-
-        # Open SSH connection to make sure it's working and ready...
-        logging.info("Connecting to virtual machine...")
-        sshinfo = get_vagrant_sshinfo()
-        sshs = paramiko.SSHClient()
-        sshs.set_missing_host_key_policy(paramiko.AutoAddPolicy())
-        sshs.connect(sshinfo['hostname'], username=sshinfo['user'],
-                     port=sshinfo['port'], timeout=300,
-                     look_for_keys=False,
-                     key_filename=sshinfo['idfile'])
-        sshs.close()
-
-        logging.info("Saving clean state of new build server")
-        retcode, _ = vagrant(['suspend'], cwd='builder')
-        if retcode != 0:
-            raise BuildException("Failed to suspend build server")
-        logging.info("...waiting a sec...")
-        time.sleep(10)
-        p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
-                         'take', 'fdroidclean'],
-                        cwd='builder')
-        if p.returncode != 0:
-            raise BuildException("Failed to take snapshot")
-        logging.info("...waiting a sec...")
-        time.sleep(10)
-        logging.info("Restarting new build server")
-        retcode, _ = vagrant(['up'], cwd='builder')
-        if retcode != 0:
-            raise BuildException("Failed to start build server")
-        logging.info("...waiting a sec...")
-        time.sleep(10)
-        # Make sure it worked...
-        p = FDroidPopen(['VBoxManage', 'snapshot', get_builder_vm_id(),
-                         'list', '--details'],
-                        cwd='builder')
-        if 'fdroidclean' not in p.output:
-            raise BuildException("Failed to take snapshot.")
-
-    return sshinfo
-
-
-def release_vm():
-    """Release the VM previously started with get_clean_vm().
-
-    This should always be called.
+# Note that 'force' here also implies test mode.
+def build_server(app, build, vcs, build_dir, output_dir, log_dir, force):
+    """Do a build on the builder vm.
+
+    :param app: app metadata dict
+    :param build:
+    :param vcs: version control system controller object
+    :param build_dir: local source-code checkout of app
+    :param output_dir: target folder for the build result
+    :param force:
     """
-    logging.info("Suspending build server")
-    subprocess.call(['vagrant', 'suspend'], cwd='builder')
 
-
-# Note that 'force' here also implies test mode.
-def build_server(app, build, vcs, build_dir, output_dir, force):
-    """Do a build on the build server."""
+    global buildserverid
 
     try:
         paramiko
@@ -255,9 +71,13 @@ def build_server(app, build, vcs, build_dir, output_dir, force):
     else:
         logging.getLogger("paramiko").setLevel(logging.WARN)
 
-    sshinfo = get_clean_vm()
+    sshinfo = vmtools.get_clean_builder('builder')
 
     try:
+        if not buildserverid:
+            buildserverid = subprocess.check_output(['vagrant', 'ssh', '-c',
+                                                     'cat /home/vagrant/buildserverid'],
+                                                    cwd='builder').rstrip()
 
         # Open SSH connection...
         logging.info("Connecting to virtual machine...")
@@ -278,19 +98,19 @@ def build_server(app, build, vcs, build_dir, output_dir, force):
 
         # Helper to copy the contents of a directory to the server...
         def send_dir(path):
-            root = os.path.dirname(path)
+            startroot = os.path.dirname(path)
             main = os.path.basename(path)
             ftp.mkdir(main)
-            for r, d, f in os.walk(path):
-                rr = os.path.relpath(rroot)
+            for root, dirs, files in os.walk(path):
+                rr = os.path.relpath(root, startroot)
                 ftp.chdir(rr)
-                for dd in d:
-                    ftp.mkdir(dd)
-                for ff in f:
-                    lfile = os.path.join(root, rr, ff)
+                for d in dirs:
+                    ftp.mkdir(d)
+                for f in files:
+                    lfile = os.path.join(startroot, rr, f)
                     if not os.path.islink(lfile):
-                        ftp.put(lfile, ff)
-                        ftp.chmod(ff, os.stat(lfile).st_mode)
+                        ftp.put(lfile, f)
+                        ftp.chmod(f, os.stat(lfile).st_mode)
                 for i in range(len(rr.split('/'))):
                     ftp.chdir('..')
             ftp.chdir('..')
@@ -318,8 +138,8 @@ def build_server(app, build, vcs, build_dir, output_dir, force):
         ftp.mkdir('metadata')
         ftp.mkdir('srclibs')
         ftp.chdir('metadata')
-        ftp.put(os.path.join('metadata', app.id + '.txt'),
-                app.id + '.txt')
+        ftp.put(app.metadatapath, os.path.basename(app.metadatapath))
+
         # And patches if there are any...
         if os.path.exists(os.path.join('metadata', app.id)):
             send_dir(os.path.join('metadata', app.id))
@@ -344,7 +164,7 @@ def build_server(app, build, vcs, build_dir, output_dir, force):
                         ftp.mkdir(d)
                     ftp.chdir(d)
                 ftp.put(libsrc, lp[-1])
-                for _ in lp[:-1]:
+                for _ignored in lp[:-1]:
                     ftp.chdir('..')
         # Copy any srclibs that are required...
         srclibpaths = []
@@ -387,24 +207,47 @@ def build_server(app, build, vcs, build_dir, output_dir, force):
             cmdline += ' --force --test'
         if options.verbose:
             cmdline += ' --verbose'
-        cmdline += " %s:%s" % (app.id, build.vercode)
+        if options.skipscan:
+            cmdline += ' --skip-scan'
+        cmdline += " %s:%s" % (app.id, build.versionCode)
         chan.exec_command('bash --login -c "' + cmdline + '"')
-        output = bytes()
-        while not chan.exit_status_ready():
-            while chan.recv_ready():
-                output += chan.recv(1024)
-            time.sleep(0.1)
+
+        # Fetch build process output ...
+        try:
+            cmd_stdout = chan.makefile('rb', 1024)
+            output = bytes()
+            output += get_android_tools_version_log(build.ndk_path()).encode()
+            while not chan.exit_status_ready():
+                line = cmd_stdout.readline()
+                if line:
+                    if options.verbose:
+                        logging.debug("buildserver > " + str(line, 'utf-8').rstrip())
+                    output += line
+                else:
+                    time.sleep(0.05)
+            for line in cmd_stdout.readlines():
+                if options.verbose:
+                    logging.debug("buildserver > " + str(line, 'utf-8').rstrip())
+                output += line
+        finally:
+            cmd_stdout.close()
+
+        # Check build process exit status ...
         logging.info("...getting exit status")
         returncode = chan.recv_exit_status()
-        while True:
-            get = chan.recv(1024)
-            if len(get) == 0:
-                break
-            output += get
         if returncode != 0:
             raise BuildException(
                 "Build.py failed on server for {0}:{1}".format(
-                    app.id, build.version), str(output, 'utf-8'))
+                    app.id, build.versionName), None if options.verbose else str(output, 'utf-8'))
+
+        # Retreive logs...
+        toolsversion_log = common.get_toolsversion_logname(app, build)
+        try:
+            ftp.chdir(os.path.join(homedir, log_dir))
+            ftp.get(toolsversion_log, os.path.join(log_dir, toolsversion_log))
+            logging.debug('retrieved %s', toolsversion_log)
+        except Exception as e:
+            logging.warn('could not get %s from builder vm: %s' % (toolsversion_log, e))
 
         # Retrieve the built files...
         logging.info("Retrieving build output...")
@@ -418,16 +261,16 @@ def build_server(app, build, vcs, build_dir, output_dir, force):
             ftp.get(apkfile, os.path.join(output_dir, apkfile))
             if not options.notarball:
                 ftp.get(tarball, os.path.join(output_dir, tarball))
-        except:
+        except Exception:
             raise BuildException(
-                "Build failed for %s:%s - missing output files".format(
-                    app.id, build.version), output)
+                "Build failed for {0}:{1} - missing output files".format(
+                    app.id, build.versionName), None if options.verbose else str(output, 'utf-8'))
         ftp.close()
 
     finally:
-
         # Suspend the build server.
-        release_vm()
+        vm = vmtools.get_build_vm('builder')
+        vm.suspend()
 
 
 def force_gradle_build_tools(build_dir, build_tools):
@@ -444,19 +287,105 @@ def force_gradle_build_tools(build_dir, build_tools):
                                path)
 
 
-def capitalize_intact(string):
-    """Like str.capitalize(), but leave the rest of the string intact without
-    switching it to lowercase."""
+def transform_first_char(string, method):
+    """Uses method() on the first character of string."""
     if len(string) == 0:
         return string
     if len(string) == 1:
-        return string.upper()
-    return string[0].upper() + string[1:]
+        return method(string)
+    return method(string[0]) + string[1:]
 
 
-def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
-    """Do a build locally."""
+def has_native_code(apkobj):
+    """aapt checks if there are architecture folders under the lib/ folder
+    so we are simulating the same behaviour"""
+    arch_re = re.compile("^lib/(.*)/.*$")
+    arch = [file for file in apkobj.get_files() if arch_re.match(file)]
+    return False if not arch else True
+
+
+def get_apk_metadata_aapt(apkfile):
+    """aapt function to extract versionCode, versionName, packageName and nativecode"""
+    vercode = None
+    version = None
+    foundid = None
+    nativecode = None
+
+    p = SdkToolsPopen(['aapt', 'dump', 'badging', apkfile], output=False)
+
+    for line in p.output.splitlines():
+        if line.startswith("package:"):
+            pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
+            m = pat.match(line)
+            if m:
+                foundid = m.group(1)
+            pat = re.compile(".*versionCode='([0-9]*)'.*")
+            m = pat.match(line)
+            if m:
+                vercode = m.group(1)
+            pat = re.compile(".*versionName='([^']*)'.*")
+            m = pat.match(line)
+            if m:
+                version = m.group(1)
+        elif line.startswith("native-code:"):
+            nativecode = line[12:]
+
+    return vercode, version, foundid, nativecode
+
+
+def get_apk_metadata_androguard(apkfile):
+    """androguard function to extract versionCode, versionName, packageName and nativecode"""
+    try:
+        from androguard.core.bytecodes.apk import APK
+        apkobject = APK(apkfile)
+    except ImportError:
+        raise BuildException("androguard library is not installed and aapt binary not found")
+    except FileNotFoundError:
+        raise BuildException("Could not open apk file for metadata analysis")
+
+    if not apkobject.is_valid_APK():
+        raise BuildException("Invalid APK provided")
+
+    foundid = apkobject.get_package()
+    vercode = apkobject.get_androidversion_code()
+    version = apkobject.get_androidversion_name()
+    nativecode = has_native_code(apkobject)
+
+    return vercode, version, foundid, nativecode
+
+
+def get_metadata_from_apk(app, build, apkfile):
+    """get the required metadata from the built APK"""
+
+    if common.SdkToolsPopen(['aapt', 'version'], output=False):
+        vercode, version, foundid, nativecode = get_apk_metadata_aapt(apkfile)
+    else:
+        vercode, version, foundid, nativecode = get_apk_metadata_androguard(apkfile)
+
+    # Ignore empty strings or any kind of space/newline chars that we don't
+    # care about
+    if nativecode is not None:
+        nativecode = nativecode.strip()
+        nativecode = None if not nativecode else nativecode
 
+    if build.buildjni and build.buildjni != ['no']:
+        if nativecode is None:
+            raise BuildException("Native code should have been built but none was packaged")
+    if build.novcheck:
+        vercode = build.versionCode
+        version = build.versionName
+    if not version or not vercode:
+        raise BuildException("Could not find version information in build in output")
+    if not foundid:
+        raise BuildException("Could not find package ID in output")
+    if foundid != app.id:
+        raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
+
+    return vercode, version
+
+
+def build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh):
+    """Do a build locally."""
     ndk_path = build.ndk_path()
     if build.ndk or (build.buildjni and build.buildjni != ['no']):
         if not ndk_path:
@@ -466,13 +395,33 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
                 if k.endswith("_orig"):
                     continue
                 logging.critical("  %s: %s" % (k, v))
-            sys.exit(3)
+            raise FDroidException()
         elif not os.path.isdir(ndk_path):
             logging.critical("Android NDK '%s' is not a directory!" % ndk_path)
-            sys.exit(3)
+            raise FDroidException()
 
     common.set_FDroidPopen_env(build)
 
+    # create ..._toolsversion.log when running in builder vm
+    if onserver:
+        # before doing anything, run the sudo commands to setup the VM
+        if build.sudo:
+            logging.info("Running 'sudo' commands in %s" % os.getcwd())
+
+            p = FDroidPopen(['sudo', 'bash', '-x', '-c', build.sudo])
+            if p.returncode != 0:
+                raise BuildException("Error running sudo command for %s:%s" %
+                                     (app.id, build.versionName), p.output)
+
+        log_path = os.path.join(log_dir,
+                                common.get_toolsversion_logname(app, build))
+        with open(log_path, 'w') as f:
+            f.write(get_android_tools_version_log(build.ndk_path()))
+    else:
+        if build.sudo:
+            logging.warning('%s:%s runs this on the buildserver with sudo:\n\t%s'
+                            % (app.id, build.versionName, build.sudo))
+
     # Prepare the source code...
     root_dir, srclibpaths = common.prepare_source(vcs, app, build,
                                                   build_dir, srclib_dir,
@@ -506,7 +455,7 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
         if flavours == ['yes']:
             flavours = []
 
-        flavours_cmd = ''.join([capitalize_intact(f) for f in flavours])
+        flavours_cmd = ''.join([transform_first_char(flav, str.upper) for flav in flavours])
 
         gradletasks += ['assemble' + flavours_cmd + 'Release']
 
@@ -526,13 +475,16 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
     elif bmethod == 'kivy':
         pass
 
+    elif bmethod == 'buildozer':
+        pass
+
     elif bmethod == 'ant':
         logging.info("Cleaning Ant project...")
         p = FDroidPopen(['ant', 'clean'], cwd=root_dir)
 
     if p is not None and p.returncode != 0:
         raise BuildException("Error cleaning %s:%s" %
-                             (app.id, build.version), p.output)
+                             (app.id, build.versionName), p.output)
 
     for root, dirs, files in os.walk(build_dir):
 
@@ -569,12 +521,15 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
     else:
         # Scan before building...
         logging.info("Scanning source for common problems...")
-        count = scanner.scan_source(build_dir, root_dir, build)
+        count = scanner.scan_source(build_dir, build)
         if count > 0:
             if force:
-                logging.warn('Scanner found %d problems' % count)
+                logging.warning(ngettext('Scanner found {} problem',
+                                         'Scanner found {} problems', count).format(count))
             else:
-                raise BuildException("Can't build due to %d errors while scanning" % count)
+                raise BuildException(ngettext(
+                    "Can't build due to {} error while scanning",
+                    "Can't build due to {} errors while scanning", count).format(count))
 
     if not options.notarball:
         # Build the source tarball right before we build the release...
@@ -601,7 +556,7 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
 
         if p.returncode != 0:
             raise BuildException("Error running build command for %s:%s" %
-                                 (app.id, build.version), p.output)
+                                 (app.id, build.versionName), p.output)
 
     # Build native stuff if required...
     if build.buildjni and build.buildjni != ['no']:
@@ -629,7 +584,7 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
                 del manifest_text
             p = FDroidPopen(cmd, cwd=os.path.join(root_dir, d))
             if p.returncode != 0:
-                raise BuildException("NDK build failed for %s:%s" % (app.id, build.version), p.output)
+                raise BuildException("NDK build failed for %s:%s" % (app.id, build.versionName), p.output)
 
     p = None
     # Build the release...
@@ -720,6 +675,73 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
         cmd.append('release')
         p = FDroidPopen(cmd, cwd=distdir)
 
+    elif bmethod == 'buildozer':
+        logging.info("Building Kivy project using buildozer...")
+
+        # parse buildozer.spez
+        spec = os.path.join(root_dir, 'buildozer.spec')
+        if not os.path.exists(spec):
+            raise BuildException("Expected to find buildozer-compatible spec at {0}"
+                                 .format(spec))
+        defaults = {'orientation': 'landscape', 'icon': '',
+                    'permissions': '', 'android.api': "19"}
+        bconfig = ConfigParser(defaults, allow_no_value=True)
+        bconfig.read(spec)
+
+        # update spec with sdk and ndk locations to prevent buildozer from
+        # downloading.
+        loc_ndk = common.env['ANDROID_NDK']
+        loc_sdk = common.env['ANDROID_SDK']
+        if loc_ndk == '$ANDROID_NDK':
+            loc_ndk = loc_sdk + '/ndk-bundle'
+
+        bc_ndk = None
+        bc_sdk = None
+        try:
+            bc_ndk = bconfig.get('app', 'android.sdk_path')
+        except Exception:
+            pass
+        try:
+            bc_sdk = bconfig.get('app', 'android.ndk_path')
+        except Exception:
+            pass
+
+        if bc_sdk is None:
+            bconfig.set('app', 'android.sdk_path', loc_sdk)
+        if bc_ndk is None:
+            bconfig.set('app', 'android.ndk_path', loc_ndk)
+
+        fspec = open(spec, 'w')
+        bconfig.write(fspec)
+        fspec.close()
+
+        logging.info("sdk_path = %s" % loc_sdk)
+        logging.info("ndk_path = %s" % loc_ndk)
+
+        p = None
+        # execute buildozer
+        cmd = ['buildozer', 'android', 'release']
+        try:
+            p = FDroidPopen(cmd, cwd=root_dir)
+        except Exception:
+            pass
+
+        # buidozer not installed ? clone repo and run
+        if (p is None or p.returncode != 0):
+            cmd = ['git', 'clone', 'https://github.com/kivy/buildozer.git']
+            p = subprocess.Popen(cmd, cwd=root_dir, shell=False)
+            p.wait()
+            if p.returncode != 0:
+                raise BuildException("Distribute build failed")
+
+            cmd = ['python', 'buildozer/buildozer/scripts/client.py', 'android', 'release']
+            p = FDroidPopen(cmd, cwd=root_dir)
+
+        # expected to fail.
+        # Signing will fail if not set by environnment vars (cf. p4a docs).
+        # But the unsigned apk will be ok.
+        p.returncode = 0
+
     elif bmethod == 'gradle':
         logging.info("Building Gradle project...")
 
@@ -743,8 +765,8 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
         bindir = os.path.join(root_dir, 'bin')
 
     if p is not None and p.returncode != 0:
-        raise BuildException("Build failed for %s:%s" % (app.id, build.version), p.output)
-    logging.info("Successfully built version " + build.version + ' of ' + app.id)
+        raise BuildException("Build failed for %s:%s" % (app.id, build.versionName), p.output)
+    logging.info("Successfully built version " + build.versionName + ' of ' + app.id)
 
     omethod = build.output_method()
     if omethod == 'maven':
@@ -772,11 +794,11 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
                            '{0}-{1}-release.apk'.format(
                                bconfig.get('app', 'title'),
                                bconfig.get('app', 'version')))
-    elif omethod == 'gradle':
+
+    elif omethod == 'buildozer':
         src = None
         for apks_dir in [
-                os.path.join(root_dir, 'build', 'outputs', 'apk'),
-                os.path.join(root_dir, 'build', 'apk'),
+                os.path.join(root_dir, '.buildozer', 'android', 'platform', 'build', 'dists', bconfig.get('app', 'title'), 'bin'),
                 ]:
             for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
                 apks = glob.glob(os.path.join(apks_dir, apkglob))
@@ -793,6 +815,37 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
         if src is None:
             raise BuildException('Failed to find any output apks')
 
+    elif omethod == 'gradle':
+        src = None
+        apk_dirs = [
+            # gradle plugin >= 3.0
+            os.path.join(root_dir, 'build', 'outputs', 'apk', 'release'),
+            # gradle plugin < 3.0 and >= 0.11
+            os.path.join(root_dir, 'build', 'outputs', 'apk'),
+            # really old path
+            os.path.join(root_dir, 'build', 'apk'),
+            ]
+        # If we build with gradle flavours with gradle plugin >= 3.0 the apk will be in
+        # a subdirectory corresponding to the flavour command used, but with different
+        # capitalization.
+        if flavours_cmd:
+            apk_dirs.append(os.path.join(root_dir, 'build', 'outputs', 'apk', transform_first_char(flavours_cmd, str.lower), 'release'))
+        for apks_dir in apk_dirs:
+            for apkglob in ['*-release-unsigned.apk', '*-unsigned.apk', '*.apk']:
+                apks = glob.glob(os.path.join(apks_dir, apkglob))
+
+                if len(apks) > 1:
+                    raise BuildException('More than one resulting apks found in %s' % apks_dir,
+                                         '\n'.join(apks))
+                if len(apks) == 1:
+                    src = apks[0]
+                    break
+            if src is not None:
+                break
+
+        if src is None:
+            raise BuildException('Failed to find any output apks')
+
     elif omethod == 'ant':
         stdout_apk = '\n'.join([
             line for line in p.output.splitlines() if '.apk' in line])
@@ -800,7 +853,8 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
                        re.S | re.M).group(1)
         src = os.path.join(bindir, src)
     elif omethod == 'raw':
-        globpath = os.path.join(root_dir, build.output)
+        output_path = common.replace_build_vars(build.output, build)
+        globpath = os.path.join(root_dir, output_path)
         apks = glob.glob(globpath)
         if len(apks) > 1:
             raise BuildException('Multiple apks match %s' % globpath, '\n'.join(apks))
@@ -809,7 +863,7 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
         src = os.path.normpath(apks[0])
 
     # Make sure it's not debuggable...
-    if common.isApkDebuggable(src, config):
+    if common.isApkAndDebuggable(src):
         raise BuildException("APK is debuggable")
 
     # By way of a sanity check, make sure the version and version
@@ -818,56 +872,17 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
     if not os.path.exists(src):
         raise BuildException("Unsigned apk is not at expected location of " + src)
 
-    p = SdkToolsPopen(['aapt', 'dump', 'badging', src], output=False)
-
-    vercode = None
-    version = None
-    foundid = None
-    nativecode = None
-    for line in p.output.splitlines():
-        if line.startswith("package:"):
-            pat = re.compile(".*name='([a-zA-Z0-9._]*)'.*")
-            m = pat.match(line)
-            if m:
-                foundid = m.group(1)
-            pat = re.compile(".*versionCode='([0-9]*)'.*")
-            m = pat.match(line)
-            if m:
-                vercode = m.group(1)
-            pat = re.compile(".*versionName='([^']*)'.*")
-            m = pat.match(line)
-            if m:
-                version = m.group(1)
-        elif line.startswith("native-code:"):
-            nativecode = line[12:]
-
-    # Ignore empty strings or any kind of space/newline chars that we don't
-    # care about
-    if nativecode is not None:
-        nativecode = nativecode.strip()
-        nativecode = None if not nativecode else nativecode
-
-    if build.buildjni and build.buildjni != ['no']:
-        if nativecode is None:
-            raise BuildException("Native code should have been built but none was packaged")
-    if build.novcheck:
-        vercode = build.vercode
-        version = build.version
-    if not version or not vercode:
-        raise BuildException("Could not find version information in build in output")
-    if not foundid:
-        raise BuildException("Could not find package ID in output")
-    if foundid != app.id:
-        raise BuildException("Wrong package ID - build " + foundid + " but expected " + app.id)
-
-    if (version != build.version or
-            vercode != build.vercode):
-        raise BuildException(("Unexpected version/version code in output;"
-                              " APK: '%s' / '%s', "
-                              " Expected: '%s' / '%s'")
-                             % (version, str(vercode), build.version,
-                                str(build.vercode))
-                             )
+    if common.get_file_extension(src) == 'apk':
+        vercode, version = get_metadata_from_apk(app, build, src)
+        if (version != build.versionName or vercode != build.versionCode):
+            raise BuildException(("Unexpected version/version code in output;"
+                                  " APK: '%s' / '%s', "
+                                  " Expected: '%s' / '%s'")
+                                 % (version, str(vercode), build.versionName,
+                                    str(build.versionCode)))
+    else:
+        vercode = build.versionCode
+        version = build.versionName
 
     # Add information for 'fdroid verify' to be able to reproduce the build
     # environment.
@@ -893,8 +908,9 @@ def build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir,
                     os.path.join(output_dir, tarname))
 
 
-def trybuild(app, build, build_dir, output_dir, also_check_dir, srclib_dir, extlib_dir,
-             tmp_dir, repo_dir, vcs, test, server, force, onserver, refresh):
+def trybuild(app, build, build_dir, output_dir, log_dir, also_check_dir,
+             srclib_dir, extlib_dir, tmp_dir, repo_dir, vcs, test,
+             server, force, onserver, refresh):
     """
     Build a particular version of an application, if it needs building.
 
@@ -902,7 +918,7 @@ def trybuild(app, build, build_dir, output_dir, also_check_dir, srclib_dir, extl
        this is the 'unsigned' directory.
     :param repo_dir: The repo directory - used for checking if the build is
        necessary.
-    :paaram also_check_dir: An additional location for checking if the build
+    :param also_check_dir: An additional location for checking if the build
        is necessary (usually the archive repo)
     :param test: True if building in test mode, in which case the build will
        always happen, even if the output already exists. In test mode, the
@@ -930,49 +946,87 @@ def trybuild(app, build, build_dir, output_dir, also_check_dir, srclib_dir, extl
         return False
 
     logging.info("Building version %s (%s) of %s" % (
-        build.version, build.vercode, app.id))
+        build.versionName, build.versionCode, app.id))
 
     if server:
         # When using server mode, still keep a local cache of the repo, by
         # grabbing the source now.
         vcs.gotorevision(build.commit)
 
-        build_server(app, build, vcs, build_dir, output_dir, force)
+        build_server(app, build, vcs, build_dir, output_dir, log_dir, force)
     else:
-        build_local(app, build, vcs, build_dir, output_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
+        build_local(app, build, vcs, build_dir, output_dir, log_dir, srclib_dir, extlib_dir, tmp_dir, force, onserver, refresh)
     return True
 
 
+def get_android_tools_versions(ndk_path=None):
+    '''get a list of the versions of all installed Android SDK/NDK components'''
+
+    global config
+    sdk_path = config['sdk_path']
+    if sdk_path[-1] != '/':
+        sdk_path += '/'
+    components = []
+    if ndk_path:
+        ndk_release_txt = os.path.join(ndk_path, 'RELEASE.TXT')
+        if os.path.isfile(ndk_release_txt):
+            with open(ndk_release_txt, 'r') as fp:
+                components.append((os.path.basename(ndk_path), fp.read()[:-1]))
+
+    pattern = re.compile('^Pkg.Revision=(.+)', re.MULTILINE)
+    for root, dirs, files in os.walk(sdk_path):
+        if 'source.properties' in files:
+            source_properties = os.path.join(root, 'source.properties')
+            with open(source_properties, 'r') as fp:
+                m = pattern.search(fp.read())
+                if m:
+                    components.append((root[len(sdk_path):], m.group(1)))
+
+    return components
+
+
+def get_android_tools_version_log(ndk_path):
+    '''get a list of the versions of all installed Android SDK/NDK components'''
+    log = '== Installed Android Tools ==\n\n'
+    components = get_android_tools_versions(ndk_path)
+    for name, version in sorted(components):
+        log += '* ' + name + ' (' + version + ')\n'
+
+    return log
+
+
 def parse_commandline():
     """Parse the command line. Returns options, parser."""
 
     parser = ArgumentParser(usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]")
     common.setup_global_opts(parser)
-    parser.add_argument("appid", nargs='*', help="app-id with optional versioncode in the form APPID[:VERCODE]")
+    parser.add_argument("appid", nargs='*', help=_("applicationId with optional versionCode in the form APPID[:VERCODE]"))
     parser.add_argument("-l", "--latest", action="store_true", default=False,
-                        help="Build only the latest version of each package")
+                        help=_("Build only the latest version of each package"))
     parser.add_argument("-s", "--stop", action="store_true", default=False,
-                        help="Make the build stop on exceptions")
+                        help=_("Make the build stop on exceptions"))
     parser.add_argument("-t", "--test", action="store_true", default=False,
-                        help="Test mode - put output in the tmp directory only, and always build, even if the output already exists.")
+                        help=_("Test mode - put output in the tmp directory only, and always build, even if the output already exists."))
     parser.add_argument("--server", action="store_true", default=False,
-                        help="Use build server")
+                        help=_("Use build server"))
     parser.add_argument("--resetserver", action="store_true", default=False,
-                        help="Reset and create a brand new build server, even if the existing one appears to be ok.")
+                        help=_("Reset and create a brand new build server, even if the existing one appears to be ok."))
     parser.add_argument("--on-server", dest="onserver", action="store_true", default=False,
-                        help="Specify that we're running on the build server")
+                        help=_("Specify that we're running on the build server"))
     parser.add_argument("--skip-scan", dest="skipscan", action="store_true", default=False,
-                        help="Skip scanning the source code for binaries and other problems")
+                        help=_("Skip scanning the source code for binaries and other problems"))
+    parser.add_argument("--dscanner", action="store_true", default=False,
+                        help=_("Setup an emulator, install the apk on it and perform a drozer scan"))
     parser.add_argument("--no-tarball", dest="notarball", action="store_true", default=False,
-                        help="Don't create a source tarball, useful when testing a build")
+                        help=_("Don't create a source tarball, useful when testing a build"))
     parser.add_argument("--no-refresh", dest="refresh", action="store_false", default=True,
-                        help="Don't refresh the repository, useful when testing a build with no internet connection")
+                        help=_("Don't refresh the repository, useful when testing a build with no internet connection"))
     parser.add_argument("-f", "--force", action="store_true", default=False,
-                        help="Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode.")
+                        help=_("Force build of disabled apps, and carries on regardless of scan problems. Only allowed in test mode."))
     parser.add_argument("-a", "--all", action="store_true", default=False,
-                        help="Build all applications available")
+                        help=_("Build all applications available"))
     parser.add_argument("-w", "--wiki", default=False, action="store_true",
-                        help="Update the wiki")
+                        help=_("Update the wiki"))
     metadata.add_metadata_arguments(parser)
     options = parser.parse_args()
     metadata.warnings_action = options.W
@@ -986,13 +1040,15 @@ def parse_commandline():
 
     return options, parser
 
+
 options = None
 config = None
+buildserverid = None
 
 
 def main():
 
-    global options, config
+    global options, config, buildserverid
 
     options, parser = parse_commandline()
 
@@ -1056,9 +1112,10 @@ def main():
     extlib_dir = os.path.join(build_dir, 'extlib')
 
     # Read all app and srclib metadata
-    allapps = metadata.read_metadata(xref=not options.onserver)
-
+    pkgs = common.read_pkg_args(options.appid, True)
+    allapps = metadata.read_metadata(not options.onserver, pkgs)
     apps = common.read_app_args(options.appid, allapps, True)
+
     for appid, app in list(apps.items()):
         if (app.Disabled and not options.force) or not app.RepoType or not app.builds:
             del apps[appid]
@@ -1089,30 +1146,30 @@ def main():
 
         for build in app.builds:
             wikilog = None
+            tools_version_log = ''
+            if not options.onserver:
+                tools_version_log = get_android_tools_version_log(build.ndk_path())
             try:
 
                 # For the first build of a particular app, we need to set up
                 # the source repo. We can reuse it on subsequent builds, if
                 # there are any.
                 if first:
-                    if app.RepoType == 'srclib':
-                        build_dir = os.path.join('build', 'srclib', app.Repo)
-                    else:
-                        build_dir = os.path.join('build', appid)
-
-                    # Set up vcs interface and make sure we have the latest code...
-                    logging.debug("Getting {0} vcs interface for {1}"
-                                  .format(app.RepoType, app.Repo))
-                    vcs = common.getvcs(app.RepoType, app.Repo, build_dir)
-
+                    vcs, build_dir = common.setup_vcs(app)
                     first = False
 
-                logging.debug("Checking " + build.version)
-                if trybuild(app, build, build_dir, output_dir,
+                logging.debug("Checking " + build.versionName)
+                if trybuild(app, build, build_dir, output_dir, log_dir,
                             also_check_dir, srclib_dir, extlib_dir,
                             tmp_dir, repo_dir, vcs, options.test,
                             options.server, options.force,
                             options.onserver, options.refresh):
+                    toolslog = os.path.join(log_dir,
+                                            common.get_toolsversion_logname(app, build))
+                    if not options.onserver and os.path.exists(toolslog):
+                        with open(toolslog, 'r') as f:
+                            tools_version_log = ''.join(f.readlines())
+                        os.remove(toolslog)
 
                     if app.Binaries is not None:
                         # This is an app where we build from source, and
@@ -1121,15 +1178,54 @@ def main():
                         # alongside our built one in the 'unsigend'
                         # directory.
                         url = app.Binaries
-                        url = url.replace('%v', build.version)
-                        url = url.replace('%c', str(build.vercode))
+                        url = url.replace('%v', build.versionName)
+                        url = url.replace('%c', str(build.versionCode))
                         logging.info("...retrieving " + url)
-                        of = "{0}_{1}.apk.binary".format(app.id, build.vercode)
+                        of = re.sub(r'.apk$', '.binary.apk', common.get_release_filename(app, build))
                         of = os.path.join(output_dir, of)
-                        net.download_file(url, local_filename=of)
+                        try:
+                            net.download_file(url, local_filename=of)
+                        except requests.exceptions.HTTPError as e:
+                            raise FDroidException(
+                                'Downloading Binaries from %s failed. %s' % (url, e))
+
+                        # Now we check weather the build can be verified to
+                        # match the supplied binary or not. Should the
+                        # comparison fail, we mark this build as a failure
+                        # and remove everything from the unsigend folder.
+                        with tempfile.TemporaryDirectory() as tmpdir:
+                            unsigned_apk = \
+                                common.get_release_filename(app, build)
+                            unsigned_apk = \
+                                os.path.join(output_dir, unsigned_apk)
+                            compare_result = \
+                                common.verify_apks(of, unsigned_apk, tmpdir)
+                            if compare_result:
+                                logging.debug('removing %s', unsigned_apk)
+                                os.remove(unsigned_apk)
+                                logging.debug('removing %s', of)
+                                os.remove(of)
+                                compare_result = compare_result.split('\n')
+                                line_count = len(compare_result)
+                                compare_result = compare_result[:299]
+                                if line_count > len(compare_result):
+                                    line_difference = \
+                                        line_count - len(compare_result)
+                                    compare_result.append('%d more lines ...' %
+                                                          line_difference)
+                                compare_result = '\n'.join(compare_result)
+                                raise FDroidException('compared built binary '
+                                                      'to supplied reference '
+                                                      'binary but failed',
+                                                      compare_result)
+                            else:
+                                logging.info('compared built binary to '
+                                             'supplied reference binary '
+                                             'successfully')
 
                     build_succeeded.append(app)
                     wikilog = "Build succeeded"
+
             except VCSException as vcse:
                 reason = str(vcse).split('\n', 1)[0] if options.verbose else str(vcse)
                 logging.error("VCS error while building app %s: %s" % (
@@ -1140,6 +1236,12 @@ def main():
                 wikilog = str(vcse)
             except FDroidException as e:
                 with open(os.path.join(log_dir, appid + '.log'), 'a+') as f:
+                    f.write('\n\n============================================================\n')
+                    f.write('versionCode: %s\nversionName: %s\ncommit: %s\n' %
+                            (build.versionCode, build.versionName, build.commit))
+                    f.write('Build completed at '
+                            + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n')
+                    f.write('\n' + tools_version_log + '\n')
                     f.write(str(e))
                 logging.error("Could not build app %s: %s" % (appid, e))
                 if options.stop:
@@ -1157,15 +1259,24 @@ def main():
             if options.wiki and wikilog:
                 try:
                     # Write a page with the last build log for this version code
-                    lastbuildpage = appid + '/lastbuild_' + build.vercode
+                    lastbuildpage = appid + '/lastbuild_' + build.versionCode
                     newpage = site.Pages[lastbuildpage]
-                    txt = "Build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + "\n\n" + wikilog
+                    with open(os.path.join('tmp', 'fdroidserverid')) as fp:
+                        fdroidserverid = fp.read().rstrip()
+                    txt = "* build completed at " + time.strftime("%Y-%m-%d %H:%M:%SZ", time.gmtime()) + '\n' \
+                          + '* fdroidserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
+                          + fdroidserverid + ' ' + fdroidserverid + ']\n\n'
+                    if options.onserver:
+                        txt += '* buildserverid: [https://gitlab.com/fdroid/fdroidserver/commit/' \
+                               + buildserverid + ' ' + buildserverid + ']\n\n'
+                    txt += tools_version_log + '\n\n'
+                    txt += '== Build Log ==\n\n' + wikilog
                     newpage.save(txt, summary='Build log')
                     # Redirect from /lastbuild to the most recent build log
                     newpage = site.Pages[appid + '/lastbuild']
                     newpage.save('#REDIRECT [[' + lastbuildpage + ']]', summary='Update redirect')
-                except:
-                    logging.error("Error while attempting to publish build log")
+                except Exception as e:
+                    logging.error("Error while attempting to publish build log: %s" % e)
 
     for app in build_succeeded:
         logging.info("success: %s" % (app.id))
@@ -1174,13 +1285,52 @@ def main():
         for fa in failed_apps:
             logging.info("Build for app %s failed:\n%s" % (fa, failed_apps[fa]))
 
-    logging.info("Finished.")
+    # perform a drozer scan of all successful builds
+    if options.dscanner and build_succeeded:
+        from .dscanner import DockerDriver
+
+        docker = DockerDriver()
+
+        try:
+            for app in build_succeeded:
+
+                logging.info("Need to sign the app before we can install it.")
+                subprocess.call("fdroid publish {0}".format(app.id), shell=True)
+
+                apk_path = None
+
+                for f in os.listdir(repo_dir):
+                    if f.endswith('.apk') and f.startswith(app.id):
+                        apk_path = os.path.join(repo_dir, f)
+                        break
+
+                if not apk_path:
+                    raise Exception("No signed APK found at path: {0}".format(apk_path))
+
+                if not os.path.isdir(repo_dir):
+                    exit(1)
+
+                logging.info("Performing Drozer scan on {0}.".format(app))
+                docker.perform_drozer_scan(apk_path, app.id, repo_dir)
+        except Exception as e:
+            logging.error(str(e))
+            logging.error("An exception happened. Making sure to clean up")
+        else:
+            logging.info("Scan succeeded.")
+
+        logging.info("Cleaning up after ourselves.")
+        docker.clean()
+
+    logging.info(_("Finished"))
     if len(build_succeeded) > 0:
-        logging.info(str(len(build_succeeded)) + ' builds succeeded')
+        logging.info(ngettext("{} build succeeded",
+                              "{} builds succeeded", len(build_succeeded)).format(len(build_succeeded)))
     if len(failed_apps) > 0:
-        logging.info(str(len(failed_apps)) + ' builds failed')
+        logging.info(ngettext("{} build failed",
+                              "{} builds failed", len(failed_apps)).format(len(failed_apps)))
 
     sys.exit(0)
 
+
 if __name__ == "__main__":
     main()