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/ANDROID_KOTLIN_COVERAGE.md b/ANDROID_KOTLIN_COVERAGE.md
new file mode 100644
index 00000000..eb694ea3
--- /dev/null
+++ b/ANDROID_KOTLIN_COVERAGE.md
@@ -0,0 +1,278 @@
+# 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
+
+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:
+
+### Bug 1: `afterEvaluate` inside `projectsEvaluated` crashes
+
+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."
+
+**Fix**: Restructure to `gradle.allprojects { project.afterEvaluate { ... } }`. The `allprojects` callback registers during configuration, so `afterEvaluate` is still valid at that point.
+
+### 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
+
+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
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..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"
@@ -47,6 +50,7 @@ android {
minSdkVersion getExtOrIntegerDefault("minSdkVersion")
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
buildConfigField "boolean", "RIVE_SKIP_SETUP", "$skipSetup"
+ buildConfigField "boolean", "RIVE_KOTLIN_COVERAGE", "$kotlinCoverage"
externalNativeBuild {
cmake {
@@ -102,6 +106,10 @@ android {
}
}
+ if (kotlinCoverage) {
+ println "@rive-app/react-native: Kotlin code coverage ENABLED"
+ }
+
lintOptions {
disable "GradleCompatible"
}
@@ -149,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/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/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/scripts/android-coverage.sh b/scripts/android-coverage.sh
new file mode 100755
index 00000000..3a1aa51d
--- /dev/null
+++ b/scripts/android-coverage.sh
@@ -0,0 +1,86 @@
+#!/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"
+
+# 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; }
+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"
+
+# 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
+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"