Skip to content

fix: [SDK-4732] track activity lifecycle from process start to fix late-init focus#2655

Open
fadi-george wants to merge 9 commits into
mainfrom
fadi/sdk-4732
Open

fix: [SDK-4732] track activity lifecycle from process start to fix late-init focus#2655
fadi-george wants to merge 9 commits into
mainfrom
fadi/sdk-4732

Conversation

@fadi-george
Copy link
Copy Markdown
Contributor

@fadi-george fadi-george commented May 29, 2026

Description

One Line Summary

Register the activity lifecycle observer at process start (via androidx.startup) and make the activity reference counter membership- and config-change-aware, so focus/entry-state tracking is correct regardless of when the SDK initializes.

Details

Motivation

Wrapper SDKs (Flutter, React Native, Capacitor) have to add their own workarounds because ApplicationService only begins observing the activity lifecycle once OneSignal.initWithContext runs. When init happens after the first activity is already started, the SDK misses lifecycle callbacks, which produces:

  • a wrong entryState (e.g. NOTIFICATION_CLICK reported for an organic cold start),
  • an unbalanced activityReferences count that can underflow and falsely drop focus, and
  • a reference count that climbs on every configuration change (rotation), permanently suppressing background detection.

This consolidates the fix in the native SDK so wrappers can drop their nudges.

Scope

  • A process-start androidx.startup Initializer attaches the ActivityLifecycleCallbacks to the Application before init runs. The same process-wide ApplicationService instance is then resolved by DI, so lifecycle observed before init is visible to the running SDK. If the startup provider is disabled (or in unit tests), DI falls back to a fresh instance and start() seeds focus state from the init context as before.
  • Activity counting is now membership-based (WeakHashMap-backed set): a stop only decrements for an activity whose start was actually counted, preventing underflow when a late-init SDK observes a transient activity's stop without its start.
  • Configuration-change recreation (e.g. rotation) no longer inflates the count: the recreated instance replaces its predecessor without incrementing, since the predecessor's stop already skipped the decrement.
  • SDK-internal trampoline activities (notification open activities) implement a new internal OneSignalInternalActivity marker and are excluded from focus/entry-state tracking.
  • No public API changes.

Testing

Unit testing

Added ApplicationServiceTests cases covering: internal trampoline activities are ignored for focus/current/entry-state; stopping an activity whose start was never counted does not drop focus (no underflow); and configuration-change recreation does not inflate the reference count.

Manual testing

Built the SDK to mavenLocal and ran the OneSignal example app (gms variant) on an Android 16 emulator, driving scenarios via a helper script:

  • Cold start: entryState=APP_OPEN (not NOTIFICATION_CLICK), one onFocus, count settles at 1.
  • Background -> foreground: exactly one onUnfocused then one onFocus, count returns to 1 with no underflow.
  • Rotation x4: count stayed pinned at 1 throughout (only values 0/1 ever observed), with no false onUnfocused/onFocus and entryState preserved.

Affected code checklist

  • Notifications
    • Open
  • Outcomes
  • Sessions

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing
  • Any Public API changes are explained in the PR details and conform to existing APIs

Testing

  • I have included test coverage for these changes, or explained why they are not needed
  • All automated tests pass, or I explained why that is not possible
  • I have personally tested this on my device, or explained why that is not possible

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

Made with Cursor

@fadi-george fadi-george requested a review from nan-li May 29, 2026 18:08
… observer

When the lifecycle observer is registered at process start, late init skips
focus seeding, leaving nextResumeIsFirstActivity false. On a cold start from a
notification the trampoline is ignored and the notification module sets
entryState=NOTIFICATION_CLICK before the host activity launches, so the
wasInBackground guard evaluates false and onFocus is never delivered to
SessionService, IAM, outcomes, and other consumers. Arm the first-foreground
flag when no activity has been observed yet so the first real start fires focus
while preserving the NOTIFICATION_CLICK entry state.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george
Copy link
Copy Markdown
Contributor Author

@claude review

…rt notifications

The previous fix armed the first-foreground flag in start(), which only covers
the cold-start path. On a warm start from a notification the SDK is already
initialized, so start() never runs: the app was backgrounded (current is null),
the notification module sets entryState=NOTIFICATION_CLICK, and the host
activity's onActivityStarted reads wasInBackground as false, so onFocus is never
delivered. Move the arming to onActivityStarted — the single choke point for
every foreground entry — so the first non-internal activity start after no
foreground activity (cold or warm) fires focus. handleFocus still preserves the
NOTIFICATION_CLICK entry state.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 29, 2026

📊 Diff Coverage Report

Diff Coverage Report (Changed Lines Only)

Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff).

Changed Files Coverage

  • CoreModule.kt: 0/1 touched executable lines (0.0%) (5 touched lines in diff)
    • 1 uncovered touched lines in this file
  • ActivityLifecycleInitializer.kt: 0/7 touched executable lines (0.0%) (30 touched lines in diff)
    • 7 uncovered touched lines in this file
  • ApplicationService.kt: 0/63 touched executable lines (0.0%) (192 touched lines in diff)
    • 63 uncovered touched lines in this file
  • NotificationOpenedActivityHMS.kt: 0/2 touched executable lines (0.0%) (8 touched lines in diff)
    • 2 uncovered touched lines in this file
  • NotificationOpenedActivityBase.kt: 0/2 touched executable lines (0.0%) (8 touched lines in diff)
    • 2 uncovered touched lines in this file

Overall (aggregate gate)

0/75 touched executable lines covered (0.0% — requires ≥ 80%)

Per-file detail (informational; gate is aggregate above):

  • CoreModule.kt: 0.0% (1 uncovered touched lines)

  • ActivityLifecycleInitializer.kt: 0.0% (7 uncovered touched lines)

  • ApplicationService.kt: 0.0% (63 uncovered touched lines)

  • NotificationOpenedActivityHMS.kt: 0.0% (2 uncovered touched lines)

  • NotificationOpenedActivityBase.kt: 0.0% (2 uncovered touched lines)

❌ Coverage Check Failed

Aggregate coverage on touched lines is 0.0% (minimum 80%).

📥 View workflow run

fadi-george and others added 2 commits May 29, 2026 13:38
… taking focus

Excluding OneSignalInternalActivity from all tracking regressed two notification
paths. (1) Foreground tap: the trampoline launches in its own task (taskAffinity=""),
so the host stops while it is on top; without the trampoline's prior contribution the
reference count crossed zero, firing a spurious onUnfocused and letting the async open
handler set NOTIFICATION_CLICK on an organic foreground tap. (2) URL/browser
notifications never launch the host, so the trampoline finished alone and left
entryState stuck at NOTIFICATION_CLICK, mis-attributing the next organic open as a
direct notification session.

Track started internal activities separately: they still never become current, take
focus, or set entry state, but while one is on top a real activity's stop is treated
as transient (the host resumes when the trampoline finishes), and when the trampoline
finishes with no current activity a stale NOTIFICATION_CLICK is reset to APP_CLOSE.

Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george
Copy link
Copy Markdown
Contributor Author

@claude review

@fadi-george
Copy link
Copy Markdown
Contributor Author

@claude review

… was uncounted

A notification-open trampoline can reach onStop without its onStart ever being
counted (e.g. a wrapper SDK that disabled the process-start initializer, so the
trampoline started before the lifecycle observer attached). That hit the
!wasCounted early return in decrementStartedActivity and skipped the
NOTIFICATION_CLICK -> APP_CLOSE reset, leaving entry state stale and
mis-attributing the next organic launch as a direct notification session.

Extract resetStaleNotificationEntryIfBackgrounded and also run it in the
early-return path (guarded on internal + no current + NOTIFICATION_CLICK, no
onUnfocused). Real uncounted activity stops are unaffected.

Co-authored-by: Cursor <cursoragent@cursor.com>
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.

1 participant