[PM-33506] feat: Add PremiumStatusChanged push notification support (#6656)

This commit is contained in:
Patrick Honkonen authored and GitHub committed 2026-03-13 17:09:26 +00:00
1 parent 85c3a1deb8
commit 13c8cc08a6
6 files changed
+117 -1

No files matched your search

@@ -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<PasswordlessRequestData>
/**
* Flow that represents premium status change notifications.
*/
val premiumStatusChangedFlow: Flow<PremiumStatusChangedData>
/**
* Flow that represents requests intended to trigger a sync cipher delete.
*/
@@ -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<NotificationLogoutData>()
private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>()
private val mutablePremiumStatusChangedSharedFlow =
bufferedMutableSharedFlow<PremiumStatusChangedData>()
private val mutableSyncCipherDeleteSharedFlow =
bufferedMutableSharedFlow<SyncCipherDeleteData>()
private val mutableSyncCipherUpsertSharedFlow =
@@ -86,6 +89,9 @@ class PushManagerImpl @Inject constructor(
override val passwordlessRequestFlow: SharedFlow<PasswordlessRequestData>
get() = mutablePasswordlessRequestSharedFlow.asSharedFlow()
override val premiumStatusChangedFlow: SharedFlow<PremiumStatusChangedData>
get() = mutablePremiumStatusChangedSharedFlow.asSharedFlow()
override val syncCipherDeleteFlow: SharedFlow<SyncCipherDeleteData>
get() = mutableSyncCipherDeleteSharedFlow.asSharedFlow()
@@ -305,6 +311,25 @@ class PushManagerImpl @Inject constructor(
}
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(it) }
}
NotificationType.PREMIUM_STATUS_CHANGED -> {
json
.decodeFromString<NotificationPayload.PremiumStatusChangedNotification>(
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),
)
}
}
}
}
@@ -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()
}
@@ -60,6 +60,9 @@ enum class NotificationType {
@SerialName("16")
AUTH_REQUEST_RESPONSE,
@SerialName("27")
PREMIUM_STATUS_CHANGED,
}
@Keep
@@ -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,
)
@@ -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
}""",
)