Skip to content

Conversation

@jinliu9508
Copy link
Contributor

@jinliu9508 jinliu9508 commented Sep 11, 2025

Description

One Line Summary

Move slow/IO-bound parts of initWithContext off the main thread and add an async overload with a completion callback for internal usage.

Details

Motivation

Avoid main-thread stalls and potential ANRs during initialization, especially when SharedPreferences or other disk/network operations are involved. Provide an async path so app components (Activities/Receivers) can trigger initialization without blocking UI or risking Receiver timeouts.

Scope

  • initWithContext(contex, appId): now move all initialization to background and return immediately.
  • initWithContext(contex) is now suspend and can only be called from a coroutine. This method should used by internal classes only. It will remain suspend until all initialization is completed.
  • Accessors remain blocked if called immediately after initWithContext and before initialization is fully completed. This is to ensure no breaking change or expectation on existing consumer use cases.
  • Receivers will now call initWithContext(contex) in background, and switch back to main on tasks that must be executed in main thread, eg, finish(), startActivity() and other app lifecycle or UI operations.
  • Workers run doWork() in background will now directly call initWithContext(contex)
  • Retain the existing synchronous overload for backward compatibility; the new overload provides a non-blocking path with a completion callback.
  • Introduces a centralized ANR timeout constant.
  • No behavioral change to notification delivery formats, APIs, or user-visible features.

Testing

Unit testing

  • New Robolectric/Kotest tests cover initWithContext in different scenarios. Ex:
    • Accessors throw before initialization.
    • Init does not block even when SharedPreferences access is artificially delayed.
    • Tags, local onesignalId, push subscription, and immediate login right after init.
  • LatchAwaiterTests testing the genetic functionalities of the waiter.

Manual testing

  • Cold-start the sample app with logging at VERBOSE. Ensure all initial operations are created in the same order.
  • Trigger init from an Activity and verify UI remains responsive.
  • For Receivers: simulate a boot or FCM broadcast; verify the receiver returns promptly (no >10s wait), and OneSignal completes initialization shortly after.
  • Notification click when app is killed / backgrounded.
  • Login, logout, addTag, addTrigger, permission update, IAM interaction
  • Environment: Android Studio Hedgehog or later; Pixel 6 emulator API 33; fresh install of the demo app.

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
    • If it is hard to explain how any codes changes are related to each other then it most likely needs to be more than one PR
  • 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.
    • Simplify with less code, followed by splitting up code into well named functions and variables, followed by adding comments to the code.
  • I have reviewed this PR myself, ensuring it meets each checklist item
    • WIP (Work In Progress) is ok, but explain what is still in progress and what you would like feedback on. Start the PR title with "WIP" to indicate this.

This change is Reviewable

@jinliu9508 jinliu9508 added the WIP Work In Progress label Sep 11, 2025
@jinliu9508 jinliu9508 force-pushed the refactor-init-in-background branch 2 times, most recently from 57aed46 to f048835 Compare September 15, 2025 05:25
@jinliu9508 jinliu9508 removed the WIP Work In Progress label Sep 15, 2025
@abdulraqeeb33 abdulraqeeb33 force-pushed the refactor-init-in-background branch from 973461e to 38f9839 Compare September 18, 2025 16:29
@jinliu9508 jinliu9508 requested a review from jkasten2 September 18, 2025 17:37
Copy link
Contributor

@abdulraqeeb33 abdulraqeeb33 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few nits and some must haves

testImplementation("org.robolectric:robolectric:4.8.1")
// kotest-extensions-android allows Robolectric to work with Kotest via @RobolectricTest
testImplementation("br.com.colman:kotest-extensions-android:0.1.1")
testImplementation("androidx.test:core-ktx:1.4.0")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we create variables for these version numbers? atleast until we move to kotlin dsl

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These testImplementations are no longer there as we removed the app level tests out.

/**
* Create an awaiter for OneSignal SDK specifically.
*/
fun createOneSignalAwaiter() = LatchAwaiter("OneSignal SDK")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we dont need these two methods?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in the follow up commit

@jinliu9508 jinliu9508 force-pushed the refactor-init-in-background branch 2 times, most recently from d8a1a50 to 97932bf Compare September 24, 2025 05:01
@jinliu9508 jinliu9508 force-pushed the refactor-init-in-background branch from 8e106eb to c80e0fb Compare September 24, 2025 23:29
Copy link
Contributor

@abdulraqeeb33 abdulraqeeb33 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nits but otherwise looks good

suspendifyOnThread {
// init OneSignal in background
if (!OneSignal.initWithContext(this)) {
jobFinished(jobParameters, false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did we need this here @jinliu9508 ? looks like jobFinished was not called earlier when returning false

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, since now we always return true, jobFinished needs to be called to cancel the job if initialization is unsuccessful.

// init OneSignal and enqueue restore work in background
suspendifyOnThread {
if (!OneSignal.initWithContext(context.applicationContext)) {
Logging.warn("NotificationRestoreReceiver skipped due to failed OneSignal init")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo - upgradeReceiver

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the fixup

Copy link
Member

@jkasten2 jkasten2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is based on main, however we discussed we want to release it with JWT so it should be based on top of that.

Comment on lines 9 to 10
* A generic latch that allows waiting for asynchronous initialization or completion
* with timeout support and detailed logging.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove the word "generic" and explain in more detail the purpose of this class? Another sentence or two worth of specifics around the main / UI thread and ANR use-cases.

Comment on lines 68 to 72
try {
AndroidUtils.isRunningOnMainThread()
} catch (_: Throwable) {
false
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove the try-catch here? I don't see how this could ever throw. Also pre-existing usages in the code base also don't have a try-catch.

if (isOrderedBroadcast) {
resultCode = Activity.RESULT_OK
private suspend fun setSuccessfulResultCode() {
withContext(Dispatchers.Main) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to run on the main thread, better if it doesn't

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the fixup

if (isOrderedBroadcast) {
// Prevents other BroadcastReceivers from firing
abortBroadcast()
withContext(Dispatchers.Main) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to run on the main thread, better if it doesn't

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the fixup

Comment on lines 75 to 76
// run init in background
suspendifyOnThread {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have concerns this is going to have side-effects. Mainly that notificationPayloadProcessorHMS.handleHMSNotificationOpenIntent will now run AFTER finish(). I recommend use the same logic from NotificationOpenedActivityBase to avoid changing order of execution.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, the logic now runs in a suspendifyBlocking. This might cause ANR as we can no longer move it to the background.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic is now unchanged. However, ANR may still occur if initialization took too long

Comment on lines 345 to 349
withTimeout(LatchAwaiter.ANDROID_ANR_TIMEOUT_MS) {
runBlocking {
suspendInitInternal(context, null)
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

withTimeout will cause suspendInitInternal to stop executing. This could lead the SDK to being in an unpredictable state so I would rather we let it run.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in the followup

Comment on lines 350 to 356
} catch (e: TimeoutCancellationException) {
Logging.log(LogLevel.ERROR, "initWithContext: Initialization timed out")
false
} catch (e: Exception) {
Logging.log(LogLevel.ERROR, "initWithContext: Initialization failed with exception", e)
false
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't catch here at all, it is going to hide root causes, as all we are going to get is a very generic throw IllegalStateException("Initialization failed. Cannot proceed.") later.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in the followup

context: Context,
appId: String?,
): Boolean =
withContext(Dispatchers.Default) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see two issues with this withContext line

  1. I don't think we should switch threads in this method, let the caller pick the thread / context to simply this.
  2. This could cause delays in init based on what else the app might be putting on this generic context. We also don't use Dispatchers.Default anywhere else in the code base either.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in the followup; the method is now no long in a thread

Comment on lines 397 to 402
} catch (e: Throwable) {
Logging.error("suspendInitInternal failed!", e)
initState = InitState.FAILED
latchAwaiter.release()
return@withContext false
}
Copy link
Member

@jkasten2 jkasten2 Sep 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching Throwable is going to hide root cause from crash reports, since we are not rethrowing here. If this was pre-existing before this PR it is ok to keep for now, but I don't think that was the case.

I do think the logic in the catch make sense to do if there is a failure, so let's keep doing that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed in the followup; no longer in try-catch

@jinliu9508 jinliu9508 force-pushed the refactor-init-in-background branch 2 times, most recently from 32acee4 to 695d830 Compare September 25, 2025 21:18
@jinliu9508 jinliu9508 requested a review from jkasten2 September 25, 2025 23:04
Comment on lines 310 to 317
synchronized(initLock) {
if (initState.isSDKAccessible()) {
Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress")
return true
}
}

// bootstrap services
startupService.bootstrap()
initState = InitState.IN_PROGRESS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

synchronized(initLock) { } block needs to be around initState = InitState.IN_PROGRESS too, otherwise it doesn't solve anything.

Copy link
Contributor Author

@jinliu9508 jinliu9508 Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the fixup

* Usage:
* val awaiter = LatchAwaiter("OneSignal SDK Init")
* awaiter.release() // when done
* awaiter.awaitOrThrow() // or await() to just check
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

awaitOrThrow no longer exists, remove this in the comment

Copy link
Contributor Author

@jinliu9508 jinliu9508 Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the fixup

Comment on lines 309 to 317
// do not do this again if already initialized or init is in progress
synchronized(initLock) {
if (initState.isSDKAccessible()) {
Logging.log(LogLevel.DEBUG, "initWithContext: SDK already initialized or in progress")
return true
}
}

// bootstrap services
startupService.bootstrap()
initState = InitState.IN_PROGRESS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

synchronized(initLock) { } block has to be around initState = InitState.IN_PROGRESS, as only around this read doesn't do anything on it's own.

Copy link
Contributor Author

@jinliu9508 jinliu9508 Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the fixup

@jinliu9508 jinliu9508 changed the base branch from main to 5.4-main September 26, 2025 20:54
@jinliu9508 jinliu9508 force-pushed the refactor-init-in-background branch from 23d672f to 808d1cc Compare September 26, 2025 21:02
@jinliu9508 jinliu9508 merged commit 6c3dfae into 5.4-main Sep 26, 2025
1 check passed
@jinliu9508 jinliu9508 deleted the refactor-init-in-background branch September 26, 2025 21:18
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.

4 participants