From 68b4dc9d4a1adfce97086f9f80962660bd104cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 19 May 2026 09:18:21 +0200 Subject: [PATCH 1/4] chore: add Kotlin code coverage via JaCoCo --- .gitignore | 6 ++ CONTRIBUTING.md | 23 +++++ android/build.gradle | 11 +++ android/src/main/AndroidManifest.xml | 7 +- .../src/main/java/com/rive/CoverageHelper.kt | 76 ++++++++++++++++ .../java/com/rive/CoverageInitProvider.kt | 42 +++++++++ example/android/app/build.gradle | 4 + scripts/android-coverage.sh | 87 +++++++++++++++++++ 8 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 android/src/main/java/com/rive/CoverageHelper.kt create mode 100644 android/src/main/java/com/rive/CoverageInitProvider.kt create mode 100755 scripts/android-coverage.sh diff --git a/.gitignore b/.gitignore index 0610dbd3..8f86d384 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,9 @@ example/src/reproducers/local/* # Test coverage coverage/ +coverage-swift.lcov +coverage-kotlin.xml +coverage-kotlin.csv +coverage-kotlin-html/ +coverage.profdata +.jacoco/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75f29e77..082ee82e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,29 @@ The `package.json` file contains various scripts for common tasks: - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. +### Kotlin code coverage + +You can measure which Kotlin code in `android/` gets exercised by the harness tests. This uses JaCoCo bytecode instrumentation and is opt-in. + +```sh +# 1. Build the example app with coverage enabled +cd example/android && ./gradlew assembleDebug -PRive_KotlinCoverage=true && cd ../.. + +# 2. Run harness tests +yarn test:harness:android + +# 3. Extract coverage and print a summary +bash scripts/android-coverage.sh +``` + +This produces `coverage-kotlin.xml` and `coverage-kotlin-html/` in the repo root. To view the HTML report: + +```sh +open coverage-kotlin-html/index.html +``` + +To go back to normal (non-coverage) builds, just build without the `-PRive_KotlinCoverage=true` flag. + ### Sending a pull request When you're sending a pull request: diff --git a/android/build.gradle b/android/build.gradle index 5cf1cd50..db0b17ca 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -43,10 +43,14 @@ android { def skipSetup = project.hasProperty('Rive_RiveRuntimeAndroidSkipSetup') ? project.property('Rive_RiveRuntimeAndroidSkipSetup').toBoolean() : false + def kotlinCoverage = project.hasProperty('Rive_KotlinCoverage') ? + project.property('Rive_KotlinCoverage').toBoolean() : false + defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") buildConfigField "boolean", "RIVE_SKIP_SETUP", "$skipSetup" + buildConfigField "boolean", "RIVE_KOTLIN_COVERAGE", "$kotlinCoverage" externalNativeBuild { cmake { @@ -97,11 +101,18 @@ android { } buildTypes { + debug { + enableAndroidTestCoverage kotlinCoverage + } release { minifyEnabled false } } + if (kotlinCoverage) { + println "@rive-app/react-native: Kotlin code coverage ENABLED" + } + lintOptions { disable "GradleCompatible" } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c91836ff..bdf31f2f 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,8 @@ - + + + diff --git a/android/src/main/java/com/rive/CoverageHelper.kt b/android/src/main/java/com/rive/CoverageHelper.kt new file mode 100644 index 00000000..e1d189d9 --- /dev/null +++ b/android/src/main/java/com/rive/CoverageHelper.kt @@ -0,0 +1,76 @@ +package com.rive + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.util.Log +import java.io.File + +object CoverageHelper { + private const val TAG = "Coverage" + private var ecFile: File? = null + private var timer: java.util.Timer? = null + + fun setup(context: Context) { + if (!BuildConfig.RIVE_KOTLIN_COVERAGE) return + + val agent = + try { + Class + .forName("org.jacoco.agent.rt.RT") + .getMethod("getAgent") + .invoke(null) + } catch (e: Exception) { + Log.w(TAG, "JaCoCo agent not available — was the app built with coverage?", e) + return + } + + val pid = android.os.Process.myPid() + ecFile = File(context.filesDir, "coverage-$pid.ec") + + val app = context.applicationContext as? Application + app?.registerActivityLifecycleCallbacks( + object : Application.ActivityLifecycleCallbacks { + override fun onActivityStopped(activity: Activity) = flush() + override fun onActivityCreated(a: Activity, b: Bundle?) {} + override fun onActivityStarted(a: Activity) {} + override fun onActivityResumed(a: Activity) {} + override fun onActivityPaused(a: Activity) {} + override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {} + override fun onActivityDestroyed(a: Activity) {} + }, + ) + + timer = + java.util.Timer("CoverageFlush", true).also { + it.scheduleAtFixedRate( + object : java.util.TimerTask() { + override fun run() = flush() + }, + 1000L, + 1000L, + ) + } + + Log.i(TAG, "pid=$pid, flushing to ${ecFile?.absolutePath}") + } + + fun flush() { + val file = ecFile ?: return + try { + val agent = + Class + .forName("org.jacoco.agent.rt.RT") + .getMethod("getAgent") + .invoke(null) + val bytes = + agent.javaClass + .getMethod("getExecutionData", Boolean::class.javaPrimitiveType) + .invoke(agent, false) as ByteArray + file.writeBytes(bytes) + } catch (e: Exception) { + Log.w(TAG, "Failed to flush coverage data", e) + } + } +} diff --git a/android/src/main/java/com/rive/CoverageInitProvider.kt b/android/src/main/java/com/rive/CoverageInitProvider.kt new file mode 100644 index 00000000..8d83dba1 --- /dev/null +++ b/android/src/main/java/com/rive/CoverageInitProvider.kt @@ -0,0 +1,42 @@ +package com.rive + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +class CoverageInitProvider : ContentProvider() { + override fun onCreate(): Boolean { + val ctx = context ?: return true + CoverageHelper.setup(ctx) + return true + } + + override fun query( + u: Uri, + p: Array?, + s: String?, + a: Array?, + o: String?, + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 20afed18..9cf150cf 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -107,9 +107,13 @@ android { keyPassword 'android' } } + def kotlinCoverage = project.hasProperty('Rive_KotlinCoverage') ? + project.property('Rive_KotlinCoverage').toBoolean() : false + buildTypes { debug { signingConfig signingConfigs.debug + enableAndroidTestCoverage kotlinCoverage } release { // Caution! In production, you need to generate your own keystore file. diff --git a/scripts/android-coverage.sh b/scripts/android-coverage.sh new file mode 100755 index 00000000..d7b2562e --- /dev/null +++ b/scripts/android-coverage.sh @@ -0,0 +1,87 @@ +#!/bin/bash +set -euo pipefail + +APP_ID="${1:-rive.example}" +REPO_ROOT=$(git rev-parse --show-toplevel) +JACOCO_VERSION="${JACOCO_VERSION:-0.8.12}" +JACOCO_CLI="$REPO_ROOT/.jacoco/jacococli.jar" + +# Download JaCoCo CLI if not cached +if [ ! -f "$JACOCO_CLI" ]; then + echo "Downloading JaCoCo CLI $JACOCO_VERSION..." + mkdir -p "$REPO_ROOT/.jacoco" + curl -sL "https://github.com/jacoco/jacoco/releases/download/v${JACOCO_VERSION}/jacoco-${JACOCO_VERSION}.zip" \ + -o /tmp/jacoco.zip + unzip -oq /tmp/jacoco.zip "lib/jacococli.jar" -d /tmp/jacoco + mv /tmp/jacoco/lib/jacococli.jar "$JACOCO_CLI" + rm -rf /tmp/jacoco /tmp/jacoco.zip +fi + +# Force-stop the app to trigger activity lifecycle flush, then wait for data +adb shell am force-stop "$APP_ID" 2>/dev/null || true +sleep 2 + +# Pull all .ec files from the app's internal storage +EC_DIR="$REPO_ROOT/.jacoco/ec" +rm -rf "$EC_DIR" +mkdir -p "$EC_DIR" + +adb shell run-as "$APP_ID" find files/ -name "*.ec" 2>/dev/null | while read -r remote; do + local_name=$(basename "$remote") + adb shell run-as "$APP_ID" cat "$remote" > "$EC_DIR/$local_name" +done + +EC_COUNT=$(find "$EC_DIR" -name "*.ec" 2>/dev/null | wc -l | tr -d ' ') +[ "$EC_COUNT" -gt 0 ] || { echo "No .ec files found in $APP_ID internal storage"; exit 1; } +echo "Found $EC_COUNT .ec file(s)" + +# Merge .ec files +MERGED_EC="$REPO_ROOT/.jacoco/merged.ec" +java -jar "$JACOCO_CLI" merge "$EC_DIR"/*.ec --destfile "$MERGED_EC" + +# Find instrumented class files for the library module +LIB_CLASSES=$(find "$REPO_ROOT/android/build" -path "*/debug/classes" -type d | head -1) +if [ -z "$LIB_CLASSES" ]; then + LIB_CLASSES=$(find "$REPO_ROOT/android/build" -path "*/tmp/kotlin-classes/debug" -type d | head -1) +fi +[ -n "$LIB_CLASSES" ] || { echo "Library class files not found — was the library built with coverage?"; exit 1; } +echo "Using class files: $LIB_CLASSES" + +LIB_SOURCES="$REPO_ROOT/android/src/main/java" + +# Generate XML report +java -jar "$JACOCO_CLI" report "$MERGED_EC" \ + --classfiles "$LIB_CLASSES" \ + --sourcefiles "$LIB_SOURCES" \ + --xml "$REPO_ROOT/coverage-kotlin.xml" + +# Generate CSV for quick summary +java -jar "$JACOCO_CLI" report "$MERGED_EC" \ + --classfiles "$LIB_CLASSES" \ + --sourcefiles "$LIB_SOURCES" \ + --csv "$REPO_ROOT/coverage-kotlin.csv" + +# Generate HTML report +java -jar "$JACOCO_CLI" report "$MERGED_EC" \ + --classfiles "$LIB_CLASSES" \ + --sourcefiles "$LIB_SOURCES" \ + --html "$REPO_ROOT/coverage-kotlin-html" + +# Print summary from CSV +echo "" +echo "=== Kotlin Coverage Summary ===" +if [ -f "$REPO_ROOT/coverage-kotlin.csv" ]; then + # CSV columns: GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,...,LINE_MISSED,LINE_COVERED,... + tail -n +2 "$REPO_ROOT/coverage-kotlin.csv" | awk -F',' ' + { line_missed += $8; line_covered += $9 } + END { + total = line_missed + line_covered + if (total > 0) printf "Lines: %d/%d (%.1f%%)\n", line_covered, total, line_covered * 100.0 / total + else print "No coverage data" + } + ' +fi + +echo "" +echo "→ coverage-kotlin.xml" +echo "→ coverage-kotlin-html/index.html" From 7b2bd62d74992b5d5941d3338c2ab45018a836a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 19 May 2026 09:57:21 +0200 Subject: [PATCH 2/4] fix: use JaCoCo offline instrumentation for actual coverage enableAndroidTestCoverage only instruments classes for the androidTest variant, not the regular debug APK. Switch to offline instrumentation via jacococli ant task that rewrites class files after Kotlin compilation. Also fix .ec file pull to use tar for reliability across multiple app restarts. --- android/build.gradle | 58 +++++++++++++++++-- .../main/resources/jacoco-agent.properties | 1 + example/android/app/build.gradle | 4 -- scripts/android-coverage.sh | 17 +++--- 4 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 android/src/main/resources/jacoco-agent.properties diff --git a/android/build.gradle b/android/build.gradle index db0b17ca..37060243 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -35,6 +35,9 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["Rive_" + name]).toInteger() } +def kotlinCoverage = project.hasProperty('Rive_KotlinCoverage') ? + project.property('Rive_KotlinCoverage').toBoolean() : false + android { namespace "com.rive" @@ -43,9 +46,6 @@ android { def skipSetup = project.hasProperty('Rive_RiveRuntimeAndroidSkipSetup') ? project.property('Rive_RiveRuntimeAndroidSkipSetup').toBoolean() : false - def kotlinCoverage = project.hasProperty('Rive_KotlinCoverage') ? - project.property('Rive_KotlinCoverage').toBoolean() : false - defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") @@ -101,9 +101,6 @@ android { } buildTypes { - debug { - enableAndroidTestCoverage kotlinCoverage - } release { minifyEnabled false } @@ -160,10 +157,59 @@ def getRiveAndroidVersion() { def riveAndroidVersion = getRiveAndroidVersion() println "@rive-app/react-native: Rive Android SDK ${riveAndroidVersion}" +configurations { + jacocoAnt +} + dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "app.rive:rive-android:${riveAndroidVersion}" implementation project(":react-native-nitro-modules") + + if (kotlinCoverage) { + implementation "org.jacoco:org.jacoco.agent:0.8.12:runtime" + jacocoAnt "org.jacoco:org.jacoco.ant:0.8.12" + } +} + +if (kotlinCoverage) { + afterEvaluate { + def classesDir = file("${buildDir}/tmp/kotlin-classes/debug") + + tasks.named("compileDebugKotlin").configure { + doLast { + if (!classesDir.exists()) return + + // Save a copy of the original (uninstrumented) classes for report generation + def origDir = file("${buildDir}/jacoco-original-classes") + if (origDir.exists()) origDir.deleteDir() + ant.copy(todir: origDir) { + fileset(dir: classesDir) + } + + // Offline-instrument class files: write to temp dir, then replace originals + def instrumentedDir = file("${buildDir}/tmp/jacoco-instrumented") + if (instrumentedDir.exists()) instrumentedDir.deleteDir() + instrumentedDir.mkdirs() + + ant.taskdef( + name: 'instrument', + classname: 'org.jacoco.ant.InstrumentTask', + classpath: configurations.jacocoAnt.asPath + ) + ant.instrument(destdir: instrumentedDir) { + fileset(dir: classesDir, includes: '**/*.class') + } + + // Replace original classes with instrumented ones + ant.copy(todir: classesDir, overwrite: true) { + fileset(dir: instrumentedDir) + } + + println "[Coverage] Instrumented classes in ${classesDir}" + } + } + } } diff --git a/android/src/main/resources/jacoco-agent.properties b/android/src/main/resources/jacoco-agent.properties new file mode 100644 index 00000000..52c4b253 --- /dev/null +++ b/android/src/main/resources/jacoco-agent.properties @@ -0,0 +1 @@ +output=none diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 9cf150cf..20afed18 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -107,13 +107,9 @@ android { keyPassword 'android' } } - def kotlinCoverage = project.hasProperty('Rive_KotlinCoverage') ? - project.property('Rive_KotlinCoverage').toBoolean() : false - buildTypes { debug { signingConfig signingConfigs.debug - enableAndroidTestCoverage kotlinCoverage } release { // Caution! In production, you need to generate your own keystore file. diff --git a/scripts/android-coverage.sh b/scripts/android-coverage.sh index d7b2562e..3a1aa51d 100755 --- a/scripts/android-coverage.sh +++ b/scripts/android-coverage.sh @@ -26,10 +26,8 @@ EC_DIR="$REPO_ROOT/.jacoco/ec" rm -rf "$EC_DIR" mkdir -p "$EC_DIR" -adb shell run-as "$APP_ID" find files/ -name "*.ec" 2>/dev/null | while read -r remote; do - local_name=$(basename "$remote") - adb shell run-as "$APP_ID" cat "$remote" > "$EC_DIR/$local_name" -done +# Use tar to reliably pull all .ec files (avoids adb shell + pipe issues) +adb shell run-as "$APP_ID" sh -c "'cd files && tar cf - *.ec 2>/dev/null'" | tar xf - -C "$EC_DIR" 2>/dev/null || true EC_COUNT=$(find "$EC_DIR" -name "*.ec" 2>/dev/null | wc -l | tr -d ' ') [ "$EC_COUNT" -gt 0 ] || { echo "No .ec files found in $APP_ID internal storage"; exit 1; } @@ -39,12 +37,13 @@ echo "Found $EC_COUNT .ec file(s)" MERGED_EC="$REPO_ROOT/.jacoco/merged.ec" java -jar "$JACOCO_CLI" merge "$EC_DIR"/*.ec --destfile "$MERGED_EC" -# Find instrumented class files for the library module -LIB_CLASSES=$(find "$REPO_ROOT/android/build" -path "*/debug/classes" -type d | head -1) -if [ -z "$LIB_CLASSES" ]; then - LIB_CLASSES=$(find "$REPO_ROOT/android/build" -path "*/tmp/kotlin-classes/debug" -type d | head -1) +# Use the original (uninstrumented) class files saved during the build +LIB_CLASSES="$REPO_ROOT/android/build/jacoco-original-classes" +if [ ! -d "$LIB_CLASSES" ]; then + echo "Original class files not found at $LIB_CLASSES" + echo "Was the library built with -PRive_KotlinCoverage=true?" + exit 1 fi -[ -n "$LIB_CLASSES" ] || { echo "Library class files not found — was the library built with coverage?"; exit 1; } echo "Using class files: $LIB_CLASSES" LIB_SOURCES="$REPO_ROOT/android/src/main/java" From 1a8ebac57bab688004f08b1fd1f3f22afe74007c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 19 May 2026 13:13:43 +0200 Subject: [PATCH 3/4] docs: add Android Kotlin coverage knowledge transfer doc --- ANDROID_KOTLIN_COVERAGE.md | 229 +++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 ANDROID_KOTLIN_COVERAGE.md diff --git a/ANDROID_KOTLIN_COVERAGE.md b/ANDROID_KOTLIN_COVERAGE.md new file mode 100644 index 00000000..feb5820e --- /dev/null +++ b/ANDROID_KOTLIN_COVERAGE.md @@ -0,0 +1,229 @@ +# Android Kotlin Code Coverage for React Native Libraries + +This documents how to collect Kotlin/Java code coverage from a React Native library running on an Android emulator, driven by external JS tests (not Android instrumented tests). This was implemented in [rive-app/rive-nitro-react-native#264](https://github.com/rive-app/rive-nitro-react-native/pull/264) and is intended as a reference for implementing generic Android coverage support in `react-native-harness`. + +## Why this is hard (and what doesn't work) + +### `enableAndroidTestCoverage` does NOT instrument the regular APK + +The obvious approach — setting `enableAndroidTestCoverage = true` in `build.gradle` — **only instruments classes for the `androidTest` APK variant**. When you build with `assembleDebug`, the classes in the APK have zero JaCoCo probes. You can verify this: + +```bash +# This class will NOT have $jacocoInit — no probes +javap -p android/build/tmp/kotlin-classes/debug/com/example/MyClass.class | grep jacoco +# (empty output) +``` + +The JaCoCo agent runtime gets loaded, and `RT.getAgent()` works, but since no bytecode has probes inserted, the execution data is empty. You end up with 0% coverage on everything except the coverage helper itself. + +This applies to both the library module and the app module — `enableAndroidTestCoverage` on either one only affects the `androidTest` build variant. + +### `-javaagent` doesn't work on Android + +JaCoCo's runtime instrumentation mode requires a JVM. Android uses ART, which does not support Java agents. This is a dead end. + +### Kover / IntelliJ agent doesn't work on Android + +JetBrains' Kover wraps JaCoCo or IntelliJ's coverage agent, but the IntelliJ agent only works on JVM, not ART. And Kover's JaCoCo mode has the same limitation — it's designed for `./gradlew test` (host JVM), not for on-device code. + +## What works: JaCoCo offline instrumentation + +The solution is **offline instrumentation** — rewriting `.class` files with JaCoCo probes after Kotlin compilation but before DEX-ing. The instrumented classes contain probe arrays that record execution at runtime. The JaCoCo agent runtime library provides the `Offline` class that manages the probe data in memory. + +### The four pieces + +#### 1. Gradle: offline-instrument class files after compilation + +Add a `doLast` to `compileDebugKotlin` that: +1. Saves a copy of the original (uninstrumented) classes (needed for report generation later) +2. Runs `org.jacoco.ant.InstrumentTask` to instrument the classes +3. Replaces the originals with instrumented versions + +The instrumented classes must be written to a **separate temp directory** first, then copied back. Writing in-place corrupts files because JaCoCo reads and writes to the same location simultaneously. + +```groovy +configurations { + jacocoAnt +} + +dependencies { + // The runtime jar provides org.jacoco.agent.rt.RT for the instrumented code + implementation "org.jacoco:org.jacoco.agent:0.8.12:runtime" + jacocoAnt "org.jacoco:org.jacoco.ant:0.8.12" +} + +afterEvaluate { + def classesDir = file("${buildDir}/tmp/kotlin-classes/debug") + + tasks.named("compileDebugKotlin").configure { + doLast { + if (!classesDir.exists()) return + + // Save originals for report generation + def origDir = file("${buildDir}/jacoco-original-classes") + if (origDir.exists()) origDir.deleteDir() + ant.copy(todir: origDir) { fileset(dir: classesDir) } + + // Instrument to temp dir, then replace + def instrumentedDir = file("${buildDir}/tmp/jacoco-instrumented") + if (instrumentedDir.exists()) instrumentedDir.deleteDir() + instrumentedDir.mkdirs() + + ant.taskdef( + name: 'instrument', + classname: 'org.jacoco.ant.InstrumentTask', + classpath: configurations.jacocoAnt.asPath + ) + ant.instrument(destdir: instrumentedDir) { + fileset(dir: classesDir, includes: '**/*.class') + } + + ant.copy(todir: classesDir, overwrite: true) { + fileset(dir: instrumentedDir) + } + } + } +} +``` + +**Important**: `org.jacoco:org.jacoco.agent:0.8.12:runtime` (note the `:runtime` classifier) is the agent JAR without the agent bootstrap — just the `RT` and `Offline` classes that the instrumented bytecode calls. Without this dependency, the app crashes at runtime with `ClassNotFoundException: org.jacoco.agent.rt.internal_aeaf9ab.Offline`. + +#### 2. `jacoco-agent.properties` — prevent crash on read-only filesystem + +When offline-instrumented code first loads, JaCoCo's `Offline` class initializes the agent singleton. By default, the agent tries to write execution data to `jacoco.exec` in the current working directory. On Android, the cwd is `/` (root filesystem, read-only). This causes a fatal `FileNotFoundException: /jacoco.exec: open failed: EROFS (Read-only file system)`. + +The fix: place a `jacoco-agent.properties` file on the classpath with `output=none`. This tells the agent not to auto-dump — our helper handles dumping manually. + +``` +# android/src/main/resources/jacoco-agent.properties +output=none +``` + +This file is harmless in non-coverage builds (JaCoCo classes aren't loaded, so the file is never read). + +#### 3. Runtime flush helper — dump `.ec` files to app storage + +The JaCoCo agent accumulates coverage data in memory. You must explicitly flush it to a `.ec` file. The key API: + +```kotlin +val agent = Class.forName("org.jacoco.agent.rt.RT") + .getMethod("getAgent").invoke(null) +val bytes = agent.javaClass + .getMethod("getExecutionData", Boolean::class.javaPrimitiveType) + .invoke(agent, false) as ByteArray +File(context.filesDir, "coverage-${Process.myPid()}.ec").writeBytes(bytes) +``` + +Flush triggers: +- **1-second periodic timer** (background daemon thread) — most reliable, catches data even if the app is killed +- **Activity lifecycle** (`onActivityStopped`) — flushes when app goes to background +- Per-process filenames (`coverage-$pid.ec`) — the harness restarts the app for each test suite, so each process gets its own file + +**Important**: `am force-stop` kills the process immediately without calling any lifecycle callbacks or giving the timer a chance to fire. The 1s timer ensures data is flushed at most 1 second before any kill. If the process is killed within 1 second of starting, that test run's data is lost. + +#### 4. Bootstrap — start the helper early + +Use a `ContentProvider` to bootstrap the helper before any Activity starts. ContentProviders run during `Application.onCreate`, which is the earliest point with a `Context` on Android (equivalent of iOS's ObjC `+load`). + +```kotlin +class CoverageInitProvider : ContentProvider() { + override fun onCreate(): Boolean { + context?.let { CoverageHelper.setup(it) } + return true + } + // ... stub all other abstract methods +} +``` + +Register in `AndroidManifest.xml`: +```xml + +``` + +Use a `BuildConfig` boolean flag to no-op in non-coverage builds: +```kotlin +fun setup(context: Context) { + if (!BuildConfig.COVERAGE_ENABLED) return + // ... +} +``` + +## Collecting coverage after tests + +### Pull `.ec` files from the emulator + +The harness restarts the app multiple times. Each restart creates a new `.ec` file. Use `tar` to pull all files reliably — `adb shell run-as` with `find` + `cat` in a pipe loses data due to buffering issues: + +```bash +adb shell am force-stop "$APP_ID" +sleep 2 # let final flush complete + +# tar is reliable across multiple files; find+cat in a pipe drops entries +adb shell run-as "$APP_ID" sh -c "'cd files && tar cf - *.ec 2>/dev/null'" \ + | tar xf - -C ./ec-files/ +``` + +### Merge and generate reports + +JaCoCo CLI merges multiple `.ec` files and generates reports. It needs the **original (uninstrumented)** class files — not the instrumented ones. + +```bash +# Merge all .ec files +java -jar jacococli.jar merge ec-files/*.ec --destfile merged.ec + +# Generate report — classfiles must be the ORIGINAL (uninstrumented) classes +java -jar jacococli.jar report merged.ec \ + --classfiles android/build/jacoco-original-classes \ + --sourcefiles android/src/main/java \ + --xml coverage.xml \ + --html coverage-html/ +``` + +**Class file mismatch warning**: The `.ec` data must match the exact build that produced the instrumented classes. If you rebuild without re-running tests, the `.ec` is stale and the report will be wrong. + +## Comparison with iOS coverage (PR #190) + +| Aspect | iOS (LLVM profiling) | Android (JaCoCo offline) | +|--------|---------------------|--------------------------| +| Instrumentation | Compiler flags (`-profile-generate -profile-coverage-mapping`) added to podspec | JaCoCo ant task rewrites `.class` files after compilation | +| Opt-in mechanism | Env var `RIVE_SWIFT_COVERAGE=1` before `pod install` | Gradle property `-PRive_KotlinCoverage=true` | +| Runtime agent | LLVM profiling runtime (built into the binary) | `org.jacoco:org.jacoco.agent:0.8.12:runtime` dependency | +| Data format | `.profraw` (one per process) | `.ec` (one per process) | +| Flush API | `__llvm_profile_write_file()` via `@_silgen_name` | `RT.getAgent().getExecutionData(false)` via reflection | +| Auto-dump config | N/A (LLVM doesn't auto-dump) | `jacoco-agent.properties` with `output=none` (prevents `/jacoco.exec` crash) | +| Bootstrap | ObjC `+load` in `CoverageSetup.m` | `ContentProvider.onCreate()` | +| Report tool | `xcrun llvm-cov export` → lcov natively | `jacococli.jar report` → XML/HTML (lcov needs a converter) | +| Class file requirement | N/A (symbols are in the binary) | Report needs the **original** (uninstrumented) `.class` files | +| Gotcha: in-place instrumentation | N/A | Must instrument to temp dir then copy back; in-place corrupts files | +| Gotcha: read-only filesystem | N/A | JaCoCo agent defaults to writing `/jacoco.exec` on root FS → crash | +| Gotcha: `enableAndroidTestCoverage` | N/A | Only instruments the `androidTest` variant, NOT the regular debug APK | + +## For react-native-harness implementation + +To make this generic in react-native-harness, the harness would need to: + +1. **Build phase**: Inject the JaCoCo offline instrumentation into the library's (or app's) Gradle build. This could be done via: + - A Gradle init script that applies to all projects + - A Gradle plugin that the harness adds + - Modifying the app's `build.gradle` programmatically (like the iOS approach modifies podspec via env var) + +2. **Runtime phase**: The harness already restarts the app per test suite. The coverage helper + ContentProvider need to be in the APK. Options: + - Ship a small AAR that contains `CoverageHelper` + `CoverageInitProvider` + `jacoco-agent.properties`, and inject it as a dependency when coverage is enabled + - Generate these files into the app project before build + +3. **Collection phase**: After all test suites complete, pull `.ec` files via `adb shell run-as ... tar`, merge with `jacococli.jar merge`, and generate the report with `jacococli.jar report`. The harness needs to know the path to the original (uninstrumented) class files — these are saved during the build at `build/jacoco-original-classes/`. + +4. **lcov conversion**: JaCoCo doesn't natively produce lcov. Use `jacoco-to-lcov` or `cover2cover.py` to convert from XML if lcov is needed for consistency with the iOS output. + +## Reference implementation + +See the full working implementation in this repo: +- [`android/build.gradle`](android/build.gradle) — Gradle offline instrumentation setup +- [`android/src/main/java/com/rive/CoverageHelper.kt`](android/src/main/java/com/rive/CoverageHelper.kt) — Runtime flush helper +- [`android/src/main/java/com/rive/CoverageInitProvider.kt`](android/src/main/java/com/rive/CoverageInitProvider.kt) — ContentProvider bootstrap +- [`android/src/main/resources/jacoco-agent.properties`](android/src/main/resources/jacoco-agent.properties) — Agent config +- [`android/src/main/AndroidManifest.xml`](android/src/main/AndroidManifest.xml) — ContentProvider registration +- [`scripts/android-coverage.sh`](scripts/android-coverage.sh) — Collection and report script From d2c79a97d2424324fd99faccecc57170b0ba14de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 19 May 2026 16:09:14 +0200 Subject: [PATCH 4/4] docs: add react-native-harness init script bugs and fixes --- ANDROID_KOTLIN_COVERAGE.md | 69 ++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/ANDROID_KOTLIN_COVERAGE.md b/ANDROID_KOTLIN_COVERAGE.md index feb5820e..eb694ea3 100644 --- a/ANDROID_KOTLIN_COVERAGE.md +++ b/ANDROID_KOTLIN_COVERAGE.md @@ -203,20 +203,69 @@ java -jar jacococli.jar report merged.ec \ ## For react-native-harness implementation -To make this generic in react-native-harness, the harness would need to: +The harness uses a Gradle init script approach (`harness-coverage-init.gradle`) that injects coverage into any library module without modifying its `build.gradle`. This was tested against rive-nitro-react-native and the following bugs were found and fixed: -1. **Build phase**: Inject the JaCoCo offline instrumentation into the library's (or app's) Gradle build. This could be done via: - - A Gradle init script that applies to all projects - - A Gradle plugin that the harness adds - - Modifying the app's `build.gradle` programmatically (like the iOS approach modifies podspec via env var) +### Bug 1: `afterEvaluate` inside `projectsEvaluated` crashes -2. **Runtime phase**: The harness already restarts the app per test suite. The coverage helper + ContentProvider need to be in the APK. Options: - - Ship a small AAR that contains `CoverageHelper` + `CoverageInitProvider` + `jacoco-agent.properties`, and inject it as a dependency when coverage is enabled - - Generate these files into the app project before build +The init script originally used `gradle.projectsEvaluated { project.afterEvaluate { ... } }`. This crashes because `projectsEvaluated` runs after all projects are evaluated — calling `afterEvaluate` at that point fails with "Cannot run Project.afterEvaluate when the project is already evaluated." -3. **Collection phase**: After all test suites complete, pull `.ec` files via `adb shell run-as ... tar`, merge with `jacococli.jar merge`, and generate the report with `jacococli.jar report`. The harness needs to know the path to the original (uninstrumented) class files — these are saved during the build at `build/jacoco-original-classes/`. +**Fix**: Restructure to `gradle.allprojects { project.afterEvaluate { ... } }`. The `allprojects` callback registers during configuration, so `afterEvaluate` is still valid at that point. -4. **lcov conversion**: JaCoCo doesn't natively produce lcov. Use `jacoco-to-lcov` or `cover2cover.py` to convert from XML if lcov is needed for consistency with the iOS output. +### Bug 2: `jacocoCli` configuration resolves to multiple files + +`project.configurations.jacocoCli.singleFile` throws "contains more than one file" because the `nodeps` classifier JAR still pulls transitive dependencies. + +**Fix**: Set `transitive = false` on the configuration: +```groovy +def jacocoCliConf = project.configurations.maybeCreate('jacocoCli') +jacocoCliConf.transitive = false +``` + +### Bug 3: Compile tasks not found — `findByName` returns null + +AGP and the React Native Gradle plugin register `compileDebugKotlin` lazily via the task configuration avoidance API. `findByName()` doesn't materialize lazy tasks, so it returns `null` even though the task exists. + +**Fix**: Use `tasks.configureEach` with name matching instead — this applies to both already-materialized and not-yet-materialized tasks: +```groovy +project.tasks.configureEach { task -> + if (task.name == 'compileDebugKotlin') { + task.doLast { instrumentClasses(kotlinClasses, 'kotlin') } + } +} +``` + +### Bug 4: `BuildConfig.COVERAGE_ENABLED` doesn't compile + +`CoverageHelper.kt` is in package `com.harness.coverage`, but `BuildConfig` is generated in the target module's namespace (e.g., `com.rive`). An unqualified `BuildConfig` reference resolves to `com.harness.coverage.BuildConfig` which doesn't exist. + +**Fix**: Remove the `BuildConfig` check entirely. Coverage is already opt-in at build time (the init script is only applied when coverage is requested). The JaCoCo agent availability check (`Class.forName("org.jacoco.agent.rt.RT")`) is sufficient as a runtime guard. + +### Bug 5: Source sets added too late — coverage helper classes not compiled + +Adding `kotlin.srcDirs` to the source set inside `gradle.projectsEvaluated` is too late — the compile task has already resolved its inputs. The coverage helper Kotlin sources are ignored, resulting in `ClassNotFoundException` at runtime for `CoverageInitProvider`. + +**Fix**: This was fixed by the same restructure as Bug 1 — moving to `gradle.allprojects` + `afterEvaluate` ensures source sets are configured before compile tasks finalize. + +### Summary of init script structure + +The correct hook point structure for a Gradle init script that injects sources and instruments classes is: + +``` +gradle.allprojects { project -> // runs during configuration + project.afterEvaluate { // runs after build.gradle is processed + // Add dependencies, source sets, (before compile tasks run) + // BuildConfig fields here + + project.tasks.configureEach { // catches lazily-registered tasks + if (task.name == '...') { + task.doLast { ... } // runs after compilation + } + } + } +} +``` + +Do **not** use `gradle.projectsEvaluated` for anything that modifies source sets, dependencies, or BuildConfig — it's too late. ## Reference implementation