Skip to content

Commit e8e862e

Browse files
authored
Merge pull request #941 from square/ray/dialog-failsafe
Close dialogs even if the managing view is never attached.
2 parents b814497 + b62f88f commit e8e862e

File tree

4 files changed

+61
-10
lines changed

4 files changed

+61
-10
lines changed

workflow-ui/core-android/api/core-android.api

+1
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ public final class com/squareup/workflow1/ui/WorkflowViewStub : android/view/Vie
269269

270270
public final class com/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport {
271271
public static final field INSTANCE Lcom/squareup/workflow1/ui/androidx/WorkflowAndroidXSupport;
272+
public final fun lifecycleOwnerFromContext (Landroid/content/Context;)Landroidx/lifecycle/LifecycleOwner;
272273
public final fun lifecycleOwnerFromViewTreeOrContextOrNull (Landroid/view/View;)Landroidx/lifecycle/LifecycleOwner;
273274
public final fun stateRegistryOwnerFromViewTreeOrContext (Landroid/view/View;)Landroidx/savedstate/SavedStateRegistryOwner;
274275
}

workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/container/DialogIntegrationTest.kt

+16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.Dialog
44
import android.view.View
55
import android.widget.EditText
66
import androidx.activity.ComponentActivity
7+
import androidx.lifecycle.Lifecycle.State.DESTROYED
78
import androidx.test.ext.junit.rules.ActivityScenarioRule
89
import androidx.test.ext.junit.runners.AndroidJUnit4
910
import com.google.common.truth.Truth.assertThat
@@ -133,4 +134,19 @@ internal class DialogIntegrationTest {
133134
assertThat(latestDialog).isSameInstanceAs(originalDialogOne)
134135
}
135136
}
137+
138+
@Test fun finishingActivityEarlyDismissesDialogs() {
139+
val screen = BodyAndOverlaysScreen(
140+
ContentRendering("body"),
141+
DialogRendering("dialog", ContentRendering("content"))
142+
)
143+
144+
scenario.onActivity { activity ->
145+
val root = WorkflowLayout(activity)
146+
root.show(screen)
147+
}
148+
149+
scenario.moveToState(DESTROYED)
150+
assertThat(latestDialog?.isShowing).isFalse()
151+
}
136152
}

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

+11
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ import kotlin.reflect.cast
1515
* Namespace for some helper functions for interacting with the AndroidX libraries.
1616
*/
1717
public object WorkflowAndroidXSupport {
18+
/**
19+
* Returns the [LifecycleOwner] managing [context].
20+
*
21+
* @throws IllegalArgumentException if [context] is unmanaged
22+
*/
23+
@WorkflowUiExperimentalApi
24+
public fun lifecycleOwnerFromContext(context: Context): LifecycleOwner =
25+
requireNotNull(context.ownerOrNull(LifecycleOwner::class)) {
26+
"Expected $context to lead to a LifecycleOwner"
27+
}
28+
1829
/**
1930
* Tries to get the parent lifecycle from the current view via [ViewTreeLifecycleOwner], if that
2031
* fails it looks up the context chain for a [LifecycleOwner], and if that fails it just returns

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

+33-10
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import android.view.MotionEvent
99
import android.view.View
1010
import android.view.View.OnAttachStateChangeListener
1111
import android.view.ViewTreeObserver.OnGlobalLayoutListener
12+
import androidx.core.view.doOnAttach
13+
import androidx.lifecycle.DefaultLifecycleObserver
1214
import androidx.lifecycle.LifecycleOwner
1315
import androidx.lifecycle.ViewTreeLifecycleOwner
1416
import com.squareup.workflow1.ui.Compatible
1517
import com.squareup.workflow1.ui.ViewEnvironment
1618
import com.squareup.workflow1.ui.WorkflowUiExperimentalApi
1719
import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport
20+
import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.lifecycleOwnerFromContext
1821
import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner
1922
import com.squareup.workflow1.ui.androidx.WorkflowSavedStateRegistryAggregator
2023
import com.squareup.workflow1.ui.container.DialogSession.KeyAndBundle
@@ -277,11 +280,11 @@ public class LayeredDialogSessions private constructor(
277280
): LayeredDialogSessions {
278281
val boundsRect = Rect()
279282
if (view.isAttachedToWindow) view.getGlobalVisibleRect(boundsRect)
280-
val bounds = MutableStateFlow(Rect(boundsRect))
283+
val boundsStateFlow = MutableStateFlow(Rect(boundsRect))
281284

282285
return LayeredDialogSessions(
283286
context = view.context,
284-
bounds = bounds,
287+
bounds = boundsStateFlow,
285288
cancelEvents = {
286289
// Note similar code in DialogSession.
287290

@@ -298,16 +301,36 @@ public class LayeredDialogSessions private constructor(
298301
"Expected a ViewTreeLifecycleOwner on $view"
299302
}
300303
}.also { dialogs ->
304+
fun closeAll() {
305+
dialogs.update(emptyList(), ViewEnvironment.EMPTY) {}
306+
}
301307

302-
val boundsListener = OnGlobalLayoutListener {
303-
if (view.getGlobalVisibleRect(boundsRect) && boundsRect != bounds.value) {
304-
bounds.value = Rect(boundsRect)
305-
}
306-
// Should we close the dialogs if getGlobalVisibleRect returns false?
307-
// https://github.com/square/workflow-kotlin/issues/599
308+
// We rely on the hosting View's WorkflowLifecycleOwner to tell us to tear things down.
309+
// WorkflowLifecycleOwner gets hooked up when the View is attached to its window.
310+
// But the Activity might finish before the hosting view is ever attached. And we have
311+
// lots of time to show Dialogs before then. They will leak.
312+
//
313+
// To guard against that we hang a default observer directly off of the Activity that
314+
// will close all Dialogs when it is destroyed; and we remove it as soon as the hosting
315+
// view is attached for the first time.
316+
val failsafe = object : DefaultLifecycleObserver {
317+
override fun onDestroy(owner: LifecycleOwner) = closeAll()
318+
}
319+
lifecycleOwnerFromContext(view.context).lifecycle.addObserver(failsafe)
320+
view.doOnAttach {
321+
lifecycleOwnerFromContext(it.context).lifecycle.removeObserver(failsafe)
308322
}
309323

324+
// While the hosting view is attached, monitor its bounds and report them
325+
// through boundsStateFlow so that managed Dialogs can constrain themselves
326+
// accordingly.
310327
val attachStateChangeListener = object : OnAttachStateChangeListener {
328+
val boundsListener = OnGlobalLayoutListener {
329+
if (view.getGlobalVisibleRect(boundsRect) && boundsRect != boundsStateFlow.value) {
330+
boundsStateFlow.value = Rect(boundsRect)
331+
}
332+
}
333+
311334
override fun onViewAttachedToWindow(v: View) {
312335
boundsListener.onGlobalLayout()
313336
v.viewTreeObserver.addOnGlobalLayoutListener(boundsListener)
@@ -316,9 +339,9 @@ public class LayeredDialogSessions private constructor(
316339
override fun onViewDetachedFromWindow(v: View) {
317340
// Don't leak the dialogs if we're suddenly yanked out of view.
318341
// https://github.com/square/workflow-kotlin/issues/314
319-
dialogs.update(emptyList(), ViewEnvironment.EMPTY) {}
342+
closeAll()
320343
v.viewTreeObserver.removeOnGlobalLayoutListener(boundsListener)
321-
bounds.value = Rect()
344+
boundsStateFlow.value = Rect()
322345
}
323346
}
324347

0 commit comments

Comments
 (0)