diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c1904b2850b6b24e58cd2763b1fa98b1d5803637..38c523583a0559ed85c008ee5ea8d2208bfd92eb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -36,7 +36,10 @@ test:
     paths:
       - "*/build/test-results/**/TEST-*.xml"
       - "*/build/reports/*.xml"
+      - "*/build/reports/cobertura/*.xml"
     reports:
+      cobertura:
+        - "*/build/reports/cobertura/*.xml"
       junit:
         - "*/build/test-results/**/TEST-*.xml"
         - "*/build/reports/*.xml"
diff --git a/bitflags/build.gradle.kts b/bitflags/build.gradle.kts
index 393ac2018b3b0aa91362e120487c84311b5e1dd1..67af7e273f1daffaae4c8a9412dce32a3bee4954 100644
--- a/bitflags/build.gradle.kts
+++ b/bitflags/build.gradle.kts
@@ -1,6 +1,7 @@
 plugins {
   kotlin("jvm")
   id("jacoco")
+  id("de.kuschku.coverageconverter")
 }
 
 tasks.withType<Test> {
diff --git a/buildSrc/src/main/kotlin/de/kuschku/coverageconverter/ConvertCoverageReportsAction.kt b/buildSrc/src/main/kotlin/de/kuschku/coverageconverter/ConvertCoverageReportsAction.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9e2f3aa360ece654c61839686900cd5d206df94f
--- /dev/null
+++ b/buildSrc/src/main/kotlin/de/kuschku/coverageconverter/ConvertCoverageReportsAction.kt
@@ -0,0 +1,84 @@
+/*
+ * Quasseldroid - Quassel client for Android
+ *
+ * Copyright (c) 2021 Janne Mareike Koschinski
+ * Copyright (c) 2021 The Quassel Project
+ *
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 3 as published
+ * by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see <http://www.gnu.org/licenses/>.
+ */
+
+package de.kuschku.coverageconverter
+
+import org.gradle.api.Action
+import org.gradle.api.Task
+import org.gradle.testing.jacoco.tasks.JacocoReport
+import java.io.File
+
+internal class ConvertCoverageReportsAction(
+  private val jacocoReportTask: JacocoReport
+) : Action<Task> {
+  private fun findOutputFile(jacocoFile: File): File? {
+    val actualFile = jacocoFile.absoluteFile
+    if (actualFile.exists() && actualFile.parentFile.name == "jacoco") {
+      val folder = File(actualFile.parentFile.parentFile, "cobertura")
+      folder.mkdirs()
+      return File(folder, actualFile.name)
+    }
+
+    return null
+  }
+
+  private fun createPythonScript(name: String, temporaryDir: File): File {
+    val file = File(temporaryDir, name)
+    if (file.exists()) {
+      file.delete()
+    }
+    val source = CoverageConverterPlugin::class.java.getResourceAsStream("/coverageconverter/$name")
+    file.writeBytes(source.readAllBytes())
+    return file
+  }
+
+
+  override fun execute(t: Task) {
+    val cover2coverScript = createPythonScript("cover2cover.py", t.temporaryDir)
+    val source2filenameScript = createPythonScript("source2filename.py", t.temporaryDir)
+
+    fun cover2cover(reportFile: File, outputFile: File, sourceDirectories: Iterable<File>) {
+      t.project.exec {
+        commandLine("python3")
+        args(cover2coverScript.absolutePath)
+        args(reportFile.absolutePath)
+        args(sourceDirectories.map(File::getAbsolutePath))
+        standardOutput = outputFile.outputStream()
+      }
+    }
+
+    fun source2filename(reportFile: File) {
+      t.project.exec {
+        commandLine("python3")
+        args(source2filenameScript.absolutePath)
+        args(reportFile.absolutePath)
+      }
+    }
+
+    jacocoReportTask.reports.forEach {
+      if (it.isEnabled && it.destination.extension == "xml") {
+        val outputFile = findOutputFile(it.destination)
+        if (outputFile != null) {
+          cover2cover(it.destination, outputFile, jacocoReportTask.sourceDirectories)
+          source2filename(outputFile)
+        }
+      }
+    }
+  }
+}
diff --git a/buildSrc/src/main/kotlin/de/kuschku/coverageconverter/CoverageConverterPlugin.kt b/buildSrc/src/main/kotlin/de/kuschku/coverageconverter/CoverageConverterPlugin.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7c16efefe95dbc32de6c1879c4d34f49cb2f71b6
--- /dev/null
+++ b/buildSrc/src/main/kotlin/de/kuschku/coverageconverter/CoverageConverterPlugin.kt
@@ -0,0 +1,30 @@
+package de.kuschku.coverageconverter
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.Task
+import org.gradle.api.tasks.testing.Test
+import org.gradle.kotlin.dsl.getByName
+import org.gradle.kotlin.dsl.register
+import org.gradle.testing.jacoco.tasks.JacocoReport
+
+class CoverageConverterPlugin : Plugin<Project> {
+  override fun apply(project: Project) {
+    project.afterEvaluate {
+      val testTask = tasks.getByName<Test>("test")
+
+      val jacocoReportTask = tasks.getByName<JacocoReport>("jacocoTestReport"){
+        dependsOn(testTask)
+      }
+
+      tasks.register("coberturaTestReport") {
+        dependsOn(jacocoReportTask)
+        mustRunAfter(jacocoReportTask)
+        group = "verification"
+
+        doLast(ConvertCoverageReportsAction(jacocoReportTask))
+      }
+    }
+  }
+}
+
diff --git a/buildSrc/src/main/resources/META-INF/gradle-plugins/de.kuschku.coverageconverter.properties b/buildSrc/src/main/resources/META-INF/gradle-plugins/de.kuschku.coverageconverter.properties
new file mode 100644
index 0000000000000000000000000000000000000000..170902fa7f3a294e7c595310b5df0120901520d2
--- /dev/null
+++ b/buildSrc/src/main/resources/META-INF/gradle-plugins/de.kuschku.coverageconverter.properties
@@ -0,0 +1 @@
+implementation-class=de.kuschku.coverageconverter.CoverageConverterPlugin
diff --git a/buildSrc/src/main/resources/coverageconverter/cover2cover.py b/buildSrc/src/main/resources/coverageconverter/cover2cover.py
new file mode 100644
index 0000000000000000000000000000000000000000..7561ad560695061ff0b2cdb106cc03abea6a2a21
--- /dev/null
+++ b/buildSrc/src/main/resources/coverageconverter/cover2cover.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python
+import sys
+import xml.etree.ElementTree as ET
+import re
+import os.path
+import time
+
+# branch-rate="0.0" complexity="0.0" line-rate="1.0"
+# branch="true" hits="1" number="86"
+
+def find_lines(j_package, filename):
+    """Return all <line> elements for a given source file in a package."""
+    lines = list()
+    sourcefiles = j_package.findall("sourcefile")
+    for sourcefile in sourcefiles:
+        if sourcefile.attrib.get("name") == os.path.basename(filename):
+            lines = lines + sourcefile.findall("line")
+    return lines
+
+def line_is_after(jm, start_line):
+    return int(jm.attrib.get('line', 0)) > start_line
+
+def method_lines(jmethod, jmethods, jlines):
+    """Filter the lines from the given set of jlines that apply to the given jmethod."""
+    start_line = int(jmethod.attrib.get('line', 0))
+    larger     = list(int(jm.attrib.get('line', 0)) for jm in jmethods if line_is_after(jm, start_line))
+    end_line   = min(larger) if len(larger) else 99999999
+
+    for jline in jlines:
+        if start_line <= int(jline.attrib['nr']) < end_line:
+            yield jline
+
+def convert_lines(j_lines, into):
+    """Convert the JaCoCo <line> elements into Cobertura <line> elements, add them under the given element."""
+    c_lines = ET.SubElement(into, 'lines')
+    for jline in j_lines:
+        mb = int(jline.attrib['mb'])
+        cb = int(jline.attrib['cb'])
+        ci = int(jline.attrib['ci'])
+
+        cline = ET.SubElement(c_lines, 'line')
+        cline.set('number', jline.attrib['nr'])
+        cline.set('hits', '1' if ci > 0 else '0') # Probably not true but no way to know from JaCoCo XML file
+
+        if mb + cb > 0:
+            percentage = str(int(100 * (float(cb) / (float(cb) + float(mb))))) + '%'
+            cline.set('branch',             'true')
+            cline.set('condition-coverage', percentage + ' (' + str(cb) + '/' + str(cb + mb) + ')')
+
+            cond = ET.SubElement(ET.SubElement(cline, 'conditions'), 'condition')
+            cond.set('number',   '0')
+            cond.set('type',     'jump')
+            cond.set('coverage', percentage)
+        else:
+            cline.set('branch', 'false')
+
+def path_to_filepath(path_to_class, sourcefilename):
+    return path_to_class[0: path_to_class.rfind("/") + 1] + sourcefilename
+
+def add_counters(source, target):
+    target.set('line-rate',   counter(source, 'LINE'))
+    target.set('branch-rate', counter(source, 'BRANCH'))
+    target.set('complexity', counter(source, 'COMPLEXITY', sum))
+
+def fraction(covered, missed):
+    return covered / (covered + missed)
+
+def sum(covered, missed):
+    return covered + missed
+
+def counter(source, type, operation=fraction):
+    cs = source.findall('counter')
+    c = next((ct for ct in cs if ct.attrib.get('type') == type), None)
+
+    if c is not None:
+        covered = float(c.attrib['covered'])
+        missed  = float(c.attrib['missed'])
+
+        return str(operation(covered, missed))
+    else:
+        return '0.0'
+
+def convert_method(j_method, j_lines):
+    c_method = ET.Element('method')
+    c_method.set('name',      j_method.attrib['name'])
+    c_method.set('signature', j_method.attrib['desc'])
+
+    add_counters(j_method, c_method)
+    convert_lines(j_lines, c_method)
+
+    return c_method
+
+def convert_class(j_class, j_package):
+    c_class = ET.Element('class')
+    c_class.set('name',     j_class.attrib['name'].replace('/', '.'))
+    c_class.set('filename', path_to_filepath(j_class.attrib['name'], j_class.attrib['sourcefilename']))
+
+    all_j_lines = list(find_lines(j_package, c_class.attrib['filename']))
+
+    c_methods   = ET.SubElement(c_class, 'methods')
+    all_j_methods = list(j_class.findall('method'))
+    for j_method in all_j_methods:
+        j_method_lines = method_lines(j_method, all_j_methods, all_j_lines)
+        c_methods.append(convert_method(j_method, j_method_lines))
+
+    add_counters(j_class, c_class)
+    convert_lines(all_j_lines, c_class)
+
+    return c_class
+
+def convert_package(j_package):
+    c_package = ET.Element('package')
+    c_package.attrib['name'] = j_package.attrib['name'].replace('/', '.')
+
+    c_classes = ET.SubElement(c_package, 'classes')
+    for j_class in j_package.findall('class'):
+        c_classes.append(convert_class(j_class, j_package))
+
+    add_counters(j_package, c_package)
+
+    return c_package
+
+def convert_root(source, target, source_roots):
+    try:
+      target.set('timestamp', str(int(source.find('sessioninfo').attrib['start']) / 1000))
+    except AttributeError as e:
+        target.set('timestamp', str(int(time.time() / 1000)))
+    sources     = ET.SubElement(target, 'sources')
+    for s in source_roots:
+        ET.SubElement(sources, 'source').text = s
+
+    packages = ET.SubElement(target, 'packages')
+
+    for group in source.findall('group'):
+        for package in group.findall('package'):
+            packages.append(convert_package(package))
+
+    for package in source.findall('package'):
+        packages.append(convert_package(package))
+
+    add_counters(source, target)
+
+def jacoco2cobertura(filename, source_roots):
+    if filename == '-':
+        root = ET.fromstring(sys.stdin.read())
+    else:
+        tree = ET.parse(filename)
+        root = tree.getroot()
+
+    into = ET.Element('coverage')
+    convert_root(root, into, source_roots)
+    print('<?xml version="1.0" ?>')
+    print(ET.tostring(into, encoding='unicode'))
+
+if __name__ == '__main__':
+    if len(sys.argv) < 2:
+        print("Usage: cover2cover.py FILENAME [SOURCE_ROOTS]")
+        sys.exit(1)
+
+    filename    = sys.argv[1]
+    source_roots = sys.argv[2:] if 2 < len(sys.argv) else '.'
+
+    jacoco2cobertura(filename, source_roots)
diff --git a/buildSrc/src/main/resources/coverageconverter/source2filename.py b/buildSrc/src/main/resources/coverageconverter/source2filename.py
new file mode 100644
index 0000000000000000000000000000000000000000..2b60a9b37850051020111393d923d189e8d0c6b7
--- /dev/null
+++ b/buildSrc/src/main/resources/coverageconverter/source2filename.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+import sys
+import xml.etree.ElementTree as ET
+import lxml.etree
+import re
+import os.path
+
+
+
+
+def convert_source(filename):
+    #read input file
+    root = lxml.etree.parse(filename)
+    sources = root.find('sources')
+    packages = root.find('packages')
+    for package in packages:
+        classes = package.find('classes')
+        for clazz in classes:
+            file_not_found = True
+            for source in sources:
+                full_filename = source.text + '/' + clazz.attrib['filename']
+                if os.path.isfile(full_filename):
+                    clazz.attrib['filename'] = full_filename
+                    file_not_found = False
+            if file_not_found:
+                print("Warning: File {} not found in all sources; removing from sources.".format(clazz.attrib['filename']))
+                clazz.getparent().remove(clazz)
+
+    data = lxml.etree.tostring(root, pretty_print=True)
+    #open the input file in write mode
+    fin = open(filename, "wb")
+    #overrite the input file with the resulting data
+    fin.write(data)
+    #close the file
+    fin.close()
+
+if __name__ == '__main__':
+    if len(sys.argv) < 2:
+        print("Usage: source2filename.py FILENAME")
+        sys.exit(1)
+
+    filename    = sys.argv[1]
+
+    convert_source(filename)
diff --git a/protocol/build.gradle.kts b/protocol/build.gradle.kts
index 27124f8ee225aafee1a81bc432af32d0b5e0d42d..a01ee699332fa9804ffe4ab91732aa69b5b25f8c 100644
--- a/protocol/build.gradle.kts
+++ b/protocol/build.gradle.kts
@@ -1,6 +1,7 @@
 plugins {
   kotlin("jvm")
   jacoco
+  id("de.kuschku.coverageconverter")
 }
 
 tasks.withType<Test> {