Skip to content

fix: [SDK-5538] never crash on invalid init or calls before init - #2793

Closed
abdulraqeeb33 wants to merge 7 commits into
mainfrom
ar/sdk-5538
Closed

abdulraqeeb33 wants to merge 7 commits into
mainfrom
ar/sdk-5538

Conversation

@abdulraqeeb33

@abdulraqeeb33 abdulraqeeb33 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Description

One Line Summary

The SDK no longer crashes the app on an invalid initWithContext or on calls made before init. It logs instead, and listener and setting registrations made before init are replayed once init succeeds. A remote kill switch can bring back the previous behavior.

Details

Motivation

Rule from SDK-5538 (doc): never crash the app, log instead. Today a null appId throws a Kotlin NullPointerException before our code runs, an empty appId with nothing cached still starts the SDK, and any accessor or login before init throws IllegalStateException. Wrappers (.NET, RN, Flutter, Unity) have been adding their own guards to work around this. The native SDK should own it so those guards can be removed.

How to read this diff

  • OneSignalImp is the previous implementation, unchanged apart from the nullable initWithContext parameters (8 lines). It is what the kill switch falls back to.
  • OneSignalImpV2 is the new implementation. The behavior below is in it, plus InitGate, DeferredManagers and AppIdResolutionV2.
  • The base commit's tests (SDKInitTests, SDKInitSuspendTests, OneSignalImpTests, AppIdHelperTests) still run against OneSignalImp. The new tests are the *V2Tests files.

Scope

Init

  • initWithContext(context: Context?, appId: String?) and initWithContextSuspend accept null.
  • Empty, null or invalid appId: use the cached appId. Cached appIds (config model and legacy prefs) are validated too, so an empty or NUL cached value is skipped. Nothing usable: skip init and log (WARN for a null appId, ERROR otherwise).
  • Null context, or ensureApplicationServiceStarted throwing: log and return false. Otherwise the sync initWithContext returns true once init is dispatched; use initWithContextSuspend or isInitialized for the outcome.
  • A retry after a failed init does not re-run bootstrap for services that already bootstrapped (StartupService.bootstrap skips them), so it does not duplicate store subscriptions or queued operations.
  • Cancelling initWithContextSuspend before init runs ends in FAILED instead of leaving IN_PROGRESS forever, so a later init can still succeed.
  • Callers waiting on an in-progress init follow a retry that starts while they wait.
  • A consent or GMS setting changed while init is finishing reaches the config model instead of being lost, and consent granted then wakes the operation queue.

Calls before init (NOT_STARTED or FAILED)

  • login, logout, updateUserJwt and the JWT listener methods log and return. The suspend versions do the same; composite login returns OneSignalResult.failure(NOT_INITIALIZED) with a message saying why.
  • User, Notifications, InAppMessages, Session and Location (and the suspend getters) return a Deferred* stand-in. Every call on it looks up the real manager, so a reference captured before init (e.g. via dependency injection) recovers by itself after init or after a retry.
  • Registrations are queued and replayed in order once init succeeds: user and push subscription observers, notification permission observers, foreground lifecycle and click listeners, IAM lifecycle and click listeners, InAppMessages.paused and Location.isShared. Each queued call logs a WARN.
  • The queue is bounded: a removal cancels its queued add, and a setting keeps only its latest value. Before init, the paused and isShared getters return the queued value.
  • Replay runs before services start and before SUCCESS, so started services and direct calls never see stale state.
  • Every other call logs an ERROR naming the call (e.g. 'User.addTag' ignored: ...) and returns a default. The init failure trace is attached once per attempt. Reading an accessor itself does not log.
  • Suspend members on a stand-in (e.g. requestPermission) suspend on init instead of blocking a thread.

Kill switch

  • NeverCrashKillSwitch picks the implementation once per process. ActivityLifecycleInitializer (androidx.startup, before Application.onCreate) reads the feature flags cached by the previous run, and OneSignal creates the implementation on first use.
  • If sdk_never_crash_disabled is in the cached sdkRemoteFeatureFlags (any casing), it uses OneSignalImp. Otherwise it uses OneSignalImpV2. A change on the backend applies on the next cold start.
  • Present means off, so installs whose init never succeeds (and so never fetch flags) keep the new behavior. A fresh install, a locked device, an unreadable cache, or first use before evaluation also keep the new behavior.
  • With the switch on, a null context throws IllegalArgumentException (requireNotNull), matching the old crash.
  • The key is read directly, with no FeatureFlag entry in the KMP SDK. The flag still has to be created on the backend before it can be turned on.
  • localOverride in NeverCrashKillSwitch forces either implementation for local testing.

Unchanged

  • During init in progress, accessors still block and return the real manager.
  • Misconfigured*Manager (missing Gradle module) still throws. That is a build setup error, not a runtime one. If a replayed call hits one, the failure is logged and the remaining calls still replay.

Internal

  • In OneSignalImpV2, init state lives in InitGate instead of the implementation class.
  • Operation names in logs come from the SdkOperation enum instead of strings.

Public API

  • String to String? and Context to Context? on initWithContext. Same JVM descriptor, so binary compatible and apiCheck passes. Only a custom Kotlin implementation of IOneSignal would need a source change.

Testing

Unit testing

  • InitGateTests: readiness per state, tryStart refusing while running or after success, waiting for the outcome, a waiter following a retry, message wording.
  • DeferredManagersTests: for every manager, non-registration calls are ignored before ready and forward after; registrations queue and replay in order, only once; a removal cancels its queued add; a setting keeps its latest value and the getter returns it; suspend members go through await; a failing replayed call does not stop the rest.
  • SDKInitV2Tests: null context; empty or null appId with and without a cached appId; accessors and login before init; captured references forward after init and after a retry; no duplicate operations on retry; pre-init registrations replayed before init reports success; one error per pre-init call with the trace once per attempt; consent not lost while init finishes; cancelled suspend init can be retried.
  • AppIdHelperV2Tests: invalid cached appIds are skipped, and an invalid config appId falls through to a valid legacy one.
  • SDKInitSuspendV2Tests, OneSignalImpV2Tests, InputGuardTests cover the new implementation.
  • StartupServiceTests: a bootstrap retried after a failure skips services that already bootstrapped.
  • NeverCrashKillSwitchTests: the cached flag selects OneSignalImp in any casing; no flag, a fresh install, an unreadable cache, a locked device, and first use before evaluation all select OneSignalImpV2.
  • The base commit's SDKInitTests, SDKInitSuspendTests, OneSignalImpTests and AppIdHelperTests pass against OneSignalImp. SDKInitSuspendTests runs one test on isolated prefs to fix a flake.
  • Reverted each fix and confirmed its test fails.
  • core 1340, location 46, in-app-messages 172, notifications 183 pass. spotless, detekt, apiCheck pass.

Manual testing

Not yet tested on a device.

Affected code checklist

  • Notifications
    • Display
    • Open
    • Push Processing
    • Confirm Deliveries
  • Outcomes
  • Sessions
  • In-App Messaging
  • REST API requests
  • Public API changes

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

initWithContext now accepts a null context or appId. An empty, null or
invalid appId falls back to the cached appId; with nothing cached, init is
skipped and an error is logged.

Calls made while the SDK is not initialized log and return a default
instead of throwing. The User, Notifications, InAppMessages, Session and
Location accessors return deferred managers that resolve the real manager
on every call, so a reference captured before init starts forwarding once
init succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33
abdulraqeeb33 requested a review from a team October 14, 2026 20:59
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📊 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

  • OneSignal.kt: 1/1 touched executable lines (100.0%) (24 touched lines in diff)
  • InputGuard.kt: 1/1 touched executable lines (100.0%) (7 touched lines in diff)
  • ActivityLifecycleInitializer.kt: 1/1 touched executable lines (100.0%) (4 touched lines in diff)
  • StartupService.kt: 6/6 touched executable lines (100.0%) (9 touched lines in diff)
  • DeferredManagers.kt: 110/116 touched executable lines (94.8%) (297 touched lines in diff)
  • InitGate.kt: 59/62 touched executable lines (95.2%) (132 touched lines in diff)
  • NeverCrashKillSwitch.kt: 29/29 touched executable lines (100.0%) (76 touched lines in diff)
  • OneSignalImp.kt: 2/2 touched executable lines (100.0%) (5 touched lines in diff)
  • OneSignalImpV2.kt: 288/342 touched executable lines (84.2%) (718 touched lines in diff)
  • SdkOperation.kt: 23/23 touched executable lines (100.0%) (29 touched lines in diff)
  • AppIdResolutionV2.kt: 11/11 touched executable lines (100.0%) (37 touched lines in diff)

Overall (aggregate gate)

531/594 touched executable lines covered (89.4% — requires ≥ 80%)

📥 View workflow run

AR Abdul Azeez and others added 6 commits October 14, 2026 16:41
- Extract init state into InitGate; waiters follow retries and the
  failure trace is logged once per attempt.
- Replace operation name strings with the SdkOperation enum.
- Deferred managers queue listener, observer and setting registrations
  before init and replay them in order once init succeeds. Suspend
  members await instead of blocking; accessors no longer log.
- Run bootstrap once so a retry after a failed init does not duplicate
  subscriptions.
- Validate cached and legacy appIds before using them.
- Consent and GMS setters reach the config model once updateConfig ran.
- A cancelled initWithContextSuspend ends in FAILED, so init can retry.
- Null appId logs at WARN; composite login returns a clear message.

Co-authored-by: Cursor <cursoragent@cursor.com>
- A removal before init cancels its queued add, and a setting keeps
  only its latest value, so the queue no longer grows without bound.
- Replay queued registrations before services start and before
  SUCCESS, so started services and direct calls never see stale state.
- Pre-init paused and isShared getters return the queued value.
- A retried bootstrap skips services that already bootstrapped, and
  the logger is created once.
- Consent granted while init is finishing wakes the operation queue.

Co-authored-by: Cursor <cursoragent@cursor.com>
- NeverCrashKillSwitch reads the feature flags cached by the previous
  run at process start (ActivityLifecycleInitializer) and picks the
  IOneSignal implementation once per process. Any failure, a locked
  device, or first use before evaluation keeps the new behavior.
- LegacyOneSignalImp and LegacyAppIdResolution are verbatim copies of
  the pre-change code at 906bb15, adapted only to the nullable
  initWithContext signatures (a null context still throws).
- The base commit's init test suites run against the legacy copy.

Depends on SDK_NEVER_CRASH_DISABLED in OneSignal-KMP-SDK, which is not
part of this commit; the branch does not compile until the submodule
is bumped.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Match the cached sdk_never_crash_disabled key directly instead of a
  shared FeatureFlag entry, so the branch builds against the pinned
  KMP submodule. Reading once at process start already gives the
  next-run activation.
- Add a local override for testing either implementation.
- Run the null-appId suspend init test on isolated prefs; background
  writes from other tests made it flaky once the legacy suites ran.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rename so the legacy code keeps its original names (OneSignalImp, resolveAppId and their tests) and the new init behavior lives in OneSignalImpV2 and resolveAppIdV2. NeverCrashKillSwitch picks OneSignalImp when sdk_never_crash_disabled is present, OneSignalImpV2 otherwise.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Queue addUserJwtInvalidatedListener and removeUserJwtInvalidatedListener
  before init like the other listeners, replayed before SUCCESS.
- An empty or NUL appId rescued by the cached appId logs one WARN instead
  of an ERROR; a skipped init still logs one ERROR.
- Set the paused and isShared getter cache under the queue lock.
- Run the tests that need an empty appId cache on isolated prefs; a
  leaked cached appId made the CI run fail.
- Session KDoc: outcomes before init are logged and ignored.
- Trim comments copied from the legacy class, drop ticket IDs from tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
@abdulraqeeb33

Copy link
Copy Markdown
Contributor Author

Superseded by the stacked PRs: #2800, #2801, #2802, #2803, #2828.

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