Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added API to discard saved state on Android #558

Merged
merged 1 commit into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions decompose/api/android/decompose.api
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@ public final class com/arkivanov/decompose/DefaultComponentContext : com/arkivan
public final class com/arkivanov/decompose/DefaultComponentContextBuilderKt {
public static final fun DefaultComponentContext (Landroidx/lifecycle/Lifecycle;Landroidx/savedstate/SavedStateRegistry;Landroidx/lifecycle/ViewModelStore;Landroidx/activity/OnBackPressedDispatcher;)Lcom/arkivanov/decompose/DefaultComponentContext;
public static synthetic fun DefaultComponentContext$default (Landroidx/lifecycle/Lifecycle;Landroidx/savedstate/SavedStateRegistry;Landroidx/lifecycle/ViewModelStore;Landroidx/activity/OnBackPressedDispatcher;ILjava/lang/Object;)Lcom/arkivanov/decompose/DefaultComponentContext;
public static final fun defaultComponentContext (Landroidx/fragment/app/Fragment;Landroidx/activity/OnBackPressedDispatcher;)Lcom/arkivanov/decompose/DefaultComponentContext;
public static final fun defaultComponentContext (Landroidx/savedstate/SavedStateRegistryOwner;)Lcom/arkivanov/decompose/DefaultComponentContext;
public static final fun defaultComponentContext (Landroidx/fragment/app/Fragment;Landroidx/activity/OnBackPressedDispatcher;ZLkotlin/jvm/functions/Function0;)Lcom/arkivanov/decompose/DefaultComponentContext;
public static final fun defaultComponentContext (Landroidx/savedstate/SavedStateRegistryOwner;ZLkotlin/jvm/functions/Function0;)Lcom/arkivanov/decompose/DefaultComponentContext;
public static synthetic fun defaultComponentContext$default (Landroidx/fragment/app/Fragment;Landroidx/activity/OnBackPressedDispatcher;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/arkivanov/decompose/DefaultComponentContext;
public static synthetic fun defaultComponentContext$default (Landroidx/savedstate/SavedStateRegistryOwner;ZLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lcom/arkivanov/decompose/DefaultComponentContext;
}

public abstract interface annotation class com/arkivanov/decompose/ExperimentalDecomposeApi : java/lang/annotation/Annotation {
Expand Down
4 changes: 4 additions & 0 deletions decompose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,9 @@ kotlin {
implementation(deps.androidx.fragment.fragmentKtx)
implementation(deps.androidx.lifecycle.lifecycleCommonJava8)
}

android.test.dependencies {
implementation(deps.robolectric.robolectric)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import com.arkivanov.essenty.backhandler.BackHandler
import com.arkivanov.essenty.instancekeeper.InstanceKeeper
import com.arkivanov.essenty.instancekeeper.instanceKeeper
import com.arkivanov.essenty.lifecycle.asEssentyLifecycle
import com.arkivanov.essenty.statekeeper.StateKeeper
import com.arkivanov.essenty.statekeeper.stateKeeper
import kotlinx.serialization.builtins.serializer
import androidx.lifecycle.Lifecycle as AndroidLifecycle

fun DefaultComponentContext(
Expand All @@ -30,33 +33,67 @@ fun DefaultComponentContext(
/**
* Creates a default implementation of [ComponentContext] and attaches it
* to the receiver (e.g. an [Activity][android.app.Activity]).
*
* @param discardSavedState a flag indicating whether any previously saved state should be discarded or not,
* default value is `false`. Can be useful for handling deep links in `onCreate`, so that the navigation state
* is not restored and initial state from the deep link is applied instead.
* @param isStateSavingAllowed called before saving the state. When `true` then the state will be saved,
* otherwise it won't. Default value is `true`.
*/
fun <T> T.defaultComponentContext(): DefaultComponentContext where
fun <T> T.defaultComponentContext(
discardSavedState: Boolean = false,
isStateSavingAllowed: () -> Boolean = { true },
): DefaultComponentContext where
T : SavedStateRegistryOwner, T : OnBackPressedDispatcherOwner, T : ViewModelStoreOwner, T : LifecycleOwner =
DefaultComponentContext(
lifecycle = (this as LifecycleOwner).lifecycle,
savedStateRegistry = savedStateRegistry,
viewModelStore = viewModelStore,
onBackPressedDispatcher = onBackPressedDispatcher,
defaultComponentContext(
backHandler = BackHandler(onBackPressedDispatcher),
discardSavedState = discardSavedState,
isStateSavingAllowed = isStateSavingAllowed,
)

/**
* Creates a default implementation of [ComponentContext] and attaches it to the [Fragment].
*
* @param onBackPressedDispatcher an optional [OnBackPressedDispatcher] for back button handling,
* or `null` if back button handling is not required. Can be obtained via `requireActivity().onBackPressedDispatcher`.
* @param discardSavedState a flag indicating whether any previously saved state should be discarded or not,
* default value is `false`. Can be useful for handling deep links in `onCreate`, so that the navigation state
* is not restored and initial state from the deep link is applied instead.
* @param isStateSavingAllowed called before saving the state. When `true` then the state will be saved,
* otherwise it won't. Default value is `true`.
*/
fun Fragment.defaultComponentContext(
onBackPressedDispatcher: OnBackPressedDispatcher?,
discardSavedState: Boolean = false,
isStateSavingAllowed: () -> Boolean = { true },
): DefaultComponentContext =
DefaultComponentContext(
lifecycle = lifecycle.asEssentyLifecycle(),
stateKeeper = StateKeeper(savedStateRegistry),
instanceKeeper = InstanceKeeper(viewModelStore),
defaultComponentContext(
backHandler = onBackPressedDispatcher?.let {
BackHandler(
onBackPressedDispatcher = it,
lifecycleOwner = this,
)
},
discardSavedState = discardSavedState,
isStateSavingAllowed = isStateSavingAllowed,
)

private fun <T> T.defaultComponentContext(
backHandler: BackHandler?,
discardSavedState: Boolean,
isStateSavingAllowed: () -> Boolean,
): DefaultComponentContext where
T : SavedStateRegistryOwner, T : ViewModelStoreOwner, T : LifecycleOwner {
val stateKeeper = stateKeeper(discardSavedState = discardSavedState, isSavingAllowed = isStateSavingAllowed)
val marker = stateKeeper.consume(key = KEY_STATE_MARKER, strategy = String.serializer())
stateKeeper.register(key = KEY_STATE_MARKER, strategy = String.serializer()) { "marker" }

return DefaultComponentContext(
lifecycle = lifecycle.asEssentyLifecycle(),
stateKeeper = stateKeeper,
instanceKeeper = instanceKeeper(discardRetainedInstances = marker == null),
backHandler = backHandler,
)
}

private const val KEY_STATE_MARKER = "DefaultComponentContext_state_marker"
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package com.arkivanov.decompose

import android.os.Bundle
import android.os.Parcel
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import com.arkivanov.decompose.router.TestInstance
import com.arkivanov.essenty.backhandler.BackCallback
import com.arkivanov.essenty.instancekeeper.getOrCreate
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue

@Suppress("TestFunctionName")
@RunWith(RobolectricTestRunner::class)
class DefaultComponentContextBuilderTest {

@Test
fun saves_and_restores_state() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
ctx.stateKeeper.register(key = "key") { "saved_state" }

owner = owner.recreate()
ctx = owner.defaultComponentContext()
val restoredState = ctx.stateKeeper.consume<String>(key = "key")

assertEquals("saved_state", restoredState)
}

@Test
fun retains_instances() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

owner = owner.recreate()
ctx = owner.defaultComponentContext()
val instance2 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

assertSame(instance1, instance2)
}

@Test
fun GIVEN_isStateSavingAllowed_is_false_on_save_THEN_state_not_saved() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext(isStateSavingAllowed = { false })
ctx.stateKeeper.register(key = "key") { "saved_state" }

owner = owner.recreate()
ctx = owner.defaultComponentContext()
val restoredState = ctx.stateKeeper.consume<String>(key = "key")

assertNull(restoredState)
}

@Test
fun GIVEN_isStateSavingAllowed_is_false_on_save_THEN_instances_not_retained() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext(isStateSavingAllowed = { false })
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

owner = owner.recreate()
ctx = owner.defaultComponentContext()
val instance2 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

assertNotSame(instance1, instance2)
}

@Test
fun GIVEN_isStateSavingAllowed_is_false_on_save_THEN_old_instances_destroyed() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext(isStateSavingAllowed = { false })
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

owner = owner.recreate()
owner.defaultComponentContext()

assertTrue(instance1.isDestroyed)
}

@Test
fun GIVEN_discardSavedState_is_true_on_restore_THEN_discards_saved_state() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
ctx.stateKeeper.register(key = "key") { "saved_state" }

owner = owner.recreate()
ctx = owner.defaultComponentContext(discardSavedState = true)
val restoredState = ctx.stateKeeper.consume<String>(key = "key")

assertNull(restoredState)
}

@Test
fun GIVEN_discardSavedState_is_true_on_restore_THEN_instances_not_retained() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

owner = owner.recreate()
ctx = owner.defaultComponentContext(discardSavedState = true)
val instance2 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

assertNotSame(instance1, instance2)
}

@Test
fun GIVEN_discardSavedState_is_true_on_restore_THEN_old_instances_destroyed() {
var owner = TestOwner()
val ctx = owner.defaultComponentContext()
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

owner = owner.recreate()
owner.defaultComponentContext(discardSavedState = true)

assertTrue(instance1.isDestroyed)
}

@Test
fun GIVEN_enabled_BackCallback_registered_WHEN_onBackPressed_THEN_callback_called() {
val owner = TestOwner()
val ctx = owner.defaultComponentContext()
var isCalled = false
ctx.backHandler.register(BackCallback { isCalled = true })

owner.onBackPressedDispatcher.onBackPressed()

assertTrue(isCalled)
}

@Test
fun GIVEN_disabled_BackCallback_registered_WHEN_onBackPressed_THEN_callback_not_called() {
val owner = TestOwner()
val ctx = owner.defaultComponentContext()
var isCalled = false
ctx.backHandler.register(BackCallback(isEnabled = false) { isCalled = true })

owner.onBackPressedDispatcher.onBackPressed()

assertFalse(isCalled)
}

private class TestOwner(
savedState: Bundle = Bundle(),
override val viewModelStore: ViewModelStore = ViewModelStore(),
) : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner, OnBackPressedDispatcherOwner {
private val savedStateRegistryController: SavedStateRegistryController = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle = LifecycleRegistry(this)
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
override val onBackPressedDispatcher: OnBackPressedDispatcher = OnBackPressedDispatcher()

init {
savedStateRegistryController.performRestore(savedState)
}

fun recreate(): TestOwner {
val bundle = Bundle()
savedStateRegistryController.performSave(bundle)

return TestOwner(savedState = bundle.parcelize().deparcelize(), viewModelStore = viewModelStore)
}

private fun Bundle.parcelize(): ByteArray {
val parcel = Parcel.obtain()
parcel.writeBundle(this)
return parcel.marshall()
}

private fun ByteArray.deparcelize(): Bundle {
val parcel = Parcel.obtain()
parcel.unmarshall(this, 0, size)
parcel.setDataPosition(0)

return requireNotNull(parcel.readBundle())
}
}
}
5 changes: 4 additions & 1 deletion deps.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

decompose = "2.2.0-compose-experimental"
kotlin = "1.9.20"
essenty = "2.0.0-dev01"
essenty = "2.0.0-dev02"
reaktive = "1.2.3"
junit = "4.13.2"
jetbrainsCompose = "1.5.10"
Expand All @@ -12,6 +12,7 @@ jetbrainsKotlinxSerialization = "1.6.0"
jetbrainsBinaryCompatibilityValidator = "0.13.2"
jetpackCompose = "1.5.0"
jetpackComposeCompiler = "1.5.4"
robolectric = "4.9.1"
androidGradle = "8.0.2"
androidMaterial = "1.6.1"
androidPlay = "1.10.3"
Expand Down Expand Up @@ -48,6 +49,8 @@ jetbrains-kotlin-serializationGradlePlug = { group = "org.jetbrains.kotlin", nam
jetbrains-kotlinx-kotlinxSerializationCore = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "jetbrainsKotlinxSerialization" }
jetbrains-kotlinx-kotlinxSerializationJson = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jetbrainsKotlinxSerialization" }

robolectric-robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }

androidx-compose-runtime-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "jetpackCompose" }

android-gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradle" }
Expand Down