From b53ac6ae7128916721e350f69cab1d7245d0b9eb Mon Sep 17 00:00:00 2001 From: Navratan Soni Date: Fri, 29 May 2026 15:29:59 +0530 Subject: [PATCH 1/3] Drive ActivityCompatOwner proxy lifecycle to RESUMED and mirror host activity The proxy LifecycleRegistry only reached ON_CREATE, so on hosts that extend plain android.app.Activity (typical Unity Android titles) Compose's WindowRecomposer stayed suspended (no recompositions / pointer-driven UI updates after the initial draw) and every BackHandler callback registered against the proxy was disabled (OnBackPressedCallback only becomes enabled when its LifecycleOwner is at least STARTED). The In-App Message rendered once, then the app could not be interacted with or dismissed. The proxy now advances through ON_START and ON_RESUME on attach (matching the host's state at attach time, since presentables only attach to a foregrounded activity), and registers an Application.ActivityLifecycleCallbacks listener scoped to the host activity so subsequent STARTED / RESUMED / PAUSED / STOPPED transitions are mirrored. The listener is unregistered and the host reference cleared in onDestroy. handleLifecycleEvent(ON_DESTROY) auto-traverses any intermediate states, so ON_PAUSE / ON_STOP are not emitted manually. --- .../internal/util/ActivityCompatOwner.kt | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt b/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt index ab6b253db..493fabded 100644 --- a/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt +++ b/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt @@ -12,6 +12,8 @@ package com.adobe.marketing.mobile.internal.util import android.app.Activity +import android.app.Application +import android.os.Bundle import android.view.View import androidx.activity.OnBackPressedDispatcher import androidx.activity.OnBackPressedDispatcherOwner @@ -51,6 +53,14 @@ internal class ActivityCompatOwnerUtils { val proxyLifeCycleOwner = ActivityCompatOwner() proxyLifeCycleOwner.onCreate() + // Bind the proxy's lifecycle to the host activity. This advances the proxy from CREATED + // to RESUMED (the host's current state at attach time, since presentables are only + // attached to a foregrounded activity) and starts mirroring future lifecycle transitions + // of the host. Without this, the proxy lifecycle would remain at CREATED, which leaves + // Compose's WindowRecomposer suspended (no recompositions / animations / pointer-driven + // UI updates after the initial draw) and disables every BackHandler callback registered + // against the proxy LifecycleOwner. + proxyLifeCycleOwner.bindToHostLifecycle(activityToAttach) proxyLifeCycleOwner.attachToView(decorView) } @@ -109,6 +119,15 @@ internal class ActivityCompatOwner : override val onBackPressedDispatcher: OnBackPressedDispatcher get() = dispatcher + // Reference to the host Activity whose lifecycle this proxy mirrors. Held only while bound + // so that [onDestroy] can unregister the lifecycle callbacks. Cleared on detach to avoid + // leaking the Activity. + private var hostActivity: Activity? = null + + // ActivityLifecycleCallbacks instance used to mirror the host's lifecycle transitions onto + // this proxy. Non-null only between [bindToHostLifecycle] and [onDestroy]. + private var hostLifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null + /** * Trigger the ON_CREATE lifecycle event for this [ActivityCompatOwner]. */ @@ -118,9 +137,73 @@ internal class ActivityCompatOwner : } /** - * Trigger the ON_DESTROY lifecycle event for this [ActivityCompatOwner]. + * Binds this proxy's lifecycle to [activity]. Advances the proxy through ON_START and + * ON_RESUME (the host's current state at attach time) and registers an + * [Application.ActivityLifecycleCallbacks] listener so that subsequent lifecycle + * transitions of the host activity are mirrored onto this proxy. + * + * Must be called on the main thread, after [onCreate] and before [attachToView]. + * + * @param activity the host activity whose lifecycle this proxy should mirror + */ + internal fun bindToHostLifecycle(activity: Activity) { + hostActivity = activity + // Presentables are only attached to a foregrounded activity, so the host is at RESUMED + // by contract at the time of attach. Advance the proxy to match. + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + + val callbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityStarted(activity: Activity) { + if (activity === hostActivity) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + } + } + + override fun onActivityResumed(activity: Activity) { + if (activity === hostActivity) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + } + + override fun onActivityPaused(activity: Activity) { + if (activity === hostActivity) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + } + } + + override fun onActivityStopped(activity: Activity) { + if (activity === hostActivity) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + } + + // Unused — proxy ON_CREATE is driven explicitly by [onCreate], and ON_DESTROY by + // [onDestroy] (called from ActivityCompatOwnerUtils.detachActivityCompatOwner). The + // host activity being destroyed independently is already handled by AEPPresentable's + // own activity-lifecycle wiring, which calls detach on this proxy. + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } + activity.application?.registerActivityLifecycleCallbacks(callbacks) + hostLifecycleCallbacks = callbacks + } + + /** + * Trigger the ON_DESTROY lifecycle event for this [ActivityCompatOwner]. Also unregisters + * the host lifecycle callbacks (if any) and releases the reference to the host activity. + * + * [LifecycleRegistry.handleLifecycleEvent] with ON_DESTROY auto-traverses any intermediate + * states (e.g. RESUMED → STARTED → CREATED → DESTROYED), dispatching ON_PAUSE / ON_STOP / + * ON_DESTROY to observers in order, so we do not emit those events manually. */ internal fun onDestroy() { + hostLifecycleCallbacks?.let { callbacks -> + hostActivity?.application?.unregisterActivityLifecycleCallbacks(callbacks) + } + hostLifecycleCallbacks = null + hostActivity = null lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) store.clear() } From 43a86bcebe4d23b43b333bdb2a96fcce3cb9cb32 Mon Sep 17 00:00:00 2001 From: Navratan Soni Date: Fri, 29 May 2026 15:37:48 +0530 Subject: [PATCH 2/3] Reference-count ActivityCompatOwner proxy for concurrent presentables The proxy is keyed to the host activity's decor view via findViewTreeLifecycleOwner(), so when multiple AEPPresentables are visible on the same plain-android.app.Activity host they implicitly share a single proxy. The previous attach gate already short-circuited on the second attach, but detach unconditionally invoked onDestroy(), destroying the shared proxy while the surviving presentable's ComposeView was still observing it. This froze whichever presentable remained visible the moment the first one dismissed. The conflict rules in FloatingButtonPresentable.hasConflicts (= false) and InAppMessagePresentable.hasConflicts (only conflicts with other IAMs / Alerts) explicitly allow FloatingButton + IAM concurrency, so this is a supported scenario on Unity hosts. Add retain / release on ActivityCompatOwner backed by an Int counter (mutated only on the main thread via @MainThread attach / detach), have attachActivityCompatOwner retain on every attach (sharing an existing proxy when one is found) and detachActivityCompatOwner only tear the proxy down once the count reaches zero. On AndroidX hosts the existing LifecycleOwner is not an ActivityCompatOwner, so the new path is a no-op for them. --- .../internal/util/ActivityCompatOwner.kt | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt b/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt index 493fabded..749e7bdec 100644 --- a/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt +++ b/code/core/src/main/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwner.kt @@ -45,9 +45,20 @@ internal class ActivityCompatOwnerUtils { */ internal fun attachActivityCompatOwner(activityToAttach: Activity) { val decorView = activityToAttach.window.decorView + val existing = decorView.findViewTreeLifecycleOwner() - if (decorView.findViewTreeLifecycleOwner() != null) { - // If the activity already has a lifecycle owner, then we don't need to attach a new one + if (existing is ActivityCompatOwner) { + // Another presentable on the same plain-Activity host has already installed a proxy. + // Share it and retain so a later detach of the other presentable does not destroy the + // proxy out from under us. Without this, e.g. a FloatingButton + In-App Message shown + // concurrently on a non-AndroidX host (Unity) would freeze whichever presentable + // remains visible the moment the first one dismisses. + existing.retain() + return + } + + if (existing != null) { + // Host already provides its own (non-proxy) LifecycleOwner — AndroidX activity. Do nothing. return } @@ -61,6 +72,7 @@ internal class ActivityCompatOwnerUtils { // UI updates after the initial draw) and disables every BackHandler callback registered // against the proxy LifecycleOwner. proxyLifeCycleOwner.bindToHostLifecycle(activityToAttach) + proxyLifeCycleOwner.retain() proxyLifeCycleOwner.attachToView(decorView) } @@ -78,8 +90,12 @@ internal class ActivityCompatOwnerUtils { return } - lifecycleOwner.detachFromView(decorView) - lifecycleOwner.onDestroy() + // Only tear the proxy down once the last attached presentable releases its retain. If + // another presentable on the same host is still using this proxy, leave it in place. + if (lifecycleOwner.release()) { + lifecycleOwner.detachFromView(decorView) + lifecycleOwner.onDestroy() + } } } @@ -128,6 +144,32 @@ internal class ActivityCompatOwner : // this proxy. Non-null only between [bindToHostLifecycle] and [onDestroy]. private var hostLifecycleCallbacks: Application.ActivityLifecycleCallbacks? = null + // Number of currently-attached presentables sharing this proxy on the same host activity. + // Multiple AEPPresentables (e.g. FloatingButton + InAppMessage) can be visible on the same + // host concurrently. On hosts that already provide a ViewTreeLifecycleOwner (AndroidX + // activities) this never matters, but on plain android.app.Activity hosts they share a single + // proxy via findViewTreeLifecycleOwner(). The first attach creates the proxy and retains it; + // subsequent attaches retain again. onDestroy is only invoked when the last presentable + // detaches and the count drops to zero. All mutations occur on the main thread (attach / + // detach are @MainThread on the calling AEPPresentable), so a plain Int is sufficient. + private var attachCount: Int = 0 + + /** + * Increment the shared-attach reference count for this proxy. + */ + internal fun retain() { + attachCount++ + } + + /** + * Decrement the shared-attach reference count. + * @return true if no presentables remain attached and the proxy should be destroyed. + */ + internal fun release(): Boolean { + attachCount-- + return attachCount <= 0 + } + /** * Trigger the ON_CREATE lifecycle event for this [ActivityCompatOwner]. */ From 2f29b8def53666a70736a40f5c8dbc480b033cc8 Mon Sep 17 00:00:00 2001 From: Navratan Soni Date: Mon, 15 Jun 2026 16:34:58 +0530 Subject: [PATCH 3/3] Added test cases --- .../internal/util/ActivityCompatOwnerTest.kt | 242 +++++++++++++++++ .../util/ActivityCompatOwnerUtilsTest.kt | 244 ++++++++++++++++++ 2 files changed, 486 insertions(+) create mode 100644 code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerTest.kt create mode 100644 code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerUtilsTest.kt diff --git a/code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerTest.kt b/code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerTest.kt new file mode 100644 index 000000000..eafce6e34 --- /dev/null +++ b/code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerTest.kt @@ -0,0 +1,242 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.internal.util + +import android.app.Activity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.findViewTreeLifecycleOwner +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +internal class ActivityCompatOwnerTest { + + private lateinit var owner: ActivityCompatOwner + + @Before + fun setUp() { + owner = ActivityCompatOwner() + } + + // --- Reference counting --- + + @Test + fun `retain and single release returns true`() { + owner.retain() + assertTrue(owner.release()) + } + + @Test + fun `two retains and first release returns false`() { + owner.retain() + owner.retain() + assertFalse(owner.release()) + } + + @Test + fun `two retains and second release returns true`() { + owner.retain() + owner.retain() + owner.release() + assertTrue(owner.release()) + } + + @Test + fun `release without prior retain returns true`() { + assertTrue(owner.release()) + } + + @Test + fun `three retains require three releases before returning true`() { + owner.retain() + owner.retain() + owner.retain() + assertFalse(owner.release()) + assertFalse(owner.release()) + assertTrue(owner.release()) + } + + // --- Lifecycle state transitions --- + + @Test + fun `onCreate advances lifecycle to CREATED`() { + owner.onCreate() + assertEquals(Lifecycle.State.CREATED, owner.lifecycle.currentState) + } + + @Test + fun `bindToHostLifecycle advances lifecycle to RESUMED`() { + owner.onCreate() + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + + owner.bindToHostLifecycle(activity) + + assertEquals(Lifecycle.State.RESUMED, owner.lifecycle.currentState) + } + + @Test + fun `onDestroy from CREATED transitions to DESTROYED`() { + owner.onCreate() + owner.onDestroy() + assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState) + } + + @Test + fun `onDestroy from RESUMED transitions through intermediate states to DESTROYED`() { + owner.onCreate() + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + owner.bindToHostLifecycle(activity) + assertEquals(Lifecycle.State.RESUMED, owner.lifecycle.currentState) + + owner.onDestroy() + + assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState) + } + + // --- Host lifecycle mirroring --- + + @Test + fun `host onPause mirrors to proxy`() { + owner.onCreate() + val controller = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + owner.bindToHostLifecycle(controller.get()) + assertEquals(Lifecycle.State.RESUMED, owner.lifecycle.currentState) + + controller.pause() + + assertEquals(Lifecycle.State.STARTED, owner.lifecycle.currentState) + } + + @Test + fun `host onStop mirrors to proxy`() { + owner.onCreate() + val controller = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + owner.bindToHostLifecycle(controller.get()) + + controller.pause().stop() + + assertEquals(Lifecycle.State.CREATED, owner.lifecycle.currentState) + } + + @Test + fun `host onResume after pause restores proxy to RESUMED`() { + owner.onCreate() + val controller = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + owner.bindToHostLifecycle(controller.get()) + + controller.pause() + assertEquals(Lifecycle.State.STARTED, owner.lifecycle.currentState) + + controller.resume() + + assertEquals(Lifecycle.State.RESUMED, owner.lifecycle.currentState) + } + + @Test + fun `host onStart after stop restores proxy to STARTED`() { + owner.onCreate() + val controller = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + owner.bindToHostLifecycle(controller.get()) + + controller.pause().stop() + assertEquals(Lifecycle.State.CREATED, owner.lifecycle.currentState) + + controller.start() + + assertEquals(Lifecycle.State.STARTED, owner.lifecycle.currentState) + } + + @Test + fun `lifecycle events from a different activity are ignored`() { + owner.onCreate() + val hostController = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + owner.bindToHostLifecycle(hostController.get()) + + val otherController = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + otherController.pause() + + assertEquals(Lifecycle.State.RESUMED, owner.lifecycle.currentState) + } + + @Test + fun `onDestroy unregisters callbacks so host events after destroy do not crash`() { + owner.onCreate() + val controller = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + owner.bindToHostLifecycle(controller.get()) + + owner.onDestroy() + assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState) + + // If callbacks were not unregistered, this would dispatch ON_PAUSE to the + // DESTROYED LifecycleRegistry, causing an IllegalStateException. + controller.pause().stop() + + assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState) + } + + // --- View tree attachment --- + + @Test + fun `attachToView sets this owner as ViewTreeLifecycleOwner`() { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + val decorView = activity.window.decorView + + owner.onCreate() + owner.attachToView(decorView) + + assertSame(owner, decorView.findViewTreeLifecycleOwner()) + } + + @Test + fun `detachFromView clears ViewTreeLifecycleOwner`() { + val activity = Robolectric.buildActivity(Activity::class.java).create().get() + val decorView = activity.window.decorView + + owner.onCreate() + owner.attachToView(decorView) + assertNotNull(decorView.findViewTreeLifecycleOwner()) + + owner.detachFromView(decorView) + + assertNull(decorView.findViewTreeLifecycleOwner()) + } + + @Test + fun `attachToView with null view does not throw`() { + owner.attachToView(null) + } + + @Test + fun `detachFromView with null view does not throw`() { + owner.detachFromView(null) + } +} diff --git a/code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerUtilsTest.kt b/code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerUtilsTest.kt new file mode 100644 index 000000000..aec875e9c --- /dev/null +++ b/code/core/src/test/java/com/adobe/marketing/mobile/internal/util/ActivityCompatOwnerUtilsTest.kt @@ -0,0 +1,244 @@ +/* + Copyright 2024 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.internal.util + +import android.app.Activity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +internal class ActivityCompatOwnerUtilsTest { + + private lateinit var utils: ActivityCompatOwnerUtils + + @Before + fun setUp() { + utils = ActivityCompatOwnerUtils() + } + + // --- attachActivityCompatOwner --- + + @Test + fun `attach installs proxy on plain Activity with no existing LifecycleOwner`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + val decorView = activity.window.decorView + assertNull(decorView.findViewTreeLifecycleOwner()) + + utils.attachActivityCompatOwner(activity) + + val owner = decorView.findViewTreeLifecycleOwner() + assertNotNull(owner) + assertTrue(owner is ActivityCompatOwner) + } + + @Test + fun `attach advances proxy lifecycle to RESUMED`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + + utils.attachActivityCompatOwner(activity) + + val proxy = activity.window.decorView.findViewTreeLifecycleOwner() as ActivityCompatOwner + assertEquals(Lifecycle.State.RESUMED, proxy.lifecycle.currentState) + } + + @Test + fun `second attach retains existing proxy instead of creating a new one`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + + utils.attachActivityCompatOwner(activity) + val firstProxy = activity.window.decorView.findViewTreeLifecycleOwner() + + utils.attachActivityCompatOwner(activity) + val secondProxy = activity.window.decorView.findViewTreeLifecycleOwner() + + assertSame(firstProxy, secondProxy) + } + + @Test + fun `attach does nothing when host already has a non-proxy LifecycleOwner`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + val decorView = activity.window.decorView + + val existingOwner = object : LifecycleOwner { + override val lifecycle: Lifecycle = LifecycleRegistry(this) + } + decorView.setViewTreeLifecycleOwner(existingOwner) + + utils.attachActivityCompatOwner(activity) + + assertSame(existingOwner, decorView.findViewTreeLifecycleOwner()) + } + + // --- detachActivityCompatOwner --- + + @Test + fun `detach after single attach destroys and removes proxy`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + val decorView = activity.window.decorView + + utils.attachActivityCompatOwner(activity) + assertNotNull(decorView.findViewTreeLifecycleOwner()) + + utils.detachActivityCompatOwner(activity) + + assertNull(decorView.findViewTreeLifecycleOwner()) + } + + @Test + fun `detach after single attach transitions proxy to DESTROYED`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + + utils.attachActivityCompatOwner(activity) + val proxy = activity.window.decorView.findViewTreeLifecycleOwner() as ActivityCompatOwner + + utils.detachActivityCompatOwner(activity) + + assertEquals(Lifecycle.State.DESTROYED, proxy.lifecycle.currentState) + } + + @Test + fun `first detach after two attaches does not destroy proxy`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + val decorView = activity.window.decorView + + utils.attachActivityCompatOwner(activity) + utils.attachActivityCompatOwner(activity) + + utils.detachActivityCompatOwner(activity) + + val owner = decorView.findViewTreeLifecycleOwner() + assertNotNull(owner) + assertTrue(owner is ActivityCompatOwner) + assertEquals(Lifecycle.State.RESUMED, owner.lifecycle.currentState) + } + + @Test + fun `second detach after two attaches destroys proxy`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + val decorView = activity.window.decorView + + utils.attachActivityCompatOwner(activity) + utils.attachActivityCompatOwner(activity) + + utils.detachActivityCompatOwner(activity) + utils.detachActivityCompatOwner(activity) + + assertNull(decorView.findViewTreeLifecycleOwner()) + } + + @Test + fun `detach with no prior attach is a no-op`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + + utils.detachActivityCompatOwner(activity) + } + + @Test + fun `detach does nothing when existing LifecycleOwner is not an AEP proxy`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + val decorView = activity.window.decorView + + val existingOwner = object : LifecycleOwner { + override val lifecycle: Lifecycle = LifecycleRegistry(this) + } + decorView.setViewTreeLifecycleOwner(existingOwner) + + utils.detachActivityCompatOwner(activity) + + assertSame(existingOwner, decorView.findViewTreeLifecycleOwner()) + } + + // --- Full attach-detach-reattach cycle --- + + @Test + fun `re-attach after full detach creates a new proxy`() { + val activity = Robolectric.buildActivity(Activity::class.java) + .create().start().resume().get() + val decorView = activity.window.decorView + + utils.attachActivityCompatOwner(activity) + val firstProxy = decorView.findViewTreeLifecycleOwner() + assertNotNull(firstProxy) + + utils.detachActivityCompatOwner(activity) + assertNull(decorView.findViewTreeLifecycleOwner()) + + utils.attachActivityCompatOwner(activity) + val secondProxy = decorView.findViewTreeLifecycleOwner() + assertNotNull(secondProxy) + assertTrue(secondProxy is ActivityCompatOwner) + assertNotSame(firstProxy, secondProxy) + assertEquals(Lifecycle.State.RESUMED, secondProxy.lifecycle.currentState) + } + + // --- Lifecycle mirroring through utils --- + + @Test + fun `proxy installed via attach mirrors host pause and resume`() { + val controller = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + + utils.attachActivityCompatOwner(controller.get()) + val proxy = controller.get().window.decorView + .findViewTreeLifecycleOwner() as ActivityCompatOwner + assertEquals(Lifecycle.State.RESUMED, proxy.lifecycle.currentState) + + controller.pause() + assertEquals(Lifecycle.State.STARTED, proxy.lifecycle.currentState) + + controller.resume() + assertEquals(Lifecycle.State.RESUMED, proxy.lifecycle.currentState) + } + + @Test + fun `detach unregisters lifecycle callbacks so host events after destroy do not crash`() { + val controller = Robolectric.buildActivity(Activity::class.java) + .create().start().resume() + + utils.attachActivityCompatOwner(controller.get()) + val proxy = controller.get().window.decorView + .findViewTreeLifecycleOwner() as ActivityCompatOwner + + utils.detachActivityCompatOwner(controller.get()) + assertEquals(Lifecycle.State.DESTROYED, proxy.lifecycle.currentState) + + // If callbacks were not unregistered this would dispatch ON_PAUSE to the + // DESTROYED LifecycleRegistry, causing an IllegalStateException. + controller.pause().stop() + } +}