Skip to content

Commit 71ed3a2

Browse files
committed
refactor(iv): drop jwt-invalidated buffer/replay; pure pub/sub matches iOS
The buffer-and-consume design (pendingJwtInvalidatedExternalId + jwtInvalidatedLock + consume-on-first-subscribe replay) was added to handle cold-start 401s when no listener was subscribed yet. iOS doesn't do this — it fires only to currently-subscribed listeners, late subscribers miss earlier events. Match iOS: drop the buffer entirely. Reverts the structure introduced by d137481 ("align jwt-invalidated replay with #2613 buffer-and-consume"). The simpler design here matches reference branch #2599. Side effects: - onModelReplaced becomes a no-op (no buffer to clear). Kept the override since ISingletonModelStoreChangeHandler requires it. - fireJwtInvalidated now uses OneSignalDispatchers.launchOnDefault per maintainer request (#3184062053) instead of a custom CoroutineScope(SupervisorJob() + Dispatchers.Default). - KDoc on IOneSignal.addUserJwtInvalidatedListener and the OneSignal.kt facade now spell out the pure-pub/sub semantics: "Subscribe early (e.g. in Application.onCreate) to avoid missing cold-start 401s." Test changes: drop the 4 buffer/replay-specific tests (listener-replay-buffered-event, consume-on-first-subscribe, fire-with-subscribers-no-buffer, onModelReplaced-clears-buffer). Replace with one test that confirms late subscribers don't receive earlier events.
1 parent ee27ce4 commit 71ed3a2

4 files changed

Lines changed: 21 additions & 140 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/IOneSignal.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,8 @@ interface IOneSignal {
151151
* a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
152152
* their backend and supplying it via [updateUserJwt].
153153
*
154-
* Listener replay: if an invalidation has already occurred before this listener is
155-
* registered, the most recent invalidation is delivered to the new listener so apps
156-
* that subscribe late don't miss the signal.
154+
* Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
155+
* event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
157156
*/
158157
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)
159158

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/OneSignal.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,8 @@ object OneSignal {
364364
* a 401 from the OneSignal backend). Apps should respond by fetching a fresh JWT from
365365
* their backend and supplying it via [updateUserJwt].
366366
*
367-
* Listener replay: if an invalidation has already occurred before this listener is
368-
* registered, the most recent invalidation is delivered to the new listener so apps
369-
* that subscribe late don't miss the signal.
367+
* Pure pub/sub: only listeners subscribed at the time of the invalidation receive the
368+
* event. Subscribe early (e.g. in `Application.onCreate`) to avoid missing events.
370369
*/
371370
@JvmStatic
372371
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) =

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/UserManager.kt

Lines changed: 11 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,8 @@ import com.onesignal.user.internal.subscriptions.SubscriptionList
2525
import com.onesignal.user.state.IUserStateObserver
2626
import com.onesignal.user.state.UserChangedState
2727
import com.onesignal.user.state.UserState
28+
import com.onesignal.common.threading.OneSignalDispatchers
2829
import com.onesignal.user.subscriptions.IPushSubscription
29-
import kotlinx.coroutines.CoroutineScope
30-
import kotlinx.coroutines.Dispatchers
31-
import kotlinx.coroutines.SupervisorJob
32-
import kotlinx.coroutines.launch
3330

3431
internal open class UserManager(
3532
private val _subscriptionManager: ISubscriptionManager,
@@ -55,22 +52,6 @@ internal open class UserManager(
5552

5653
private val jwtInvalidatedNotifier = EventProducer<IUserJwtInvalidatedListener>()
5754

58-
/**
59-
* Buffers a fired invalidation when no listeners are subscribed yet (e.g. SDK init / cold-start
60-
* 401 before app code wires up its listener). Consumed-on-first-subscribe by the next
61-
* [addJwtInvalidatedListener] call. Cleared automatically when the IdentityModel is replaced
62-
* (login or logout) so a stale event doesn't leak across users.
63-
*/
64-
private val jwtInvalidatedLock = Any()
65-
private var pendingJwtInvalidatedExternalId: String? = null
66-
67-
/**
68-
* Async dispatch of [IUserJwtInvalidatedListener] callbacks so HYDRATE / op-repo paths
69-
* that synchronously trigger invalidation don't run app code on the SDK's internal thread.
70-
* Replay (synchronous, on the calling thread) bypasses this scope.
71-
*/
72-
private val jwtInvalidatedDispatchScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
73-
7455
override val pushSubscription: IPushSubscription
7556
get() = _subscriptionManager.subscriptions.push
7657

@@ -91,47 +72,25 @@ internal open class UserManager(
9172
_jwtTokenStore.subscribe(this)
9273
}
9374

94-
/**
95-
* Subscribe a developer-facing listener for JWT-invalidated events. If an invalidation
96-
* has already fired before any listener was subscribed (e.g. early-startup 401), the
97-
* buffered event is delivered to this listener synchronously and consumed — subsequent
98-
* subscribers do not receive that same event.
99-
*/
10075
fun addJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
101-
val pendingExternalId: String?
102-
synchronized(jwtInvalidatedLock) {
103-
jwtInvalidatedNotifier.subscribe(listener)
104-
pendingExternalId = pendingJwtInvalidatedExternalId
105-
pendingJwtInvalidatedExternalId = null
106-
}
107-
// Deliver the replay outside the lock so a slow listener doesn't block other
108-
// subscribe/unsubscribe/fire calls. Replay runs on the caller's thread (sync).
109-
pendingExternalId?.let {
110-
runCatching { listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(it)) }
111-
.onFailure { ex -> Logging.warn("UserManager: replayed jwt-invalidated listener threw", ex) }
112-
}
76+
jwtInvalidatedNotifier.subscribe(listener)
11377
}
11478

11579
fun removeJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
11680
jwtInvalidatedNotifier.unsubscribe(listener)
11781
}
11882

11983
/**
120-
* Fire [IUserJwtInvalidatedListener.onUserJwtInvalidated] to all subscribed listeners on
121-
* a background dispatcher. If no listener is currently subscribed, buffer the externalId
122-
* to be delivered to the next listener that subscribes (consume-on-first-subscribe).
84+
* Fire [IUserJwtInvalidatedListener.onUserJwtInvalidated] to all currently-subscribed
85+
* listeners on a background dispatcher (so HYDRATE / op-repo paths that synchronously
86+
* trigger invalidation don't run app code on the SDK's internal thread). Pure pub/sub —
87+
* matches iOS: late subscribers don't get a replay of earlier events.
12388
*/
12489
fun fireJwtInvalidated(externalId: String) {
125-
synchronized(jwtInvalidatedLock) {
126-
if (jwtInvalidatedNotifier.hasSubscribers) {
127-
jwtInvalidatedDispatchScope.launch {
128-
jwtInvalidatedNotifier.fire { listener ->
129-
runCatching { listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) }
130-
.onFailure { ex -> Logging.warn("UserManager: jwt-invalidated listener threw", ex) }
131-
}
132-
}
133-
} else {
134-
pendingJwtInvalidatedExternalId = externalId
90+
OneSignalDispatchers.launchOnDefault {
91+
jwtInvalidatedNotifier.fire { listener ->
92+
runCatching { listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId)) }
93+
.onFailure { ex -> Logging.warn("UserManager: jwt-invalidated listener threw", ex) }
13594
}
13695
}
13796
}
@@ -342,13 +301,7 @@ internal open class UserManager(
342301
override fun onModelReplaced(
343302
model: IdentityModel,
344303
tag: String,
345-
) {
346-
// IdentityModel replacement = login or logout switch. Clear any buffered invalidation
347-
// so the next user's listener doesn't replay the previous user's stale event.
348-
synchronized(jwtInvalidatedLock) {
349-
pendingJwtInvalidatedExternalId = null
350-
}
351-
}
304+
) { }
352305

353306
override fun onModelUpdated(
354307
args: ModelChangedArgs,

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/user/internal/UserManagerTests.kt

Lines changed: 6 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,9 @@ class UserManagerTests : FunSpec({
227227
firedExternalId shouldBe "alice"
228228
}
229229

230-
test("listener replay: first subscriber after a no-listener fire receives the buffered event") {
231-
// Given
230+
test("late subscriber does not receive earlier events — pure pub/sub, no replay") {
231+
// Matches iOS: only listeners subscribed at the time of the fire receive the event.
232+
// No buffering of earlier invalidations for late subscribers.
232233
val jwtTokenStore = JwtTokenStore(MockPreferencesService())
233234
val userManager =
234235
UserManager(
@@ -239,86 +240,15 @@ class UserManagerTests : FunSpec({
239240
MockHelper.languageContext(),
240241
jwtTokenStore,
241242
)
242-
// Fire BEFORE any listener is registered → buffered.
243-
userManager.fireJwtInvalidated("alice")
244243

245-
// When: listener subscribes after the fire.
246-
var lateExternalId: String? = null
247-
userManager.addJwtInvalidatedListener { event -> lateExternalId = event.externalId }
248-
249-
// Then: replay delivers synchronously on subscribe.
250-
lateExternalId shouldBe "alice"
251-
}
252-
253-
test("buffered event is consumed by the first subscriber; second subscriber gets nothing") {
254-
val jwtTokenStore = JwtTokenStore(MockPreferencesService())
255-
val userManager =
256-
UserManager(
257-
mockk<ISubscriptionManager>(),
258-
MockHelper.identityModelStore(),
259-
MockHelper.propertiesModelStore(),
260-
MockHelper.customEventController(),
261-
MockHelper.languageContext(),
262-
jwtTokenStore,
263-
)
264-
userManager.fireJwtInvalidated("alice")
265-
266-
// First subscriber consumes the buffered event.
267-
var firstFired: String? = null
268-
userManager.addJwtInvalidatedListener { event -> firstFired = event.externalId }
269-
firstFired shouldBe "alice"
270-
271-
// Second subscriber must NOT receive a replay (buffer was already consumed).
272-
var secondFired = false
273-
userManager.addJwtInvalidatedListener { secondFired = true }
274-
secondFired shouldBe false
275-
}
276-
277-
test("fire when subscribers exist does NOT buffer for late subscribers") {
278-
val jwtTokenStore = JwtTokenStore(MockPreferencesService())
279-
val userManager =
280-
UserManager(
281-
mockk<ISubscriptionManager>(),
282-
MockHelper.identityModelStore(),
283-
MockHelper.propertiesModelStore(),
284-
MockHelper.customEventController(),
285-
MockHelper.languageContext(),
286-
jwtTokenStore,
287-
)
288-
289-
// Existing subscriber at the time of fire.
290-
userManager.addJwtInvalidatedListener { /* no-op */ }
244+
// Fire before any listener is registered.
291245
userManager.fireJwtInvalidated("alice")
292246
Thread.sleep(50) // allow async fire to dispatch
293247

294-
// Late subscriber must NOT receive a replay (event was not buffered, since
295-
// there was already a listener at fire time).
296-
var lateFired = false
297-
userManager.addJwtInvalidatedListener { lateFired = true }
298-
lateFired shouldBe false
299-
}
300-
301-
test("onModelReplaced clears any buffered invalidation event (login/logout switch)") {
302-
val identityModelStore = MockHelper.identityModelStore()
303-
val jwtTokenStore = JwtTokenStore(MockPreferencesService())
304-
val userManager =
305-
UserManager(
306-
mockk<ISubscriptionManager>(),
307-
identityModelStore,
308-
MockHelper.propertiesModelStore(),
309-
MockHelper.customEventController(),
310-
MockHelper.languageContext(),
311-
jwtTokenStore,
312-
)
313-
userManager.fireJwtInvalidated("alice")
314-
315-
// Simulate a user-switch: IdentityModelStore replaces the model and notifies
316-
// subscribers (UserManager subscribes itself in init).
317-
userManager.onModelReplaced(identityModelStore.model, "")
318-
319-
// Late subscriber must NOT receive the (now-cleared) buffered event.
248+
// Late subscriber must not receive the earlier event.
320249
var lateFired = false
321250
userManager.addJwtInvalidatedListener { lateFired = true }
251+
Thread.sleep(50)
322252
lateFired shouldBe false
323253
}
324254

0 commit comments

Comments
 (0)