Skip to content

Commit 67cd220

Browse files
sherwinskifadi-georgeAR Abdul Azeezabdulraqeeb33
authored
fix: [SDK-4474] self-heal SDK-4388-stuck push subscriptions on session start (#2636)
Co-authored-by: Fadi George <fadii925@gmail.com> Co-authored-by: AR Abdul Azeez <abdul@onesignal.com> Co-authored-by: abdulraqeeb33 <abdulraqeeb33@gmail.com>
1 parent e3d72a5 commit 67cd220

2 files changed

Lines changed: 243 additions & 5 deletions

File tree

OneSignalSDK/onesignal/core/src/main/java/com/onesignal/user/internal/operations/impl/executors/RefreshUserOperationExecutor.kt

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ import com.onesignal.core.internal.operations.Operation
1313
import com.onesignal.debug.LogLevel
1414
import com.onesignal.debug.internal.logging.Logging
1515
import com.onesignal.user.internal.backend.IUserBackendService
16+
import com.onesignal.user.internal.backend.SubscriptionObject
1617
import com.onesignal.user.internal.backend.SubscriptionObjectType
1718
import com.onesignal.user.internal.builduser.IRebuildUserService
1819
import com.onesignal.user.internal.identity.IdentityModel
1920
import com.onesignal.user.internal.identity.IdentityModelStore
2021
import com.onesignal.user.internal.jwt.JwtTokenStore
2122
import com.onesignal.user.internal.operations.RefreshUserOperation
23+
import com.onesignal.user.internal.operations.UpdateSubscriptionOperation
24+
import com.onesignal.user.internal.operations.impl.listeners.SubscriptionModelStoreListener
2225
import com.onesignal.user.internal.operations.impl.states.NewRecordsState
2326
import com.onesignal.user.internal.properties.PropertiesModel
2427
import com.onesignal.user.internal.properties.PropertiesModelStore
@@ -98,7 +101,13 @@ internal class RefreshUserOperationExecutor(
98101
// No longer hydrate timezone from remote, set locally
99102
propertiesModel.timezone = TimeUtils.getTimeZoneId()
100103

104+
// Retrieve the push subscription ID from the backend configuration model store. Used
105+
// both for re-attaching the locally-cached push model below and for the push
106+
// self-heal divergence check inside the loop.
107+
val pushSubscriptionIdFromConfig = _configModelStore.model.pushSubscriptionId
108+
101109
val subscriptionModels = mutableListOf<SubscriptionModel>()
110+
var pushSelfHealOperationForStuckSubscription: UpdateSubscriptionOperation? = null
102111
for (subscription in response.subscriptions) {
103112
val subscriptionModel = SubscriptionModel()
104113
subscriptionModel.id = subscription.id!!
@@ -126,10 +135,21 @@ internal class RefreshUserOperationExecutor(
126135
// so we don't want to cache these subscriptions from the backend.
127136
if (subscriptionModel.type != SubscriptionType.PUSH) {
128137
subscriptionModels.add(subscriptionModel)
138+
} else if (subscription.id == pushSubscriptionIdFromConfig && pushSelfHealOperationForStuckSubscription == null) {
139+
// Self-heal for users stuck at "Never Subscribed". Older SDK builds dispatched
140+
// the merged create-subscription + update-subscription(SUBSCRIBED) batch as a
141+
// POST /subscriptions carrying the already-existing server-side id; the server
142+
// responded 200 {} (no-op) and the local op queue was emptied, leaving the
143+
// server stuck at enabled:false / notification_types:0. Once that user
144+
// upgrades to a fixed SDK build, nothing in the queue triggers the fix path.
145+
//
146+
// Detect the divergence here (every session start runs RefreshUser) and
147+
// re-assert local truth via PATCH. "Device is the source of truth" is the
148+
// existing policy for push; this just enforces it across the wire when the
149+
// server has drifted out of sync.
150+
pushSelfHealOperationForStuckSubscription = buildPushSelfHealOperationForStuckSubscription(op, subscription, pushSubscriptionIdFromConfig)
129151
}
130152
}
131-
// Retrieve the push subscription ID from the backend configuration model store
132-
val pushSubscriptionIdFromConfig = _configModelStore.model.pushSubscriptionId
133153

134154
if (pushSubscriptionIdFromConfig != null) {
135155
// Retrieve the push subscription model from the model store
@@ -145,7 +165,10 @@ internal class RefreshUserOperationExecutor(
145165
_propertiesModelStore.replace(propertiesModel, ModelChangeTags.HYDRATE)
146166
_subscriptionsModelStore.replaceAll(subscriptionModels, ModelChangeTags.HYDRATE)
147167

148-
return ExecutionResponse(ExecutionResult.SUCCESS)
168+
return ExecutionResponse(
169+
ExecutionResult.SUCCESS,
170+
operations = pushSelfHealOperationForStuckSubscription?.let { listOf<Operation>(it) },
171+
)
149172
} catch (ex: BackendException) {
150173
val responseType = NetworkUtils.getResponseStatusType(ex.statusCode)
151174

@@ -175,6 +198,50 @@ internal class RefreshUserOperationExecutor(
175198
}
176199
}
177200

201+
/**
202+
* Push self-heal builder. Returns an [UpdateSubscriptionOperation] iff the cached push
203+
* subscription model resolves to enabled-and-opted-in but the server response recorded
204+
* the same id as disabled. Returns null otherwise (no divergence — no-op).
205+
*
206+
* Caller has already verified that [serverSubscription.id] matches the cached
207+
* `pushSubscriptionId`, so this method only inspects the local model + server flags.
208+
*/
209+
private fun buildPushSelfHealOperationForStuckSubscription(
210+
op: RefreshUserOperation,
211+
serverSubscription: SubscriptionObject,
212+
pushSubscriptionId: String,
213+
): UpdateSubscriptionOperation? {
214+
val cachedPushSubscriptionModel = _subscriptionsModelStore.get(pushSubscriptionId)
215+
if (cachedPushSubscriptionModel == null || cachedPushSubscriptionModel.type != SubscriptionType.PUSH) {
216+
return null
217+
}
218+
219+
val (localEnabled, localStatus) = SubscriptionModelStoreListener.getSubscriptionEnabledAndStatus(cachedPushSubscriptionModel)
220+
val serverEnabled = (serverSubscription.enabled == true) && ((serverSubscription.notificationTypes ?: 0) > 0)
221+
val divergent = localEnabled && !serverEnabled
222+
223+
return if (divergent) {
224+
Logging.info(
225+
"RefreshUserOperationExecutor: push subscription $pushSubscriptionId diverged from server " +
226+
"(server enabled=${serverSubscription.enabled} notificationTypes=${serverSubscription.notificationTypes}; " +
227+
"local opted-in and SUBSCRIBED). Enqueuing follow-up update-subscription op to re-assert local " +
228+
"truth via PATCH /subscriptions/{id}.",
229+
)
230+
UpdateSubscriptionOperation(
231+
op.appId,
232+
op.onesignalId,
233+
_identityModelStore.model.externalId,
234+
pushSubscriptionId,
235+
cachedPushSubscriptionModel.type,
236+
localEnabled,
237+
cachedPushSubscriptionModel.address,
238+
localStatus,
239+
)
240+
} else {
241+
null
242+
}
243+
}
244+
178245
companion object {
179246
const val REFRESH_USER = "refresh-user"
180247
}

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

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import com.onesignal.common.exceptions.BackendException
55
import com.onesignal.common.modeling.ModelChangeTags
66
import com.onesignal.core.internal.operations.ExecutionResult
77
import com.onesignal.core.internal.operations.Operation
8+
import com.onesignal.debug.LogLevel
9+
import com.onesignal.debug.internal.logging.Logging
810
import com.onesignal.mocks.MockHelper
911
import com.onesignal.user.internal.backend.CreateUserResponse
1012
import com.onesignal.user.internal.backend.IUserBackendService
@@ -18,6 +20,7 @@ import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getIdentit
1820
import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getJwtTokenStore
1921
import com.onesignal.user.internal.operations.ExecutorMocks.Companion.getNewRecordState
2022
import com.onesignal.user.internal.operations.impl.executors.RefreshUserOperationExecutor
23+
import com.onesignal.user.internal.operations.impl.executors.SubscriptionOperationExecutor
2124
import com.onesignal.user.internal.properties.PropertiesModel
2225
import com.onesignal.user.internal.subscriptions.SubscriptionModel
2326
import com.onesignal.user.internal.subscriptions.SubscriptionModelStore
@@ -61,8 +64,10 @@ class RefreshUserOperationExecutorTests : FunSpec({
6164
mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId, "aliasLabel1" to "aliasValue1"),
6265
PropertiesObject(country = remoteCountry, language = remoteLanguage, timezoneId = remoteTimeZone, tags = remoteTags),
6366
listOf(
64-
SubscriptionObject(existingSubscriptionId1, SubscriptionObjectType.ANDROID_PUSH, enabled = true, token = "on-backend-push-token"),
65-
SubscriptionObject(remoteSubscriptionId1, SubscriptionObjectType.ANDROID_PUSH, enabled = true, token = "pushToken2"),
67+
// notificationTypes = 1 keeps server-side view healthy so the push
68+
// self-heal divergence check is a no-op for this happy-path test.
69+
SubscriptionObject(existingSubscriptionId1, SubscriptionObjectType.ANDROID_PUSH, enabled = true, notificationTypes = 1, token = "on-backend-push-token"),
70+
SubscriptionObject(remoteSubscriptionId1, SubscriptionObjectType.ANDROID_PUSH, enabled = true, notificationTypes = 1, token = "pushToken2"),
6671
SubscriptionObject(remoteSubscriptionId2, SubscriptionObjectType.EMAIL, token = "name@company.com"),
6772
),
6873
)
@@ -321,6 +326,172 @@ class RefreshUserOperationExecutorTests : FunSpec({
321326
}
322327
}
323328

329+
// Push self-heal divergence detection. Verifies that when the device-cached push
330+
// subscription resolves to enabled-and-opted-in but the GET /users response returns the
331+
// same subscription as disabled (the "Never Subscribed" stuck state),
332+
// RefreshUserOperationExecutor emits a follow-up UpdateSubscriptionOperation to re-assert
333+
// local truth via PATCH.
334+
fun buildSelfHealHarness(
335+
serverPushEnabled: Boolean,
336+
serverNotificationTypes: Int?,
337+
localOptedIn: Boolean,
338+
localStatus: SubscriptionStatus,
339+
localAddress: String,
340+
): Triple<RefreshUserOperationExecutor, SubscriptionModel, IUserBackendService> {
341+
val mockUserBackendService = mockk<IUserBackendService>()
342+
coEvery { mockUserBackendService.getUser(appId, IdentityConstants.ONESIGNAL_ID, remoteOneSignalId) } returns
343+
CreateUserResponse(
344+
mapOf(IdentityConstants.ONESIGNAL_ID to remoteOneSignalId),
345+
PropertiesObject(),
346+
listOf(
347+
SubscriptionObject(
348+
existingSubscriptionId1,
349+
SubscriptionObjectType.ANDROID_PUSH,
350+
enabled = serverPushEnabled,
351+
notificationTypes = serverNotificationTypes,
352+
token = "on-backend-push-token",
353+
),
354+
),
355+
)
356+
357+
val mockIdentityModelStore = MockHelper.identityModelStore()
358+
val mockIdentityModel = IdentityModel()
359+
mockIdentityModel.onesignalId = remoteOneSignalId
360+
every { mockIdentityModelStore.model } returns mockIdentityModel
361+
every { mockIdentityModelStore.replace(any(), any()) } just runs
362+
363+
val mockPropertiesModelStore = MockHelper.propertiesModelStore()
364+
val mockPropertiesModel = PropertiesModel()
365+
mockPropertiesModel.onesignalId = remoteOneSignalId
366+
every { mockPropertiesModelStore.model } returns mockPropertiesModel
367+
every { mockPropertiesModelStore.replace(any(), any()) } just runs
368+
369+
val mockSubscriptionsModelStore = mockk<SubscriptionModelStore>()
370+
every { mockSubscriptionsModelStore.replaceAll(any(), any()) } just runs
371+
372+
val cachedPushSubscriptionModel = SubscriptionModel()
373+
cachedPushSubscriptionModel.id = existingSubscriptionId1
374+
cachedPushSubscriptionModel.type = SubscriptionType.PUSH
375+
cachedPushSubscriptionModel.address = localAddress
376+
cachedPushSubscriptionModel.status = localStatus
377+
cachedPushSubscriptionModel.optedIn = localOptedIn
378+
every { mockSubscriptionsModelStore.get(existingSubscriptionId1) } returns cachedPushSubscriptionModel
379+
380+
val mockConfigModelStore =
381+
MockHelper.configModelStore {
382+
it.pushSubscriptionId = existingSubscriptionId1
383+
}
384+
385+
val executor =
386+
RefreshUserOperationExecutor(
387+
mockUserBackendService,
388+
mockIdentityModelStore,
389+
mockPropertiesModelStore,
390+
mockSubscriptionsModelStore,
391+
mockConfigModelStore,
392+
mockk<IRebuildUserService>(),
393+
getNewRecordState(),
394+
getJwtTokenStore(), getIdentityVerificationService(),
395+
)
396+
397+
return Triple(executor, cachedPushSubscriptionModel, mockUserBackendService)
398+
}
399+
400+
test("push self-heal: enqueues follow-up update-subscription op when server is stuck-disabled but local is enabled") {
401+
// Given: server view says push is disabled (the stuck state), local view says enabled
402+
val (executor, _, _) =
403+
buildSelfHealHarness(
404+
serverPushEnabled = false,
405+
serverNotificationTypes = 0,
406+
localOptedIn = true,
407+
localStatus = SubscriptionStatus.SUBSCRIBED,
408+
localAddress = onDevicePushToken,
409+
)
410+
411+
// Suppress logcat output for the self-heal WARN line so the unmocked android.util.Log
412+
// in robolectric-free unit tests doesn't blow up. Restored in finally.
413+
val originalLogLevel = Logging.logLevel
414+
Logging.logLevel = LogLevel.NONE
415+
try {
416+
// When
417+
val response = executor.execute(listOf(RefreshUserOperation(appId, remoteOneSignalId, null)))
418+
419+
// Then a single UpdateSubscriptionOperation is returned to the op repo
420+
response.result shouldBe ExecutionResult.SUCCESS
421+
response.operations?.count() shouldBe 1
422+
val followup = response.operations!![0]
423+
(followup is UpdateSubscriptionOperation) shouldBe true
424+
followup as UpdateSubscriptionOperation
425+
followup.name shouldBe SubscriptionOperationExecutor.UPDATE_SUBSCRIPTION
426+
followup.appId shouldBe appId
427+
followup.onesignalId shouldBe remoteOneSignalId
428+
followup.subscriptionId shouldBe existingSubscriptionId1
429+
followup.type shouldBe SubscriptionType.PUSH
430+
followup.enabled shouldBe true
431+
followup.address shouldBe onDevicePushToken
432+
followup.status shouldBe SubscriptionStatus.SUBSCRIBED
433+
} finally {
434+
Logging.logLevel = originalLogLevel
435+
}
436+
}
437+
438+
test("push self-heal: does NOT enqueue follow-up op when server matches local (healthy push subscription)") {
439+
// Given: server already enabled and notificationTypes=1, local also enabled
440+
val (executor, _, _) =
441+
buildSelfHealHarness(
442+
serverPushEnabled = true,
443+
serverNotificationTypes = 1,
444+
localOptedIn = true,
445+
localStatus = SubscriptionStatus.SUBSCRIBED,
446+
localAddress = onDevicePushToken,
447+
)
448+
449+
// When
450+
val response = executor.execute(listOf(RefreshUserOperation(appId, remoteOneSignalId, null)))
451+
452+
// Then no follow-up ops emitted
453+
response.result shouldBe ExecutionResult.SUCCESS
454+
response.operations shouldBe null
455+
}
456+
457+
test("push self-heal: does NOT enqueue follow-up op when local is opted out (UNSUBSCRIBE is intentional)") {
458+
// Given: server is disabled, but so is local (user explicitly opted out)
459+
val (executor, _, _) =
460+
buildSelfHealHarness(
461+
serverPushEnabled = false,
462+
serverNotificationTypes = -2,
463+
localOptedIn = false,
464+
localStatus = SubscriptionStatus.SUBSCRIBED,
465+
localAddress = onDevicePushToken,
466+
)
467+
468+
// When
469+
val response = executor.execute(listOf(RefreshUserOperation(appId, remoteOneSignalId, null)))
470+
471+
// Then no follow-up — opt-out is the user's intent, not divergence
472+
response.result shouldBe ExecutionResult.SUCCESS
473+
response.operations shouldBe null
474+
}
475+
476+
test("push self-heal: does NOT enqueue follow-up op when local has NO_PERMISSION (OS-level disable is real)") {
477+
// Given: server disabled, local also has no notification permission
478+
val (executor, _, _) =
479+
buildSelfHealHarness(
480+
serverPushEnabled = false,
481+
serverNotificationTypes = 0,
482+
localOptedIn = true,
483+
localStatus = SubscriptionStatus.NO_PERMISSION,
484+
localAddress = onDevicePushToken,
485+
)
486+
487+
// When
488+
val response = executor.execute(listOf(RefreshUserOperation(appId, remoteOneSignalId, null)))
489+
490+
// Then no follow-up — OS denies notifications, server's view is correct
491+
response.result shouldBe ExecutionResult.SUCCESS
492+
response.operations shouldBe null
493+
}
494+
324495
test("refresh user is retried when backend returns MISSING, but isInMissingRetryWindow") {
325496
// Given
326497
val mockUserBackendService = mockk<IUserBackendService>()

0 commit comments

Comments
 (0)