Skip to content

Commit c690532

Browse files
committed
Stop pretending everyone implements view state right.
Makes the two sites where we call `View.restoreHierarchyState` defensive, catching and logging thrown exceptions. We normally prefer to let exceptions fly (https://en.wikipedia.org/wiki/Fail-fast_system), but `View.onRestoreInstanceState` is a particularly unforgiving method. Naive implementations throw if a view of one type attempts to restore state written by one of another type, which can happen if view ids are mismanaged. When these errors cause crashes in large apps, they are unlikely to be discovered before release and can be hellish to track down. Crashing in such a case is an overreaction when the alternative, typically losing scroller positions or partially entered text values on rotation or when popping a back stack, is such a non-event.
1 parent bc51102 commit c690532

File tree

5 files changed

+118
-17
lines changed

5 files changed

+118
-17
lines changed

workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/ViewStateCacheTest.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,38 @@ internal class ViewStateCacheTest {
106106
assertThat(firstViewRestored.viewState).isEqualTo("hello world")
107107
}
108108

109+
@Test fun doesnt_restore_child_states_on_error() {
110+
val cache = ViewStateCache()
111+
val firstRendering = NamedScreen(content = AScreen, name = "first")
112+
val secondRendering = NamedScreen(content = AScreen, name = "second")
113+
// Android requires ID to be set for view hierarchy to be saved or restored.
114+
val firstView = createTestView(firstRendering, id = 1)
115+
val secondView = createTestView(secondRendering)
116+
117+
// Set some state on the first view that will be saved.
118+
firstView.viewState = "hello world"
119+
120+
// Show the first screen.
121+
cache.update(retainedRenderings = emptyList(), oldHolderMaybe = null, newHolder = firstView)
122+
123+
// "Navigate" to the second screen, saving the first screen.
124+
cache.update(
125+
retainedRenderings = listOf(firstRendering),
126+
oldHolderMaybe = firstView,
127+
newHolder = secondView
128+
)
129+
130+
// "Navigate" back to the first screen, restoring state.
131+
val firstViewRestored = createTestView(firstRendering, id = 1, crashOnRestore = true)
132+
firstViewRestored.viewState = "should not be clobbered"
133+
134+
cache.update(listOf(), oldHolderMaybe = secondView, newHolder = firstViewRestored)
135+
// We didn't crash, that's already a good sign.
136+
137+
// Check that the "hard coded" view state is in tact due to the swallowed exception.
138+
assertThat(firstViewRestored.viewState).isEqualTo("should not be clobbered")
139+
}
140+
109141
@Test fun doesnt_restore_state_when_restored_view_id_is_different() {
110142
val cache = ViewStateCache()
111143
val firstRendering = NamedScreen(content = AScreen, name = "first")
@@ -198,9 +230,10 @@ internal class ViewStateCacheTest {
198230

199231
private fun createTestView(
200232
firstRendering: NamedScreen<*>,
201-
id: Int? = null
233+
id: Int? = null,
234+
crashOnRestore: Boolean = false
202235
): ScreenViewHolder<NamedScreen<*>> {
203-
val view = ViewStateTestView(instrumentation.context).also { view ->
236+
val view = ViewStateTestView(instrumentation.context, crashOnRestore).also { view ->
204237
id?.let { view.id = id }
205238
WorkflowLifecycleOwner.installOn(view, fakeOnBack)
206239
}

workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/fixtures/ViewStateTestView.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ import com.squareup.workflow1.ui.navigation.fixtures.BackStackContainerLifecycle
1313
* [onSaveInstanceState] and [onRestoreInstanceState] methods.
1414
*/
1515
@OptIn(WorkflowUiExperimentalApi::class)
16-
internal class ViewStateTestView(context: Context) : LeafView<LeafRendering>(context) {
16+
internal class ViewStateTestView(
17+
context: Context,
18+
private val crashOnRestore: Boolean = false
19+
) : LeafView<LeafRendering>(context) {
1720

1821
var viewState: String = ""
1922

@@ -23,6 +26,7 @@ internal class ViewStateTestView(context: Context) : LeafView<LeafRendering>(con
2326
}
2427

2528
override fun onRestoreInstanceState(state: Parcelable?) {
29+
if (crashOnRestore) throw IllegalArgumentException("Crashing on restore.")
2630
(state as? SavedState)?.let {
2731
viewState = it.viewState
2832
super.onRestoreInstanceState(state.superState)
@@ -43,7 +47,10 @@ internal class ViewStateTestView(context: Context) : LeafView<LeafRendering>(con
4347
viewState = source.readString()!!
4448
}
4549

46-
override fun writeToParcel(out: Parcel, flags: Int) {
50+
override fun writeToParcel(
51+
out: Parcel,
52+
flags: Int
53+
) {
4754
super.writeToParcel(out, flags)
4855
out.writeString(viewState)
4956
}

workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.os.Parcel
66
import android.os.Parcelable
77
import android.os.Parcelable.Creator
88
import android.util.AttributeSet
9+
import android.util.Log
910
import android.util.SparseArray
1011
import android.widget.FrameLayout
1112
import androidx.lifecycle.Lifecycle
@@ -65,7 +66,11 @@ public class WorkflowLayout(
6566
showing.show(rootScreen, environment.withOnBackDispatcher())
6667
restoredChildState?.let { restoredState ->
6768
restoredChildState = null
68-
showing.actual.restoreHierarchyState(restoredState)
69+
try {
70+
showing.actual.restoreHierarchyState(restoredState)
71+
} catch (e: Exception) {
72+
Log.w("Workflow", "WorkflowLayout failed to restore view state", e)
73+
}
6974
}
7075
}
7176

workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/ViewStateCache.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.os.Build.VERSION_CODES
55
import android.os.Parcel
66
import android.os.Parcelable
77
import android.os.Parcelable.Creator
8+
import android.util.Log
89
import android.util.SparseArray
910
import android.view.View
1011
import androidx.annotation.VisibleForTesting
@@ -57,8 +58,8 @@ internal constructor(
5758

5859
/**
5960
* @param retainedRenderings the renderings to be considered hidden after this update. Any
60-
* associated view state will be retained in the cache, possibly to be restored to [newView]
61-
* on a succeeding call to his method. Any other cached view state will be dropped.
61+
* associated view state will be retained in the cache, possibly to be restored to the view
62+
* of [newHolder] on a succeeding call to his method. Any other cached view state will be dropped.
6263
*
6364
* @param oldHolderMaybe the view that is being removed, if any, which is expected to be showing
6465
* a [NamedScreen] rendering. If that rendering is
@@ -90,7 +91,13 @@ internal constructor(
9091
stateRegistryAggregator.installChildRegistryOwnerOn(newHolder.view, newKey)
9192

9293
viewStates.remove(newKey)
93-
?.let { newHolder.view.restoreHierarchyState(it.viewState) }
94+
?.let {
95+
try {
96+
newHolder.view.restoreHierarchyState(it.viewState)
97+
} catch (e: Exception) {
98+
Log.w("Workflow", "ViewStateCache failed to restore view state for $newKey", e)
99+
}
100+
}
94101

95102
// Save both the view state and state registry of the view that's going away, as long as it's
96103
// still in the backstack.

workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/WorkflowLayoutTest.kt

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import android.os.Bundle
55
import android.os.Parcelable
66
import android.util.SparseArray
77
import android.view.View
8+
import androidx.activity.OnBackPressedDispatcher
9+
import androidx.activity.OnBackPressedDispatcherOwner
810
import androidx.lifecycle.Lifecycle
911
import androidx.lifecycle.testing.TestLifecycleOwner
1012
import androidx.test.core.app.ApplicationProvider
1113
import com.google.common.truth.Truth.assertThat
14+
import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey
1215
import com.squareup.workflow1.ui.navigation.WrappedScreen
1316
import kotlinx.coroutines.ExperimentalCoroutinesApi
1417
import kotlinx.coroutines.flow.flowOf
@@ -29,23 +32,49 @@ internal class WorkflowLayoutTest {
2932
private val workflowLayout = WorkflowLayout(context).apply { id = 42 }
3033

3134
@Test fun ignoresAlienViewState() {
32-
var saved = false
33-
val weirdView = object : View(context) {
34-
override fun onSaveInstanceState(): Parcelable {
35-
saved = true
36-
super.onSaveInstanceState()
37-
return Bundle()
38-
}
39-
}.apply { id = 42 }
35+
val weirdView = BundleSavingView(context)
4036

4137
val viewState = SparseArray<Parcelable>()
4238
weirdView.saveHierarchyState(viewState)
43-
assertThat(saved).isTrue()
39+
assertThat(weirdView.saved).isTrue()
4440

4541
workflowLayout.restoreHierarchyState(viewState)
4642
// No crash, no bug.
4743
}
4844

45+
@Test fun ignoresNestedViewStateMistakes() {
46+
class AScreen : AndroidScreen<AScreen> {
47+
override val viewFactory =
48+
ScreenViewFactory.fromCode<AScreen> { _, initialEnvironment, context, _ ->
49+
ScreenViewHolder(initialEnvironment, BundleSavingView(context)) { _, _ -> }
50+
}
51+
}
52+
53+
class BScreen : AndroidScreen<BScreen> {
54+
override val viewFactory =
55+
ScreenViewFactory.fromCode<BScreen> { _, initialEnvironment, context, _ ->
56+
ScreenViewHolder(initialEnvironment, SaveStateSavingView(context)) { _, _ -> }
57+
}
58+
}
59+
60+
val onBack = object : OnBackPressedDispatcherOwner {
61+
override fun getOnBackPressedDispatcher() = error("nope")
62+
override val lifecycle: Lifecycle get() = error("also nope")
63+
}
64+
65+
val env = ViewEnvironment.EMPTY + (OnBackPressedDispatcherOwnerKey to onBack)
66+
67+
val original = WorkflowLayout(context).apply { id = 1 }
68+
original.show(AScreen(), env)
69+
70+
val viewState = SparseArray<Parcelable>()
71+
original.saveHierarchyState(viewState)
72+
73+
val unoriginal = WorkflowLayout(context).apply { id = 1 }
74+
unoriginal.restoreHierarchyState(viewState)
75+
unoriginal.show(BScreen(), env)
76+
}
77+
4978
@Test fun usesLifecycleDispatcher() {
5079
val lifecycleDispatcher = UnconfinedTestDispatcher()
5180
val collectionContext: CoroutineContext = UnconfinedTestDispatcher()
@@ -62,4 +91,24 @@ internal class WorkflowLayoutTest {
6291

6392
// No crash then we safely removed the dispatcher.
6493
}
94+
95+
private class BundleSavingView(context: Context) : View(context) {
96+
var saved = false
97+
98+
init {
99+
id = 42
100+
}
101+
102+
override fun onSaveInstanceState(): Parcelable {
103+
saved = true
104+
super.onSaveInstanceState()
105+
return Bundle()
106+
}
107+
}
108+
109+
private class SaveStateSavingView(context: Context) : View(context) {
110+
init {
111+
id = 42
112+
}
113+
}
65114
}

0 commit comments

Comments
 (0)