Skip to content

Commit ef8d100

Browse files
committed
Adds BackPressedHandlerTest
1 parent 3ac5d21 commit ef8d100

File tree

4 files changed

+147
-4
lines changed

4 files changed

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