Updated on 2026-08-14
This commit is contained in:
parent
457e8870a4
commit
7c82be8da5
2 changed files with 151 additions and 20 deletions
|
|
@ -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 },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue