Repository navigation
fix: [SDK-5538] never crash on invalid init or calls before init - #2793
Closed
abdulraqeeb33 wants to merge 7 commits into
Closed
abdulraqeeb33 wants to merge 7 commits into
abdulraqeeb33 wants to merge 7 commits into
Conversation
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>
Contributor
📊 Diff Coverage ReportDiff 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
Overall (aggregate gate)531/594 touched executable lines covered (89.4% — requires ≥ 80%) |
- 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>
This was referenced Oct 16, 2026
8 of 18 tasks
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
One Line Summary
The SDK no longer crashes the app on an invalid
initWithContextor 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
NullPointerExceptionbefore our code runs, an empty appId with nothing cached still starts the SDK, and any accessor orloginbefore init throwsIllegalStateException. 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
OneSignalImpis the previous implementation, unchanged apart from the nullableinitWithContextparameters (8 lines). It is what the kill switch falls back to.OneSignalImpV2is the new implementation. The behavior below is in it, plusInitGate,DeferredManagersandAppIdResolutionV2.SDKInitTests,SDKInitSuspendTests,OneSignalImpTests,AppIdHelperTests) still run againstOneSignalImp. The new tests are the*V2Testsfiles.Scope
Init
initWithContext(context: Context?, appId: String?)andinitWithContextSuspendaccept null.ensureApplicationServiceStartedthrowing: log and returnfalse. Otherwise the syncinitWithContextreturnstrueonce init is dispatched; useinitWithContextSuspendorisInitializedfor the outcome.StartupService.bootstrapskips them), so it does not duplicate store subscriptions or queued operations.initWithContextSuspendbefore init runs ends in FAILED instead of leaving IN_PROGRESS forever, so a later init can still succeed.Calls before init (NOT_STARTED or FAILED)
login,logout,updateUserJwtand the JWT listener methods log and return. The suspend versions do the same; compositeloginreturnsOneSignalResult.failure(NOT_INITIALIZED)with a message saying why.User,Notifications,InAppMessages,SessionandLocation(and the suspend getters) return aDeferred*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.InAppMessages.pausedandLocation.isShared. Each queued call logs a WARN.pausedandisSharedgetters return the queued value.'User.addTag' ignored: ...) and returns a default. The init failure trace is attached once per attempt. Reading an accessor itself does not log.requestPermission) suspend on init instead of blocking a thread.Kill switch
NeverCrashKillSwitchpicks the implementation once per process.ActivityLifecycleInitializer(androidx.startup, beforeApplication.onCreate) reads the feature flags cached by the previous run, andOneSignalcreates the implementation on first use.sdk_never_crash_disabledis in the cachedsdkRemoteFeatureFlags(any casing), it usesOneSignalImp. Otherwise it usesOneSignalImpV2. A change on the backend applies on the next cold start.IllegalArgumentException(requireNotNull), matching the old crash.FeatureFlagentry in the KMP SDK. The flag still has to be created on the backend before it can be turned on.localOverrideinNeverCrashKillSwitchforces either implementation for local testing.Unchanged
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
OneSignalImpV2, init state lives inInitGateinstead of the implementation class.SdkOperationenum instead of strings.Public API
StringtoString?andContexttoContext?oninitWithContext. Same JVM descriptor, so binary compatible andapiCheckpasses. Only a custom Kotlin implementation ofIOneSignalwould need a source change.Testing
Unit testing
InitGateTests: readiness per state,tryStartrefusing 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,InputGuardTestscover the new implementation.StartupServiceTests: a bootstrap retried after a failure skips services that already bootstrapped.NeverCrashKillSwitchTests: the cached flag selectsOneSignalImpin any casing; no flag, a fresh install, an unreadable cache, a locked device, and first use before evaluation all selectOneSignalImpV2.SDKInitTests,SDKInitSuspendTests,OneSignalImpTestsandAppIdHelperTestspass againstOneSignalImp.SDKInitSuspendTestsruns one test on isolated prefs to fix a flake.Manual testing
Not yet tested on a device.
Affected code checklist
Checklist
Overview
Testing
Final pass