Fix In-App Message freeze and concurrent-presentable crash on plain android.app.Activity hosts (Unity)#813
Conversation
…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.
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.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
I evaluated reusing the existing AppLifecycleProvider callbacks instead of registering a new Application.ActivityLifecycleCallbacks in the proxy. The following callbacks are coming from existing AppLifecycleProvider. However, the same approach cannot work for the RESUMED state. On a ComponentActivity host, the activity itself is the LifecycleOwner and is already at RESUMED by the time IAM shows — no proxy is involved. On a plain Activity host, we create a proxy from scratch during AEPPresentable.show(). At that point, AppLifecycleProvider.onActivityResumed has already fired — the activity is already in the foreground, which is a precondition for showing. The presentable registers with AppLifecycleProvider during show(), so it only receives future events, not a replay of the current state. If we relied on onActivityResumed to advance the proxy, it would stay at CREATED until the user backgrounds and foregrounds the app — reproducing the original bug. This is why the proxy must be advanced to RESUMED explicitly at creation time inside attachActivityCompatOwner, and the Application.ActivityLifecycleCallbacks mirrors subsequent host lifecycle transitions between attach and detach. |
Description
The In-App Message UI is built with Jetpack Compose. Compose requires the view tree to expose a LifecycleOwner (via ViewTreeLifecycleOwner) for its WindowRecomposer and OnBackPressedDispatcher to function. AndroidX activities (AppCompatActivity, ComponentActivity, GameActivity) install themselves as the ViewTreeLifecycleOwner in their constructor — Compose just works on these hosts.
Plain android.app.Activity hosts — the most common case being Unity's classic UnityPlayerActivity — do not set a ViewTreeLifecycleOwner. The SDK bridges this with ActivityCompatOwner, a proxy that installs the required owners on the decor view before any ComposeView attaches. This PR fixes two bugs in that proxy.
On a plain-Activity host an In-App Message renders once and then the app becomes completely unresponsive — the user cannot interact with the message, cannot dismiss it via the close button, and back press is silently swallowed. When multiple presentables are shown concurrently on the same host, dismissing one freezes the others with the same failure mode.
Root cause — proxy lifecycle stuck at CREATED. The proxy's LifecycleRegistry was advanced only to ON_CREATE and never further. Two Compose subsystems behave incorrectly when the lifecycle is stuck at CREATED:
WindowRecomposer — Compose's per-window frame pump only becomes active when the LifecycleOwner reaches STARTED. Stuck at CREATED, the recomposer stays Inactive. The initial draw still happens (triggered by the View attach, not the recomposer), so the IAM appears. After that the recomposer pumps no further frames: no animations, no recompositions, no pointer-state-driven UI updates. The window looks alive; Compose is silent.
OnBackPressedCallback — The IAM's BackHandler registers its callback via dispatcher.addCallback(lifecycleOwner, cb). Callbacks are only enabled when the LifecycleOwner is at least STARTED. Stuck at CREATED, the callback is never enabled; back press routes to the dispatcher's fallback Runnable (an empty lambda) and is silently swallowed.
Root cause — proxy not reference-counted. The proxy is keyed to the host activity's decor view via findViewTreeLifecycleOwner(), so multiple concurrent presentables on the same plain-Activity host implicitly share a single proxy instance. The previous attach gate short-circuited correctly on the second attach, but detach unconditionally called proxy.onDestroy() — transitioning the shared LifecycleOwner to DESTROYED while any surviving presentable's ComposeView was still observing it. This cancelled the survivor's WindowRecomposer and removed its BackHandler callback, producing the same frozen state.
Lifecycle binding. bindToHostLifecycle(activity) is called on attach. It immediately advances the proxy through ON_START → ON_RESUME (matching the host's actual state — presentables are only shown on a foregrounded activity) and registers an Application.ActivityLifecycleCallbacks listener scoped to the host activity so future transitions are mirrored onto the proxy. The listener is unregistered and the host reference cleared in onDestroy.
Reference counting. A reference count is maintained on the proxy. retain() is called on every attach (both the create-new and find-existing paths); release() is called on every detach. onDestroy is only invoked when the count drops to zero. The attach gate now explicitly distinguishes an existing AEP proxy (retain and share) from a host-provided LifecycleOwner (no-op — AndroidX path unchanged).
Related Issue
Motivation and Context
How Has This Been Tested?
-> Unit testing and manual testing
-> New activity host added in sample app, and tested same scenarios.
Screenshots (if appropriate):
Types of changes
Checklist: