Skip to content

2.0.0-RC2

Pre-release
Pre-release

Choose a tag to compare

@ioannisa ioannisa released this 28 Apr 13:45

Cumulative 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
    Without close(), abandoned instances pin their DataStore scope on Dispatchers.IO. Idempotent; after close() 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