Skip to content

Fix In-App Message freeze and concurrent-presentable crash on plain android.app.Activity hosts (Unity)#813

Merged
navratan-soni merged 3 commits into
adobe:dev-v3.7.1from
navratan-soni:lifecycle_in_activity
Jun 19, 2026
Merged

Fix In-App Message freeze and concurrent-presentable crash on plain android.app.Activity hosts (Unity)#813
navratan-soni merged 3 commits into
adobe:dev-v3.7.1from
navratan-soni:lifecycle_in_activity

Conversation

@navratan-soni

Copy link
Copy Markdown
Contributor

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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • I have signed the Adobe Open Source CLA.
  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

…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

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.29412% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...keting/mobile/internal/util/ActivityCompatOwner.kt 85.29% 2 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@navratan-soni

navratan-soni commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

@sagar-sharma-adobe

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.
The existing AppLifecycleProvider already propagates onActivityDestroyed, as well as resume to AEPPresentable, onDestroy calls detachActivityCompatOwner → proxy.onDestroy(). This path works — the proxy's DESTROYED state is already handled through the existing flow without any new callback, while in case of onResume()
AppLifecycleProvider fires onActivityResumed
→ AEPPresentable receives it, calls attachActivityCompatOwner(activity)
→ creates proxy, calls proxy.onCreate() ← proxy stuck at CREATED. Done.

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.

@navratan-soni navratan-soni changed the base branch from main to dev-v3.7.1 June 19, 2026 05:09
@navratan-soni navratan-soni merged commit f5a42a9 into adobe:dev-v3.7.1 Jun 19, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants