From e1c2a8fee85eaa7e3cf87eef8c1bf886364df45a Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 00:58:07 +0300 Subject: [PATCH 01/17] add setup for saved state plugin module --- app/build.gradle.kts | 3 +++ build.gradle.kts | 1 + savedstate/build.gradle.kts | 16 ++++++++++++++++ settings.gradle.kts | 1 + 4 files changed, 21 insertions(+) create mode 100644 savedstate/build.gradle.kts diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3a0f619b..50240f23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") kotlin("android") id("kotlin-parcelize") + alias(libs.plugins.serialization) } private val PluginPrefix = "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination" @@ -41,9 +42,11 @@ dependencies { implementation(projects.android) implementation(projects.compose) implementation(projects.androidView) + implementation(projects.savedstate) implementation(libs.bundles.koin) implementation(libs.koin.android.compose) + implementation(libs.kotlin.serialization.json) implementation(libs.androidx.core) diff --git a/build.gradle.kts b/build.gradle.kts index 49d5cd7e..33e5212f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,7 @@ plugins { alias(libs.plugins.dokka) alias(libs.plugins.atomicfu) alias(libs.plugins.dependencyAnalysis) + alias(libs.plugins.serialization) apply false alias(libs.plugins.jetbrainsCompose) apply false // plugins already on a classpath (conventions) // alias(libs.plugins.androidApplication) apply false diff --git a/savedstate/build.gradle.kts b/savedstate/build.gradle.kts new file mode 100644 index 00000000..e8960342 --- /dev/null +++ b/savedstate/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("pro.respawn.shared-library") + alias(libs.plugins.serialization) +} + +android { + namespace = "${Config.namespace}.savedstate" +} + +dependencies { + commonMainApi(projects.core) + commonMainImplementation(libs.kotlin.atomicfu) + commonMainImplementation(libs.kotlin.io) + commonMainImplementation(libs.kotlin.serialization.json) + androidMainImplementation(libs.lifecycle.savedstate) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5d0f37cb..51e6c23a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -100,3 +100,4 @@ include(":android") include(":android-compose") include(":android-view") include(":compose") +include(":savedstate") From a42f22f195786b9b8f740552074f1f9177215d63 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 00:58:21 +0300 Subject: [PATCH 02/17] implement JVM/android file compression via gzip --- .../savedstate/dsl/Compress.android.kt | 19 +++++++++++++++++ .../flowmvi/savedstate/dsl/Compress.kt | 21 +++++++++++++++++++ .../flowmvi/savedstate/dsl/Compress.js.kt | 6 ++++++ .../flowmvi/savedstate/dsl/Compress.jvm.kt | 21 +++++++++++++++++++ .../flowmvi/savedstate/dsl/Compress.native.kt | 6 ++++++ 5 files changed, 73 insertions(+) create mode 100644 savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.android.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt create mode 100644 savedstate/src/jsMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.js.kt create mode 100644 savedstate/src/jvmMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.jvm.kt create mode 100644 savedstate/src/nativeMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.native.kt diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.android.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.android.kt new file mode 100644 index 00000000..28e0d3db --- /dev/null +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.android.kt @@ -0,0 +1,19 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +@Suppress("INVISIBLE_MEMBER") // we want to access the JVM api of kotlinx.io... +internal actual suspend fun writeCompressed(data: String, to: Path) = withContext(Dispatchers.IO) { + GZIPOutputStream(FileOutputStream(to.file)).writer().use { it.write(data) } +} + +@Suppress("INVISIBLE_MEMBER") // we want to access the JVM api of kotlinx.io... +internal actual suspend fun readCompressed(from: Path): String? = withContext(Dispatchers.IO) { + GZIPInputStream(FileInputStream(from.file)).reader().use { it.readText() } +} diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt new file mode 100644 index 00000000..f42f94ca --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt @@ -0,0 +1,21 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString +import kotlinx.io.writeString + +internal expect suspend fun writeCompressed(data: String, to: Path) +internal expect suspend fun readCompressed(from: Path): String? + +@OptIn(ExperimentalStdlibApi::class) +internal fun write(data: String, to: Path) { + SystemFileSystem.sink(to).buffered().use { it.writeString(data) } +} + +@OptIn(ExperimentalStdlibApi::class) +internal fun read(from: Path): String = SystemFileSystem + .source(from) + .buffered() + .use { source -> source.readString() } diff --git a/savedstate/src/jsMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.js.kt b/savedstate/src/jsMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.js.kt new file mode 100644 index 00000000..76ed4510 --- /dev/null +++ b/savedstate/src/jsMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.js.kt @@ -0,0 +1,6 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.io.files.Path + +internal actual suspend fun writeCompressed(data: String, to: Path) = write(data, to) +internal actual suspend fun readCompressed(from: Path): String? = read(from) diff --git a/savedstate/src/jvmMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.jvm.kt b/savedstate/src/jvmMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.jvm.kt new file mode 100644 index 00000000..06149333 --- /dev/null +++ b/savedstate/src/jvmMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.jvm.kt @@ -0,0 +1,21 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +// TODO: Can't share sources between jvm and android yet + +@Suppress("INVISIBLE_MEMBER") // we want to access the JVM api of kotlinx.io... +internal actual suspend fun writeCompressed(data: String, to: Path) = withContext(Dispatchers.IO) { + GZIPOutputStream(FileOutputStream(to.file)).writer().use { it.write(data) } +} + +@Suppress("INVISIBLE_MEMBER") // we want to access the JVM api of kotlinx.io... +internal actual suspend fun readCompressed(from: Path): String? = withContext(Dispatchers.IO) { + GZIPInputStream(FileInputStream(from.file)).reader().use { it.readText() } +} diff --git a/savedstate/src/nativeMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.native.kt b/savedstate/src/nativeMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.native.kt new file mode 100644 index 00000000..76ed4510 --- /dev/null +++ b/savedstate/src/nativeMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.native.kt @@ -0,0 +1,6 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.io.files.Path + +internal actual suspend fun writeCompressed(data: String, to: Path) = write(data, to) +internal actual suspend fun readCompressed(from: Path): String? = read(from) From a92c52d24779387fdcbbac601b258ac9b2209850 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 00:58:40 +0300 Subject: [PATCH 03/17] deprecate old savedState API --- .../flowmvi/android/plugins/SavedStatePlugin.kt | 3 +++ .../pro/respawn/flowmvi/plugins/SavedStatePlugin.kt | 10 ++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt b/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt index 37882c91..ff54cdf4 100644 --- a/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt +++ b/android/src/main/kotlin/pro/respawn/flowmvi/android/plugins/SavedStatePlugin.kt @@ -17,6 +17,7 @@ import java.io.Serializable * Your state must be [Parcelable] to use this. * @see savedStatePlugin */ +@Deprecated("If you want to save state, use the new `savedstate` module dependency") @FlowMVIDSL public fun parcelizeStatePlugin( key: String, @@ -34,6 +35,7 @@ public fun parcelizeStatePlugin( * Your state must be [Serializable] to use this * @see savedStatePlugin */ +@Deprecated("If you want to save state, use the new `savedstate` module dependency") @FlowMVIDSL public fun serializeStatePlugin( key: String, @@ -50,6 +52,7 @@ public fun serializeStatePlugin( * Your state must be [Parcelable] to use this. * @see savedStatePlugin */ +@Deprecated("If you want to save state, use the new `savedstate` module dependency") @FlowMVIDSL public fun StoreBuilder.parcelizeState( handle: SavedStateHandle, diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt index 58d6ca25..97d4bafb 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/plugins/SavedStatePlugin.kt @@ -23,11 +23,12 @@ public const val DefaultSavedStatePluginName: String = "SavedState" * There are platform overloads for this function. */ @FlowMVIDSL +@Deprecated("If you want to save state, use the new `savedstate` module dependency") public inline fun savedStatePlugin( name: String = DefaultSavedStatePluginName, context: CoroutineContext = EmptyCoroutineContext, - @BuilderInference crossinline get: S.() -> S?, - @BuilderInference crossinline set: (S) -> Unit, + @BuilderInference crossinline get: suspend S.() -> S?, + @BuilderInference crossinline set: suspend (S) -> Unit, ): StorePlugin = plugin { this.name = name onState { _, new -> @@ -47,9 +48,10 @@ public inline fun savedStatePlugin( * Creates and installs a new [savedStatePlugin]. */ @FlowMVIDSL +@Deprecated("If you want to save state, use the new `savedstate` module dependency") public inline fun StoreBuilder.saveState( name: String = DefaultSavedStatePluginName, context: CoroutineContext = EmptyCoroutineContext, - @BuilderInference crossinline get: S.() -> S?, - @BuilderInference crossinline set: S.() -> Unit, + @BuilderInference crossinline get: suspend S.() -> S?, + @BuilderInference crossinline set: suspend S.() -> Unit, ): Unit = install(savedStatePlugin(name, context, get, set)) From c62a391cfe946fb1be5d38e1df7ad37b35c944b9 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 00:59:00 +0300 Subject: [PATCH 04/17] implement savedState plugins --- .../flowmvi/savedstate/dsl/SavedStateSaver.kt | 30 ++++++++ .../respawn/flowmvi/savedstate/api/Saver.kt | 11 +++ .../flowmvi/savedstate/dsl/FileSaver.kt | 58 +++++++++++++++ .../flowmvi/savedstate/dsl/JsonSaver.kt | 23 ++++++ .../flowmvi/savedstate/dsl/TypedSaver.kt | 15 ++++ .../savedstate/plugins/SavedStatePlugin.kt | 72 +++++++++++++++++++ .../plugins/SerializeStatePlugin.kt | 69 ++++++++++++++++++ 7 files changed, 278 insertions(+) create mode 100644 savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt new file mode 100644 index 00000000..fd657d69 --- /dev/null +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt @@ -0,0 +1,30 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import pro.respawn.flowmvi.savedstate.api.Saver +import pro.respawn.flowmvi.savedstate.api.ThrowRecover + +private fun SavedStateSaver( + handle: SavedStateHandle, + key: String, + recover: suspend (e: Exception) -> T? = ThrowRecover, +): Saver = object : Saver { + override suspend fun recover(e: Exception): T? = recover.invoke(e) + override suspend fun restore(): T? = handle[key] + override suspend fun save(state: T?) { + if (state == null) handle.remove(key) else handle[key] = state + } +} + +private fun ParcelableSaver( + handle: SavedStateHandle, + key: String, + recover: suspend (e: Exception) -> T? = ThrowRecover, +): Saver = SavedStateSaver(handle, key, recover) + +private fun SerializableSaver( + handle: SavedStateHandle, + key: String, + recover: suspend (e: Exception) -> T? = ThrowRecover, +): Saver = SavedStateSaver(handle, key, recover) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt new file mode 100644 index 00000000..d6143e65 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt @@ -0,0 +1,11 @@ +package pro.respawn.flowmvi.savedstate.api + +public inline val NullRecover: suspend (Exception) -> Nothing? get() = { null } +public inline val ThrowRecover: suspend (e: Exception) -> Nothing? get() = { throw it } + +public interface Saver { + + public suspend fun save(state: T?) + public suspend fun restore(): T? + public suspend fun recover(e: Exception): T? = ThrowRecover(e) +} diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt new file mode 100644 index 00000000..68b1a03e --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt @@ -0,0 +1,58 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import pro.respawn.flowmvi.savedstate.api.Saver +import pro.respawn.flowmvi.savedstate.api.ThrowRecover + +internal inline fun NonNullFileSaver( + dir: String, + fileName: String, + crossinline write: suspend (data: String, to: Path) -> Unit, + crossinline read: suspend (from: Path) -> String?, + noinline recover: suspend (Exception) -> String?, +): Saver = object : Saver { + val directory = Path(dir) + val file = Path(directory, fileName) + + override suspend fun recover(e: Exception): String? = recover.invoke(e) + override suspend fun save( + state: String? + ) = with(SystemFileSystem) { + if (state == null) { + delete(file, false) + } else { + createDirectories(directory) + write(state, file) + } + } + + override suspend fun restore(): String? = file + .takeIf { SystemFileSystem.exists(file) } + ?.let { read(it) } + ?.takeIf { it.isNotBlank() } +} + +public fun FileSaver( + dir: String, + fileName: String, + recover: suspend (Exception) -> String? = ThrowRecover, +): Saver = NonNullFileSaver( + dir = dir, + fileName = fileName, + recover = recover, + write = ::write, + read = ::read, +) + +public fun CompressedFileSaver( + dir: String, + fileName: String, + recover: suspend (Exception) -> String? = ThrowRecover, +): Saver = NonNullFileSaver( + dir = dir, + fileName = fileName, + recover = recover, + write = ::writeCompressed, + read = ::readCompressed, +) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt new file mode 100644 index 00000000..42969ec2 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt @@ -0,0 +1,23 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import pro.respawn.flowmvi.savedstate.api.Saver + +public inline fun JsonSaver( + json: Json, + delegate: Saver, + serializer: KSerializer, + noinline recover: suspend (Exception) -> T? = { e -> + delegate.recover(e)?.let { json.decodeFromString(serializer, it) } + }, +): Saver = object : Saver { + + override suspend fun recover(e: Exception): T? = recover.invoke(e) + + override suspend fun save(state: T?) { + delegate.save(state?.let { json.encodeToString(serializer, it) }) + } + + override suspend fun restore(): T? = delegate.restore()?.let { json.decodeFromString(serializer, it) } +} diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt new file mode 100644 index 00000000..2a305703 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt @@ -0,0 +1,15 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.savedstate.api.Saver +import pro.respawn.flowmvi.util.withType + +public inline fun TypedSaver( + delegate: Saver, +): Saver = object : Saver { + override suspend fun restore(): S? = delegate.restore() + override suspend fun recover(e: Exception): S? = delegate.recover(e) + override suspend fun save(state: S?) { + state.withType { delegate.save(this) } + } +} diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt new file mode 100644 index 00000000..ca53f83a --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt @@ -0,0 +1,72 @@ +package pro.respawn.flowmvi.savedstate.plugins + +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.api.UnrecoverableException +import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.dsl.plugin +import pro.respawn.flowmvi.savedstate.api.Saver +import kotlin.coroutines.CoroutineContext + +@PublishedApi +internal inline fun DefaultName(): String = "${T::class.simpleName}" + +@PublishedApi +internal val PluginNameSuffix: String = "SaveStatePlugin" + +@PublishedApi +internal suspend fun Saver.saveCatching(state: S?): Unit = try { + save(state) +} catch (expected: Exception) { + recover(expected) + Unit +} + +@PublishedApi +internal suspend fun Saver.restoreCatching(): S? = try { + restore() +} catch (expected: Exception) { + recover(expected) +} + +@FlowMVIDSL +public inline fun saveStatePlugin( + saver: Saver, + context: CoroutineContext, + name: String = "${DefaultName()}$PluginNameSuffix", + saveOnChange: Boolean = false, + resetOnException: Boolean = true, +): StorePlugin = plugin { + this.name = name + onStart { + withContext(this + context) { + updateState { saver.restoreCatching() ?: this } + } + } + if (saveOnChange) onState { _, new -> + launch(context) { saver.saveCatching(new) } + new + } else onUnsubscribe { + launch(context) { withState { saver.saveCatching(this) } } + } + onException { + if (it is UnrecoverableException || resetOnException) withContext(this + context) { + saver.saveCatching(null) + } + it + } +} + +@FlowMVIDSL +public inline fun StoreBuilder.saveState( + saver: Saver, + context: CoroutineContext, + name: String = "${DefaultName()}$PluginNameSuffix", + saveOnChange: Boolean = false, + resetOnException: Boolean = true, +): Unit = install(saveStatePlugin(saver, context, name, saveOnChange, resetOnException)) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt new file mode 100644 index 00000000..b04d9286 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt @@ -0,0 +1,69 @@ +package pro.respawn.flowmvi.savedstate.plugins + +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.savedstate.api.ThrowRecover +import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver +import pro.respawn.flowmvi.savedstate.dsl.JsonSaver +import pro.respawn.flowmvi.savedstate.dsl.TypedSaver +import kotlin.coroutines.CoroutineContext + +@FlowMVIDSL +public inline fun serializeStatePlugin( + dir: String, + json: Json, + serializer: KSerializer, + filename: String = DefaultName(), + context: CoroutineContext = Dispatchers.Default, + saveOnChange: Boolean = false, + resetOnException: Boolean = true, + noinline recover: suspend (Exception) -> T? = ThrowRecover, +): StorePlugin = saveStatePlugin( + saver = TypedSaver( + JsonSaver( + json = json, + serializer = serializer, + delegate = CompressedFileSaver(dir, "$filename.json", ThrowRecover), + recover = recover + ) + ), + context = context, + name = "$filename$PluginNameSuffix", + saveOnChange = saveOnChange, + resetOnException = resetOnException +) + +@Suppress("Indentation") // detekt <> IDE conflict +@FlowMVIDSL +public inline fun < + reified T : S, + reified S : MVIState, + I : MVIIntent, + A : MVIAction + > StoreBuilder.serializeState( + dir: String, + json: Json, + serializer: KSerializer, + context: CoroutineContext = Dispatchers.Default, + saveOnChange: Boolean = false, + resetOnException: Boolean = true, + noinline recover: suspend (Exception) -> T? = ThrowRecover, +): Unit = install( + serializeStatePlugin( + dir = dir, + json = json, + filename = name ?: DefaultName(), + context = context, + saveOnChange = saveOnChange, + resetOnException = resetOnException, + recover = recover, + serializer = serializer, + ) +) From 82d5f89720c253d5b27ffde196ea8a1db362f7f4 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 00:59:16 +0300 Subject: [PATCH 05/17] update sample app to use saved state, fix deprecations --- .../respawn/flowmvi/sample/CounterModels.kt | 14 +++--- .../respawn/flowmvi/sample/MVIApplication.kt | 2 + .../flowmvi/sample/compose/ComposeScreen.kt | 22 +++++++++ .../sample/compose/CounterContainer.kt | 47 +++++++++++++++++-- .../respawn/flowmvi/sample/di/AppModule.kt | 2 + .../pro/respawn/flowmvi/sample/di/KoinExt.kt | 7 +-- .../flowmvi/sample/view/CounterActivity.kt | 2 +- .../flowmvi/sample/view/LambdaViewModel.kt | 1 - app/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 21 +++++---- 10 files changed, 95 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt index 8c0b8eea..cb542afe 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt @@ -1,25 +1,22 @@ package pro.respawn.flowmvi.sample -import android.os.Parcelable -import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.dsl.LambdaIntent -sealed interface CounterState : MVIState, Parcelable { +sealed interface CounterState : MVIState { - @Parcelize data object Loading : CounterState - @Parcelize data class Error(val e: Exception) : CounterState - @Parcelize + @Serializable data class DisplayingCounter( val timer: Int, val counter: Int = 0, - val param: String, + val input: String, ) : CounterState } @@ -30,6 +27,9 @@ sealed interface CounterIntent : MVIIntent { data object ClickedCounter : CounterIntent data object ClickedUndo : CounterIntent data object ClickedBack : CounterIntent + + @JvmInline + value class InputChanged(val value: String) : CounterIntent } sealed interface CounterAction : MVIAction { diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/MVIApplication.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/MVIApplication.kt index 20a0f79e..757548e5 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/MVIApplication.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/MVIApplication.kt @@ -1,6 +1,7 @@ package pro.respawn.flowmvi.sample import android.app.Application +import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin import pro.respawn.flowmvi.sample.di.appModule @@ -11,6 +12,7 @@ class MVIApplication : Application() { startKoin { modules(appModule) + androidContext(applicationContext) } } } diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/ComposeScreen.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/ComposeScreen.kt index 0ae6afee..e9a85c5d 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/ComposeScreen.kt @@ -3,10 +3,16 @@ package pro.respawn.flowmvi.sample.compose import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize.Max import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.Button import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Scaffold import androidx.compose.material.ScaffoldState import androidx.compose.material.Text @@ -33,6 +39,7 @@ import pro.respawn.flowmvi.sample.CounterAction.ShowLambdaMessage import pro.respawn.flowmvi.sample.CounterIntent import pro.respawn.flowmvi.sample.CounterIntent.ClickedBack import pro.respawn.flowmvi.sample.CounterIntent.ClickedCounter +import pro.respawn.flowmvi.sample.CounterIntent.InputChanged import pro.respawn.flowmvi.sample.CounterState import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter import pro.respawn.flowmvi.sample.CounterState.Error @@ -84,6 +91,21 @@ private fun IntentReceiver.ComposeScreenContent( verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + OutlinedTextField( + value = state.input, + onValueChange = { intent(InputChanged(it)) }, + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 600.dp), + label = { Text(stringResource(R.string.counter_input_label)) } + ) + Text( + stringResource(R.string.counter_input_hint), + style = MaterialTheme.typography.caption, + modifier = Modifier.width(Max), + ) + } Text( text = stringResource(id = R.string.timer_template, state.timer), ) diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt index b39c4b85..ce604f83 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt @@ -1,16 +1,21 @@ package pro.respawn.flowmvi.sample.compose +import android.content.Context import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import pro.respawn.flowmvi.api.Container import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.dsl.store import pro.respawn.flowmvi.dsl.updateState import pro.respawn.flowmvi.plugins.disallowRestartPlugin +import pro.respawn.flowmvi.plugins.manageJobs import pro.respawn.flowmvi.plugins.platformLoggingPlugin import pro.respawn.flowmvi.plugins.recover import pro.respawn.flowmvi.plugins.reduce +import pro.respawn.flowmvi.plugins.register +import pro.respawn.flowmvi.plugins.registerOrReplace import pro.respawn.flowmvi.plugins.undoRedo import pro.respawn.flowmvi.plugins.whileSubscribed import pro.respawn.flowmvi.sample.CounterAction @@ -20,9 +25,17 @@ import pro.respawn.flowmvi.sample.CounterIntent import pro.respawn.flowmvi.sample.CounterIntent.ClickedBack import pro.respawn.flowmvi.sample.CounterIntent.ClickedCounter import pro.respawn.flowmvi.sample.CounterIntent.ClickedUndo +import pro.respawn.flowmvi.sample.CounterIntent.InputChanged import pro.respawn.flowmvi.sample.CounterState import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter import pro.respawn.flowmvi.sample.repository.CounterRepository +import pro.respawn.flowmvi.savedstate.api.NullRecover +import pro.respawn.flowmvi.savedstate.api.ThrowRecover +import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver +import pro.respawn.flowmvi.savedstate.dsl.JsonSaver +import pro.respawn.flowmvi.savedstate.dsl.TypedSaver +import pro.respawn.flowmvi.savedstate.plugins.saveState +import pro.respawn.flowmvi.savedstate.plugins.serializeState import pro.respawn.flowmvi.util.typed import kotlin.random.Random @@ -31,32 +44,52 @@ private typealias Ctx = PipelineContext { + private val cacheDir = context.cacheDir.resolve("state").path + override val store = store(CounterState.Loading) { - name = "Counter" + name = "CounterContainer" install( platformLoggingPlugin(), disallowRestartPlugin() // store does not restart when it is in a viewmodel ) + serializeState( + dir = cacheDir, + json = json, + serializer = DisplayingCounter.serializer(), + recover = ThrowRecover + ) val undoRedo = undoRedo(10) + val jobManager = manageJobs() recover { if (it is IllegalArgumentException) action(ShowErrorMessage(it.message)) else updateState { + jobManager.cancelAndJoin("timer") CounterState.Error(it) } null } whileSubscribed { - repo.getTimer() - .onEach { produceState(it) } - .consume(Dispatchers.Default) + launch { + repo.getTimer() + .onEach { produceState(it) } + .consume(Dispatchers.Default) + }.apply { + registerOrReplace(jobManager, "timer") + join() + } } reduce { when (it) { is ClickedUndo -> undoRedo.undo() is ClickedBack -> action(GoBack) + is InputChanged -> updateState { + copy(input = it.value) + } is ClickedCounter -> launch { require(Random.nextBoolean()) { "Oops, there was an error in a job" } undoRedo( @@ -79,6 +112,10 @@ class CounterContainer( private suspend fun Ctx.produceState(timer: Int) = updateState { // remember that you have to merge states when you are running produceState val current = typed() - DisplayingCounter(timer, current?.counter ?: 0, param) + DisplayingCounter( + timer = timer, + counter = current?.counter ?: 0, + input = current?.input ?: param + ) } } diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/AppModule.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/AppModule.kt index 0293ab6e..b3d91585 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/AppModule.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/AppModule.kt @@ -1,5 +1,6 @@ package pro.respawn.flowmvi.sample.di +import kotlinx.serialization.json.Json import org.koin.androidx.viewmodel.dsl.viewModelOf import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf @@ -14,4 +15,5 @@ val appModule = module { factoryOf(::CounterContainer) storeViewModel() + single { Json { ignoreUnknownKeys = true } } } diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt index 94dfc0c4..006eb96e 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt @@ -6,8 +6,9 @@ import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import org.koin.androidx.compose.defaultExtras import org.koin.androidx.compose.getViewModel +import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.dsl.viewModel -import org.koin.compose.getKoinScope +import org.koin.compose.currentKoinScope import org.koin.core.module.Module import org.koin.core.parameter.ParametersDefinition import org.koin.core.qualifier.qualifier @@ -29,6 +30,6 @@ inline fun , S : MVIState, I : MVIIntent, A : MVI }, key: String? = null, extras: CreationExtras = defaultExtras(viewModelStoreOwner), - scope: Scope = getKoinScope(), + scope: Scope = currentKoinScope(), noinline parameters: ParametersDefinition? = null, -): StoreViewModel = getViewModel(qualifier(), viewModelStoreOwner, key, extras, scope, parameters) +): StoreViewModel = koinViewModel(qualifier(), viewModelStoreOwner, key, extras, scope, parameters) diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/CounterActivity.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/CounterActivity.kt index 007d2a6d..8d4e16d8 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/CounterActivity.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/CounterActivity.kt @@ -68,7 +68,7 @@ class CounterActivity : } with(tvParam) { isVisible = true - text = state.param + text = state.input } with(tvTimer) { isVisible = true diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt index 7da0107e..d7084a40 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt @@ -38,7 +38,6 @@ class LambdaViewModel( name = "Counter" debuggable = BuildConfig.DEBUG install(platformLoggingPlugin()) - parcelizeState(savedStateHandle) whileSubscribed { repo.getTimer() .onEach { produceState(it) } // set mapped states diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0655d51..047a2d61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,4 +11,6 @@ To compose screen Undo Back + Enter value + The value of this field is persisted across app restarts. Try it! diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4dae128b..98fcdf28 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -activity = "1.8.1" +activity = "1.8.2" compose = "1.5.4" compose-plugin = "1.5.11" -compose-compiler = "1.5.6" +compose-compiler = "1.5.7" composeDetektPlugin = "1.3.0" core-ktx = "1.12.0" coroutines = "1.7.3" @@ -11,21 +11,23 @@ detekt = "1.23.4" detektFormattingPlugin = "1.23.4" dokka = "1.9.10" fragment = "1.6.2" -gradleAndroid = "8.3.0-alpha18" +gradleAndroid = "8.3.0-beta01" gradleDoctorPlugin = "0.9.1" junit = "4.13.2" -koin = "3.5.0" -koin-compose = "1.1.1-RC1" +koin = "3.5.3" +koin-compose = "1.1.2" kotest = "5.8.0" kotest-plugin = "5.8.0" # @pin kotlin = "1.9.21" kotlinx-atomicfu = "0.23.1" lifecycle = "2.6.2" -material = "1.10.0" +material = "1.11.0" turbine = "1.0.0" -versionCatalogUpdatePlugin = "0.8.1" +versionCatalogUpdatePlugin = "0.8.3" versionsPlugin = "0.50.0" +kotlin-io = "0.3.0" +serialization = "1.6.2" [libraries] android-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradleAndroid" } @@ -62,8 +64,10 @@ kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-co kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } +kotlin-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlin-io" } +kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } +kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } lifecycle-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "lifecycle" } lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } @@ -100,3 +104,4 @@ kotest = { id = "io.kotest.multiplatform", version.ref = "kotest-plugin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } version-catalog-update = { id = "nl.littlerobots.version-catalog-update", version.ref = "versionCatalogUpdatePlugin" } versions = { id = "com.github.ben-manes.versions", version.ref = "versionsPlugin" } +serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From 7a30673807596c8e6747131d21de70076840fadf Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 01:13:37 +0300 Subject: [PATCH 06/17] fix some api, add parcelize state plugin --- .../flowmvi/savedstate/dsl/SavedStateSaver.kt | 10 +---- .../plugins/ParcelizeStatePlugin.kt | 45 +++++++++++++++++++ .../flowmvi/savedstate/dsl/TypedSaver.kt | 3 +- 3 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt index fd657d69..1a65fafb 100644 --- a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.SavedStateHandle import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.savedstate.api.ThrowRecover -private fun SavedStateSaver( +public fun SavedStateSaver( handle: SavedStateHandle, key: String, recover: suspend (e: Exception) -> T? = ThrowRecover, @@ -17,13 +17,7 @@ private fun SavedStateSaver( } } -private fun ParcelableSaver( - handle: SavedStateHandle, - key: String, - recover: suspend (e: Exception) -> T? = ThrowRecover, -): Saver = SavedStateSaver(handle, key, recover) - -private fun SerializableSaver( +public fun ParcelableSaver( handle: SavedStateHandle, key: String, recover: suspend (e: Exception) -> T? = ThrowRecover, diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt new file mode 100644 index 00000000..cdfee430 --- /dev/null +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt @@ -0,0 +1,45 @@ +package pro.respawn.flowmvi.savedstate.plugins + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.Dispatchers +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.StorePlugin +import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.savedstate.api.ThrowRecover +import pro.respawn.flowmvi.savedstate.dsl.ParcelableSaver +import pro.respawn.flowmvi.savedstate.dsl.TypedSaver +import kotlin.coroutines.CoroutineContext + +@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") +@FlowMVIDSL +public inline fun parcelizeStatePlugin( + handle: SavedStateHandle, + context: CoroutineContext = Dispatchers.IO, + key: String = DefaultName(), + saveOnChange: Boolean = false, + resetOnException: Boolean = true, + noinline recover: suspend (Exception) -> T? = ThrowRecover, +): StorePlugin where T : Parcelable, T : S = saveStatePlugin( + saver = TypedSaver(ParcelableSaver(handle, key, recover)), + context = context, + name = "$key$PluginNameSuffix", + saveOnChange = saveOnChange, + resetOnException = resetOnException +) + +@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER", "Indentation") +@FlowMVIDSL +public inline fun StoreBuilder.parcelizeState( + handle: SavedStateHandle, + context: CoroutineContext = Dispatchers.IO, + key: String = name?.let { "${it}State" } ?: DefaultName(), + saveOnChange: Boolean = false, + resetOnException: Boolean = true, + noinline recover: suspend (Exception) -> T? = ThrowRecover, +): Unit where T : Parcelable, T : S = install( + parcelizeStatePlugin(handle, context, key, saveOnChange, resetOnException, recover) +) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt index 2a305703..3621e3b5 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt @@ -1,10 +1,9 @@ package pro.respawn.flowmvi.savedstate.dsl -import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.util.withType -public inline fun TypedSaver( +public inline fun TypedSaver( delegate: Saver, ): Saver = object : Saver { override suspend fun restore(): S? = delegate.restore() From f7a2086ce43b62f08123294fb7d3d32422487e7d Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 01:13:53 +0300 Subject: [PATCH 07/17] update sample app, fix lint --- .../main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt | 5 ++++- .../pro/respawn/flowmvi/sample/compose/CounterContainer.kt | 6 ------ .../main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt | 1 - .../pro/respawn/flowmvi/sample/view/LambdaViewModel.kt | 3 ++- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt index cb542afe..a8e00f73 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt @@ -1,5 +1,7 @@ package pro.respawn.flowmvi.sample +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent @@ -13,11 +15,12 @@ sealed interface CounterState : MVIState { data class Error(val e: Exception) : CounterState @Serializable + @Parcelize data class DisplayingCounter( val timer: Int, val counter: Int = 0, val input: String, - ) : CounterState + ) : CounterState, Parcelable } typealias CounterLambdaIntent = LambdaIntent diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt index ce604f83..76ef77bc 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt @@ -14,7 +14,6 @@ import pro.respawn.flowmvi.plugins.manageJobs import pro.respawn.flowmvi.plugins.platformLoggingPlugin import pro.respawn.flowmvi.plugins.recover import pro.respawn.flowmvi.plugins.reduce -import pro.respawn.flowmvi.plugins.register import pro.respawn.flowmvi.plugins.registerOrReplace import pro.respawn.flowmvi.plugins.undoRedo import pro.respawn.flowmvi.plugins.whileSubscribed @@ -29,12 +28,7 @@ import pro.respawn.flowmvi.sample.CounterIntent.InputChanged import pro.respawn.flowmvi.sample.CounterState import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter import pro.respawn.flowmvi.sample.repository.CounterRepository -import pro.respawn.flowmvi.savedstate.api.NullRecover import pro.respawn.flowmvi.savedstate.api.ThrowRecover -import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver -import pro.respawn.flowmvi.savedstate.dsl.JsonSaver -import pro.respawn.flowmvi.savedstate.dsl.TypedSaver -import pro.respawn.flowmvi.savedstate.plugins.saveState import pro.respawn.flowmvi.savedstate.plugins.serializeState import pro.respawn.flowmvi.util.typed import kotlin.random.Random diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt index 006eb96e..bf487877 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import org.koin.androidx.compose.defaultExtras -import org.koin.androidx.compose.getViewModel import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.compose.currentKoinScope diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt index d7084a40..a3463ec1 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.onEach -import pro.respawn.flowmvi.android.plugins.parcelizeState import pro.respawn.flowmvi.api.ImmutableContainer import pro.respawn.flowmvi.api.PipelineContext import pro.respawn.flowmvi.dsl.intent @@ -21,6 +20,7 @@ import pro.respawn.flowmvi.sample.CounterLambdaIntent import pro.respawn.flowmvi.sample.CounterState import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter import pro.respawn.flowmvi.sample.repository.CounterRepository +import pro.respawn.flowmvi.savedstate.plugins.parcelizeState import pro.respawn.flowmvi.util.typed private typealias Ctx = PipelineContext @@ -36,6 +36,7 @@ class LambdaViewModel( scope = viewModelScope, ) { name = "Counter" + parcelizeState(savedStateHandle) debuggable = BuildConfig.DEBUG install(platformLoggingPlugin()) whileSubscribed { From a6044c2649b6cb3f524273539067d688ea72daad Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 17:31:01 +0300 Subject: [PATCH 08/17] added MapSaver, NoOpSaver and SaveBehavior --- .../flowmvi/savedstate/api/SaveBehavior.kt | 17 ++++++++ .../respawn/flowmvi/savedstate/api/Saver.kt | 6 +-- .../flowmvi/savedstate/dsl/Compress.kt | 5 +-- .../flowmvi/savedstate/dsl/FileSaver.kt | 41 ++++++++++++------ .../flowmvi/savedstate/dsl/JsonSaver.kt | 2 +- .../flowmvi/savedstate/dsl/NoOpSaver.kt | 13 ++++++ .../flowmvi/savedstate/dsl/TypedSaver.kt | 26 +++++++---- .../savedstate/plugins/SavedStatePlugin.kt | 43 ++++++++++++++----- .../plugins/SerializeStatePlugin.kt | 14 +++--- 9 files changed, 120 insertions(+), 47 deletions(-) create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt new file mode 100644 index 00000000..884d78f5 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt @@ -0,0 +1,17 @@ +package pro.respawn.flowmvi.savedstate.api + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +public sealed interface SaveBehavior { + + public data class OnChange(val timeout: Duration = DefaultSaveTimeoutMs.milliseconds) : SaveBehavior + + public data class OnUnsubscribe(val remainingSubscribers: Int = 0) : SaveBehavior + + public companion object { + + public val Default: Set = setOf(OnChange(), OnUnsubscribe()) + public const val DefaultSaveTimeoutMs: Int = 2000 + } +} diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt index d6143e65..75185abe 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt @@ -1,11 +1,11 @@ package pro.respawn.flowmvi.savedstate.api -public inline val NullRecover: suspend (Exception) -> Nothing? get() = { null } -public inline val ThrowRecover: suspend (e: Exception) -> Nothing? get() = { throw it } +public val NullRecover: suspend (Exception) -> Nothing? = { null } +public val ThrowRecover: suspend (e: Exception) -> Nothing? = { throw it } public interface Saver { public suspend fun save(state: T?) public suspend fun restore(): T? - public suspend fun recover(e: Exception): T? = ThrowRecover(e) + public suspend fun recover(e: Exception): T? = throw e } diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt index f42f94ca..9c408b22 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt @@ -15,7 +15,4 @@ internal fun write(data: String, to: Path) { } @OptIn(ExperimentalStdlibApi::class) -internal fun read(from: Path): String = SystemFileSystem - .source(from) - .buffered() - .use { source -> source.readString() } +internal fun read(from: Path): String = SystemFileSystem.source(from).buffered().use { it.readString() } diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt index 68b1a03e..485eab47 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt @@ -1,43 +1,56 @@ package pro.respawn.flowmvi.savedstate.dsl +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.savedstate.api.ThrowRecover -internal inline fun NonNullFileSaver( +public inline fun DefaultFileSaver( dir: String, fileName: String, crossinline write: suspend (data: String, to: Path) -> Unit, crossinline read: suspend (from: Path) -> String?, - noinline recover: suspend (Exception) -> String?, + crossinline recover: suspend (Exception) -> String?, ): Saver = object : Saver { val directory = Path(dir) val file = Path(directory, fileName) + // prevent concurrent file access + private val mutex = Mutex() + override suspend fun recover(e: Exception): String? = recover.invoke(e) override suspend fun save( state: String? - ) = with(SystemFileSystem) { - if (state == null) { - delete(file, false) - } else { - createDirectories(directory) - write(state, file) + ) = withContext(NonCancellable) { // prevent partial writes + mutex.withLock { + with(SystemFileSystem) { + if (state == null) { + delete(file, false) + } else { + createDirectories(directory) + write(state, file) + } + } } } - override suspend fun restore(): String? = file - .takeIf { SystemFileSystem.exists(file) } - ?.let { read(it) } - ?.takeIf { it.isNotBlank() } + // allow cancelling reads (no "NonCancellable here") + override suspend fun restore(): String? = mutex.withLock { + file.takeIf { SystemFileSystem.exists(file) } + ?.let { read(it) } + ?.takeIf(String::isNotBlank) + } } public fun FileSaver( dir: String, fileName: String, recover: suspend (Exception) -> String? = ThrowRecover, -): Saver = NonNullFileSaver( +): Saver = DefaultFileSaver( dir = dir, fileName = fileName, recover = recover, @@ -49,7 +62,7 @@ public fun CompressedFileSaver( dir: String, fileName: String, recover: suspend (Exception) -> String? = ThrowRecover, -): Saver = NonNullFileSaver( +): Saver = DefaultFileSaver( dir = dir, fileName = fileName, recover = recover, diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt index 42969ec2..e7ada8c5 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt @@ -8,7 +8,7 @@ public inline fun JsonSaver( json: Json, delegate: Saver, serializer: KSerializer, - noinline recover: suspend (Exception) -> T? = { e -> + @BuilderInference noinline recover: suspend (Exception) -> T? = { e -> delegate.recover(e)?.let { json.decodeFromString(serializer, it) } }, ): Saver = object : Saver { diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt new file mode 100644 index 00000000..2dde9115 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt @@ -0,0 +1,13 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import pro.respawn.flowmvi.savedstate.api.Saver + +private object NoOpSaver : Saver { + + override suspend fun save(state: Nothing?): Unit = Unit + override suspend fun restore(): Nothing? = null + override suspend fun recover(e: Exception): Nothing? = null +} + +@Suppress("UNCHECKED_CAST") +public fun NoOpSaver(): Saver = NoOpSaver as Saver diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt index 3621e3b5..fa92ffb1 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt @@ -1,14 +1,22 @@ package pro.respawn.flowmvi.savedstate.dsl import pro.respawn.flowmvi.savedstate.api.Saver -import pro.respawn.flowmvi.util.withType +import pro.respawn.flowmvi.util.typed -public inline fun TypedSaver( - delegate: Saver, -): Saver = object : Saver { - override suspend fun restore(): S? = delegate.restore() - override suspend fun recover(e: Exception): S? = delegate.recover(e) - override suspend fun save(state: S?) { - state.withType { delegate.save(this) } - } +public inline fun MapSaver( + delegate: Saver, + @BuilderInference crossinline from: suspend (R?) -> T?, + @BuilderInference crossinline to: suspend (T?) -> R?, +): Saver = object : Saver { + override suspend fun save(state: T?) = delegate.save(to(state)) + override suspend fun restore(): T? = from(delegate.restore()) + override suspend fun recover(e: Exception): T? = from(delegate.recover(e)) } + +public inline fun TypedSaver( + delegate: Saver, +): Saver = MapSaver( + delegate = delegate, + from = { it }, + to = { it.typed() }, +) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt index ca53f83a..6b9c4d18 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt @@ -1,5 +1,9 @@ package pro.respawn.flowmvi.savedstate.plugins +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import pro.respawn.flowmvi.api.FlowMVIDSL @@ -10,6 +14,9 @@ import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.api.UnrecoverableException import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.plugin +import pro.respawn.flowmvi.savedstate.api.SaveBehavior +import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnChange +import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnUnsubscribe import pro.respawn.flowmvi.savedstate.api.Saver import kotlin.coroutines.CoroutineContext @@ -38,8 +45,8 @@ internal suspend fun Saver.restoreCatching(): S? = try { public inline fun saveStatePlugin( saver: Saver, context: CoroutineContext, + behaviors: Set = SaveBehavior.Default, name: String = "${DefaultName()}$PluginNameSuffix", - saveOnChange: Boolean = false, resetOnException: Boolean = true, ): StorePlugin = plugin { this.name = name @@ -48,17 +55,31 @@ public inline fun saveState updateState { saver.restoreCatching() ?: this } } } - if (saveOnChange) onState { _, new -> - launch(context) { saver.saveCatching(new) } - new - } else onUnsubscribe { + onException { + if (it !is UnrecoverableException && !resetOnException) return@onException it + withContext(this + context) { saver.saveCatching(null) } + it + } + val onUnsubscribe = behaviors.filterIsInstance() + if (onUnsubscribe.isNotEmpty()) onUnsubscribe { remainingSubs -> + val shouldSave = onUnsubscribe.any { remainingSubs <= it.remainingSubscribers } + if (!shouldSave) return@onUnsubscribe launch(context) { withState { saver.saveCatching(this) } } } - onException { - if (it is UnrecoverableException || resetOnException) withContext(this + context) { - saver.saveCatching(null) + val saveTimeout = behaviors + .asSequence() + .filterIsInstance() + .minOfOrNull { it.timeout } + ?: return@plugin + + var job: Job? by atomic(null) + onState { _, new -> + job?.cancelAndJoin() + job = launch(context) { + delay(saveTimeout) + withState { saver.saveCatching(this) } } - it + new } } @@ -66,7 +87,7 @@ public inline fun saveState public inline fun StoreBuilder.saveState( saver: Saver, context: CoroutineContext, + behaviors: Set = SaveBehavior.Default, name: String = "${DefaultName()}$PluginNameSuffix", - saveOnChange: Boolean = false, resetOnException: Boolean = true, -): Unit = install(saveStatePlugin(saver, context, name, saveOnChange, resetOnException)) +): Unit = install(saveStatePlugin(saver, context, behaviors, name, resetOnException)) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt index b04d9286..30c64442 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt @@ -9,6 +9,7 @@ import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.savedstate.api.SaveBehavior import pro.respawn.flowmvi.savedstate.api.ThrowRecover import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver import pro.respawn.flowmvi.savedstate.dsl.JsonSaver @@ -20,9 +21,10 @@ public inline fun , + behaviors: Set = SaveBehavior.Default, filename: String = DefaultName(), + fileExtension: String = ".json", context: CoroutineContext = Dispatchers.Default, - saveOnChange: Boolean = false, resetOnException: Boolean = true, noinline recover: suspend (Exception) -> T? = ThrowRecover, ): StorePlugin = saveStatePlugin( @@ -30,13 +32,13 @@ public inline fun , + behaviors: Set = SaveBehavior.Default, + fileExtension: String = ".json", context: CoroutineContext = Dispatchers.Default, - saveOnChange: Boolean = false, resetOnException: Boolean = true, noinline recover: suspend (Exception) -> T? = ThrowRecover, ): Unit = install( @@ -61,9 +64,10 @@ public inline fun < json = json, filename = name ?: DefaultName(), context = context, - saveOnChange = saveOnChange, + behaviors = behaviors, resetOnException = resetOnException, recover = recover, serializer = serializer, + fileExtension = fileExtension, ) ) From 461ef5e42dad4df9593fe6b3d162e636a978260b Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 17:52:28 +0300 Subject: [PATCH 09/17] add vararg overloads for intent/action functions --- .../pro/respawn/flowmvi/dsl/IntentDsl.kt | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt new file mode 100644 index 00000000..7c66b78c --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt @@ -0,0 +1,36 @@ +package pro.respawn.flowmvi.dsl + +import pro.respawn.flowmvi.api.ActionReceiver +import pro.respawn.flowmvi.api.DelicateStoreApi +import pro.respawn.flowmvi.api.IntentReceiver +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent + +// ----- intents ----- + +public inline fun IntentReceiver.intent( + vararg intents: I +): Unit = intents.forEach(::intent) + +public inline fun IntentReceiver.send( + vararg intents: I +): Unit = intent(intents = intents) + +public suspend inline fun IntentReceiver.emit( + vararg intents: I +): Unit = intents.forEach { emit(it) } + +// ----- actions ----- + +public suspend inline fun ActionReceiver.action( + vararg actions: A +): Unit = actions.forEach { action(it) } + +public suspend inline fun ActionReceiver.emit( + vararg actions: A +): Unit = action(actions = actions) + +@DelicateStoreApi +public inline fun ActionReceiver.send( + vararg actions: A +): Unit = actions.forEach(::send) From c58af647f2b69269a91b9b2bdd8c972cf0349849 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 18:06:08 +0300 Subject: [PATCH 10/17] made send/emit functions of IntentReceiver, ActionReceiver extensions --- .../pro/respawn/flowmvi/api/ActionReceiver.kt | 20 ++++----- .../pro/respawn/flowmvi/api/Consumer.kt | 2 +- .../pro/respawn/flowmvi/api/IntentReceiver.kt | 17 +++---- .../pro/respawn/flowmvi/dsl/IntentDsl.kt | 44 ++++++++++++++++++- .../plugins/ParcelizeStatePlugin.kt | 9 ++-- .../flowmvi/savedstate/dsl/JsonSaver.kt | 4 +- .../plugins/SerializeStatePlugin.kt | 10 ++--- 7 files changed, 71 insertions(+), 35 deletions(-) diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ActionReceiver.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ActionReceiver.kt index dd95b297..91b7ec71 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ActionReceiver.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/ActionReceiver.kt @@ -5,24 +5,24 @@ package pro.respawn.flowmvi.api * Actions are collected by the [ActionConsumer]. * This is most often implemented by a [Store] and exposed through [PipelineContext] */ -@Suppress("FUN_INTERFACE_WITH_SUSPEND_FUNCTION") // https://youtrack.jetbrains.com/issue/KTIJ-7642 public interface ActionReceiver { /** - * Send a new side-effect to be processed by subscribers, only once. - * How actions will be distributed and handled depends on [ActionShareBehavior]. - * Actions that make the capacity overflow may be dropped or the function may suspend until the buffer is freed. + * Alias for [action] with one difference - + * this function will launch a new coroutine to send the intent in background. + * + * The launched coroutine may suspend to + * wait for the buffer to become available based on [ActionShareBehavior]. + * + * @see action */ @DelicateStoreApi public fun send(action: A) /** - * Alias for [send] for parity with [IntentReceiver.send] - */ - public suspend fun emit(action: A): Unit = action(action) - - /** - * Alias for [send] + * Send a new side-effect to be processed by subscribers, only once. + * How actions will be distributed and handled depends on [ActionShareBehavior]. + * Actions that make the capacity overflow may be dropped or the function may suspend until the buffer is freed. */ public suspend fun action(action: A) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Consumer.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Consumer.kt index 6e36fd68..b8363056 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Consumer.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/Consumer.kt @@ -11,6 +11,6 @@ public interface Consumer : IntentRe * Container, an object that wraps a Store. */ public val container: Container - override fun intent(intent: I): Unit = container.store.send(intent) + override fun intent(intent: I): Unit = container.store.intent(intent) override suspend fun emit(intent: I): Unit = container.store.emit(intent) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/IntentReceiver.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/IntentReceiver.kt index 7e736f7f..16e4c192 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/IntentReceiver.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/api/IntentReceiver.kt @@ -8,6 +8,12 @@ import androidx.compose.runtime.Stable @Stable public interface IntentReceiver { + /** + * Alias for [intent] with one difference - this function will suspend if + * [pro.respawn.flowmvi.dsl.StoreBuilder.onOverflow] permits it. + */ + public suspend fun emit(intent: I) + /** * Send an intent asynchronously. The intent is sent to the receiver and is placed in a queue. * When [IntentReceiver] is available (e.g. when the [Store] is started), the intent will be processed. @@ -17,16 +23,5 @@ public interface IntentReceiver { * once the store is started**. * @See MVIIntent */ - public fun send(intent: I): Unit = intent(intent) - - /** - * Alias for [send] with one difference - this function will suspend if - * [pro.respawn.flowmvi.dsl.StoreBuilder.onOverflow] permits it. - */ - public suspend fun emit(intent: I) - - /** - * Alias for [send] - */ public fun intent(intent: I) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt index 7c66b78c..64791232 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt @@ -1,3 +1,5 @@ +@file:Suppress("NOTHING_TO_INLINE") + package pro.respawn.flowmvi.dsl import pro.respawn.flowmvi.api.ActionReceiver @@ -7,30 +9,68 @@ import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent // ----- intents ----- - +/** + * Alias for [IntentReceiver.intent] for multiple intents + * + * @see IntentReceiver.intent + */ public inline fun IntentReceiver.intent( vararg intents: I ): Unit = intents.forEach(::intent) +/** + * Alias for [IntentReceiver.intent] for multiple intents + * + * @see IntentReceiver.intent + */ public inline fun IntentReceiver.send( vararg intents: I ): Unit = intent(intents = intents) +/** + * Alias for [IntentReceiver.intent] + */ +public fun IntentReceiver.send(intent: I): Unit = intent(intent) + +/** + * Alias for [IntentReceiver.emit] for multiple intents + * + * @see IntentReceiver.emit + */ public suspend inline fun IntentReceiver.emit( vararg intents: I ): Unit = intents.forEach { emit(it) } // ----- actions ----- - +/** + * Alias for [ActionReceiver.action] for multiple actions + * + * @see ActionReceiver.action + */ public suspend inline fun ActionReceiver.action( vararg actions: A ): Unit = actions.forEach { action(it) } +/** + * Alias for [ActionReceiver.action] for multiple actions + * + * @see ActionReceiver.action + */ public suspend inline fun ActionReceiver.emit( vararg actions: A ): Unit = action(actions = actions) +/** + * Alias for [ActionReceiver.send] for multiple actions + * + * @see ActionReceiver.send + */ @DelicateStoreApi public inline fun ActionReceiver.send( vararg actions: A ): Unit = actions.forEach(::send) + +/** + * Alias for [ActionReceiver.action] + */ +public suspend inline fun ActionReceiver.emit(action: A): Unit = action(action) diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt index cdfee430..9a4ef689 100644 --- a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt @@ -9,6 +9,7 @@ import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.dsl.StoreBuilder +import pro.respawn.flowmvi.savedstate.api.SaveBehavior import pro.respawn.flowmvi.savedstate.api.ThrowRecover import pro.respawn.flowmvi.savedstate.dsl.ParcelableSaver import pro.respawn.flowmvi.savedstate.dsl.TypedSaver @@ -20,14 +21,14 @@ public inline fun (), - saveOnChange: Boolean = false, + behaviors: Set = SaveBehavior.Default, resetOnException: Boolean = true, noinline recover: suspend (Exception) -> T? = ThrowRecover, ): StorePlugin where T : Parcelable, T : S = saveStatePlugin( saver = TypedSaver(ParcelableSaver(handle, key, recover)), context = context, name = "$key$PluginNameSuffix", - saveOnChange = saveOnChange, + behaviors = behaviors, resetOnException = resetOnException ) @@ -37,9 +38,9 @@ public inline fun (), - saveOnChange: Boolean = false, + behaviors: Set = SaveBehavior.Default, resetOnException: Boolean = true, noinline recover: suspend (Exception) -> T? = ThrowRecover, ): Unit where T : Parcelable, T : S = install( - parcelizeStatePlugin(handle, context, key, saveOnChange, resetOnException, recover) + parcelizeStatePlugin(handle, context, key, behaviors, resetOnException, recover) ) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt index e7ada8c5..a516a4df 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt @@ -4,11 +4,11 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import pro.respawn.flowmvi.savedstate.api.Saver -public inline fun JsonSaver( +public fun JsonSaver( json: Json, delegate: Saver, serializer: KSerializer, - @BuilderInference noinline recover: suspend (Exception) -> T? = { e -> + @BuilderInference recover: suspend (Exception) -> T? = { e -> // TODO: Compiler bug does not permit inlining this delegate.recover(e)?.let { json.decodeFromString(serializer, it) } }, ): Saver = object : Saver { diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt index 30c64442..85d44ed0 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt @@ -45,11 +45,11 @@ public inline fun IDE conflict @FlowMVIDSL public inline fun < - reified T : S, - reified S : MVIState, - I : MVIIntent, - A : MVIAction - > StoreBuilder.serializeState( + reified T : S, + reified S : MVIState, + I : MVIIntent, + A : MVIAction + > StoreBuilder.serializeState( dir: String, json: Json, serializer: KSerializer, From 8f56935bfdf2aa24f78c2212bdbbead8dc198595 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 19:46:31 +0300 Subject: [PATCH 11/17] add docs --- .../flowmvi/savedstate/dsl/SavedStateSaver.kt | 23 ++++-- .../plugins/ParcelizeStatePlugin.kt | 37 +++++++-- .../flowmvi/savedstate/api/SaveBehavior.kt | 36 +++++++- .../respawn/flowmvi/savedstate/api/Saver.kt | 33 ++++++++ .../flowmvi/savedstate/dsl/Compress.kt | 6 +- .../flowmvi/savedstate/dsl/FileSaver.kt | 53 +++++++++--- .../flowmvi/savedstate/dsl/JsonSaver.kt | 5 ++ .../flowmvi/savedstate/dsl/NoOpSaver.kt | 7 +- .../flowmvi/savedstate/dsl/TypedSaver.kt | 16 +++- .../savedstate/plugins/SavedStatePlugin.kt | 82 +++++++++++++++---- .../plugins/SerializeStatePlugin.kt | 25 +++++- 11 files changed, 275 insertions(+), 48 deletions(-) diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt index 1a65fafb..c2536320 100644 --- a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt @@ -1,11 +1,19 @@ +@file:Suppress("FunctionName") + package pro.respawn.flowmvi.savedstate.dsl import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.savedstate.api.ThrowRecover +import pro.respawn.flowmvi.savedstate.plugins.nameByType -public fun SavedStateSaver( +/** + * A [Saver] implementation that saves the specified value of [T] to a [handle]. + * The type of [T] **must** be saveable in a bundle, or the framework code will throw. + * If your state is [Parcelable], use the [ParcelableSaver] instead. + */ +public fun SavedStateHandleSaver( handle: SavedStateHandle, key: String, recover: suspend (e: Exception) -> T? = ThrowRecover, @@ -17,8 +25,13 @@ public fun SavedStateSaver( } } -public fun ParcelableSaver( +/** + * A [Saver] implementation that saves the given [Parcelable] state to a [handle]. + * + * The [key] parameter is derived from the simple class name of the state by default. + */ +public inline fun ParcelableSaver( handle: SavedStateHandle, - key: String, - recover: suspend (e: Exception) -> T? = ThrowRecover, -): Saver = SavedStateSaver(handle, key, recover) + key: String = nameByType() ?: "State", + noinline recover: suspend (e: Exception) -> T? = ThrowRecover, +): Saver = SavedStateHandleSaver(handle, key, recover) diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt index 9a4ef689..4aa46a4b 100644 --- a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt @@ -11,36 +11,61 @@ import pro.respawn.flowmvi.api.StorePlugin import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.savedstate.api.SaveBehavior import pro.respawn.flowmvi.savedstate.api.ThrowRecover +import pro.respawn.flowmvi.savedstate.dsl.MapSaver import pro.respawn.flowmvi.savedstate.dsl.ParcelableSaver import pro.respawn.flowmvi.savedstate.dsl.TypedSaver import kotlin.coroutines.CoroutineContext -@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") +/** + * Creates a new [saveStatePlugin] that saves the state value of given type [T] into a [handle]. + * Your state must be [Parcelable] to use this function. + * + * * By default, this plugin will use the class name of the state as a key for the savedStateHandle. + * * The state will be written **in full**, so be careful not to exceed the maximum parcel size, or use [MapSaver] and + * [saveStatePlugin] manually to map the state, or serialize the state to a json using [serializeStatePlugin]. + * + * See the documentation for [saveStatePlugin] for docs on other parameters. + * + * @see parcelizeStatePlugin + * @see ParcelableSaver + */ +@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") // should be applicable to java only @FlowMVIDSL public inline fun parcelizeStatePlugin( handle: SavedStateHandle, context: CoroutineContext = Dispatchers.IO, - key: String = DefaultName(), + key: String = nameByType() ?: "State", behaviors: Set = SaveBehavior.Default, resetOnException: Boolean = true, + name: String = "$key$PluginNameSuffix", noinline recover: suspend (Exception) -> T? = ThrowRecover, ): StorePlugin where T : Parcelable, T : S = saveStatePlugin( saver = TypedSaver(ParcelableSaver(handle, key, recover)), context = context, - name = "$key$PluginNameSuffix", + name = name, behaviors = behaviors, resetOnException = resetOnException ) -@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER", "Indentation") +/** + * Creates and installs a new [parcelizeStatePlugin]. + * + * Please see the parent overload documentation for more details. + * + * @see saveStatePlugin + * @see parcelizeStatePlugin + * @see ParcelableSaver + */ +@Suppress("BOUNDS_NOT_ALLOWED_IF_BOUNDED_BY_TYPE_PARAMETER") @FlowMVIDSL public inline fun StoreBuilder.parcelizeState( handle: SavedStateHandle, context: CoroutineContext = Dispatchers.IO, - key: String = name?.let { "${it}State" } ?: DefaultName(), + key: String = "${this.name ?: nameByType().orEmpty()}State", behaviors: Set = SaveBehavior.Default, + name: String = "$key$PluginNameSuffix", resetOnException: Boolean = true, noinline recover: suspend (Exception) -> T? = ThrowRecover, ): Unit where T : Parcelable, T : S = install( - parcelizeStatePlugin(handle, context, key, behaviors, resetOnException, recover) + parcelizeStatePlugin(handle, context, key, behaviors, resetOnException, name, recover) ) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt index 884d78f5..0747c1fa 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt @@ -1,17 +1,49 @@ package pro.respawn.flowmvi.savedstate.api +import pro.respawn.flowmvi.savedstate.plugins.saveStatePlugin import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +/** + * An interface that specifies **when** [saveStatePlugin] is going to save the store's state. + * Multiple variants can be used, but there are restrictions on how you can combine them + * (see [saveStatePlugin] documentation for more details). + */ public sealed interface SaveBehavior { - public data class OnChange(val timeout: Duration = DefaultSaveTimeoutMs.milliseconds) : SaveBehavior + /** + * A saving behavior that saves the state when after it has changed. + * The newest state will be saved. (i.e. the state the store has after the [delay] has passed. + * The delay will be reset and previous state save job will be canceled if state changes again. + * + * This effectively "throttles" the saving as the user changes the state. + * + * When multiple [OnChange] are provided, the **minimum** delay across all of them will be used. + * @see [SaveBehavior] + * @see [saveStatePlugin] + */ + public data class OnChange(val delay: Duration = DefaultDelayMs.milliseconds) : SaveBehavior + /** + * A saving behavior that saves the state when [remainingSubscribers] count drops below the specified amount. + * * By default, `0` is used. + * * If you specify multiple [remainingSubscribers] values, the **maximum** value will be used. + * * This will not save the state when the parent store stops, so that, usually, if the user left on purpose, + * the state is not persisted. + * @see [SaveBehavior] + * @see [saveStatePlugin] + */ public data class OnUnsubscribe(val remainingSubscribers: Int = 0) : SaveBehavior public companion object { + private const val DefaultDelayMs: Int = 2000 + + /** + * A default [SaveBehavior] that saves the state both on each change with a delay, + * and on when all subscribers leave. + * @see [saveStatePlugin] + */ public val Default: Set = setOf(OnChange(), OnUnsubscribe()) - public const val DefaultSaveTimeoutMs: Int = 2000 } } diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt index 75185abe..ee4b372c 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt @@ -1,11 +1,44 @@ package pro.respawn.flowmvi.savedstate.api +import pro.respawn.flowmvi.savedstate.plugins.saveStatePlugin + +/** + * A [Saver] recovery function that will simply return `null` value and no state will be saved/restored. + * Existing state will be cleared. + */ public val NullRecover: suspend (Exception) -> Nothing? = { null } + +/** + * A [Saver] recovery function that will throw on any exception when saving and restoring state. + * + * Usually, this is the default. + */ public val ThrowRecover: suspend (e: Exception) -> Nothing? = { throw it } +/** + * A Saver is an object that specifies **how** to save the state of the [saveStatePlugin]. + * See docs for specific functions to learn how to override them. + */ public interface Saver { + /** + * [save] function should persist the state to some place that outlives the lifespan of the store + * If `null` is passed to [save], it **must** clean up the persisted state completely. + * You must adhere to this contract if you implement your own saver. + */ public suspend fun save(state: T?) + + /** + * [restore] function should restore the state from the storage. + * It could be a file, a bundle on Android, or some other place. State is restored **before** the store starts, + * so it is not advised to suspend in this function for very long periods of time. + * If this function returns `null`, the state will not be restored (there is nothing to restore). + */ public suspend fun restore(): T? + + /** [recover] allows to return a state to save and restore even if an exception has been + * caught while the saver was working. By default, it just throws the exception and the parent store will decide + * how to handle it. + **/ public suspend fun recover(e: Exception): T? = throw e } diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt index 9c408b22..58c82626 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt @@ -15,4 +15,8 @@ internal fun write(data: String, to: Path) { } @OptIn(ExperimentalStdlibApi::class) -internal fun read(from: Path): String = SystemFileSystem.source(from).buffered().use { it.readString() } +internal fun read(from: Path): String? = SystemFileSystem + .source(from) + .buffered() + .use { it.readString() } + .takeIf { it.isNotBlank() } diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt index 485eab47..47bf8156 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt @@ -9,22 +9,32 @@ import kotlinx.io.files.SystemFileSystem import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.savedstate.api.ThrowRecover -public inline fun DefaultFileSaver( +/** + * A [Saver] implementation that saves the given state to a file in a specified [dir] and [fileName]. + * + * * You still need to provide your own [write] and [read] functions for this overload. + * Use [FileSaver] and [CompressedFileSaver] if you want to save already serialized state. + * + * * This saver creates the necessary [dir] and file if not present and writes to file in an atomic way using a [Mutex]. + * * If `null` is passed to [Saver.save], it will delete the file, but not the directory. + * * The writes to the file cannot be canceled to prevent saving partial data. + */ +public inline fun DefaultFileSaver( dir: String, fileName: String, - crossinline write: suspend (data: String, to: Path) -> Unit, - crossinline read: suspend (from: Path) -> String?, - crossinline recover: suspend (Exception) -> String?, -): Saver = object : Saver { + crossinline write: suspend (data: T, to: Path) -> Unit, + crossinline read: suspend (from: Path) -> T?, + crossinline recover: suspend (Exception) -> T?, +): Saver = object : Saver { val directory = Path(dir) val file = Path(directory, fileName) // prevent concurrent file access private val mutex = Mutex() - override suspend fun recover(e: Exception): String? = recover.invoke(e) + override suspend fun recover(e: Exception): T? = recover.invoke(e) override suspend fun save( - state: String? + state: T? ) = withContext(NonCancellable) { // prevent partial writes mutex.withLock { with(SystemFileSystem) { @@ -39,13 +49,21 @@ public inline fun DefaultFileSaver( } // allow cancelling reads (no "NonCancellable here") - override suspend fun restore(): String? = mutex.withLock { - file.takeIf { SystemFileSystem.exists(file) } - ?.let { read(it) } - ?.takeIf(String::isNotBlank) + override suspend fun restore(): T? = mutex.withLock { + file.takeIf { SystemFileSystem.exists(file) }?.let { read(it) } } } +/** + * A [DefaultFileSaver] implementation that saves [String] state to the file system. + * + * Usually used as a decorator for [JsonSaver] + * + * See the overload for more details. + * @see DefaultFileSaver + * @see JsonSaver + * @see Saver + */ public fun FileSaver( dir: String, fileName: String, @@ -58,6 +76,19 @@ public fun FileSaver( read = ::read, ) +/** + * A [DefaultFileSaver] implementation that saves a **compressed** [String] state to the file system. + * Usually used as a decorator for [JsonSaver] + * + * This saver is only available on JVM and Android for now, and therefore will be identical to [FileSaver] + * on other platforms. (will **not** compress the file) + * + * See the overload for more details. + * + * @see DefaultFileSaver + * @see JsonSaver + * @see Saver + */ public fun CompressedFileSaver( dir: String, fileName: String, diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt index a516a4df..fa374b17 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt @@ -4,6 +4,11 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.json.Json import pro.respawn.flowmvi.savedstate.api.Saver +/** + * A [Saver] implementation that will transform the given state to a JSON string before passing it to the [delegate]. + * It will use the specified [json] instance and [serializer] to transform the state. + * By default it will recover by trying the [delegate]s' recover first, but if deserialization fails, it will throw. + */ public fun JsonSaver( json: Json, delegate: Saver, diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt index 2dde9115..68c57aa0 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt @@ -9,5 +9,10 @@ private object NoOpSaver : Saver { override suspend fun recover(e: Exception): Nothing? = null } -@Suppress("UNCHECKED_CAST") +/** + * A [Saver] that will do nothing to save the state and will not [Saver.restore] to any state. + * + * Useful for testing. + */ +@Suppress("UNCHECKED_CAST", "FunctionName") public fun NoOpSaver(): Saver = NoOpSaver as Saver diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt index fa92ffb1..088c528c 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt @@ -3,16 +3,26 @@ package pro.respawn.flowmvi.savedstate.dsl import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.util.typed +/** + * A [Saver] that maps the saved state to a value of [T] before passing it to the [delegate]. + * + * It will not map `null` values. + */ public inline fun MapSaver( delegate: Saver, - @BuilderInference crossinline from: suspend (R?) -> T?, + @BuilderInference crossinline from: suspend (R) -> T?, @BuilderInference crossinline to: suspend (T?) -> R?, ): Saver = object : Saver { override suspend fun save(state: T?) = delegate.save(to(state)) - override suspend fun restore(): T? = from(delegate.restore()) - override suspend fun recover(e: Exception): T? = from(delegate.recover(e)) + override suspend fun restore(): T? = delegate.restore()?.let { from(it) } + override suspend fun recover(e: Exception): T? = delegate.recover(e)?.let { from(it) } } +/** + * A [MapSaver] that will only persist values of type [T]. + * + * @see Saver + */ public inline fun TypedSaver( delegate: Saver, ): Saver = MapSaver( diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt index 6b9c4d18..2b14beee 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt @@ -11,21 +11,33 @@ import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.api.StorePlugin -import pro.respawn.flowmvi.api.UnrecoverableException import pro.respawn.flowmvi.dsl.StoreBuilder import pro.respawn.flowmvi.dsl.plugin import pro.respawn.flowmvi.savedstate.api.SaveBehavior import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnChange import pro.respawn.flowmvi.savedstate.api.SaveBehavior.OnUnsubscribe import pro.respawn.flowmvi.savedstate.api.Saver +import pro.respawn.flowmvi.savedstate.dsl.CompressedFileSaver +import pro.respawn.flowmvi.savedstate.dsl.DefaultFileSaver +import pro.respawn.flowmvi.savedstate.dsl.FileSaver +import pro.respawn.flowmvi.savedstate.dsl.JsonSaver +import pro.respawn.flowmvi.savedstate.dsl.MapSaver +import pro.respawn.flowmvi.savedstate.dsl.NoOpSaver +import pro.respawn.flowmvi.savedstate.dsl.TypedSaver import kotlin.coroutines.CoroutineContext @PublishedApi -internal inline fun DefaultName(): String = "${T::class.simpleName}" +internal inline fun nameByType(): String? = T::class.simpleName?.removeSuffix("State") @PublishedApi internal val PluginNameSuffix: String = "SaveStatePlugin" +@PublishedApi +internal const val EmptyBehaviorsMessage: String = """ +You wanted to save the state but have not provided any behaviors. +Please supply at least one behavior or remove the plugin as it would do nothing otherwise. +""" + @PublishedApi internal suspend fun Saver.saveCatching(state: S?): Unit = try { save(state) @@ -41,53 +53,91 @@ internal suspend fun Saver.restoreCatching(): S? = try { recover(expected) } +/** + * Creates a plugin for persisting and restoring [MVIState] of the current store. + * + * This function takes a [Saver] as a parameter, which it will use for determining how and where to save the state. + * [Saver]s can be decorated and extended to implement your own logic. There are a couple of default savers: + * * [MapSaver] for saving partial data. + * * [TypedSaver] for saving a state of a particular subtype. + * * [JsonSaver] for saving the state as a JSON. + * * [FileSaver] for saving the state to a file. See [DefaultFileSaver] for custom file writing logic. + * * [CompressedFileSaver] for saving the state to a file and compressing it. + * * [NoOpSaver] for testing. + * + * The plugin will determine **when** to save the state based on [behaviors]. + * Please see [SaveBehavior] documentation for more details. + * this function will throw if the [behaviors] are empty. + * ---- + * * By default, the plugin will use the name derived from the store's name, or the state [S] class name. + * * If [resetOnException] is `true`, the plugin will attempt to clear the state if an exception is thrown. + * * All state saving is done in a background coroutine. + * * The state **restoration**, however, is done **before** the store starts. + * This means that while the state is being restored, the store will not process intents and state changes. + * * The installation order of this plugin is **very** important. If other plugins, installed **after** this one, + * change the state in [StorePlugin.onStart], your restored state may be overwritten. + * + * @see [Saver] + */ @FlowMVIDSL public inline fun saveStatePlugin( saver: Saver, context: CoroutineContext, behaviors: Set = SaveBehavior.Default, - name: String = "${DefaultName()}$PluginNameSuffix", + name: String? = "${nameByType().orEmpty()}$PluginNameSuffix", resetOnException: Boolean = true, ): StorePlugin = plugin { + require(behaviors.isNotEmpty()) { EmptyBehaviorsMessage } this.name = name + var job: Job? by atomic(null) + onStart { withContext(this + context) { updateState { saver.restoreCatching() ?: this } } } - onException { - if (it !is UnrecoverableException && !resetOnException) return@onException it + if (resetOnException) onException { withContext(this + context) { saver.saveCatching(null) } it } - val onUnsubscribe = behaviors.filterIsInstance() - if (onUnsubscribe.isNotEmpty()) onUnsubscribe { remainingSubs -> - val shouldSave = onUnsubscribe.any { remainingSubs <= it.remainingSubscribers } - if (!shouldSave) return@onUnsubscribe - launch(context) { withState { saver.saveCatching(this) } } + + val maxSubscribers = behaviors + .asSequence() + .filterIsInstance() + .maxOfOrNull { it.remainingSubscribers } + ?.also { require(it >= 0) { "Subscriber count must be >= 0" } } + if (maxSubscribers != null) onUnsubscribe { remainingSubs -> + if (remainingSubs > maxSubscribers) return@onUnsubscribe + job?.cancelAndJoin() + job = launch(context) { withState { saver.saveCatching(this) } } } + val saveTimeout = behaviors .asSequence() .filterIsInstance() - .minOfOrNull { it.timeout } - ?: return@plugin - - var job: Job? by atomic(null) - onState { _, new -> + .minOfOrNull { it.delay } + ?.also { require(!it.isNegative() && it.isFinite()) { "Delay must be >= 0" } } + if (saveTimeout != null) onState { _, new -> job?.cancelAndJoin() job = launch(context) { delay(saveTimeout) + // defer state read until delay has passed withState { saver.saveCatching(this) } } new } } +/** + * Creates and installs a new [saveStatePlugin]. Please see the parent overload for more info. + * + * @see saveStatePlugin + */ @FlowMVIDSL public inline fun StoreBuilder.saveState( saver: Saver, context: CoroutineContext, behaviors: Set = SaveBehavior.Default, - name: String = "${DefaultName()}$PluginNameSuffix", + name: String? = "${this.name ?: nameByType().orEmpty()}$PluginNameSuffix", resetOnException: Boolean = true, ): Unit = install(saveStatePlugin(saver, context, behaviors, name, resetOnException)) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt index 85d44ed0..54040816 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt @@ -16,17 +16,29 @@ import pro.respawn.flowmvi.savedstate.dsl.JsonSaver import pro.respawn.flowmvi.savedstate.dsl.TypedSaver import kotlin.coroutines.CoroutineContext +/** + * An overload of [saveStatePlugin] that is configured with some default values for convenience. + * + * This overload will save a GZip-compressed JSON of the state value of type [T] to a file + * in the [dir] directory and named [filename] with a specified [fileExtension]. + * + * * This will save the state according to the [behaviors] specified in [SaveBehavior.Default]. + * * By default, this will use [Dispatchers.Default] to save the state ([context]). + * * This will only compress the JSON if the platform permits it (Android, JVM). ([CompressedFileSaver]). + * * This will reset the state on exceptions in the store ([resetOnException]). + * * This will invoke [recover] if an exception is encountered when saving or restoring the state. + */ @FlowMVIDSL public inline fun serializeStatePlugin( dir: String, json: Json, serializer: KSerializer, behaviors: Set = SaveBehavior.Default, - filename: String = DefaultName(), + filename: String = nameByType() ?: "State", fileExtension: String = ".json", context: CoroutineContext = Dispatchers.Default, resetOnException: Boolean = true, - noinline recover: suspend (Exception) -> T? = ThrowRecover, + noinline recover: suspend (Exception) -> T?, ): StorePlugin = saveStatePlugin( saver = TypedSaver( JsonSaver( @@ -42,6 +54,13 @@ public inline fun IDE conflict @FlowMVIDSL public inline fun < @@ -62,7 +81,7 @@ public inline fun < serializeStatePlugin( dir = dir, json = json, - filename = name ?: DefaultName(), + filename = name ?: nameByType() ?: "State", context = context, behaviors = behaviors, resetOnException = resetOnException, From e0b8358ca4d1e7661aac84410c645ae21677775b Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 19:57:56 +0300 Subject: [PATCH 12/17] update readme --- README.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c4e51563..4e8055fc 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,13 @@ FlowMVI is a Kotlin Multiplatform MVI library based on coroutines that has a few flowmvi = "< Badge above 👆🏻 >" [dependencies] -flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" } # multiplatform +flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" } # core KMP code flowmvi-test = { module = "pro.respawn.flowmvi:test", version.ref = "flowmvi" } # test DSL flowmvi-compose = { module = "pro.respawn.flowmvi:compose", version.ref = "flowmvi" } # compose multiplatform flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowmvi" } # common android flowmvi-view = { module = "pro.respawn.flowmvi:android-view", version.ref = "flowmvi" } # view-based android +flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" } # KMP state preservation ``` ### Kotlin DSL ```kotlin @@ -45,6 +46,7 @@ dependencies { val flowmvi = "< Badge above 👆🏻 >" commonMainImplementation("pro.respawn.flowmvi:core:$flowmvi") commonMainImplementation("pro.respawn.flowmvi:compose:$flowmvi") + commonMainImplementation("pro.respawn.flowmvi:savedstate:$flowmvi") commonTestImplementation("pro.respawn.flowmvi:test:$flowmvi") androidMainImplementation("pro.respawn.flowmvi:android:$flowmvi") @@ -54,12 +56,14 @@ dependencies { ## Features: -Rich, plugin-based store DSL: +Rich store DSL with dozens of useful pre-made plugins: ```kotlin sealed interface CounterState : MVIState { data object Loading : CounterState data class Error(val e: Exception) : CounterState + + @Serializable data class DisplayingCounter( val timer: Int, val counter: Int, @@ -90,10 +94,14 @@ class CounterContainer( timeTravelPlugin(), // unit test stores and track changes ) - saveState { // persist and restore state - get = { repo.restoreStateFromFile() } - set = { repo.saveStateToFile(this) } - } + // one-liner for persisting and restoring compressed state to/from files, + // bundles, or anywhere + serializeState( + dir = repo.cacheDir, + json = Json, + serializer = DisplayingCounter.serializer(), + recover = ThrowRecover + ) val undoRedoPlugin = undoRedo(maxQueueSize = 10) // undo and redo any changes From 83040258470d271145614783d4cc839ed466194f Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 21:09:53 +0300 Subject: [PATCH 13/17] add callback saver and saver builder --- savedstate/build.gradle.kts | 5 ++-- .../flowmvi/savedstate/api/SaveBehavior.kt | 2 +- .../flowmvi/savedstate/dsl/CallbackSaver.kt | 27 +++++++++++++++++++ .../flowmvi/savedstate/dsl/JsonSaver.kt | 15 ++++------- .../respawn/flowmvi/savedstate/dsl/Saver.kt | 16 +++++++++++ .../flowmvi/savedstate/dsl/TypedSaver.kt | 10 +++---- 6 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/CallbackSaver.kt create mode 100644 savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Saver.kt diff --git a/savedstate/build.gradle.kts b/savedstate/build.gradle.kts index e8960342..a21d3230 100644 --- a/savedstate/build.gradle.kts +++ b/savedstate/build.gradle.kts @@ -9,8 +9,9 @@ android { dependencies { commonMainApi(projects.core) + commonMainApi(libs.kotlin.serialization.json) commonMainImplementation(libs.kotlin.atomicfu) commonMainImplementation(libs.kotlin.io) - commonMainImplementation(libs.kotlin.serialization.json) - androidMainImplementation(libs.lifecycle.savedstate) + + androidMainApi(libs.lifecycle.savedstate) } diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt index 0747c1fa..3223df8f 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt @@ -41,7 +41,7 @@ public sealed interface SaveBehavior { /** * A default [SaveBehavior] that saves the state both on each change with a delay, - * and on when all subscribers leave. + * and when all subscribers leave. * @see [saveStatePlugin] */ public val Default: Set = setOf(OnChange(), OnUnsubscribe()) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/CallbackSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/CallbackSaver.kt new file mode 100644 index 00000000..b91d75f3 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/CallbackSaver.kt @@ -0,0 +1,27 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import pro.respawn.flowmvi.savedstate.api.Saver + +/** + * A [Saver] implementation that does nothing but invoke + * the given [onSave], [onRestore], [onException] callbacks when the state changes + * @see Saver + */ +public inline fun CallbackSaver( + delegate: Saver, + crossinline onSave: suspend (T?) -> Unit = {}, + crossinline onRestore: suspend (T?) -> Unit = { }, + crossinline onException: suspend (e: Exception) -> Unit = {}, +): Saver = object : Saver by delegate { + override suspend fun save(state: T?) { + onSave(state) + return delegate.save(state) + } + + override suspend fun restore(): T? = delegate.restore().also { onRestore(it) } + + override suspend fun recover(e: Exception): T? { + onException(e) + return delegate.recover(e) + } +} diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt index fa374b17..5d490bfd 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt @@ -16,13 +16,8 @@ public fun JsonSaver( @BuilderInference recover: suspend (Exception) -> T? = { e -> // TODO: Compiler bug does not permit inlining this delegate.recover(e)?.let { json.decodeFromString(serializer, it) } }, -): Saver = object : Saver { - - override suspend fun recover(e: Exception): T? = recover.invoke(e) - - override suspend fun save(state: T?) { - delegate.save(state?.let { json.encodeToString(serializer, it) }) - } - - override suspend fun restore(): T? = delegate.restore()?.let { json.decodeFromString(serializer, it) } -} +): Saver = Saver( + recover = recover, + save = { state -> delegate.save(state?.let { json.encodeToString(serializer, it) }) }, + restore = { delegate.restore()?.let { json.decodeFromString(serializer, it) } } +) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Saver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Saver.kt new file mode 100644 index 00000000..456a3ba7 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Saver.kt @@ -0,0 +1,16 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import pro.respawn.flowmvi.savedstate.api.Saver + +/** + * A [Saver] builder function + */ +public inline fun Saver( + crossinline save: suspend (T?) -> Unit, + crossinline restore: suspend () -> T?, + crossinline recover: suspend (e: Exception) -> T? = { throw it }, +): Saver = object : Saver { + override suspend fun save(state: T?) = save.invoke(state) + override suspend fun restore(): T? = restore.invoke() + override suspend fun recover(e: Exception): T? = recover.invoke(e) +} diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt index 088c528c..cfc2ef0e 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt @@ -12,11 +12,11 @@ public inline fun MapSaver( delegate: Saver, @BuilderInference crossinline from: suspend (R) -> T?, @BuilderInference crossinline to: suspend (T?) -> R?, -): Saver = object : Saver { - override suspend fun save(state: T?) = delegate.save(to(state)) - override suspend fun restore(): T? = delegate.restore()?.let { from(it) } - override suspend fun recover(e: Exception): T? = delegate.recover(e)?.let { from(it) } -} +): Saver = Saver( + save = { delegate.save(to(it)) }, + restore = { delegate.restore()?.let { from(it) } }, + recover = { e -> delegate.recover(e)?.let { from(it) } }, +) /** * A [MapSaver] that will only persist values of type [T]. From 1faa98ba699a5b95837ac66e2c94740dfc08f8a1 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 21:28:15 +0300 Subject: [PATCH 14/17] add documentation to website --- .../respawn/flowmvi/sample/CounterModels.kt | 2 +- .../flowmvi/sample/view/LambdaViewModel.kt | 16 +- docs/_navbar.md | 1 + docs/savedstate.md | 165 ++++++++++++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 docs/savedstate.md diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt index a8e00f73..cae10a6e 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt @@ -18,7 +18,7 @@ sealed interface CounterState : MVIState { @Parcelize data class DisplayingCounter( val timer: Int, - val counter: Int = 0, + val counter: Int, val input: String, ) : CounterState, Parcelable } diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt index a3463ec1..56c5791b 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/LambdaViewModel.kt @@ -1,5 +1,6 @@ package pro.respawn.flowmvi.sample.view +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -20,7 +21,10 @@ import pro.respawn.flowmvi.sample.CounterLambdaIntent import pro.respawn.flowmvi.sample.CounterState import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter import pro.respawn.flowmvi.sample.repository.CounterRepository -import pro.respawn.flowmvi.savedstate.plugins.parcelizeState +import pro.respawn.flowmvi.savedstate.dsl.CallbackSaver +import pro.respawn.flowmvi.savedstate.dsl.ParcelableSaver +import pro.respawn.flowmvi.savedstate.dsl.TypedSaver +import pro.respawn.flowmvi.savedstate.plugins.saveState import pro.respawn.flowmvi.util.typed private typealias Ctx = PipelineContext @@ -36,9 +40,17 @@ class LambdaViewModel( scope = viewModelScope, ) { name = "Counter" - parcelizeState(savedStateHandle) debuggable = BuildConfig.DEBUG install(platformLoggingPlugin()) + saveState( + saver = CallbackSaver( + delegate = TypedSaver(ParcelableSaver(savedStateHandle)), + onSave = { Log.d("Parcel", "Saved state: $it") }, + onRestore = { Log.d("Parcel", "restored state: $it") }, + onException = { Log.e("Parcel", "Exception when saving: $it") }, + ), + Dispatchers.IO, + ) whileSubscribed { repo.getTimer() .onEach { produceState(it) } // set mapped states diff --git a/docs/_navbar.md b/docs/_navbar.md index 9256fb18..688edf08 100644 --- a/docs/_navbar.md +++ b/docs/_navbar.md @@ -2,6 +2,7 @@ * [Quickstart](quickstart.md) * [Plugins](plugins.md) * [Android](android.md) +* [Saving State](savedstate.md) * [FAQ](faq.md) * [Roadmap](roadmap.md) * [Javadocs](https://opensource.respawn.pro/FlowMVI/javadocs/index.html) diff --git a/docs/savedstate.md b/docs/savedstate.md new file mode 100644 index 00000000..5250d411 --- /dev/null +++ b/docs/savedstate.md @@ -0,0 +1,165 @@ +# Learn how to persist and restore State + +The `savedstate` artifact contains plugins and API necessary to save and restore the state of a store to a place +that outlives its lifespan. This is useful in many cases and provides an unparalleled UX. For example, a person may +leave the app while the form they were filling is unfinished, then return to the app and see all of their data +being restored, continuing their work. + +## 1. Adding a dependency + +```toml +flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" } +``` + +The artifact depends on: + +* `kotlinx-io`, and as a consequence, on `okio` +* `kotlinx-serialization`, including Json +* `androidx-lifecycle-savedstate` on Android to parcelize the state. + +The module depends on quite a few things, so it would be best to avoid adding it to all of your modules. +Instead, you can inject the plugin or savers using DI. + +## 2. Defining `Saver`s + +The basic building block of the module is the `Saver` interface/function. Saver defines **how** to save the state. +Use the `Saver` function to build a saver or implement the +interface to write your custom saving logic, or use one of the prebuilt ones: + +* `MapSaver` for saving partial data. +* `TypedSaver` for saving a state of a particular subtype. +* `JsonSaver` for saving the state as a JSON. +* `FileSaver` for saving the state to a file. See `DefaultFileSaver` for custom file writing logic. +* `CompressedFileSaver` for saving the state to a file and compressing it. +* `NoOpSaver` for testing. + +`Saver`s can be decorated and extended. For example, you can build a saver chain to store a particular type of the state +in a compressed Json file: + +```kotlin +val saver = TypedSaver( + JsonSaver( + json = Json, + serializer = DisplayingCounter.serializer(), + delegate = CompressedFileSaver(dir, "counter_state.json"), + ) +) +``` + +You can invoke the `save` method of the saver manually if you keep a reference to it. + +## 3. Choosing `SaveBehavior` + +For now there are two types of behaviors that you can use to decide **when** to save the state. + +### `OnChange` + +This behavior will save the state each time it is changed and after a specified `delay`. +If the state changes before or during the operation of saving the state, the delay will be restarted and the previous +job will be canceled. +In general, don't use multiple values of this behavior, because only the minimum delay value will be respected. + +### `OnUnsubscribe` + +This behavior will persist the state when a subscriber is removed and the store is left with a specified number of +`remainingSubscribers`. + +This will happen, for example, when the app goes into the background. +Don't use multiple instances of this behavior, as only the maximum number of subscribers will be respected. + +?> By default, both of these are used - on each change, with a sensible delay, and when all subscribers leave. +You can customize this via the `behaviors` parameter of the plugin. + +## 4. Installing the plugin + +To start saving the state, just install your preferred variation of the `saveState` plugin: + +### Custom state savers + +```kotlin +val store = store(initial = Loading) { // start with a default loading value as we still need it + saveState( + saver = CustomSaver(), + context = Dispatchers.IO, + resetOnException = true, // or false if you're brave + ) +} +``` + +### Serializing state to a file + +You don't have to define your own savers if you don't need to. There is an overload of the `saveStatePlugin` that +provides sensible defaults for you, called `serializeState`: + +```kotlin +serializeState( + dir = cacheDir, // (1) + json = json, // (2) + serializer = DisplayingCounter.serializer(), // (3) + recover = NullRecover // (4) +) +``` + +1. Provide a directory where the state will be saved. + * The filename will be derived from the store / class name, but watch out for conflicts and provide your own name in + this case. + * It's best to use a subdirectory of your cache dir to prevent it from being fiddled with by other code. +2. For performance and safety, inject and reuse a single json instance with relaxed restrictions on parsing data. +3. Mark your state class as `@Serializable` to generate the serializer for it. + * It's best to store only a particular subset of states of the store because you don't want to restore the user + to an error / loading state, do you? +4. Provide a way for the plugin to recover from errors when parsing, saving or reading the state. The bare minimum + is to ignore all errors and not restore or save anything, but a better solution like logging the errors can be used + instead. By default, the plugin will just throw and let the store (`recoverPlugin`) handle the exception. + +### Storing the state in a bundle + +If you're on android, there is the `parcelizeState` plugin that will store the state in a `SavedStateHandle`: + +```kotlin +parcelizeState( + handle = savedStateHandle, + key = "CounterState", +) +``` + +* The `key` parameter will be derived from the store / class name if you don't specify it, but watch out for conflicts +* This plugin uses the `ParcelableSaver` by default, which you can use too. + +!> Watch out for parcel size overflow exceptions! The library will not check the resulting parcel size for you. + +?> According to the documentation, any writes to your saved state will only be restored if the activity +was killed by the OS. This means you will not see any state restoration results unless the OS kills the activity +itself (i.e. exiting the app will not result in the state being restored). + +## 5. Caveats + +### App updates + +Saving state is great, but think about what will happen to your app when the app is updated and the resulting state +structure changes. For example, the name of the property may stay the same but its meaning may have changed. +This means, when the state will be restored, unpredictable behavior may occur. This does not necessarily mean +restoration will fail, but that the logic may be affected. On Android, the system will clear the saved state for you, +but if you persist the state to a file, you have to keep track of this yourself. + +The best way to solve this would be to clear the saved state (for example, by deleting the directory) on each app +update. + +You can do this by registering a broadcast receiver or checking if the app was updated upon startup. +Implementation of this logic is out of scope of the library. + +### Crashes and failures + +If the app fails for whatever reason, it's important that that may invalidate the state or even result in further +crashes. If your app crashes, make sure to invalidate the saved state as well, for example, by using Crashlytics, +overriding the main looper to catch fatal exceptions, or any other means. + +The library will clear the state when an exception happens in the store and can let you recover from errors, but +that is not enough as crashes may happen in other places in your app, such as the UI layer. + +### Saving sensitive data + +If you save the state, you have to think about excluding sensitive data such as passwords and phone numbers +from it. Annotate the serializable state fields with `@Transient`, for example, or create a subset of the +state properties that you will save. +Unless you implement savers that encrypt the data, ensure the safety of the user by not storing sensitive data at all. From e49c2fb0a04e75699a518da0103941e445fdf909 Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 21:41:36 +0300 Subject: [PATCH 15/17] bump version --- buildSrc/src/main/kotlin/Config.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index cf0d7087..980b227d 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -16,8 +16,8 @@ object Config { const val artifactId = "$group.$artifact" const val majorRelease = 2 - const val minorRelease = 2 - const val patch = 2 + const val minorRelease = 3 + const val patch = 0 const val postfix = "rc" const val versionName = "$majorRelease.$minorRelease.$patch-$postfix" const val url = "https://github.com/respawn-app/FlowMVI" From 7e1ba4cd0dfb45e3a2354be61a8c96603703561a Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Wed, 3 Jan 2024 21:42:58 +0300 Subject: [PATCH 16/17] fix build --- .../pro/respawn/flowmvi/android/compose/Deprecated.kt | 2 +- .../kotlin/pro/respawn/flowmvi/store/NativeStore.kt | 2 +- .../flowmvi/test/store/ActionShareBehaviorTest.kt | 2 +- .../pro/respawn/flowmvi/test/store/StoreEventsTest.kt | 5 ++--- .../respawn/flowmvi/test/store/StoreExceptionsText.kt | 5 ++--- .../pro/respawn/flowmvi/test/store/StoreLaunchTest.kt | 5 +++-- .../pro/respawn/flowmvi/test/store/StoreStatesTest.kt | 9 +++++---- savedstate/build.gradle.kts | 1 + .../kotlin/pro/respawn/flowmvi/test/TestDsl.kt | 5 ++--- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/android-compose/src/main/kotlin/pro/respawn/flowmvi/android/compose/Deprecated.kt b/android-compose/src/main/kotlin/pro/respawn/flowmvi/android/compose/Deprecated.kt index fbe25a4c..77cf48ce 100644 --- a/android-compose/src/main/kotlin/pro/respawn/flowmvi/android/compose/Deprecated.kt +++ b/android-compose/src/main/kotlin/pro/respawn/flowmvi/android/compose/Deprecated.kt @@ -108,7 +108,7 @@ internal data class ConsumerScopeImpl = mutableStateOf(store.state) - override fun intent(intent: I) = store.send(intent) + override fun intent(intent: I) = store.intent(intent) override suspend fun emit(intent: I) = store.emit(intent) @Composable diff --git a/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt b/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt index cfed8433..19568279 100644 --- a/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt +++ b/core/src/appleMain/kotlin/pro/respawn/flowmvi/store/NativeStore.kt @@ -47,7 +47,7 @@ public class NativeStore( /** * See [pro.respawn.flowmvi.api.IntentReceiver.send] */ - public fun send(intent: I): Unit = store.send(intent) + public fun send(intent: I): Unit = store.intent(intent) /** * See [pro.respawn.flowmvi.api.IntentReceiver.send] diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt index c9449562..a05d9553 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/ActionShareBehaviorTest.kt @@ -73,7 +73,7 @@ class ActionShareBehaviorTest : FreeSpec({ } idle() val intent = TestIntent { action(TestAction.Some) } - send(intent) + intent(intent) joinAll(job1, job2) plugin.intents shouldContain intent plugin.actions shouldContain TestAction.Some diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt index a807564f..f3f4fc96 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreEventsTest.kt @@ -8,7 +8,6 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.launch import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.dsl.intent -import pro.respawn.flowmvi.dsl.send import pro.respawn.flowmvi.plugins.recover import pro.respawn.flowmvi.plugins.reduce import pro.respawn.flowmvi.plugins.timeTravelPlugin @@ -31,7 +30,7 @@ class StoreEventsTest : FreeSpec({ val store = testStore(plugin) "then intents result in actions" { store.subscribeAndTest { - send { send(TestAction.Some) } + intent { action(TestAction.Some) } actions.test { awaitItem() shouldBe TestAction.Some } @@ -71,7 +70,7 @@ class StoreEventsTest : FreeSpec({ store.subscribeAndTest { states.test { awaitItem() shouldBe TestState.Some - send { } + intent { } awaitItem() shouldBe newState } } diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsText.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsText.kt index 182bc4d4..c2524f59 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsText.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreExceptionsText.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch import pro.respawn.flowmvi.dsl.intent -import pro.respawn.flowmvi.dsl.send import pro.respawn.flowmvi.modules.RecoverModule import pro.respawn.flowmvi.plugins.init import pro.respawn.flowmvi.plugins.recover @@ -54,7 +53,7 @@ class StoreExceptionsText : FreeSpec({ "then exceptions in processing scope do not cancel the pipeline" { store.test { job -> - send { } + intent { } idle() job.isActive shouldBe true plugin.intents.shouldBeSingleton() @@ -86,7 +85,7 @@ class StoreExceptionsText : FreeSpec({ recover { null } } store.test { job -> - send { + intent { launch a@{ println("job 1 started") this@a.launch b@{ diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt index 6f898dc9..41589275 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreLaunchTest.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeout +import pro.respawn.flowmvi.dsl.intent import pro.respawn.flowmvi.dsl.send import pro.respawn.flowmvi.test.subscribeAndTest import pro.respawn.flowmvi.test.test @@ -62,7 +63,7 @@ class StoreLaunchTest : FreeSpec({ "then if launched can process intents" { coroutineScope { store.subscribeAndTest { - send { } + intent { } idle() plugin.intents.shouldBeSingleton() } @@ -87,7 +88,7 @@ class StoreLaunchTest : FreeSpec({ val intent = TestIntent { throw IllegalArgumentException("intent was handled") } "and if store is launched and closed" - { store.test { - send { println("intent") } + intent { println("intent") } idle() } idle() diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt index e9b2f293..57b8b489 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/test/store/StoreStatesTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.dsl.LambdaIntent +import pro.respawn.flowmvi.dsl.intent import pro.respawn.flowmvi.dsl.send import pro.respawn.flowmvi.plugins.timeTravelPlugin import pro.respawn.flowmvi.test.subscribeAndTest @@ -37,7 +38,7 @@ class StoreStatesTest : FreeSpec({ "then state is never updated by another intent" { store.subscribeAndTest { send(blockingIntent) - send { + intent { updateState { TestState.SomeData(1) } @@ -52,7 +53,7 @@ class StoreStatesTest : FreeSpec({ "then withState is never executed" { store.subscribeAndTest { send(blockingIntent) - send { + intent { withState { throw AssertionError("WithState was executed") } @@ -69,8 +70,8 @@ class StoreStatesTest : FreeSpec({ store.subscribeAndTest { states.test { awaitItem() shouldBe TestState.Some - send(blockingIntent) - send { useState { newState } } + intent(blockingIntent) + intent { useState { newState } } awaitItem() shouldBe newState state shouldBe newState } diff --git a/savedstate/build.gradle.kts b/savedstate/build.gradle.kts index a21d3230..526b7b99 100644 --- a/savedstate/build.gradle.kts +++ b/savedstate/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { commonMainApi(libs.kotlin.serialization.json) commonMainImplementation(libs.kotlin.atomicfu) commonMainImplementation(libs.kotlin.io) + commonMainCompileOnly(projects.annotations) androidMainApi(libs.lifecycle.savedstate) } diff --git a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt index 17d5b7f4..10e760b8 100644 --- a/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt +++ b/test/src/commonMain/kotlin/pro/respawn/flowmvi/test/TestDsl.kt @@ -28,9 +28,8 @@ public class StoreTestScope @Publish @OptIn(DelicateStoreApi::class) override val state: S by store::state - override fun send(intent: I): Unit = store.send(intent) - override suspend fun emit(intent: I): Unit = store.send(intent) - override fun intent(intent: I): Unit = store.send(intent) + override suspend fun emit(intent: I): Unit = store.emit(intent) + override fun intent(intent: I): Unit = store.intent(intent) /** * Assert that [Provider.state] is equal to [state] From 265659e4d885fb820d72e4698b65342f05c8054e Mon Sep 17 00:00:00 2001 From: Nek-12 Date: Thu, 4 Jan 2024 12:15:10 +0300 Subject: [PATCH 17/17] make "nameByType" public --- .../kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt | 2 +- .../respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt index 5d490bfd..2a5ac224 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/JsonSaver.kt @@ -11,8 +11,8 @@ import pro.respawn.flowmvi.savedstate.api.Saver */ public fun JsonSaver( json: Json, - delegate: Saver, serializer: KSerializer, + delegate: Saver, @BuilderInference recover: suspend (Exception) -> T? = { e -> // TODO: Compiler bug does not permit inlining this delegate.recover(e)?.let { json.decodeFromString(serializer, it) } }, diff --git a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt index 2b14beee..4f4e380b 100644 --- a/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt @@ -26,8 +26,10 @@ import pro.respawn.flowmvi.savedstate.dsl.NoOpSaver import pro.respawn.flowmvi.savedstate.dsl.TypedSaver import kotlin.coroutines.CoroutineContext -@PublishedApi -internal inline fun nameByType(): String? = T::class.simpleName?.removeSuffix("State") +/** + * Get the name of the class, removing the "State" suffix, if present + */ +public inline fun nameByType(): String? = T::class.simpleName?.removeSuffix("State") @PublishedApi internal val PluginNameSuffix: String = "SaveStatePlugin"