Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1524970
:sparkles: Add crash history core with Android auto-start and MetricKit
MaxMichel2 Nov 3, 2026
5fe5dab
:sparkles: Add crash history module UI with copy, share and export
MaxMichel2 Nov 3, 2026
dfce418
:sparkles: Showcase crash history in the sample app
MaxMichel2 Nov 3, 2026
c5e34f0
:memo: Document crash history module
MaxMichel2 Nov 3, 2026
3dcf667
:lock: Exclude crash history from iOS backups
MaxMichel2 Nov 5, 2026
2c1a5d0
:bug: Serialize crash storage and acknowledge only persisted imports
MaxMichel2 Nov 5, 2026
5069f7d
:bug: Apply and validate crash history retention updates
MaxMichel2 Nov 5, 2026
b747dd5
:bug: Make Android crash imports idempotent and fatal path lock-free
MaxMichel2 Nov 5, 2026
fac6a32
:bug: Isolate Android crash exports and handle share failures
MaxMichel2 Nov 5, 2026
73f06e4
:heavy_minus_sign: Trim Compose UI dependencies from crashhistory-core
MaxMichel2 Nov 5, 2026
641dbcb
:bug: Fix crash history test compilation on iOS
MaxMichel2 Nov 5, 2026
ae642bc
:memo: Record Handoff A iOS crash history verification
MaxMichel2 Nov 5, 2026
9255bad
:sparkles: Add MetricKit Swift bridge contract and Kotlin mapping
MaxMichel2 Nov 5, 2026
04a2df5
:sparkles: Implement MetricKit crash bridge with replay-stable IDs
MaxMichel2 Nov 5, 2026
660d8ed
:memo: Record Handoff B MetricKit mac verification
MaxMichel2 Nov 5, 2026
3f14706
:construction_worker: Run Swift-bridge CI jobs on the Xcode 27 runner
MaxMichel2 Nov 5, 2026
fa2ba8c
:bug: Distinguish MetricKit exceptions and order clean Swift builds
MaxMichel2 Nov 5, 2026
79297e5
:memo: Document crash history hardening and MetricKit bridge
MaxMichel2 Nov 5, 2026
95fffa1
:memo: Record Handoff C clean parallel iOS verification
MaxMichel2 Nov 5, 2026
7975192
:memo: Remove completed Mac verification handoff
MaxMichel2 Nov 5, 2026
1fde552
:memo: Record Handoff D iOS 27 runtime verification
MaxMichel2 Nov 5, 2026
74deae0
:memo: Remove completed iOS 27 verification handoff
MaxMichel2 Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:
# Skip if head commit contains 'skip ci'
if: "!contains(github.event.head_commit.message, 'skip ci')"

runs-on: macos-26
runs-on: xcode-27
needs: [ build ]
timeout-minutes: 30

Expand All @@ -63,6 +63,9 @@ jobs:

- uses: gradle/actions/setup-gradle@v6

- name: Show Xcode version
run: xcodebuild -version

- name: Compile iOS targets
run: ./gradlew compileKotlinIosArm64 iosSimulatorArm64Test

Expand Down Expand Up @@ -170,7 +173,7 @@ jobs:

metalava:
if: "!contains(github.event.head_commit.message, 'skip ci')"
runs-on: macos-latest
runs-on: xcode-27
timeout-minutes: 30

steps:
Expand All @@ -187,6 +190,9 @@ jobs:

- uses: gradle/actions/setup-gradle@v6

- name: Show Xcode version
run: xcodebuild -version

- name: Check API compatibility
run: ./gradlew metalavaCheckCompatibility -Pandroidx.baselineprofile.skipgeneration

Expand Down Expand Up @@ -306,7 +312,7 @@ jobs:
deploy:
if: github.ref == 'refs/heads/main'

runs-on: macos-26
runs-on: xcode-27
needs: [ ios-build, unit-and-host-tests, device-tests ]
timeout-minutes: 30

Expand All @@ -321,6 +327,9 @@ jobs:

- uses: gradle/actions/setup-gradle@v6

- name: Show Xcode version
run: xcodebuild -version

- name: Publish snapshot to Maven Central
run: ./gradlew publish --no-configuration-cache
env:
Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ concurrency:

jobs:
build:
runs-on: macos-latest
runs-on: xcode-27
timeout-minutes: 60

steps:
Expand All @@ -28,6 +28,9 @@ jobs:

- uses: gradle/actions/setup-gradle@v6

- name: Show Xcode version
run: xcodebuild -version

- name: Assemble and check
run: ./gradlew check assembleDebug -Pandroidx.baselineprofile.skipgeneration

Expand All @@ -40,7 +43,7 @@ jobs:
**/build/reports/**

detekt:
runs-on: macos-latest
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
Expand Down Expand Up @@ -96,7 +99,7 @@ jobs:
path: 'konsist/build/reports/tests/**'

publish:
runs-on: macos-latest
runs-on: xcode-27
needs: [ build, detekt, konsist ]
timeout-minutes: 30

Expand All @@ -114,6 +117,9 @@ jobs:
- name: Set release version in gradle.properties
run: sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=${{ github.ref_name }}/" gradle.properties

- name: Show Xcode version
run: xcodebuild -version

- name: Publish to Maven Central
run: ./gradlew publish --no-configuration-cache
env:
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Crash History: new `devview-crashhistory` module and `devview-crashhistory-core` recorder
(#148). Keeps a bounded, local list of crashes (stack trace, app and device info) in
app-private storage and shows it in a new `CrashHistory` module with copy, share and
export. Captures uncaught exceptions on Android and iOS, chains to any existing handler
(Crashlytics, Sentry) and never uploads anything. Android 11+ also imports what
`ApplicationExitInfo` reports about earlier runs, and iOS imports MetricKit crash
diagnostics. On Android a manifest-declared provider starts recording before
`Application.onCreate` once `CrashHistory()` has run; iOS apps can call
`CrashHistoryIos.start()` for the same effect.
- Crash History: Swift MetricKit bridge using the iOS 27 async API and legacy delivery on
iOS 15–26, with replay-stable diagnostic IDs, historical app/device metadata and reporting
period labels. iOS builds now require Xcode 27; runtime support starts at iOS 15.

### Fixed
- Crash History: exclude iOS records from backups (best-effort, verified on startup), serialize
ordinary storage/import operations, reject stale queued work after Clear, and acknowledge
OS imports only after record persistence succeeds.
- Crash History: validate positive retention limits, honor updated configurations without
reinstalling handlers, use unique atomic-write temporary files and stable Android exit IDs,
bound ANR trace reads, and capture Android metadata before installing the fatal handler.
- Crash History: preserve Android export attachments with unique filenames and aged cleanup,
show export/share failure feedback, check iOS writes and anchor iPad share popovers. The core
recorder no longer pulls in Material3 or Navigation3.

## [0.2.0-alpha07] - 2026-11-02

### Added
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies {
implementation("com.worldline.devview:devview-featureflip:<version>") // feature flags
implementation("com.worldline.devview:devview-analytics:<version>") // analytics inspector
implementation("com.worldline.devview:devview-consolelogger:<version>") // native console log viewer
implementation("com.worldline.devview:devview-crashhistory:<version>") // local crash history
implementation("com.worldline.devview:devview-timecapsule:<version>") // per-screen state history
implementation("com.worldline.devview:devview-networkmock:<version>") // network mock UI

Expand Down Expand Up @@ -179,6 +180,8 @@ with no debugger attached).
| FeatureFlip | `devview-featureflip` | Runtime feature flag management with Compose UI. Supports local and remote-config flags with local overrides. |
| Analytics | `devview-analytics` | Real-time analytics event inspector with filtering by type, category, and time range. |
| Console Logger | `devview-consolelogger` | Native console log viewer (logcat/stdout), untethered, with level filters and text search. |
| Crash History | `devview-crashhistory` | Local, bounded history of crashes (stack trace, device and app info) with copy, share and export. Complements Crashlytics or Sentry, never uploads. |
| Crash History Core | `devview-crashhistory-core` | Crash recorder and platform capture. No UI dependency. |
| TimeCapsule | `devview-timecapsule` | Records the state history of the currently visible screen and lets you restore any earlier state back into it. |
| NetworkMock (UI) | `devview-networkmock` | Full mock management UI: enable/disable endpoints, switch responses, preview and diff mock payloads. |
| NetworkMock Core | `devview-networkmock-core` | Mock engine: OpenAPI 3.x spec parsing, request matching, DataStore state. No UI dependency. |
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ plugins {
alias(libs.plugins.mokkery) apply false
alias(libs.plugins.poko) apply false
alias(libs.plugins.room) apply false
alias(libs.plugins.spmforkmp) apply false
}

tasks.register("detektFull") {
Expand Down
53 changes: 53 additions & 0 deletions devview-crashhistory-core/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Module Does

`devview-crashhistory-core` is the engine behind the local crash history. It captures uncaught exceptions, accepts errors reported by the host app, imports what the OS recorded about earlier runs, and keeps a bounded list of `CrashRecord`s in app-private storage. It has no UI: `devview-crashhistory` (screens, copy/share/export) and future crash-SDK adapters depend on it.

Nothing is uploaded or redacted. Records are raw and only leave the device when the user shares them.

## Commands

```shell
.\gradlew.bat :devview-crashhistory-core:testAndroidHostTest "-Pandroidx.baselineprofile.skipgeneration"
```

Regenerate the public API signature after changing a public symbol:

```shell
.\gradlew.bat :devview-crashhistory-core:metalavaGenerateSignature "-Pandroidx.baselineprofile.skipgeneration"
```

`iosMain` compiles on Windows too (`compileKotlinIosSimulatorArm64`, klib only), but `iosTest` can only run on macOS: CI runs `compileKotlinIosArm64 iosSimulatorArm64Test` there.

## Public API Surface

| Class / Object | Role |
|---|---|
| `CrashRecorder` | Singleton. `@Composable install(maxRecords)` (called by `CrashHistory.initModule()`), `records: StateFlow<List<CrashRecord>>`, `record(...)` for handled errors / SDK adapters, `clear()`, size limit constants |
| `CrashRecord` | `@Serializable` record: id, timestamp, `kind`, exception type/message/stack trace, thread, process id, `source`, `isCrash`, `metadata`, `truncated` |
| `CrashRecordKind` | `UNCAUGHT_EXCEPTION` (in-process handler), `APP_REPORTED` (`CrashRecorder.record`), `SYSTEM_REPORTED` (Android `ApplicationExitInfo`, iOS MetricKit) |
| `CrashMetadata` | Platform, OS version, device model, app version, build number |
| `toReportText()` / `toJson()` | Formatters used for copy, share and export |
| `CrashHistoryIos` | iOS entry point: `start()` / `start(maxRecords)` from Swift, so crashes before the first composition are captured |

## Architecture

- `CrashRecorderEngine` holds all logic and is internal so tests can build isolated instances with a fake `CrashPlatform` and their own scope. `CrashRecorder` is a thin singleton over one engine.
- The engine is lock-free (`MutableStateFlow.update`, `AtomicBoolean`): the crash handler runs inside a dying process and must not block. Only the first crash of a process is written.
- `CrashStore` writes one JSON file per record (temp file + `atomicMove`) in `noBackupFilesDir` (Android) or Application Support (iOS), plus small state files (`enabled`, `exitinfo-cursor`, `metrickit-seen`). DataStore is deliberately not used: it is asynchronous and needs Compose to initialise.
- `CrashPlatform` is the platform seam: handler installation and history import. Android: chained `Thread` default handler + `ApplicationExitInfo` (API 30+). iOS: chained Kotlin unhandled-exception hook + `NSSetUncaughtExceptionHandler` + MetricKit (`MXCrashDiagnostic`, iOS 14+).
- Handlers always call the previously installed handler (Crashlytics, ...) after recording, and never throw.
- Android auto-start: `CrashHistoryInitProvider` (declared in this module's manifest, merged automatically) starts recording before `Application.onCreate`, but only when the `enabled` marker file written by `install()` exists, so merely having the dependency records nothing on first launch. The provider has no `initOrder` on purpose so Firebase's provider (100) runs first and its Crashlytics handler gets wrapped.

## Testing

- `commonTest`: report formatting, MetricKit JSON mapping (pure common code, so it runs on every host).
- `androidHostTest`: store, engine, handler chaining, `ApplicationExitInfo` mapping, init provider.
- `iosTest`: hook chaining invoked directly. Only runs on macOS.

## Detekt notes

`NamedArguments` allows 0 unnamed arguments: every call with an argument must name it, including stdlib calls (`take(n = ...)`, `abs(n = ...)`). Caught exceptions must be named `ignored` to pass `SwallowedException`.
99 changes: 99 additions & 0 deletions devview-crashhistory-core/api/api.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Signature format: 4.0
package com.worldline.devview.crashhistory.core {

public final class CrashHistoryIos {
method public void start();
method public void start(int maxRecords);
field public static final com.worldline.devview.crashhistory.core.CrashHistoryIos INSTANCE;
}

@kotlinx.serialization.Serializable public final class CrashMetadata {
ctor public CrashMetadata(String platform, String osVersion, String deviceModel, String appVersion, String buildNumber);
method public String component1();
method public String component2();
method public String component3();
method public String component4();
method public String component5();
method public com.worldline.devview.crashhistory.core.CrashMetadata copy(optional String platform, optional String osVersion, optional String deviceModel, optional String appVersion, optional String buildNumber);
method @InaccessibleFromKotlin public String getAppVersion();
method @InaccessibleFromKotlin public String getBuildNumber();
method @InaccessibleFromKotlin public String getDeviceModel();
method @InaccessibleFromKotlin public String getOsVersion();
method @InaccessibleFromKotlin public String getPlatform();
property public String appVersion;
property public String buildNumber;
property public String deviceModel;
property public String osVersion;
property public String platform;
}

@kotlinx.serialization.Serializable public final class CrashRecord {
ctor public CrashRecord(String id, long timestampMillis, com.worldline.devview.crashhistory.core.CrashRecordKind kind, optional String? exceptionType, optional String? message, optional String? stackTrace, optional String? threadName, optional Integer? processId, optional String? source, optional boolean isCrash, com.worldline.devview.crashhistory.core.CrashMetadata metadata, optional boolean truncated);
method public String component1();
method public boolean component10();
method public com.worldline.devview.crashhistory.core.CrashMetadata component11();
method public boolean component12();
method public long component2();
method public com.worldline.devview.crashhistory.core.CrashRecordKind component3();
method public String? component4();
method public String? component5();
method public String? component6();
method public String? component7();
method public Integer? component8();
method public String? component9();
method public com.worldline.devview.crashhistory.core.CrashRecord copy(optional String id, optional long timestampMillis, optional com.worldline.devview.crashhistory.core.CrashRecordKind kind, optional String? exceptionType, optional String? message, optional String? stackTrace, optional String? threadName, optional Integer? processId, optional String? source, optional boolean isCrash, optional com.worldline.devview.crashhistory.core.CrashMetadata metadata, optional boolean truncated);
method @InaccessibleFromKotlin public String? getExceptionType();
method @InaccessibleFromKotlin public String getId();
method @InaccessibleFromKotlin public com.worldline.devview.crashhistory.core.CrashRecordKind getKind();
method @InaccessibleFromKotlin public String? getMessage();
method @InaccessibleFromKotlin public com.worldline.devview.crashhistory.core.CrashMetadata getMetadata();
method @InaccessibleFromKotlin public Integer? getProcessId();
method @InaccessibleFromKotlin public String? getSource();
method @InaccessibleFromKotlin public String? getStackTrace();
method @InaccessibleFromKotlin public String? getThreadName();
method @InaccessibleFromKotlin public long getTimestampMillis();
method @InaccessibleFromKotlin public boolean getTruncated();
method @InaccessibleFromKotlin public boolean isCrash();
property public String? exceptionType;
property public String id;
property public boolean isCrash;
property public com.worldline.devview.crashhistory.core.CrashRecordKind kind;
property public String? message;
property public com.worldline.devview.crashhistory.core.CrashMetadata metadata;
property public Integer? processId;
property public String? source;
property public String? stackTrace;
property public String? threadName;
property public long timestampMillis;
property public boolean truncated;
}

@kotlinx.serialization.Serializable public enum CrashRecordKind {
enum_constant public static final com.worldline.devview.crashhistory.core.CrashRecordKind APP_REPORTED;
enum_constant public static final com.worldline.devview.crashhistory.core.CrashRecordKind SYSTEM_REPORTED;
enum_constant public static final com.worldline.devview.crashhistory.core.CrashRecordKind UNCAUGHT_EXCEPTION;
}

public final class CrashRecorder {
method public void clear();
method @InaccessibleFromKotlin public kotlinx.coroutines.flow.StateFlow<java.util.List<com.worldline.devview.crashhistory.core.CrashRecord>> getRecords();
method @KotlinOnly @androidx.compose.runtime.Composable public void install(optional int maxRecords);
method public void record(String exceptionType, String? message, String? stackTrace, optional long timestampMillis);
method public void record(Throwable throwable);
property public static int DEFAULT_MAX_RECORDS;
property public static int MAX_MESSAGE_LENGTH;
property public static int MAX_STACK_TRACE_LENGTH;
property public kotlinx.coroutines.flow.StateFlow<java.util.List<com.worldline.devview.crashhistory.core.CrashRecord>> records;
field public static final int DEFAULT_MAX_RECORDS = 20; // 0x14
field public static final com.worldline.devview.crashhistory.core.CrashRecorder INSTANCE;
field public static final int MAX_MESSAGE_LENGTH = 2000; // 0x7d0
field public static final int MAX_STACK_TRACE_LENGTH = 32000; // 0x7d00
}

public final class CrashReportKt {
method public static String toJson(java.util.List<com.worldline.devview.crashhistory.core.CrashRecord>);
method public static String toReportText(com.worldline.devview.crashhistory.core.CrashRecord);
}

}

58 changes: 58 additions & 0 deletions devview-crashhistory-core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import io.github.frankois944.spmForKmp.swiftPackageConfig
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget

plugins {
alias(libs.plugins.convention.multiplatform.library)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.plugin.serialization)
alias(libs.plugins.convention.unitTest)
alias(libs.plugins.convention.kover)
alias(libs.plugins.convention.metalava)
alias(libs.plugins.dokka)
alias(libs.plugins.maven.publish)
alias(libs.plugins.spmforkmp)
}

kotlin {
addDefaultDevViewTargets()

targets.withType<KotlinNativeTarget>().configureEach {
compilerOptions.optIn.add("kotlinx.cinterop.ExperimentalForeignApi")
binaries.getTest("debug").freeCompilerArgs +=
"-Xoverride-konan-properties=osVersionMin.ios_simulator_arm64=15.0"
swiftPackageConfig("crashHistoryBridge") {
minIos = "15.0"
publishSafe = true
}
}

android {
namespace = "com.worldline.devview.crashhistory.core"
}

sourceSets {
commonMain {
dependencies {
implementation(libs.jetbrains.compose.runtime)
implementation(libs.jetbrains.compose.ui)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.io.core)
implementation(libs.kotlinx.coroutines.core)
}
}
}
}

// spmForKmp 1.9.7 prepares a shared Sources directory; concurrent target builds race on clean output.
val deviceBridgeBuild = tasks.matching {
it.name == "SwiftPackageConfigAppleCrashHistoryBridgeCompileSwiftPackageIosArm64"
}
tasks.matching {
it.name == "SwiftPackageConfigAppleCrashHistoryBridgeCompileSwiftPackageIosSimulatorArm64"
}.configureEach {
mustRunAfter(deviceBridgeBuild)
}

tasks.withType<Test> {
failOnNoDiscoveredTests.set(false)
}
2 changes: 2 additions & 0 deletions devview-crashhistory-core/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
POM_ARTIFACT_ID=devview-crashhistory-core
POM_NAME=DevView Crash History Core
Loading
Loading