From d7b905cafa35aba4f8110faa5261496ffe8d27b9 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 16 Oct 2026 11:57:42 -0500 Subject: [PATCH 1/2] feat: [SDK-5538] deferred managers that stand in before init Deferred User, Notifications, InAppMessages, Session and Location managers, plus JWT invalidated listeners. Every call re-resolves the real manager through a ManagerSource. Before init, registrations are queued (a removal cancels its queued add, a setting keeps its latest value) and replayed in order once; other calls are logged and dropped. Not wired in yet; OneSignalImpV2 hands these out later in this stack. Co-authored-by: Cursor --- .../onesignal/internal/DeferredManagers.kt | 297 ++++++++++++ .../internal/DeferredManagersTests.kt | 425 ++++++++++++++++++ 2 files changed, 722 insertions(+) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt new file mode 100644 index 000000000..d043ed5d7 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt @@ -0,0 +1,297 @@ +package com.onesignal.internal + +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.inAppMessages.IInAppMessageClickListener +import com.onesignal.inAppMessages.IInAppMessageLifecycleListener +import com.onesignal.inAppMessages.IInAppMessagesManager +import com.onesignal.location.ILocationManager +import com.onesignal.notifications.INotificationClickListener +import com.onesignal.notifications.INotificationLifecycleListener +import com.onesignal.notifications.INotificationsManager +import com.onesignal.notifications.IPermissionObserver +import com.onesignal.session.ISessionManager +import com.onesignal.user.IUserManager +import com.onesignal.user.internal.jwt.JwtTokenStore +import com.onesignal.user.state.IUserStateObserver +import com.onesignal.user.subscriptions.IPushSubscription +import com.onesignal.user.subscriptions.IPushSubscriptionObserver + +/** Resolves the real manager behind a deferred one. [member] names the call in logs. */ +internal interface ManagerSource { + /** The real manager once init succeeds, else null after logging that [member] was ignored. */ + fun get(member: String): T? + + /** Like [get], but silent. */ + fun peek(): T? + + suspend fun await(member: String): T? + + fun logQueued(member: String) +} + +// Handed out before init; apps may keep it, so every call re-resolves. Registrations are queued and +// replayed in order on success (a removal cancels its queued add, a setting keeps its latest value); +// other calls are logged and dropped. +internal open class DeferredManager( + protected val source: ManagerSource, +) { + private class Pending(val member: String, val target: Any?, val call: (T) -> Unit) + + private val pending = mutableListOf>() + private var replayed = false + + protected fun registerOrQueue( + member: String, + target: Any, + call: (T) -> Unit, + ) = forwardOrQueue(member, call) { pending.add(Pending(member, target, call)) } + + // Before replay the real manager holds nothing, so a removal only has to cancel the queued add. + protected fun unregisterOrCancel( + member: String, + addMember: String, + target: Any, + call: (T) -> Unit, + ) = forwardOrQueue(member, call) { + val index = pending.indexOfLast { it.member == addMember && it.target === target } + if (index >= 0) pending.removeAt(index) + } + + // [onQueued] runs under the queue lock, so a getter cache it sets always matches what replays. + protected fun setOrQueue( + member: String, + onQueued: () -> Unit, + call: (T) -> Unit, + ) = forwardOrQueue(member, call) { + pending.removeAll { it.member == member } + pending.add(Pending(member, null, call)) + onQueued() + } + + private fun forwardOrQueue( + member: String, + call: (T) -> Unit, + enqueue: () -> Unit, + ) { + source.peek()?.let { + call(it) + return + } + val queued = synchronized(pending) { if (!replayed) true.also { enqueue() } else false } + if (queued) source.logQueued(member) else source.get(member)?.let(call) + } + + @Suppress("TooGenericExceptionCaught") + open fun replay(real: T) { + val calls = + synchronized(pending) { + replayed = true + pending.toList().also { pending.clear() } + } + for (entry in calls) { + try { + entry.call(real) + } catch (e: Exception) { + Logging.error("Replaying '${entry.member}' after initWithContext failed", e) + } + } + } +} + +@Suppress("TooManyFunctions") // one per IUserManager member +internal class DeferredUserManager( + source: ManagerSource, +) : DeferredManager(source), IUserManager { + private val deferredPush = DeferredPushSubscription(PushSubscriptionSource(source)) + + override val pushSubscription: IPushSubscription get() = source.peek()?.pushSubscription ?: deferredPush + override val onesignalId: String get() = source.get("onesignalId")?.onesignalId ?: "" + override val externalId: String get() = source.get("externalId")?.externalId ?: "" + + override fun setLanguage(value: String) = source.get("setLanguage")?.setLanguage(value) ?: Unit + + override fun addAlias( + label: String, + id: String, + ) = source.get("addAlias")?.addAlias(label, id) ?: Unit + + override fun addAliases(aliases: Map) = source.get("addAliases")?.addAliases(aliases) ?: Unit + + override fun removeAlias(label: String) = source.get("removeAlias")?.removeAlias(label) ?: Unit + + override fun removeAliases(labels: Collection) = source.get("removeAliases")?.removeAliases(labels) ?: Unit + + override fun addEmail(email: String) = source.get("addEmail")?.addEmail(email) ?: Unit + + override fun removeEmail(email: String) = source.get("removeEmail")?.removeEmail(email) ?: Unit + + override fun addSms(sms: String) = source.get("addSms")?.addSms(sms) ?: Unit + + override fun removeSms(sms: String) = source.get("removeSms")?.removeSms(sms) ?: Unit + + override fun addTag( + key: String, + value: String, + ) = source.get("addTag")?.addTag(key, value) ?: Unit + + override fun addTags(tags: Map) = source.get("addTags")?.addTags(tags) ?: Unit + + override fun removeTag(key: String) = source.get("removeTag")?.removeTag(key) ?: Unit + + override fun removeTags(keys: Collection) = source.get("removeTags")?.removeTags(keys) ?: Unit + + override fun getTags(): Map = source.get("getTags")?.getTags() ?: emptyMap() + + override fun addObserver(observer: IUserStateObserver) = registerOrQueue("addObserver", observer) { it.addObserver(observer) } + + override fun removeObserver(observer: IUserStateObserver) = unregisterOrCancel("removeObserver", "addObserver", observer) { it.removeObserver(observer) } + + override fun trackEvent( + name: String, + properties: Map?, + ) = source.get("trackEvent")?.trackEvent(name, properties) ?: Unit + + override fun replay(real: IUserManager) { + super.replay(real) + deferredPush.replay(real.pushSubscription) + } +} + +private class PushSubscriptionSource( + private val user: ManagerSource, +) : ManagerSource { + override fun get(member: String) = user.get("pushSubscription.$member")?.pushSubscription + + override fun peek() = user.peek()?.pushSubscription + + override suspend fun await(member: String) = user.await("pushSubscription.$member")?.pushSubscription + + override fun logQueued(member: String) = user.logQueued("pushSubscription.$member") +} + +internal class DeferredPushSubscription( + source: ManagerSource, +) : DeferredManager(source), IPushSubscription { + override val id: String get() = source.get("id")?.id ?: "" + override val token: String get() = source.get("token")?.token ?: "" + override val optedIn: Boolean get() = source.get("optedIn")?.optedIn ?: false + + override fun optIn() = source.get("optIn")?.optIn() ?: Unit + + override fun optOut() = source.get("optOut")?.optOut() ?: Unit + + override fun addObserver(observer: IPushSubscriptionObserver) = registerOrQueue("addObserver", observer) { it.addObserver(observer) } + + override fun removeObserver(observer: IPushSubscriptionObserver) = unregisterOrCancel("removeObserver", "addObserver", observer) { it.removeObserver(observer) } +} + +internal class DeferredNotificationsManager( + source: ManagerSource, +) : DeferredManager(source), INotificationsManager { + override val permission: Boolean get() = source.get("permission")?.permission ?: false + override val canRequestPermission: Boolean get() = source.get("canRequestPermission")?.canRequestPermission ?: false + + override suspend fun requestPermission(fallbackToSettings: Boolean): Boolean = + source.await("requestPermission")?.requestPermission(fallbackToSettings) ?: false + + override fun removeNotification(id: Int) = source.get("removeNotification")?.removeNotification(id) ?: Unit + + override fun removeGroupedNotifications(group: String) = source.get("removeGroupedNotifications")?.removeGroupedNotifications(group) ?: Unit + + override fun clearAllNotifications() = source.get("clearAllNotifications")?.clearAllNotifications() ?: Unit + + override fun addPermissionObserver(observer: IPermissionObserver) = + registerOrQueue("addPermissionObserver", observer) { it.addPermissionObserver(observer) } + + override fun removePermissionObserver(observer: IPermissionObserver) = + unregisterOrCancel("removePermissionObserver", "addPermissionObserver", observer) { it.removePermissionObserver(observer) } + + override fun addForegroundLifecycleListener(listener: INotificationLifecycleListener) = + registerOrQueue("addForegroundLifecycleListener", listener) { it.addForegroundLifecycleListener(listener) } + + override fun removeForegroundLifecycleListener(listener: INotificationLifecycleListener) = + unregisterOrCancel("removeForegroundLifecycleListener", "addForegroundLifecycleListener", listener) { it.removeForegroundLifecycleListener(listener) } + + override fun addClickListener(listener: INotificationClickListener) = registerOrQueue("addClickListener", listener) { it.addClickListener(listener) } + + override fun removeClickListener(listener: INotificationClickListener) = + unregisterOrCancel("removeClickListener", "addClickListener", listener) { it.removeClickListener(listener) } +} + +internal class DeferredInAppMessagesManager( + source: ManagerSource, +) : DeferredManager(source), IInAppMessagesManager { + @Volatile + private var queuedPaused: Boolean? = null + + override var paused: Boolean + get() = source.peek()?.paused ?: queuedPaused ?: source.get("paused")?.paused ?: false + set(value) { + setOrQueue("paused", { queuedPaused = value }) { it.paused = value } + } + + override fun addTrigger( + key: String, + value: String, + ) = source.get("addTrigger")?.addTrigger(key, value) ?: Unit + + override fun addTriggers(triggers: Map) = source.get("addTriggers")?.addTriggers(triggers) ?: Unit + + override fun removeTrigger(key: String) = source.get("removeTrigger")?.removeTrigger(key) ?: Unit + + override fun removeTriggers(keys: Collection) = source.get("removeTriggers")?.removeTriggers(keys) ?: Unit + + override fun clearTriggers() = source.get("clearTriggers")?.clearTriggers() ?: Unit + + override fun addLifecycleListener(listener: IInAppMessageLifecycleListener) = + registerOrQueue("addLifecycleListener", listener) { it.addLifecycleListener(listener) } + + override fun removeLifecycleListener(listener: IInAppMessageLifecycleListener) = + unregisterOrCancel("removeLifecycleListener", "addLifecycleListener", listener) { it.removeLifecycleListener(listener) } + + override fun addClickListener(listener: IInAppMessageClickListener) = registerOrQueue("addClickListener", listener) { it.addClickListener(listener) } + + override fun removeClickListener(listener: IInAppMessageClickListener) = + unregisterOrCancel("removeClickListener", "addClickListener", listener) { it.removeClickListener(listener) } +} + +internal class DeferredSessionManager( + source: ManagerSource, +) : DeferredManager(source), ISessionManager { + override fun addOutcome(name: String) = source.get("addOutcome")?.addOutcome(name) ?: Unit + + override fun addUniqueOutcome(name: String) = source.get("addUniqueOutcome")?.addUniqueOutcome(name) ?: Unit + + override fun addOutcomeWithValue( + name: String, + value: Float, + ) = source.get("addOutcomeWithValue")?.addOutcomeWithValue(name, value) ?: Unit +} + +internal class DeferredLocationManager( + source: ManagerSource, +) : DeferredManager(source), ILocationManager { + @Volatile + private var queuedIsShared: Boolean? = null + + override var isShared: Boolean + get() = source.peek()?.isShared ?: queuedIsShared ?: source.get("isShared")?.isShared ?: false + set(value) { + setOrQueue("isShared", { queuedIsShared = value }) { it.isShared = value } + } + + override suspend fun requestPermission(): Boolean = source.await("requestPermission")?.requestPermission() ?: false +} + +internal class DeferredJwtListeners( + source: ManagerSource, +) : DeferredManager(source) { + fun add(listener: IUserJwtInvalidatedListener) = + registerOrQueue("addUserJwtInvalidatedListener", listener) { it.addUserJwtInvalidatedListener(listener) } + + fun remove(listener: IUserJwtInvalidatedListener) = + unregisterOrCancel("removeUserJwtInvalidatedListener", "addUserJwtInvalidatedListener", listener) { + it.removeUserJwtInvalidatedListener(listener) + } +} diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt new file mode 100644 index 000000000..4218e4b11 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt @@ -0,0 +1,425 @@ +package com.onesignal.internal + +import com.onesignal.IUserJwtInvalidatedListener +import com.onesignal.inAppMessages.IInAppMessageClickListener +import com.onesignal.inAppMessages.IInAppMessageLifecycleListener +import com.onesignal.inAppMessages.IInAppMessagesManager +import com.onesignal.location.ILocationManager +import com.onesignal.notifications.INotificationClickListener +import com.onesignal.notifications.INotificationLifecycleListener +import com.onesignal.notifications.INotificationsManager +import com.onesignal.notifications.IPermissionObserver +import com.onesignal.session.ISessionManager +import com.onesignal.user.IUserManager +import com.onesignal.user.internal.jwt.JwtTokenStore +import com.onesignal.user.state.IUserStateObserver +import com.onesignal.user.subscriptions.IPushSubscription +import com.onesignal.user.subscriptions.IPushSubscriptionObserver +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.runBlocking + +private class FakeSource(private val real: T) : ManagerSource { + var ready = false + val ignored = mutableListOf() + val queued = mutableListOf() + val awaited = mutableListOf() + + override fun get(member: String): T? = if (ready) real else null.also { ignored += member } + + override fun peek(): T? = if (ready) real else null + + override suspend fun await(member: String): T? { + awaited += member + return get(member) + } + + override fun logQueued(member: String) { + queued += member + } +} + +// Before ready, nothing reaches the real manager and each call is logged by name; after, each forwards once. +private fun assertForwardsOnlyWhenReady( + real: T, + source: FakeSource, + deferred: T, + calls: List Unit>>, +) { + calls.forEach { (_, call) -> deferred.call() } + confirmVerified(real) + source.ignored shouldContainExactly calls.map { it.first } + + source.ready = true + calls.forEach { (_, call) -> + deferred.call() + verify(exactly = 1) { real.call() } + } + confirmVerified(real) +} + +// Before ready, registrations are queued (not forwarded); replay applies them in order, once. +private fun assertQueuesAndReplays( + real: T, + source: FakeSource, + deferred: DeferredManager, + calls: List Unit>>, +) { + @Suppress("UNCHECKED_CAST") + val manager = deferred as T + calls.forEach { (_, call) -> manager.call() } + confirmVerified(real) + source.queued shouldContainExactly calls.map { it.first } + source.ignored shouldBe emptyList() + + source.ready = true + deferred.replay(real) + verifyOrder { calls.forEach { (_, call) -> real.call() } } + deferred.replay(real) + calls.forEach { (_, call) -> verify(exactly = 1) { real.call() } } +} + +class DeferredManagersTests : FunSpec({ + test("DeferredUserManager returns defaults before ready and the real values after") { + val real = mockk() + every { real.onesignalId } returns "osid" + every { real.externalId } returns "extid" + every { real.getTags() } returns mapOf("k" to "v") + val source = FakeSource(real) + val deferred = DeferredUserManager(source) + + deferred.onesignalId shouldBe "" + deferred.externalId shouldBe "" + deferred.getTags() shouldBe emptyMap() + + source.ready = true + deferred.onesignalId shouldBe "osid" + deferred.externalId shouldBe "extid" + deferred.getTags() shouldBe mapOf("k" to "v") + } + + test("DeferredUserManager forwards every action only once ready") { + val real = mockk(relaxed = true) + val source = FakeSource(real) + + assertForwardsOnlyWhenReady( + real, + source, + DeferredUserManager(source), + listOf( + "setLanguage" to { setLanguage("en") }, + "addAlias" to { addAlias("label", "id") }, + "addAliases" to { addAliases(mapOf("label" to "id")) }, + "removeAlias" to { removeAlias("label") }, + "removeAliases" to { removeAliases(listOf("label")) }, + "addEmail" to { addEmail("a@b.c") }, + "removeEmail" to { removeEmail("a@b.c") }, + "addSms" to { addSms("+15555550100") }, + "removeSms" to { removeSms("+15555550100") }, + "addTag" to { addTag("key", "value") }, + "addTags" to { addTags(mapOf("key" to "value")) }, + "removeTag" to { removeTag("key") }, + "removeTags" to { removeTags(listOf("key")) }, + "trackEvent" to { trackEvent("event", mapOf("p" to 1)) }, + ), + ) + } + + test("DeferredUserManager queues observer registrations and replays them") { + val real = mockk(relaxed = true) + val source = FakeSource(real) + val observer = mockk() + + assertQueuesAndReplays( + real, + source, + DeferredUserManager(source), + listOf( + "addObserver" to { addObserver(observer) }, + ), + ) + } + + test("push subscription captured before ready forwards actions and replays observers") { + val realPush = mockk(relaxed = true) + every { realPush.id } returns "subid" + every { realPush.token } returns "token" + every { realPush.optedIn } returns true + val realUser = mockk(relaxed = true) + every { realUser.pushSubscription } returns realPush + val source = FakeSource(realUser) + val deferredUser = DeferredUserManager(source) + val captured = deferredUser.pushSubscription + val observer = mockk() + + captured.id shouldBe "" + captured.token shouldBe "" + captured.optedIn shouldBe false + captured.optIn() + captured.optOut() + captured.addObserver(observer) + confirmVerified(realPush) + source.ignored shouldContainExactly + listOf("pushSubscription.id", "pushSubscription.token", "pushSubscription.optedIn", "pushSubscription.optIn", "pushSubscription.optOut") + source.queued shouldContainExactly listOf("pushSubscription.addObserver") + + source.ready = true + deferredUser.replay(realUser) + verify(exactly = 1) { realPush.addObserver(observer) } + captured.id shouldBe "subid" + captured.token shouldBe "token" + captured.optedIn shouldBe true + captured.optIn() + captured.removeObserver(observer) + verify(exactly = 1) { + realPush.optIn() + realPush.removeObserver(observer) + } + } + + test("DeferredNotificationsManager returns defaults before ready, forwards actions and replays listeners") { + val real = mockk(relaxed = true) + every { real.permission } returns true + every { real.canRequestPermission } returns true + coEvery { real.requestPermission(any()) } returns true + val source = FakeSource(real) + val deferred = DeferredNotificationsManager(source) + val permissionObserver = mockk() + val lifecycleListener = mockk() + val clickListener = mockk() + + deferred.permission shouldBe false + deferred.canRequestPermission shouldBe false + runBlocking { deferred.requestPermission(true) } shouldBe false + source.ignored.clear() + + assertQueuesAndReplays( + real, + source, + deferred, + listOf( + "addPermissionObserver" to { addPermissionObserver(permissionObserver) }, + "addForegroundLifecycleListener" to { addForegroundLifecycleListener(lifecycleListener) }, + "addClickListener" to { addClickListener(clickListener) }, + ), + ) + + source.ready = false + assertForwardsOnlyWhenReady( + real, + source, + deferred, + listOf( + "removeNotification" to { removeNotification(1) }, + "removeGroupedNotifications" to { removeGroupedNotifications("group") }, + "clearAllNotifications" to { clearAllNotifications() }, + ), + ) + + deferred.permission shouldBe true + deferred.canRequestPermission shouldBe true + runBlocking { deferred.requestPermission(true) } shouldBe true + coVerify(exactly = 1) { real.requestPermission(true) } + } + + test("DeferredInAppMessagesManager forwards actions and replays listeners and paused") { + val real = mockk(relaxed = true) + every { real.paused } returns true + val source = FakeSource(real) + val deferred = DeferredInAppMessagesManager(source) + val lifecycleListener = mockk() + val clickListener = mockk() + + deferred.paused shouldBe false + source.ignored.clear() + + assertQueuesAndReplays( + real, + source, + deferred, + listOf( + "paused" to { paused = true }, + "addLifecycleListener" to { addLifecycleListener(lifecycleListener) }, + "addClickListener" to { addClickListener(clickListener) }, + ), + ) + + source.ready = false + assertForwardsOnlyWhenReady( + real, + source, + deferred, + listOf( + "addTrigger" to { addTrigger("key", "value") }, + "addTriggers" to { addTriggers(mapOf("key" to "value")) }, + "removeTrigger" to { removeTrigger("key") }, + "removeTriggers" to { removeTriggers(listOf("key")) }, + "clearTriggers" to { clearTriggers() }, + ), + ) + + deferred.paused shouldBe true + } + + test("DeferredSessionManager forwards every action only once ready") { + val real = mockk(relaxed = true) + val source = FakeSource(real) + + assertForwardsOnlyWhenReady( + real, + source, + DeferredSessionManager(source), + listOf( + "addOutcome" to { addOutcome("outcome") }, + "addUniqueOutcome" to { addUniqueOutcome("outcome") }, + "addOutcomeWithValue" to { addOutcomeWithValue("outcome", 1.5f) }, + ), + ) + } + + test("DeferredLocationManager replays isShared and suspends for requestPermission") { + val real = mockk(relaxed = true) + every { real.isShared } returns true + coEvery { real.requestPermission() } returns true + val source = FakeSource(real) + val deferred = DeferredLocationManager(source) + + deferred.isShared shouldBe false + runBlocking { deferred.requestPermission() } shouldBe false + source.ignored.clear() + + assertQueuesAndReplays(real, source, deferred, listOf("isShared" to { isShared = true })) + + deferred.isShared shouldBe true + runBlocking { deferred.requestPermission() } shouldBe true + source.awaited shouldContainExactly listOf("requestPermission", "requestPermission") + } + + test("suspend members resolve through await, never the blocking get") { + val real = mockk(relaxed = true) + val source = FakeSource(real).apply { ready = true } + + runBlocking { DeferredNotificationsManager(source).requestPermission(false) } + + source.awaited shouldContainExactly listOf("requestPermission") + } + + test("a registration made after replay forwards instead of queueing") { + val real = mockk(relaxed = true) + val source = FakeSource(real) + val deferred = DeferredUserManager(source) + val observer = mockk() + + deferred.replay(real) + deferred.addObserver(observer) + + source.queued shouldBe emptyList() + verify(exactly = 0) { real.addObserver(observer) } + source.ignored shouldContainExactly listOf("addObserver") + } + + test("a replayed call that throws does not stop the rest") { + val real = mockk(relaxed = true) + val clickListener = mockk() + every { real.paused = any() } throws IllegalStateException("module missing") + val source = FakeSource(real) + val deferred = DeferredInAppMessagesManager(source) + com.onesignal.debug.internal.logging.Logging.logLevel = com.onesignal.debug.LogLevel.NONE + + deferred.paused = true + deferred.addClickListener(clickListener) + deferred.replay(real) + + verify(exactly = 1) { real.addClickListener(clickListener) } + } + + test("a removal before replay cancels its queued add, so add and remove pairs never pile up") { + val real = mockk(relaxed = true) + val deferred = DeferredNotificationsManager(FakeSource(real)) + val kept = mockk() + + repeat(1_000) { + val listener = mockk() + deferred.addClickListener(listener) + deferred.removeClickListener(listener) + } + deferred.addClickListener(kept) + val pending = DeferredManager::class.java.getDeclaredField("pending").apply { isAccessible = true } + (pending.get(deferred) as List<*>).size shouldBe 1 + + deferred.replay(real) + verify(exactly = 1) { real.addClickListener(any()) } + verify(exactly = 1) { real.addClickListener(kept) } + verify(exactly = 0) { real.removeClickListener(any()) } + } + + test("a removal cancels only the matching queued add of the same kind") { + val real = mockk(relaxed = true) + every { real.pushSubscription } returns mockk(relaxed = true) + val deferred = DeferredUserManager(FakeSource(real)) + val first = mockk() + val second = mockk() + + deferred.addObserver(first) + deferred.addObserver(second) + deferred.removeObserver(first) + deferred.replay(real) + + verify(exactly = 0) { real.addObserver(first) } + verify(exactly = 1) { real.addObserver(second) } + } + + test("a setting changed twice before replay applies only its latest value, and reads back before init") { + val iam = mockk(relaxed = true) + val iamSource = FakeSource(iam) + val deferredIam = DeferredInAppMessagesManager(iamSource) + val location = mockk(relaxed = true) + val locationSource = FakeSource(location) + val deferredLocation = DeferredLocationManager(locationSource) + + deferredIam.paused = true + deferredIam.paused = false + deferredLocation.isShared = false + deferredLocation.isShared = true + + deferredIam.paused shouldBe false + deferredLocation.isShared shouldBe true + iamSource.ignored shouldBe emptyList() + locationSource.ignored shouldBe emptyList() + + deferredIam.replay(iam) + deferredLocation.replay(location) + verify(exactly = 1) { iam.paused = false } + verify(exactly = 0) { iam.paused = true } + verify(exactly = 1) { location.isShared = true } + verify(exactly = 0) { location.isShared = false } + } + + test("JWT invalidated listeners are queued before ready, replayed once, and a removal cancels its add") { + val store = mockk(relaxed = true) + val source = FakeSource(store) + val deferred = DeferredJwtListeners(source) + val kept = mockk() + val cancelled = mockk() + + deferred.add(kept) + deferred.add(cancelled) + deferred.remove(cancelled) + confirmVerified(store) + source.ignored shouldBe emptyList() + + deferred.replay(store) + deferred.replay(store) + verify(exactly = 1) { store.addUserJwtInvalidatedListener(kept) } + verify(exactly = 0) { store.addUserJwtInvalidatedListener(cancelled) } + verify(exactly = 0) { store.removeUserJwtInvalidatedListener(any()) } + } +}) From def35cf2e26d61b14f9569e8b649bbf4dc999657 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 20 Oct 2026 14:27:12 -0500 Subject: [PATCH 2/2] fix: [SDK-5538] bound the pre-init queue and forward to the replayed manager Caps queued listener registrations at 100 per manager; past the cap an add takes the unqueued path and is logged as ignored. Removals and setting changes are never capped, so they still cancel or replace queued entries. After replay, registrations and settings go to the manager already replayed into, so a listener that calls back in during replay, or a registration after init fails post-replay, is not dropped. A failing pushSubscription resolve during replay is logged instead of failing init. Co-authored-by: Cursor --- .../onesignal/internal/DeferredManagers.kt | 45 +++++++++--- .../internal/DeferredManagersTests.kt | 68 +++++++++++++++++-- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt index d043ed5d7..e954ca767 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/DeferredManagers.kt @@ -41,11 +41,18 @@ internal open class DeferredManager( private val pending = mutableListOf>() private var replayed = false + // Kept after replay: if init then fails, or a replayed listener calls back in, the gate is not ready + // but this manager already holds the replayed registrations, so later ones must reach it too. + private var replayedTo: T? = null + + /** The real manager if the gate is ready, else the one already replayed into, else null. Silent. */ + protected fun resolved(): T? = source.peek() ?: synchronized(pending) { replayedTo } + protected fun registerOrQueue( member: String, target: Any, call: (T) -> Unit, - ) = forwardOrQueue(member, call) { pending.add(Pending(member, target, call)) } + ) = forwardOrQueue(member, call) { pending.size < MAX_PENDING && pending.add(Pending(member, target, call)) } // Before replay the real manager holds nothing, so a removal only has to cancel the queued add. protected fun unregisterOrCancel( @@ -56,9 +63,11 @@ internal open class DeferredManager( ) = forwardOrQueue(member, call) { val index = pending.indexOfLast { it.member == addMember && it.target === target } if (index >= 0) pending.removeAt(index) + true } // [onQueued] runs under the queue lock, so a getter cache it sets always matches what replays. + // Not capped: a setting holds one slot and only replaces it. protected fun setOrQueue( member: String, onQueued: () -> Unit, @@ -67,19 +76,28 @@ internal open class DeferredManager( pending.removeAll { it.member == member } pending.add(Pending(member, null, call)) onQueued() + true } + // [enqueue] returns false when the queue is full; the call then takes the unqueued path and is logged as ignored. private fun forwardOrQueue( member: String, call: (T) -> Unit, - enqueue: () -> Unit, + enqueue: () -> Boolean, ) { source.peek()?.let { call(it) return } - val queued = synchronized(pending) { if (!replayed) true.also { enqueue() } else false } - if (queued) source.logQueued(member) else source.get(member)?.let(call) + val (queued, target) = + synchronized(pending) { + if (replayed) false to replayedTo else enqueue() to null + } + when { + queued -> source.logQueued(member) + target != null -> call(target) + else -> source.get(member)?.let(call) + } } @Suppress("TooGenericExceptionCaught") @@ -87,16 +105,22 @@ internal open class DeferredManager( val calls = synchronized(pending) { replayed = true + replayedTo = real pending.toList().also { pending.clear() } } for (entry in calls) { try { entry.call(real) } catch (e: Exception) { - Logging.error("Replaying '${entry.member}' after initWithContext failed", e) + Logging.error("Replaying '${entry.member}' threw; that queued call is lost", e) } } } + + internal companion object { + // Per manager. If init never succeeds the queue lives for the whole process, so it must stay bounded. + const val MAX_PENDING = 100 + } } @Suppress("TooManyFunctions") // one per IUserManager member @@ -152,9 +176,14 @@ internal class DeferredUserManager( properties: Map?, ) = source.get("trackEvent")?.trackEvent(name, properties) ?: Unit + @Suppress("TooGenericExceptionCaught") override fun replay(real: IUserManager) { super.replay(real) - deferredPush.replay(real.pushSubscription) + try { + deferredPush.replay(real.pushSubscription) + } catch (e: Exception) { + Logging.error("Replaying 'pushSubscription' threw; its queued observers are lost", e) + } } } @@ -226,7 +255,7 @@ internal class DeferredInAppMessagesManager( private var queuedPaused: Boolean? = null override var paused: Boolean - get() = source.peek()?.paused ?: queuedPaused ?: source.get("paused")?.paused ?: false + get() = resolved()?.paused ?: queuedPaused ?: source.get("paused")?.paused ?: false set(value) { setOrQueue("paused", { queuedPaused = value }) { it.paused = value } } @@ -276,7 +305,7 @@ internal class DeferredLocationManager( private var queuedIsShared: Boolean? = null override var isShared: Boolean - get() = source.peek()?.isShared ?: queuedIsShared ?: source.get("isShared")?.isShared ?: false + get() = resolved()?.isShared ?: queuedIsShared ?: source.get("isShared")?.isShared ?: false set(value) { setOrQueue("isShared", { queuedIsShared = value }) { it.isShared = value } } diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt index 4218e4b11..c4d451273 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/internal/DeferredManagersTests.kt @@ -312,18 +312,74 @@ class DeferredManagersTests : FunSpec({ source.awaited shouldContainExactly listOf("requestPermission") } - test("a registration made after replay forwards instead of queueing") { + test("a registration or setting made after replay reaches the replayed manager even if the gate is not ready") { + val real = mockk(relaxed = true) + every { real.paused } returns true + val source = FakeSource(real) + val deferred = DeferredInAppMessagesManager(source) + val listener = mockk() + + deferred.replay(real) + deferred.addClickListener(listener) + deferred.paused = true + + source.queued shouldBe emptyList() + source.ignored shouldBe emptyList() + verify(exactly = 1) { real.addClickListener(listener) } + verify(exactly = 1) { real.paused = true } + deferred.paused shouldBe true + } + + test("a listener that registers another while it is replayed reaches the real manager without waiting") { val real = mockk(relaxed = true) + every { real.pushSubscription } returns mockk(relaxed = true) val source = FakeSource(real) val deferred = DeferredUserManager(source) - val observer = mockk() + val first = mockk() + val second = mockk() + every { real.addObserver(first) } answers { deferred.addObserver(second) } + deferred.addObserver(first) deferred.replay(real) - deferred.addObserver(observer) - source.queued shouldBe emptyList() - verify(exactly = 0) { real.addObserver(observer) } - source.ignored shouldContainExactly listOf("addObserver") + verifyOrder { + real.addObserver(first) + real.addObserver(second) + } + source.ignored shouldBe emptyList() + } + + test("a full queue still applies removals and setting changes") { + val real = mockk(relaxed = true) + val source = FakeSource(real) + val deferred = DeferredInAppMessagesManager(source) + val removed = mockk() + + deferred.paused = true + deferred.addClickListener(removed) + repeat(DeferredManager.MAX_PENDING) { deferred.addClickListener(mockk()) } + deferred.removeClickListener(removed) + deferred.paused = false + + deferred.paused shouldBe false + deferred.replay(real) + verify(exactly = 0) { real.addClickListener(removed) } + verify(exactly = 0) { real.removeClickListener(any()) } + verify(exactly = 1) { real.paused = false } + verify(exactly = 0) { real.paused = true } + } + + test("the queue is bounded: registrations past the cap are logged as ignored, not queued") { + val real = mockk(relaxed = true) + val source = FakeSource(real) + val deferred = DeferredNotificationsManager(source) + + repeat(DeferredManager.MAX_PENDING + 5) { deferred.addClickListener(mockk()) } + + source.queued.size shouldBe DeferredManager.MAX_PENDING + source.ignored shouldContainExactly List(5) { "addClickListener" } + deferred.replay(real) + verify(exactly = DeferredManager.MAX_PENDING) { real.addClickListener(any()) } } test("a replayed call that throws does not stop the rest") {