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 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/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/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/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt index 8c0b8eea..cae10a6e 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt @@ -2,25 +2,25 @@ 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 + @Serializable @Parcelize data class DisplayingCounter( val timer: Int, - val counter: Int = 0, - val param: String, - ) : CounterState + val counter: Int, + val input: String, + ) : CounterState, Parcelable } typealias CounterLambdaIntent = LambdaIntent @@ -30,6 +30,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..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 @@ -1,16 +1,20 @@ 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.registerOrReplace import pro.respawn.flowmvi.plugins.undoRedo import pro.respawn.flowmvi.plugins.whileSubscribed import pro.respawn.flowmvi.sample.CounterAction @@ -20,9 +24,12 @@ 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.ThrowRecover +import pro.respawn.flowmvi.savedstate.plugins.serializeState import pro.respawn.flowmvi.util.typed import kotlin.random.Random @@ -31,32 +38,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 +106,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..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,9 +5,9 @@ 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.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 +29,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..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,11 +1,11 @@ package pro.respawn.flowmvi.sample.view +import android.util.Log import androidx.lifecycle.SavedStateHandle 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 +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.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 @@ -38,7 +42,15 @@ class LambdaViewModel( name = "Counter" debuggable = BuildConfig.DEBUG install(platformLoggingPlugin()) - parcelizeState(savedStateHandle) + 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/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/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/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" 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/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 new file mode 100644 index 00000000..64791232 --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/IntentDsl.kt @@ -0,0 +1,76 @@ +@file:Suppress("NOTHING_TO_INLINE") + +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 ----- +/** + * 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/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)) 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/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. 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" } diff --git a/savedstate/build.gradle.kts b/savedstate/build.gradle.kts new file mode 100644 index 00000000..526b7b99 --- /dev/null +++ b/savedstate/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("pro.respawn.shared-library") + alias(libs.plugins.serialization) +} + +android { + namespace = "${Config.namespace}.savedstate" +} + +dependencies { + commonMainApi(projects.core) + commonMainApi(libs.kotlin.serialization.json) + commonMainImplementation(libs.kotlin.atomicfu) + commonMainImplementation(libs.kotlin.io) + commonMainCompileOnly(projects.annotations) + + androidMainApi(libs.lifecycle.savedstate) +} 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/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..c2536320 --- /dev/null +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt @@ -0,0 +1,37 @@ +@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 + +/** + * 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, +): 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 + } +} + +/** + * 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 = 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 new file mode 100644 index 00000000..4aa46a4b --- /dev/null +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/ParcelizeStatePlugin.kt @@ -0,0 +1,71 @@ +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.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 + +/** + * 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 = 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 = name, + behaviors = behaviors, + resetOnException = resetOnException +) + +/** + * 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 = "${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, 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 new file mode 100644 index 00000000..3223df8f --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/SaveBehavior.kt @@ -0,0 +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 { + + /** + * 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 when all subscribers leave. + * @see [saveStatePlugin] + */ + public val Default: Set = setOf(OnChange(), OnUnsubscribe()) + } +} 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..ee4b372c --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/api/Saver.kt @@ -0,0 +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/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/Compress.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt new file mode 100644 index 00000000..58c82626 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.kt @@ -0,0 +1,22 @@ +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 { 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 new file mode 100644 index 00000000..47bf8156 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/FileSaver.kt @@ -0,0 +1,102 @@ +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 + +/** + * 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: 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): T? = recover.invoke(e) + override suspend fun save( + state: T? + ) = withContext(NonCancellable) { // prevent partial writes + mutex.withLock { + with(SystemFileSystem) { + if (state == null) { + delete(file, false) + } else { + createDirectories(directory) + write(state, file) + } + } + } + } + + // allow cancelling reads (no "NonCancellable here") + 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, + recover: suspend (Exception) -> String? = ThrowRecover, +): Saver = DefaultFileSaver( + dir = dir, + fileName = fileName, + recover = recover, + write = ::write, + 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, + recover: suspend (Exception) -> String? = ThrowRecover, +): Saver = DefaultFileSaver( + 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..2a5ac224 --- /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 + +/** + * 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, + 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) } + }, +): 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/NoOpSaver.kt b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt new file mode 100644 index 00000000..68c57aa0 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/NoOpSaver.kt @@ -0,0 +1,18 @@ +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 +} + +/** + * 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/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 new file mode 100644 index 00000000..cfc2ef0e --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/TypedSaver.kt @@ -0,0 +1,32 @@ +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 to: suspend (T?) -> R?, +): 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]. + * + * @see Saver + */ +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 new file mode 100644 index 00000000..4f4e380b --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SavedStatePlugin.kt @@ -0,0 +1,145 @@ +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 +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.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 + +/** + * 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" + +@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) +} catch (expected: Exception) { + recover(expected) + Unit +} + +@PublishedApi +internal suspend fun Saver.restoreCatching(): S? = try { + restore() +} catch (expected: Exception) { + 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? = "${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 } + } + } + if (resetOnException) onException { + withContext(this + context) { saver.saveCatching(null) } + it + } + + 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.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? = "${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 new file mode 100644 index 00000000..54040816 --- /dev/null +++ b/savedstate/src/commonMain/kotlin/pro/respawn/flowmvi/savedstate/plugins/SerializeStatePlugin.kt @@ -0,0 +1,92 @@ +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.SaveBehavior +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 + +/** + * 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 = nameByType() ?: "State", + fileExtension: String = ".json", + context: CoroutineContext = Dispatchers.Default, + resetOnException: Boolean = true, + noinline recover: suspend (Exception) -> T?, +): StorePlugin = saveStatePlugin( + saver = TypedSaver( + JsonSaver( + json = json, + serializer = serializer, + delegate = CompressedFileSaver(dir, "$filename$fileExtension", ThrowRecover), + recover = recover + ) + ), + behaviors = behaviors, + context = context, + name = "$filename$PluginNameSuffix", + resetOnException = resetOnException +) + +/** + * Install a [serializeStatePlugin]. + * + * Please see the parent overload for more info. + * + * @see serializeStatePlugin + */ +@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, + behaviors: Set = SaveBehavior.Default, + fileExtension: String = ".json", + context: CoroutineContext = Dispatchers.Default, + resetOnException: Boolean = true, + noinline recover: suspend (Exception) -> T? = ThrowRecover, +): Unit = install( + serializeStatePlugin( + dir = dir, + json = json, + filename = name ?: nameByType() ?: "State", + context = context, + behaviors = behaviors, + resetOnException = resetOnException, + recover = recover, + serializer = serializer, + fileExtension = fileExtension, + ) +) 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) 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") 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]