From 13c8cc08a6d18b7a1849c850c4dfd86d159a1c09 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:09:26 -0400 Subject: [PATCH] [PM-33506] feat: Add PremiumStatusChanged push notification support (#6656) --- .../data/platform/manager/PushManager.kt | 6 ++ .../data/platform/manager/PushManagerImpl.kt | 25 ++++++++ .../manager/model/NotificationPayload.kt | 11 +++- .../manager/model/NotificationType.kt | 3 + .../manager/model/PremiumStatusChangedData.kt | 12 ++++ .../data/platform/manager/PushManagerTest.kt | 61 +++++++++++++++++++ 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PremiumStatusChangedData.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManager.kt index d6f07394b1..57b9f42a69 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManager.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData +import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData @@ -29,6 +30,11 @@ interface PushManager { */ val passwordlessRequestFlow: Flow + /** + * Flow that represents premium status change notifications. + */ + val premiumStatusChangedFlow: Flow + /** * Flow that represents requests intended to trigger a sync cipher delete. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt index 827f26e517..d833bffe72 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData import com.x8bit.bitwarden.data.platform.manager.model.NotificationPayload import com.x8bit.bitwarden.data.platform.manager.model.NotificationType import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData +import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData import com.x8bit.bitwarden.data.platform.manager.model.PushNotificationLogOutReason import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData @@ -63,6 +64,8 @@ class PushManagerImpl @Inject constructor( private val mutableLogoutSharedFlow = bufferedMutableSharedFlow() private val mutablePasswordlessRequestSharedFlow = bufferedMutableSharedFlow() + private val mutablePremiumStatusChangedSharedFlow = + bufferedMutableSharedFlow() private val mutableSyncCipherDeleteSharedFlow = bufferedMutableSharedFlow() private val mutableSyncCipherUpsertSharedFlow = @@ -86,6 +89,9 @@ class PushManagerImpl @Inject constructor( override val passwordlessRequestFlow: SharedFlow get() = mutablePasswordlessRequestSharedFlow.asSharedFlow() + override val premiumStatusChangedFlow: SharedFlow + get() = mutablePremiumStatusChangedSharedFlow.asSharedFlow() + override val syncCipherDeleteFlow: SharedFlow get() = mutableSyncCipherDeleteSharedFlow.asSharedFlow() @@ -305,6 +311,25 @@ class PushManagerImpl @Inject constructor( } ?.let { mutableSyncSendDeleteSharedFlow.tryEmit(it) } } + + NotificationType.PREMIUM_STATUS_CHANGED -> { + json + .decodeFromString( + string = notification.payload, + ) + .takeIf { it.userId != null && it.isPremium != null } + ?.let { payload -> + mutablePremiumStatusChangedSharedFlow.tryEmit( + PremiumStatusChangedData( + userId = requireNotNull(payload.userId), + isPremium = requireNotNull(payload.isPremium), + ), + ) + mutableFullSyncSharedFlow.tryEmit( + requireNotNull(payload.userId), + ) + } + } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt index a362f16c77..a64bd3d790 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationPayload.kt @@ -10,7 +10,7 @@ import java.time.Instant /** * The payload of a push notification. * - * Note: The data we receive is not always reliable, so everything is nullable and we validate the + * Note: The data we receive is not always reliable, so everything is nullable, and we validate the * data in the [PushManager] as necessary. */ @OptIn(ExperimentalSerializationApi::class) @@ -97,4 +97,13 @@ sealed class NotificationPayload { data class SyncNotification( @JsonNames("UserId", "userId") override val userId: String?, ) : NotificationPayload() + + /** + * A notification payload for premium status changes. + */ + @Serializable + data class PremiumStatusChangedNotification( + @JsonNames("UserId", "userId") override val userId: String?, + @JsonNames("Premium", "premium") val isPremium: Boolean?, + ) : NotificationPayload() } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationType.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationType.kt index 46400aaeff..53ab739430 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationType.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/NotificationType.kt @@ -60,6 +60,9 @@ enum class NotificationType { @SerialName("16") AUTH_REQUEST_RESPONSE, + + @SerialName("27") + PREMIUM_STATUS_CHANGED, } @Keep diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PremiumStatusChangedData.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PremiumStatusChangedData.kt new file mode 100644 index 0000000000..0569223bca --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/PremiumStatusChangedData.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.data.platform.manager.model + +/** + * Data class representing a premium status changed push notification. + * + * @property userId The user ID associated with the status change. + * @property isPremium Whether premium is now enabled. + */ +data class PremiumStatusChangedData( + val userId: String, + val isPremium: Boolean, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt index d69cb8f988..f47e6a1d99 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt @@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData +import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData @@ -163,6 +164,48 @@ class PushManagerTest { } } + @Test + fun `onMessageReceived with premium status changed emits to premiumStatusChangedFlow`() = + runTest { + pushManager.premiumStatusChangedFlow.test { + pushManager.onMessageReceived( + PREMIUM_STATUS_CHANGED_NOTIFICATION_MAP, + ) + assertEquals( + PremiumStatusChangedData( + userId = "078966a2-93c2-4618-ae2a-0a2394c88d37", + isPremium = true, + ), + awaitItem(), + ) + } + } + + @Test + fun `onMessageReceived with premium status changed also emits to fullSyncFlow`() = + runTest { + pushManager.fullSyncFlow.test { + pushManager.onMessageReceived( + PREMIUM_STATUS_CHANGED_NOTIFICATION_MAP, + ) + assertEquals( + "078966a2-93c2-4618-ae2a-0a2394c88d37", + awaitItem(), + ) + } + } + + @Test + fun `onMessageReceived with premium status changed with null fields does not emit`() = + runTest { + pushManager.premiumStatusChangedFlow.test { + pushManager.onMessageReceived( + PREMIUM_STATUS_CHANGED_NULL_FIELDS_NOTIFICATION_MAP, + ) + expectNoEvents() + } + } + @Nested inner class LoggedOutUserState { @BeforeEach @@ -1063,3 +1106,21 @@ private val SYNC_VAULT_NOTIFICATION_MAP = mapOf( "Date": "2023-10-27T12:00:00.000Z" }""", ) + +private val PREMIUM_STATUS_CHANGED_NOTIFICATION_MAP = mapOf( + "contextId" to "801f459d-8e51-47d0-b072-3f18c9f66f64", + "type" to "27", + "payload" to """{ + "UserId": "078966a2-93c2-4618-ae2a-0a2394c88d37", + "Premium": true + }""", +) + +private val PREMIUM_STATUS_CHANGED_NULL_FIELDS_NOTIFICATION_MAP = mapOf( + "contextId" to "801f459d-8e51-47d0-b072-3f18c9f66f64", + "type" to "27", + "payload" to """{ + "UserId": null, + "Premium": null + }""", +)