From dc31a5745ed5974777d4ee92ae8d6ddc99d5f015 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 16 Oct 2026 11:57:42 -0500 Subject: [PATCH] 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()) } + } +})