2.0.0-RC2
Pre-releaseCumulative changes over 2.0.0-RC1. Four purely additive public APIs and a hardening pass on cancellation handling. No breakage — no behavioural change for callers who don't opt in.
Added
rememberKSafeState — persistent composable-body state
A new :ksafe-compose helper that brings rememberSaveable ergonomics to KSafe: composable-local state that survives app restarts, not just configuration changes.
@Composable
fun TabbedScreen(ksafe: KSafe) {
var currentlySelectedIndex by ksafe.rememberKSafeState(0) // key = "currentlySelectedIndex"
var draftMessage by ksafe.rememberKSafeState("") // key = "draftMessage"
var explicit by ksafe.rememberKSafeState("", key = "screen.draft")
}Built for state that naturally lives in the composable — bottom-tab indexes, scroll positions, expanded sections, draft input, sort-order toggles — where routing through a ViewModel would be overkill. Auto-keys from the property name (same convention as ksafe.mutableStateOf).
| Use case | API |
|---|---|
| ViewModel / class property | var x by ksafe.mutableStateOf(default) (unchanged) |
| Composable-body local state | var x by ksafe.rememberKSafeState(default) (new) |
The state is materialised inside remember(key) and the self-heal / external-change collector run in a LaunchedEffect, so leaving the composition cancels everything automatically. No detached coroutines — safe at recomposition rate. Defaults to KSafeWriteMode.Plain; pass mode = KSafeWriteMode.Encrypted(...) to opt in.
:ksafe-compose now applies the Compose compiler plugin so the @Composable provideDelegate codegen works. Published artifact shape is unchanged; consumers need no new setup.
asWritableFlow — observable + writable in one declaration
A cold Flow<T> you can also write to via set(value). Collapses the previous two-binding pattern into one:
// Before — two properties, keys kept in sync manually
val themeMode: Flow<ThemeMode> by ksafe.asFlow(ThemeMode.DEVICE, key = "themeMode")
private var themeModeValue: ThemeMode by ksafe(ThemeMode.DEVICE, key = "themeMode")
fun setThemeMode(mode: ThemeMode) { themeModeValue = mode }
// After
class SettingsRepository(ksafe: KSafe) {
val themeMode: WritableKSafeFlow<ThemeMode> by ksafe.asWritableFlow(ThemeMode.DEVICE)
fun setThemeMode(mode: ThemeMode) { themeMode.set(mode) }
}WritableKSafeFlow<T> : Flow<T>, so observe-only consumers can take a plain Flow<T>. No synchronous getter — collection only — which keeps the contract identical on every platform, including web cold-start.
| API | Read | Write | Scope | Hot/Cold |
|---|---|---|---|---|
asFlow |
✅ flow | — | none | cold |
asStateFlow |
✅ flow + .value |
— | required | hot |
asWritableFlow (new) |
✅ flow | ✅ set() |
none | cold |
asMutableStateFlow |
✅ flow + .value |
✅ .value = |
required | hot |
KSafe.close() — optional instance disposal
Releases the long-running coroutine infrastructure a KSafe instance owns: write-channel consumer, snapshot collector, and the DataStore scope (file watcher, write coordinator, cached Preferences MutableStateFlow). On Android, also evicts the per-file entry from the process-static DataStore cache when this instance owns it.
val ksafe = KSafe(fileName = "session_$userId")
// ... use it ...
ksafe.close()Optional and almost always unnecessary — process-lifetime singletons don't need it. Call close() when you re-create KSafe mid-process:
- Account/profile switching that changes the
fileName - Long-running JVM services that construct a fresh instance per session/tenant/request
- Dev-time hot-reload that rebuilds the DI graph
Withoutclose(), abandoned instances pin their DataStore scope onDispatchers.IO. Idempotent; afterclose()discard the reference. See docs/SETUP.md for lifecycle scenarios.
Internal: observeFromStorage
Consolidates the previously-duplicated branch logic at mutableStateOf's call site into a single @PublishedApi internal suspend helper. Both mutableStateOf and rememberKSafeState route through it. No public-API or behavioural change for mutableStateOf callers.
Fixed
CancellationException no longer swallowed in KSafeCore and :ksafe-compose
Several runCatching { … } and broad catch (Throwable) blocks in coroutine-owning paths used to swallow CancellationException along with everything else. Structural shutdown still worked, but cancel()/close() mid-batch produced spurious "KSafe: processBatch failed, dropping N writes: …" log lines on every clean teardown, and kotlinx-coroutines-test could see one extra batch run after cancel(), occasionally surfacing as UncaughtExceptionsBeforeTest in the next test.
Full Changelog: 2.0.0-RC1...2.0.0-RC2