Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `devview-timecapsule` module: records the state history of the currently visible screen
via `TimeCapsuleEffect`/`TimeCapsuleOwner`, and lets a developer restore any earlier
state back into that screen from the DevView overlay. History resets automatically when
the screen leaves composition.

## [0.1.4] - 2026-07-22

### Changed
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ devview-utils (DataStore contracts, platform createDataStore)
devview (core: Module interface, ModuleRegistry DSL, DevView composable, Navigation3 host)
├── devview-analytics (analytics log capture + Compose UI)
├── devview-featureflip (feature flag management + Compose UI)
├── devview-timecapsule (per-screen state history + restore, Compose UI)
└── devview-networkmock (network mock UI)
devview-networkmock-core (mock engine: JSON config, request matching, DataStore state)
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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-timecapsule:<version>") // per-screen state history
implementation("com.worldline.devview:devview-networkmock:<version>") // network mock UI

// Ktor plugin only (no UI — lightweight alternative for network-layer integration)
Expand Down Expand Up @@ -130,6 +131,24 @@ val client = HttpClient(OkHttp) {

`rememberModules { }` must be called (and composed) before the first HTTP request reaches this client.

### 5. TimeCapsule

Implement `TimeCapsuleOwner` on a screen's state holder and record it with one call:

```kotlin
class CounterViewModel : ViewModel(), TimeCapsuleOwner<CounterState> {
override val state: StateFlow<CounterState> = _state.asStateFlow()
override fun restoreState(state: CounterState) { _state.value = state }
}

@Composable
fun CounterScreen(viewModel: CounterViewModel) {
TimeCapsuleEffect(owner = viewModel)
}
```

The recorded history resets automatically when the screen leaves composition.

---

## Available Modules
Expand All @@ -139,6 +158,7 @@ val client = HttpClient(OkHttp) {
| Core | `devview` | `DevView` composable + `rememberModules` DSL. Required by all modules. |
| 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. |
| 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: JSON config parsing, request matching, DataStore state. No UI dependency. |
| NetworkMock Ktor | `devview-networkmock-ktor` | Ktor `HttpClientPlugin` that intercepts requests and returns mock responses. |
Expand Down
80 changes: 80 additions & 0 deletions devview-timecapsule/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# CLAUDE.md

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

## What This Module Does

`devview-timecapsule` records the state history of whichever screen is currently visible
under the DevView overlay, and lets a developer restore any earlier state back into that
screen from the DevView UI. It tracks exactly one screen at a time and resets whenever
that screen leaves composition (the host app navigates away). Nothing is persisted to
disk — the history is in-memory only, for the lifetime of the recording composable.

## Public API Surface

| Symbol | Kind | Description |
|---|---|---|
| `TimeCapsuleOwner<S>` | interface | Contract implemented by a screen's state holder: `state: StateFlow<S>` and `fun restoreState(state: S)` |
| `TimeCapsuleEffect(owner, label, maxEntries)` | `@Composable` | Records `owner.state` for as long as it stays in composition; registers/unregisters with `TimeCapsule` via `DisposableEffect` |
| `TimeCapsule` | `object : Module` | Module entry point; registered with no arguments, like `FeatureFlip` |
| `TimeCapsule.DEFAULT_MAX_ENTRIES` | `const Int` | Default retention (50) when `TimeCapsuleEffect` doesn't specify `maxEntries` |
| `TimeCapsuleDestination.Main` | `@Serializable data object` | Only navigation destination; title "Time Capsule" |

`ScreenCapsule<S>` and `Recorded<S>` (the recorder and its entry model) are `internal` —
all screen recording and retention logic lives there, but integrators only ever touch
`TimeCapsuleOwner` and `TimeCapsuleEffect`.

## Internal Architecture

```
TimeCapsuleEffect(owner, label, maxEntries)
├─ remember { ScreenCapsule(owner, label, maxEntries) }
├─ DisposableEffect: TimeCapsule.register(capsule) → onDispose { TimeCapsule.unregister(capsule) }
└─ LaunchedEffect: owner.state.collect(capsule::record)

TimeCapsule (object : Module)
├─ activeCapsules: SnapshotStateList<ScreenCapsule<*>>
├─ current: ScreenCapsule<*>? = activeCapsules.lastOrNull()
└─ TimeCapsuleScreen reads `current` directly (no CompositionLocal — this module
has exactly one consumer of the registry, its own screen)

ScreenCapsule<S>
├─ recordedEntries: SnapshotStateList<Recorded<S>>
├─ record(state): appends, drops oldest at maxEntries
├─ restore(id): owner.restoreState(entry.state) — no cast, entry is typed S
└─ clear()
```

## Why a List, Not a Single Slot, for the Active Registry

During a host-app navigation transition the incoming screen's `TimeCapsuleEffect` can
compose before the outgoing screen's disposes. A single "current capsule" slot would have
the outgoing screen's `onDispose` null it out right after the incoming screen set it. The
registry is a list instead: the outgoing capsule is removed from wherever it sits, and
`current` (`lastOrNull()`) still resolves to whichever registered most recently. See
`TimeCapsuleTest` for the ordering test this protects.

## Non-Obvious Patterns

- **Dedup and initial-value replay are `StateFlow`'s job, not `ScreenCapsule`'s.**
`record()` is a plain function that always appends when called. The "record the current
value on subscribe, skip consecutive equal values" behaviour comes for free from
collecting `owner.state` (a `StateFlow`) in `TimeCapsuleEffect` — `ScreenCapsule` itself
has no dedup logic and shouldn't need any.
- **Restoring re-records.** `ScreenCapsule.restore()` calls `owner.restoreState(...)`,
which (if the owner routes it back through the same `StateFlow`) is observed by the same
`LaunchedEffect` and recorded as a new entry. This is intentional — a restore is a state
transition — but it does mean repeated restores grow the timeline. Marked with a
`ponytail:` comment in `ScreenCapsule.kt`.
- **No CompositionLocal.** Unlike `devview-analytics`'s `LocalAnalytics`, `TimeCapsule`'s
registry is read directly from the object inside `TimeCapsuleScreen`. There's exactly
one consumer (the module's own screen) and no host-app use case for reading the registry
elsewhere, so the extra indirection isn't justified here.
- **Row delta, not wall-clock time.** `TimeCapsuleRow` shows the time elapsed since the
previous entry (`+120ms`), not an absolute timestamp — more useful for a state timeline
and avoids a date-formatting dependency.

## Platform-Specific Code

There is no `androidMain` or `iosMain` source set in this module — all source lives in
`commonMain`. There are no `expect`/`actual` declarations here.
84 changes: 84 additions & 0 deletions devview-timecapsule/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# DevView TimeCapsule Module

A Kotlin Multiplatform library that records the state history of whichever screen is
currently visible under the DevView overlay, and lets a developer restore any earlier
state back into that screen while the app keeps running.

## Installation

Add the dependency to your `build.gradle.kts`:

```kotlin
dependencies {
implementation(projects.devviewTimecapsule)
}
```

## Quick Start

Implement `TimeCapsuleOwner` on the screen's state holder, then record it with one call:

```kotlin
import com.worldline.devview.timecapsule.TimeCapsuleEffect
import com.worldline.devview.timecapsule.TimeCapsuleOwner

data class CounterState(val count: Int)

class CounterViewModel : ViewModel(), TimeCapsuleOwner<CounterState> {
private val _state = MutableStateFlow(CounterState(count = 0))
override val state: StateFlow<CounterState> = _state.asStateFlow()

override fun restoreState(state: CounterState) {
_state.value = state
}

fun increment() {
_state.update { it.copy(count = it.count + 1) }
}
}

@Composable
fun CounterScreen(viewModel: CounterViewModel) {
TimeCapsuleEffect(owner = viewModel, label = { "Count = ${it.count}" })
// ...screen content
}
```

Register the module like any other:

```kotlin
val modules = rememberModules {
module(TimeCapsule)
}
```

## How Scoping Works

Only one screen records at a time — whichever most recently called `TimeCapsuleEffect`.
The recorded history lives only as long as that composable stays in composition: navigate
away from the screen in your host app, and the history is discarded. There is no
cross-screen history and nothing is persisted to disk.

## API

- **`TimeCapsuleOwner<S>`**: contract implemented by a state holder — `state: StateFlow<S>`
and `fun restoreState(state: S)`.
- **`TimeCapsuleEffect(owner, label, maxEntries)`**: composable that records `owner.state`
and registers it with the module for as long as it stays composed.
- **`TimeCapsule`**: the `Module` entry point, registered with no arguments.

## Risk

Restoring a state is the integrator's responsibility to use safely. DevView does not
guarantee the host app keeps working correctly after a state is pushed back into a
running screen out of band.

## Documentation

All public APIs are documented with KDoc comments. View the documentation:
- In your IDE using Quick Documentation (Ctrl+Q / Cmd+J)
- Generate HTML docs using Dokka: `./gradlew dokkaHtml`

## License

This module is part of the DevView project and follows the same licensing terms.
41 changes: 41 additions & 0 deletions devview-timecapsule/api/api.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Signature format: 4.0
package com.worldline.devview.timecapsule {

public final class TimeCapsule implements com.worldline.devview.core.Module {
method @InaccessibleFromKotlin public kotlinx.collections.immutable.PersistentMap<kotlin.reflect.KClass<? extends androidx.navigation3.runtime.NavKey>,com.worldline.devview.core.DestinationMetadata> getDestinations();
method @InaccessibleFromKotlin public androidx.navigation3.runtime.NavKey getEntryDestination();
method @InaccessibleFromKotlin public kotlin.jvm.functions.Function1<kotlinx.serialization.modules.PolymorphicModuleBuilder<? super androidx.navigation3.runtime.NavKey>,kotlin.Unit> getRegisterSerializers();
method @InaccessibleFromKotlin public com.worldline.devview.core.Section getSection();
method @KotlinOnly public void registerContent(androidx.navigation3.runtime.EntryProviderScope<androidx.navigation3.runtime.NavKey>, kotlin.jvm.functions.Function0<kotlin.Unit> onNavigateBack, kotlin.jvm.functions.Function1<androidx.navigation3.runtime.NavKey,kotlin.Unit> onNavigate, androidx.compose.ui.unit.Dp bottomPadding);
property public static int DEFAULT_MAX_ENTRIES;
property public kotlinx.collections.immutable.PersistentMap<kotlin.reflect.KClass<? extends androidx.navigation3.runtime.NavKey>,com.worldline.devview.core.DestinationMetadata> destinations;
property public androidx.navigation3.runtime.NavKey entryDestination;
property public kotlin.jvm.functions.Function1<kotlinx.serialization.modules.PolymorphicModuleBuilder<androidx.navigation3.runtime.NavKey>,kotlin.Unit> registerSerializers;
property public com.worldline.devview.core.Section section;
field public static final int DEFAULT_MAX_ENTRIES = 50; // 0x32
field public static final com.worldline.devview.timecapsule.TimeCapsule INSTANCE;
}

public sealed exhaustive interface TimeCapsuleDestination extends androidx.navigation3.runtime.NavKey {
}

@kotlinx.serialization.Serializable public static final class TimeCapsuleDestination.Main implements com.worldline.devview.timecapsule.TimeCapsuleDestination {
field public static final com.worldline.devview.timecapsule.TimeCapsuleDestination.Main INSTANCE;
}

public final class TimeCapsuleEffectKt {
method @KotlinOnly @androidx.compose.runtime.Composable public static <S> void TimeCapsuleEffect(com.worldline.devview.timecapsule.TimeCapsuleOwner<S> owner, optional kotlin.jvm.functions.Function1<S,java.lang.String> label, optional int maxEntries);
}

public interface TimeCapsuleOwner<S> {
method @InaccessibleFromKotlin public kotlinx.coroutines.flow.StateFlow<S> getState();
method public void restoreState(S state);
property public abstract kotlinx.coroutines.flow.StateFlow<S> state;
}

public final class TimeCapsuleScreenKt {
method @KotlinOnly @androidx.compose.runtime.Composable public static void TimeCapsuleScreen(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding);
}

}

31 changes: 31 additions & 0 deletions devview-timecapsule/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
plugins {
alias(libs.plugins.convention.multiplatform.library)
alias(libs.plugins.convention.compose.multiplatform)
alias(libs.plugins.convention.unitTest)
alias(libs.plugins.convention.kover)
alias(libs.plugins.convention.metalava)
alias(libs.plugins.dokka)
alias(libs.plugins.maven.publish)
}

kotlin {
addDefaultDevViewTargets()

android {
namespace = "com.worldline.devview.timecapsule"
}

sourceSets {
commonMain {
dependencies {
api(projects.devview)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.collections.immutable)
}
}
}
}

tasks.withType<Test> {
failOnNoDiscoveredTests.set(false)
}
2 changes: 2 additions & 0 deletions devview-timecapsule/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
POM_ARTIFACT_ID=devview-timecapsule
POM_NAME=DevView Time Capsule
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.worldline.devview.timecapsule

import androidx.compose.runtime.mutableStateListOf
import kotlin.time.Clock

/** A single recorded state, labelled and timestamped at the moment it was captured. */
internal data class Recorded<out S : Any>(
val id: Long,
val atMillis: Long,
val label: String,
val state: S
)

/**
* Records the state history of a single screen and replays entries back into it.
*
* Lives only as long as the screen's [TimeCapsuleEffect] composition — created on first
* composition, discarded on dispose. This is what makes the timeline reset when the host
* app navigates away.
*/
internal class ScreenCapsule<S : Any>(
private val owner: TimeCapsuleOwner<S>,
private val label: (S) -> String,
private val maxEntries: Int
) {
private val recordedEntries = mutableStateListOf<Recorded<S>>()
private var nextId = 0L

val entries: List<Recorded<S>> get() = recordedEntries

fun record(state: S) {
if (recordedEntries.size == maxEntries) {
recordedEntries.removeAt(index = 0)
}
recordedEntries += Recorded(
id = nextId++,
atMillis = Clock.System.now().toEpochMilliseconds(),
label = label(state),
state = state
)
}

// ponytail: restoring re-enters `owner.state`, which records the restored value as a
// new entry. Truthful (a restore is a state transition) but repeated restores grow the
// timeline. Suppressing this needs a race-prone flag or identity tracking; not worth it.
fun restore(id: Long) {
val entry = recordedEntries.firstOrNull { it.id == id } ?: return
owner.restoreState(state = entry.state)
}

fun clear() {
recordedEntries.clear()
}
}
Loading
Loading