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..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 @@ -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 @@ -43,14 +45,34 @@ 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 } 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.retain() proxyLifeCycleOwner.attachToView(decorView) } @@ -68,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() + } } } @@ -109,6 +135,41 @@ 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 + + // 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]. */ @@ -118,9 +179,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() } 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() + } +}