Updated on 2026-08-14

This commit is contained in:
Tangem 2026-08-04 12:59:14 +03:00
parent 457e8870a4
commit 7c82be8da5
2 changed files with 151 additions and 20 deletions

View file

@ -128,32 +128,48 @@ internal class PushNotificationSettingsModel @Inject constructor(
fun onResume() { fun onResume() {
osNotificationsEnabled.value = systemNotificationsStateProvider.areNotificationsEnabled() osNotificationsEnabled.value = systemNotificationsStateProvider.areNotificationsEnabled()
if (cachedPrefs != null) autoApplyFirstActivationIfNeeded() if (cachedPrefs == null) return
// An explicitly tapped toggle wins over the silent first-activation rule.
if (pendingPermissionToggle != null) {
modelScope.launch { applyPendingToggle() }
} else {
autoApplyFirstActivationIfNeeded()
}
} }
fun onPermissionResult(isGranted: Boolean) { fun onPermissionResult(isGranted: Boolean) {
val tapped = pendingPermissionToggle
pendingPermissionToggle = null
modelScope.launch { modelScope.launch {
// Enable all three only on a real grant that actually turned notifications on: an // isGranted alone is unreliable: an already-granted-but-OS-disabled wallet returns true
// already-granted-but-OS-disabled wallet returns isGranted=true instantly, so also require // instantly, and pre-Android 13 there is no runtime permission at all.
// areNotificationsEnabled() before triggering the rule; a deny routes the user to settings.
val isNotificationsEnabled = systemNotificationsStateProvider.areNotificationsEnabled() val isNotificationsEnabled = systemNotificationsStateProvider.areNotificationsEnabled()
osNotificationsEnabled.value = isNotificationsEnabled osNotificationsEnabled.value = isNotificationsEnabled
analyticsEventHandler.send(PushNotificationAnalyticEvents.PermissionStatus(isAllowed = isGranted)) analyticsEventHandler.send(PushNotificationAnalyticEvents.PermissionStatus(isAllowed = isGranted))
if (!isGranted || !isNotificationsEnabled) { if (isNotificationsEnabled) {
markFirstActivationDone(userWalletId) applyPendingToggle()
} else {
// The tapped toggle stays pending until the user comes back from the system settings.
showEnableNotificationsDialog() showEnableNotificationsDialog()
return@launch
} }
}
}
if (isFirstActivationDone(userWalletId)) { /**
tapped?.let { applyOptimisticToggle(it, newValue = true) } * Applies the toggle the user tapped before the permission ask, once notifications are actually enabled.
} else if (enableAllCategories(initiatingToggle = tapped)) { * A refusal never fixes the first-activation flag, so the rule survives until a real grant.
// Fix the flag only after a successful enable, so a transient failure can retry. */
markFirstActivationDone(userWalletId) private suspend fun applyPendingToggle() {
} val tapped = pendingPermissionToggle ?: return
if (!osNotificationsEnabled.value || cachedPrefs == null) return
// Consumed before the first suspension point, so a concurrent resume cannot apply it twice.
pendingPermissionToggle = null
if (isFirstActivationDone(userWalletId)) {
applyOptimisticToggle(tapped, newValue = true)
} else if (enableAllCategories(initiatingToggle = tapped)) {
// Fix the flag only after a successful enable, so a transient failure can retry.
markFirstActivationDone(userWalletId)
} }
} }
@ -373,7 +389,8 @@ internal class PushNotificationSettingsModel @Inject constructor(
), ),
secondAction = EventMessageAction( secondAction = EventMessageAction(
title = resourceReference(R.string.push_notifications_permission_alert_negative_button), title = resourceReference(R.string.push_notifications_permission_alert_negative_button),
onClick = {}, // Declining is a final answer: the tapped toggle is dropped.
onClick = { pendingPermissionToggle = null },
), ),
), ),
) )

View file

@ -7,6 +7,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.ui.UiMessageSender import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider import com.tangem.core.navigation.notifications.SystemNotificationsStateProvider
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.navigation.settings.SettingsManager import com.tangem.core.navigation.settings.SettingsManager
import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.notifications.repository.NotificationsRepository import com.tangem.domain.notifications.repository.NotificationsRepository
@ -29,6 +30,7 @@ import io.mockk.coVerify
import io.mockk.coVerifyOrder import io.mockk.coVerifyOrder
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -341,7 +343,7 @@ class PushNotificationSettingsModelTest {
} }
@Test @Test
fun `GIVEN permission granted but notifications disabled WHEN result THEN no all-three and settings dialog`() = fun `GIVEN permission granted but notifications disabled WHEN result THEN dialog shown AND flag not marked`() =
runTest { runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1) val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse()) flow.tryEmit(allFalse())
@ -359,7 +361,7 @@ class PushNotificationSettingsModelTest {
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) } coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { updatePreference(any(), any(), any()) } coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
coVerify(exactly = 1) { messageSender.send(any()) } coVerify(exactly = 1) { messageSender.send(any()) }
coVerify(exactly = 1) { markFirstActivationDone(userWalletId) } coVerify(exactly = 0) { markFirstActivationDone(userWalletId) }
} }
@Test @Test
@ -464,7 +466,7 @@ class PushNotificationSettingsModelTest {
} }
@Test @Test
fun `WHEN Deny THEN dialog shown AND flag marked AND no writes`() = runTest { fun `WHEN Deny THEN dialog shown AND flag not marked AND no writes`() = runTest {
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1) val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse()) flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow) val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow)
@ -478,7 +480,119 @@ class PushNotificationSettingsModelTest {
advanceUntilIdle() advanceUntilIdle()
coVerify(exactly = 1) { messageSender.send(any()) } coVerify(exactly = 1) { messageSender.send(any()) }
coVerify(exactly = 1) { markFirstActivationDone(userWalletId) } coVerify(exactly = 0) { markFirstActivationDone(userWalletId) }
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
}
@Test
fun `GIVEN tapped toggle refused WHEN notifications enabled in settings THEN toggle applied on resume`() =
runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
coEvery {
updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true)
} returns Either.Right(Unit)
val model = model(osEnabled = false, firstActivationDone = true, preferencesFlow = flow)
advanceUntilIdle()
// Act: tap -> refused -> notifications enabled in the OS settings -> back to the screen.
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle()
model.onPermissionResult(isGranted = false)
advanceUntilIdle()
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onResume()
advanceUntilIdle()
// Assert
coVerify(exactly = 1) { updatePreference(userWalletId, PushNotificationCategory.OffersUpdates, true) }
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
assertThat(offers.isOn).isTrue()
}
@Test
fun `GIVEN first activation not done AND toggle refused WHEN notifications enabled THEN all three enabled`() =
runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = false, preferencesFlow = flow)
advanceUntilIdle()
// Act
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle()
model.onPermissionResult(isGranted = false)
advanceUntilIdle()
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onResume()
advanceUntilIdle()
// Assert: the refusal did not consume the first-activation rule, so it runs on the late grant.
coVerify(exactly = 1) { setNotificationsEnabled(userWalletId, isEnabled = true) }
coVerify(exactly = 1) { setAllPreferences(userWalletId, true, true, true) }
coVerify(exactly = 1) { markFirstActivationDone(userWalletId) }
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
}
@Test
fun `GIVEN tapped toggle refused WHEN returning with notifications still disabled THEN nothing is written`() =
runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = true, preferencesFlow = flow)
advanceUntilIdle()
// Act
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle()
model.onPermissionResult(isGranted = false)
advanceUntilIdle()
model.onResume()
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
coVerify(exactly = 0) { markFirstActivationDone(any()) }
val offers = (model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
assertThat(offers.isOn).isFalse()
}
@Test
fun `GIVEN dialog declined WHEN notifications enabled afterwards THEN tapped toggle is not applied`() = runTest {
// Arrange
val flow = MutableSharedFlow<WalletPushNotificationPreferences>(replay = 1)
flow.tryEmit(allFalse())
val model = model(osEnabled = false, firstActivationDone = true, preferencesFlow = flow)
advanceUntilIdle()
val dialog = slot<DialogMessage>()
// Act
(model.uiState.value as PushNotificationSettingsUM.Content)
.toggles.first { it.id == ToggleId.OffersUpdates }
.onCheckedChange(true)
advanceUntilIdle()
model.onPermissionResult(isGranted = false)
advanceUntilIdle()
verify { messageSender.send(capture(dialog)) }
dialog.captured.secondAction?.onClick?.invoke()
every { systemNotificationsStateProvider.areNotificationsEnabled() } returns true
model.onResume()
advanceUntilIdle()
// Assert
coVerify(exactly = 0) { updatePreference(any(), any(), any()) } coVerify(exactly = 0) { updatePreference(any(), any(), any()) }
coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) } coVerify(exactly = 0) { setAllPreferences(any(), any(), any(), any()) }
} }