Skip to content

Commit b8ac7ba

Browse files
committed
Adds BackPressedHandlerTest
1 parent 3ac5d21 commit b8ac7ba

File tree

4 files changed

+146
-4
lines changed

4 files changed

+146
-4
lines changed

workflow-ui/core-android/src/androidTest/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
<activity
88
android:name="com.squareup.workflow1.ui.container.fixtures.BackStackContainerLifecycleActivity"
99
android:theme="@style/Theme.AppCompat.NoActionBar"/>
10-
<activity android:name="androidx.activity.ComponentActivity"/>
10+
<activity android:name="androidx.activity.ComponentActivity"/>
1111
</application>
1212
</manifest>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package com.squareup.workflow1.ui
2+
3+
import android.view.View
4+
import android.view.ViewGroup
5+
import androidx.activity.ComponentActivity
6+
import androidx.activity.OnBackPressedCallback
7+
import androidx.activity.OnBackPressedDispatcherSpy
8+
import androidx.lifecycle.Lifecycle.State.DESTROYED
9+
import androidx.lifecycle.Lifecycle.State.RESUMED
10+
import androidx.lifecycle.LifecycleRegistry
11+
import androidx.lifecycle.ViewTreeLifecycleOwner
12+
import androidx.test.ext.junit.rules.ActivityScenarioRule
13+
import com.google.common.truth.Truth.assertThat
14+
import com.squareup.workflow1.ui.internal.test.DetectLeaksAfterTestSuccess
15+
import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule
16+
import org.junit.Rule
17+
import org.junit.Test
18+
import org.junit.rules.RuleChain
19+
20+
@OptIn(WorkflowUiExperimentalApi::class)
21+
internal class BackPressedHandlerTest {
22+
private val scenarioRule = ActivityScenarioRule(ComponentActivity::class.java)
23+
private val scenario get() = scenarioRule.scenario
24+
25+
@get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess())
26+
.around(scenarioRule)
27+
.around(IdlingDispatcherRule)
28+
29+
private var viewHandlerCount = 0
30+
private val viewBackHandler: BackPressHandler = {
31+
viewHandlerCount++
32+
}
33+
34+
@Test fun itWorksWhenHandlerIsAddedBeforeAttach() {
35+
scenario.onActivity { activity ->
36+
val view = View(activity)
37+
view.backPressedHandler = viewBackHandler
38+
39+
activity.setContentView(view)
40+
assertThat(viewHandlerCount).isEqualTo(0)
41+
42+
activity.onBackPressed()
43+
assertThat(viewHandlerCount).isEqualTo(1)
44+
}
45+
}
46+
47+
@Test fun itWorksWhenHandlerIsAddedAfterAttach() {
48+
scenario.onActivity { activity ->
49+
val view = View(activity)
50+
view.backPressedHandler = viewBackHandler
51+
52+
activity.setContentView(view)
53+
assertThat(viewHandlerCount).isEqualTo(0)
54+
55+
activity.onBackPressed()
56+
assertThat(viewHandlerCount).isEqualTo(1)
57+
}
58+
}
59+
60+
@Test fun onlyActiveWhileViewIsAttached() {
61+
var fallbackCallCount = 0
62+
val defaultBackHandler = object : OnBackPressedCallback(true) {
63+
override fun handleOnBackPressed() {
64+
fallbackCallCount++
65+
}
66+
}
67+
68+
scenario.onActivity { activity ->
69+
activity.onBackPressedDispatcher.addCallback(defaultBackHandler)
70+
71+
val view = View(activity)
72+
view.backPressedHandler = viewBackHandler
73+
74+
activity.onBackPressed()
75+
assertThat(fallbackCallCount).isEqualTo(1)
76+
assertThat(viewHandlerCount).isEqualTo(0)
77+
78+
activity.setContentView(view)
79+
activity.onBackPressed()
80+
assertThat(fallbackCallCount).isEqualTo(1)
81+
assertThat(viewHandlerCount).isEqualTo(1)
82+
83+
(view.parent as ViewGroup).removeView(view)
84+
activity.onBackPressed()
85+
assertThat(fallbackCallCount).isEqualTo(2)
86+
assertThat(viewHandlerCount).isEqualTo(1)
87+
88+
activity.setContentView(view)
89+
activity.onBackPressed()
90+
assertThat(fallbackCallCount).isEqualTo(2)
91+
assertThat(viewHandlerCount).isEqualTo(2)
92+
}
93+
}
94+
95+
@Test fun callbackIsRemoved() {
96+
scenario.onActivity { activity ->
97+
val spy = OnBackPressedDispatcherSpy(activity.onBackPressedDispatcher)
98+
assertThat(spy.callbacks()).isEmpty()
99+
100+
val lifecycle = LifecycleRegistry(activity)
101+
lifecycle.currentState = RESUMED
102+
103+
val view = View(activity)
104+
view.backPressedHandler = viewBackHandler
105+
assertThat(spy.callbacks()).hasSize(1)
106+
107+
ViewTreeLifecycleOwner.set(view) { lifecycle }
108+
activity.setContentView(view)
109+
110+
(view.parent as ViewGroup).removeView(view)
111+
assertThat(spy.callbacks()).hasSize(1)
112+
113+
lifecycle.currentState = DESTROYED
114+
assertThat(spy.callbacks()).isEmpty()
115+
}
116+
}
117+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package androidx.activity;
2+
3+
import java.util.ArrayDeque;
4+
5+
public class OnBackPressedDispatcherSpy {
6+
private final OnBackPressedDispatcher dispatcher;
7+
8+
public OnBackPressedDispatcherSpy(OnBackPressedDispatcher dispatcher) {
9+
this.dispatcher = dispatcher;
10+
}
11+
12+
public ArrayDeque<OnBackPressedCallback> callbacks() {
13+
return dispatcher.mOnBackPressedCallbacks;
14+
}
15+
}

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,19 @@ private var View.observerOrNull: AttachStateAndLifecycleObserver?
6464
* so that we can know when it's time to remove the [onBackPressedCallback] from
6565
* the dispatch stack
6666
* ([no memory leaks please](https://github.com/square/workflow-kotlin/issues/889)).
67-
* As a belt-and-suspenders guard against leaking, we also take care to null out the
68-
* pointer from the [onBackPressedCallback] to the actual [handler] while the [view]
69-
* is detached.
67+
*
68+
* Why is it okay to wait for the [ViewTreeLifecycleOwner] to be destroyed before we
69+
* remove [onBackPressedCallback] from the dispatcher? In normal apps that's
70+
* the `Activity` or a `Fragment`, which will live a very long time, but Workflow UI
71+
* is more controlling than that. `WorkflowViewStub` and the rest of the stock container
72+
* classes use `WorkflowLifecycleOwner` to provide a short lived [ViewTreeLifecycleOwner]
73+
* for each [View] they create, and tear it down before moving to the next one.
74+
*
75+
* None the less, as a belt-and-suspenders guard against leaking,
76+
* we also take care to null out the pointer from the [onBackPressedCallback] to the
77+
* actual [handler] while the [view] is detached. We can't be confident that the
78+
* [ViewTreeLifecycleOwner] we find will be a well behaved one that was put in place
79+
* by `WorkflowLifecycleOwner`. Who knows what adventures our clients will get up to.
7080
*/
7181
@WorkflowUiExperimentalApi
7282
private class AttachStateAndLifecycleObserver(

0 commit comments

Comments
 (0)