Skip to content

Commit ca64cdf

Browse files
committed
Decouple JWT invalidated delivery and document threading
OperationRepo: on FAIL_UNAUTHORIZED, invalidate JWT and re-queue before notifying the app; invoke setJwtInvalidatedHandler synchronously with try/catch so listener failures do not hit executeOperations outer catch. UserManager.fireJwtInvalidated performs the single async hop (SupervisorJob + Dispatchers.Default) with Otel-style launch try/catch and per-listener isolation. IdentityVerificationService: call forceExecuteOperations before fireJwtInvalidated on HYDRATE. Documentation: IOperationRepo handler runs on the op-repo thread and must return quickly; public API documents background-thread delivery for IUserJwtInvalidatedListener, addUserJwtInvalidatedListener, and UserJwtInvalidatedEvent (LiveData postValue guidance). Tests: FAIL_UNAUTHORIZED JWT handler coverage; no artificial delay after sync repo handler.
1 parent cdc61b8 commit ca64cdf

File tree

9 files changed

+116
-11
lines changed

9 files changed

+116
-11
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,8 @@ interface IOneSignal {
256256
* Add a listener that will be called when a user's JWT is invalidated (e.g. expired
257257
* or rejected by the server). Use this to provide a fresh token via [updateUserJwt].
258258
*
259+
* The listener is invoked on a background thread; see [IUserJwtInvalidatedListener].
260+
*
259261
* @param listener The listener to add.
260262
*/
261263
fun addUserJwtInvalidatedListener(listener: IUserJwtInvalidatedListener)

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ package com.onesignal
22

33
/**
44
* Implement this interface and provide an instance to [OneSignal.addUserJwtInvalidatedListener]
5-
* in order to receive control when the JWT for the current user is invalidated.
5+
* to be notified when the JWT for a user is invalidated.
66
*
7+
* Callbacks are delivered on a background thread.
78
*/
89
interface IUserJwtInvalidatedListener {
910
/**
10-
* Called when the JWT is invalidated
11+
* Called when the JWT is invalidated for [UserJwtInvalidatedEvent.externalId].
12+
* Invoked on a background thread; see [IUserJwtInvalidatedListener] class documentation.
1113
*
12-
* @param event The user JWT that expired.
14+
* @param event Describes which user's JWT was invalidated.
1315
*/
1416
fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent)
1517
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ object OneSignal {
360360
* Add a listener that will be called when a user's JWT is invalidated (e.g. expired
361361
* or rejected by the server). Use this to provide a fresh token via [updateUserJwt].
362362
*
363+
* The listener is invoked on a background thread; see [IUserJwtInvalidatedListener].
364+
*
363365
* @param listener The listener to add.
364366
*/
365367
@JvmStatic

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package com.onesignal
22

33
/**
4-
* The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated], it provides access
5-
* to the external ID whose JWT has just been invalidated.
6-
*
4+
* The event passed into [IUserJwtInvalidatedListener.onUserJwtInvalidated]. Delivery occurs on
5+
* a background thread; see [IUserJwtInvalidatedListener].
76
*/
87
class UserJwtInvalidatedEvent(
98
val externalId: String,

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/IdentityVerificationService.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,21 @@ internal class IdentityVerificationService(
4242

4343
val useIV = model.useIdentityVerification
4444

45+
var jwtInvalidatedExternalId: String? = null
4546
if (useIV == true) {
4647
Logging.debug("IdentityVerificationService: IV enabled, purging anonymous operations")
4748
_operationRepo.removeOperationsWithoutExternalId()
4849

4950
val externalId = _identityModelStore.model.externalId
5051
if (externalId != null && _jwtTokenStore.getJwt(externalId) == null) {
51-
Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, firing invalidated event")
52-
_userManager.fireJwtInvalidated(externalId)
52+
Logging.debug("IdentityVerificationService: IV enabled but no JWT for $externalId, will fire invalidated event after queue wake")
53+
jwtInvalidatedExternalId = externalId
5354
}
5455
}
5556

5657
_operationRepo.forceExecuteOperations()
58+
59+
jwtInvalidatedExternalId?.let { _userManager.fireJwtInvalidated(it) }
5760
}
5861

5962
override fun onModelUpdated(

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/IOperationRepo.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ interface IOperationRepo {
5454
* Register a handler to be called when a runtime 401 Unauthorized response
5555
* invalidates a JWT. This allows the caller to notify the developer so they
5656
* can supply a fresh token via [OneSignal.updateUserJwt].
57+
*
58+
* The handler is invoked synchronously on the operation repo thread immediately
59+
* after JWT invalidation and re-queue. It must return quickly; defer heavy work
60+
* to another thread. The SDK default handler only schedules listener delivery.
5761
*/
5862
fun setJwtInvalidatedHandler(handler: ((String) -> Unit)?)
5963
}

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/operations/impl/OperationRepo.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,13 @@ internal class OperationRepo(
247247
}
248248
}
249249

250+
private fun dispatchJwtInvalidatedToApp(externalId: String) {
251+
_jwtInvalidatedHandler?.let { handler ->
252+
runCatching { handler(externalId) }
253+
.onFailure { Logging.warn("Failed to run JWT invalidated handler for externalId=$externalId", it) }
254+
}
255+
}
256+
250257
internal suspend fun executeOperations(ops: List<OperationQueueItem>) {
251258
try {
252259
val startingOp = ops.first()
@@ -280,11 +287,11 @@ internal class OperationRepo(
280287
val externalId = startingOp.operation.externalId
281288
if (externalId != null) {
282289
_jwtTokenStore.invalidateJwt(externalId)
283-
_jwtInvalidatedHandler?.invoke(externalId)
284290
Logging.warn("Operation execution failed with 401 Unauthorized, JWT invalidated for user: $externalId. Operations re-queued.")
285291
synchronized(queue) {
286292
ops.reversed().forEach { queue.add(0, it) }
287293
}
294+
dispatchJwtInvalidatedToApp(externalId)
288295
} else {
289296
Logging.warn("Operation execution failed with 401 Unauthorized for anonymous user. Operations dropped.")
290297
ops.forEach { _operationModelStore.remove(it.operation.id) }

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ import com.onesignal.user.state.IUserStateObserver
2424
import com.onesignal.user.state.UserChangedState
2525
import com.onesignal.user.state.UserState
2626
import com.onesignal.user.subscriptions.IPushSubscription
27+
import kotlinx.coroutines.CoroutineScope
28+
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.SupervisorJob
30+
import kotlinx.coroutines.launch
2731

2832
internal open class UserManager(
2933
private val _subscriptionManager: ISubscriptionManager,
@@ -47,6 +51,10 @@ internal open class UserManager(
4751
val changeHandlersNotifier = EventProducer<IUserStateObserver>()
4852
private val jwtInvalidatedNotifier = EventProducer<IUserJwtInvalidatedListener>()
4953

54+
// Coroutine scope for async JWT invalidated listener delivery (non-blocking)
55+
private val jwtInvalidatedAppCallbackScope =
56+
CoroutineScope(SupervisorJob() + Dispatchers.Default)
57+
5058
fun addJwtInvalidatedListener(listener: IUserJwtInvalidatedListener) {
5159
jwtInvalidatedNotifier.subscribe(listener)
5260
}
@@ -55,9 +63,31 @@ internal open class UserManager(
5563
jwtInvalidatedNotifier.unsubscribe(listener)
5664
}
5765

66+
/**
67+
* Schedules [IUserJwtInvalidatedListener] delivery on a background dispatcher so HYDRATE and
68+
* operation-repo paths can finish internal work before app code runs.
69+
*/
70+
@Suppress("TooGenericExceptionCaught")
5871
fun fireJwtInvalidated(externalId: String) {
59-
jwtInvalidatedNotifier.fire {
60-
it.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId))
72+
// Deliver asynchronously (non-blocking)
73+
jwtInvalidatedAppCallbackScope.launch {
74+
try {
75+
jwtInvalidatedNotifier.fire { listener ->
76+
try {
77+
listener.onUserJwtInvalidated(UserJwtInvalidatedEvent(externalId))
78+
} catch (t: Throwable) {
79+
Logging.warn(
80+
"onUserJwtInvalidated listener threw for externalId=$externalId",
81+
t,
82+
)
83+
}
84+
}
85+
} catch (t: Throwable) {
86+
Logging.warn(
87+
"Failed to deliver JWT invalidated event for externalId=$externalId",
88+
t,
89+
)
90+
}
6191
}
6292
}
6393

OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/operations/OperationRepoTests.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,62 @@ class OperationRepoTests : FunSpec({
960960
handlerCalledWith shouldBe "test-user"
961961
}
962962

963+
test("FAIL_UNAUTHORIZED still re-queues when JWT invalidated handler throws") {
964+
val configModelStore =
965+
MockHelper.configModelStore {
966+
it.useIdentityVerification = true
967+
}
968+
val identityModelStore =
969+
MockHelper.identityModelStore {
970+
it.externalId = "test-user"
971+
}
972+
val jwtTokenStore = mockk<JwtTokenStore>(relaxed = true)
973+
every { jwtTokenStore.getJwt("test-user") } returns "valid-jwt"
974+
975+
val operationModelStore =
976+
run {
977+
val operationStoreList = mutableListOf<Operation>()
978+
val mock = mockk<OperationModelStore>()
979+
every { mock.loadOperations() } just runs
980+
every { mock.list() } answers { operationStoreList.toList() }
981+
every { mock.add(any()) } answers { operationStoreList.add(firstArg<Operation>()) }
982+
every { mock.remove(any()) } answers {
983+
val id = firstArg<String>()
984+
operationStoreList.removeIf { it.id == id }
985+
}
986+
mock
987+
}
988+
989+
val executor = mockk<IOperationExecutor>()
990+
every { executor.operations } returns listOf("DUMMY_OPERATION")
991+
coEvery { executor.execute(any()) } returns
992+
ExecutionResponse(ExecutionResult.FAIL_UNAUTHORIZED) andThen
993+
ExecutionResponse(ExecutionResult.SUCCESS)
994+
995+
val operationRepo =
996+
OperationRepo(
997+
listOf(executor),
998+
operationModelStore,
999+
configModelStore,
1000+
Time(),
1001+
getNewRecordState(configModelStore),
1002+
jwtTokenStore,
1003+
identityModelStore,
1004+
)
1005+
1006+
operationRepo.setJwtInvalidatedHandler { throw IllegalStateException("app callback failed") }
1007+
1008+
val operation = mockOperation()
1009+
every { operation.externalId } returns "test-user"
1010+
1011+
operationRepo.start()
1012+
val response = operationRepo.enqueueAndWait(operation)
1013+
1014+
response shouldBe true
1015+
verify { jwtTokenStore.invalidateJwt("test-user") }
1016+
coVerify(exactly = 2) { executor.execute(any()) }
1017+
}
1018+
9631019
test("FAIL_UNAUTHORIZED drops operations for anonymous user") {
9641020
// Given
9651021
val mocks = Mocks()

0 commit comments

Comments
 (0)