BIT-1362 Receive and expose push notification events from PushManager (#581)

This commit is contained in:
Sean Weiser 2024-01-12 13:39:27 -06:00 committed by Álison Fernandes
parent 739004cc57
commit 5a2b1e61c2
16 changed files with 1255 additions and 111 deletions

View file

@ -187,8 +187,9 @@ koverReport {
"*.*DefaultImpls*",
// OS-level components
"com.x8bit.bitwarden.BitwardenApplication",
"com.x8bit.bitwarden.data.autofill.BitwardenAutofillService*",
"com.x8bit.bitwarden.MainActivity*",
"com.x8bit.bitwarden.data.autofill.BitwardenAutofillService*",
"com.x8bit.bitwarden.data.push.BitwardenFirebaseMessagingService*",
// Empty Composables
"com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt",
// Databases

View file

@ -1,9 +1,73 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
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
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import kotlinx.coroutines.flow.Flow
/**
* Manager to handle push notification registration.
*/
interface PushManager {
/**
* Flow that represents requests intended for full syncs.
*/
val fullSyncFlow: Flow<Unit>
/**
* Flow that represents requests intended to log a user out.
*/
val logoutFlow: Flow<Unit>
/**
* Flow that represents requests intended to trigger a passwordless request.
*/
val passwordlessRequestFlow: Flow<PasswordlessRequestData>
/**
* Flow that represents requests intended to trigger a sync cipher delete.
*/
val syncCipherDeleteFlow: Flow<SyncCipherDeleteData>
/**
* Flow that represents requests intended to trigger a sync cipher upsert.
*/
val syncCipherUpsertFlow: Flow<SyncCipherUpsertData>
/**
* Flow that represents requests intended to trigger a sync cipher delete.
*/
val syncFolderDeleteFlow: Flow<SyncFolderDeleteData>
/**
* Flow that represents requests intended to trigger a sync folder upsert.
*/
val syncFolderUpsertFlow: Flow<SyncFolderUpsertData>
/**
* Flow that represents requests intended to trigger syncing organization keys.
*/
val syncOrgKeysFlow: Flow<Unit>
/**
* Flow that represents requests intended to trigger a sync send delete.
*/
val syncSendDeleteFlow: Flow<SyncSendDeleteData>
/**
* Flow that represents requests intended to trigger a sync send upsert.
*/
val syncSendUpsertFlow: Flow<SyncSendUpsertData>
/**
* Handles the necessary steps to take when a push notification with payload [data] is received.
*/
fun onMessageReceived(data: String)
/**
* Registers a [token] for the current user with Bitwarden's server if needed.
*/

View file

@ -5,12 +5,27 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.BitwardenNotification
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.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
@ -25,11 +40,60 @@ class PushManagerImpl @Inject constructor(
private val pushDiskSource: PushDiskSource,
private val pushService: PushService,
private val clock: Clock,
private val json: Json,
dispatcherManager: DispatcherManager,
) : PushManager {
private val ioScope = CoroutineScope(dispatcherManager.io)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val mutableFullSyncSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>()
private val mutableSyncCipherDeleteSharedFlow =
bufferedMutableSharedFlow<SyncCipherDeleteData>()
private val mutableSyncCipherUpsertSharedFlow =
bufferedMutableSharedFlow<SyncCipherUpsertData>()
private val mutableSyncFolderDeleteSharedFlow =
bufferedMutableSharedFlow<SyncFolderDeleteData>()
private val mutableSyncFolderUpsertSharedFlow =
bufferedMutableSharedFlow<SyncFolderUpsertData>()
private val mutableSyncOrgKeysSharedFlow = bufferedMutableSharedFlow<Unit>()
private val mutableSyncSendDeleteSharedFlow =
bufferedMutableSharedFlow<SyncSendDeleteData>()
private val mutableSyncSendUpsertSharedFlow =
bufferedMutableSharedFlow<SyncSendUpsertData>()
override val fullSyncFlow: SharedFlow<Unit>
get() = mutableFullSyncSharedFlow.asSharedFlow()
override val logoutFlow: SharedFlow<Unit>
get() = mutableLogoutSharedFlow.asSharedFlow()
override val passwordlessRequestFlow: SharedFlow<PasswordlessRequestData>
get() = mutablePasswordlessRequestSharedFlow.asSharedFlow()
override val syncCipherDeleteFlow: SharedFlow<SyncCipherDeleteData>
get() = mutableSyncCipherDeleteSharedFlow.asSharedFlow()
override val syncCipherUpsertFlow: SharedFlow<SyncCipherUpsertData>
get() = mutableSyncCipherUpsertSharedFlow.asSharedFlow()
override val syncFolderDeleteFlow: SharedFlow<SyncFolderDeleteData>
get() = mutableSyncFolderDeleteSharedFlow.asSharedFlow()
override val syncFolderUpsertFlow: SharedFlow<SyncFolderUpsertData>
get() = mutableSyncFolderUpsertSharedFlow.asSharedFlow()
override val syncOrgKeysFlow: SharedFlow<Unit>
get() = mutableSyncOrgKeysSharedFlow.asSharedFlow()
override val syncSendDeleteFlow: SharedFlow<SyncSendDeleteData>
get() = mutableSyncSendDeleteSharedFlow.asSharedFlow()
override val syncSendUpsertFlow: SharedFlow<SyncSendUpsertData>
get() = mutableSyncSendUpsertSharedFlow.asSharedFlow()
init {
authDiskSource
.userStateFlow
@ -39,6 +103,127 @@ class PushManagerImpl @Inject constructor(
.launchIn(unconfinedScope)
}
@Suppress("LongMethod", "CyclomaticComplexMethod", "ReturnCount")
override fun onMessageReceived(data: String) {
val notification = try {
json.decodeFromString<BitwardenNotification>(data)
} catch (exception: IllegalArgumentException) {
return
}
if (authDiskSource.uniqueAppId == notification.contextId) return
val userId = authDiskSource.userState?.activeUserId
when (val type = notification.notificationType) {
NotificationType.AUTH_REQUEST,
NotificationType.AUTH_REQUEST_RESPONSE,
-> {
val payload: NotificationPayload.PasswordlessRequestNotification =
json.decodeFromJsonElement(notification.payload)
mutablePasswordlessRequestSharedFlow.tryEmit(
PasswordlessRequestData(
loginRequestId = payload.id,
userId = payload.userId,
),
)
}
NotificationType.LOG_OUT -> {
if (userId == null) return
mutableLogoutSharedFlow.tryEmit(Unit)
}
NotificationType.SYNC_CIPHER_CREATE,
NotificationType.SYNC_CIPHER_UPDATE,
-> {
val payload: NotificationPayload.SyncCipherNotification =
json.decodeFromJsonElement(notification.payload)
if (!payload.userMatchesNotification(userId)) return
mutableSyncCipherUpsertSharedFlow.tryEmit(
SyncCipherUpsertData(
cipherId = payload.id,
revisionDate = payload.revisionDate,
isUpdate = type == NotificationType.SYNC_CIPHER_UPDATE,
),
)
}
NotificationType.SYNC_CIPHER_DELETE,
NotificationType.SYNC_LOGIN_DELETE,
-> {
val payload: NotificationPayload.SyncCipherNotification =
json.decodeFromJsonElement(notification.payload)
if (!payload.userMatchesNotification(userId)) return
mutableSyncCipherDeleteSharedFlow.tryEmit(
SyncCipherDeleteData(payload.id),
)
}
NotificationType.SYNC_CIPHERS,
NotificationType.SYNC_SETTINGS,
NotificationType.SYNC_VAULT,
-> {
if (userId == null) return
mutableFullSyncSharedFlow.tryEmit(Unit)
}
NotificationType.SYNC_FOLDER_CREATE,
NotificationType.SYNC_FOLDER_UPDATE,
-> {
val payload: NotificationPayload.SyncFolderNotification =
json.decodeFromJsonElement(notification.payload)
if (!payload.userMatchesNotification(userId)) return
mutableSyncFolderUpsertSharedFlow.tryEmit(
SyncFolderUpsertData(
folderId = payload.id,
revisionDate = payload.revisionDate,
isUpdate = type == NotificationType.SYNC_FOLDER_UPDATE,
),
)
}
NotificationType.SYNC_FOLDER_DELETE -> {
val payload: NotificationPayload.SyncFolderNotification =
json.decodeFromJsonElement(notification.payload)
if (!payload.userMatchesNotification(userId)) return
mutableSyncFolderDeleteSharedFlow.tryEmit(
SyncFolderDeleteData(payload.id),
)
}
NotificationType.SYNC_ORG_KEYS -> {
if (userId == null) return
mutableSyncOrgKeysSharedFlow.tryEmit(Unit)
}
NotificationType.SYNC_SEND_CREATE,
NotificationType.SYNC_SEND_UPDATE,
-> {
val payload: NotificationPayload.SyncSendNotification =
json.decodeFromJsonElement(notification.payload)
if (!payload.userMatchesNotification(userId)) return
mutableSyncSendUpsertSharedFlow.tryEmit(
SyncSendUpsertData(
sendId = payload.id,
revisionDate = payload.revisionDate,
isUpdate = type == NotificationType.SYNC_SEND_UPDATE,
),
)
}
NotificationType.SYNC_SEND_DELETE -> {
val payload: NotificationPayload.SyncSendNotification =
json.decodeFromJsonElement(notification.payload)
if (!payload.userMatchesNotification(userId)) return
mutableSyncSendDeleteSharedFlow.tryEmit(
SyncSendDeleteData(payload.id),
)
}
}
}
override fun registerPushTokenIfNecessary(token: String) {
pushDiskSource.registeredPushToken = token
val userId = authDiskSource.userState?.activeUserId ?: return
@ -102,3 +287,7 @@ class PushManagerImpl @Inject constructor(
)
}
}
private fun NotificationPayload.userMatchesNotification(userId: String?): Boolean {
return this.userId != null && this.userId == userId
}

View file

@ -10,6 +10,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
@ -28,11 +29,13 @@ object PushManagerModule {
pushService: PushService,
dispatcherManager: DispatcherManager,
clock: Clock,
json: Json,
): PushManager = PushManagerImpl(
authDiskSource = authDiskSource,
pushDiskSource = pushDiskSource,
pushService = pushService,
dispatcherManager = dispatcherManager,
clock = clock,
json = json,
)
}

View file

@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.platform.manager.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Represents a Bitwarden push notification.
*
* @property contextId The context ID. This is mainly used to check if the push notification
* originated from this app.
* @property notificationType The type of notication.
* @property payload Data associated with the push notification.
*/
@Serializable
data class BitwardenNotification(
@SerialName("contextId") val contextId: String,
@SerialName("type") val notificationType: NotificationType,
@SerialName("payload") val payload: JsonElement,
)

View file

@ -0,0 +1,71 @@
package com.x8bit.bitwarden.data.platform.manager.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.ZonedDateTime
/**
* The payload of a push notification.
*/
@Serializable
sealed class NotificationPayload {
/**
* The user ID associated with the push notification.
*/
abstract val userId: String?
/**
* A notification payload for sync cipher operations.
*/
@Serializable
data class SyncCipherNotification(
@SerialName("id") val id: String,
@SerialName("userId") override val userId: String?,
@SerialName("organizationId") val organizationId: String?,
@SerialName("collectionIds") val collectionIds: List<String>,
@Contextual
@SerialName("revisionDate") val revisionDate: ZonedDateTime,
) : NotificationPayload()
/**
* A notification payload for sync folder operations.
*/
@Serializable
data class SyncFolderNotification(
@SerialName("id") val id: String,
@SerialName("userId") override val userId: String,
@Contextual
@SerialName("revisionDate") val revisionDate: ZonedDateTime,
) : NotificationPayload()
/**
* A notification payload for user-based operations.
*/
@Serializable
data class UserNotification(
@SerialName("userId") override val userId: String,
@Contextual
@SerialName("date") val date: ZonedDateTime,
) : NotificationPayload()
/**
* A notification payload for sync send operations.
*/
@Serializable
data class SyncSendNotification(
@SerialName("id") val id: String,
@SerialName("userId") override val userId: String,
@Contextual
@SerialName("revisionDate") val revisionDate: ZonedDateTime,
) : NotificationPayload()
/**
* A notification payload for passwordless requests.
*/
@Serializable
data class PasswordlessRequestNotification(
@SerialName("userId") override val userId: String,
@SerialName("id") val id: String,
) : NotificationPayload()
}

View file

@ -0,0 +1,67 @@
package com.x8bit.bitwarden.data.platform.manager.model
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Possible notification types.
*/
@Serializable(NotificationTypeSerializer::class)
enum class NotificationType {
@SerialName("0")
SYNC_CIPHER_UPDATE,
@SerialName("1")
SYNC_CIPHER_CREATE,
@SerialName("2")
SYNC_LOGIN_DELETE,
@SerialName("3")
SYNC_FOLDER_DELETE,
@SerialName("4")
SYNC_CIPHERS,
@SerialName("5")
SYNC_VAULT,
@SerialName("6")
SYNC_ORG_KEYS,
@SerialName("7")
SYNC_FOLDER_CREATE,
@SerialName("8")
SYNC_FOLDER_UPDATE,
@SerialName("9")
SYNC_CIPHER_DELETE,
@SerialName("10")
SYNC_SETTINGS,
@SerialName("11")
LOG_OUT,
@SerialName("12")
SYNC_SEND_CREATE,
@SerialName("13")
SYNC_SEND_UPDATE,
@SerialName("14")
SYNC_SEND_DELETE,
@SerialName("15")
AUTH_REQUEST,
@SerialName("16")
AUTH_REQUEST_RESPONSE,
}
@Keep
private class NotificationTypeSerializer :
BaseEnumeratedIntSerializer<NotificationType>(NotificationType.entries.toTypedArray())

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for passwordless requests.
*
* @property loginRequestId The login request ID.
* @property userId The user ID.
*/
data class PasswordlessRequestData(
val loginRequestId: String,
val userId: String,
)

View file

@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync cipher delete operations.
*
* @property cipherId The cipher ID.
*/
data class SyncCipherDeleteData(
val cipherId: String,
)

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.manager.model
import java.time.ZonedDateTime
/**
* Required data for sync cipher upsert operations.
*
* @property cipherId The cipher ID.
* @property revisionDate The cipher's revision date. This is used to determine if the local copy of
* the cipher is out-of-date.
* @property isUpdate Whether or not this is an update of an existing cipher.
*/
data class SyncCipherUpsertData(
val cipherId: String,
val revisionDate: ZonedDateTime,
val isUpdate: Boolean,
)

View file

@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync folder delete operations.
*
* @property folderId The folder ID.
*/
data class SyncFolderDeleteData(
val folderId: String,
)

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.manager.model
import java.time.ZonedDateTime
/**
* Required data for sync folder upsert operations.
*
* @property folderId The folder ID.
* @property revisionDate The folder's revision date. This is used to determine if the local copy of
* the folder is out-of-date.
* @property isUpdate Whether or not this is an update of an existing folder.
*/
data class SyncFolderUpsertData(
val folderId: String,
val revisionDate: ZonedDateTime,
val isUpdate: Boolean,
)

View file

@ -0,0 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync send delete operations.
*
* @property sendId The send ID.
*/
data class SyncSendDeleteData(
val sendId: String,
)

View file

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.platform.manager.model
import java.time.ZonedDateTime
/**
* Required data for sync send upsert operations.
*
* @property sendId The send ID.
* @property revisionDate The send's revision date. This is used to determine if the local copy of
* the send is out-of-date.
* @property isUpdate Whether or not this is an update of an existing send.
*/
data class SyncSendUpsertData(
val sendId: String,
val revisionDate: ZonedDateTime,
val isUpdate: Boolean,
)

View file

@ -15,7 +15,8 @@ class BitwardenFirebaseMessagingService : FirebaseMessagingService() {
lateinit var pushManager: PushManager
override fun onMessageReceived(message: RemoteMessage) {
// TODO handle new messages. See BIT-1362.
val data = message.data["data"] ?: return
pushManager.onMessageReceived(data)
}
override fun onNewToken(token: String) {

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
@ -7,14 +8,23 @@ import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
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
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
@ -51,9 +61,412 @@ class PushManagerTest {
pushService = pushService,
dispatcherManager = dispatcherManager,
clock = clock,
json = PlatformNetworkModule.providesJson(),
)
}
@Nested
inner class PushNotificationHandling {
@Test
fun `onMessageReceived invalid JSON does not crash`() {
pushManager.onMessageReceived(INVALID_NOTIFICATION_JSON)
}
@Test
fun `onMessageReceived auth request emits to passwordlessRequestFlow`() =
runTest {
pushManager.passwordlessRequestFlow.test {
pushManager.onMessageReceived(AUTH_REQUEST_NOTIFICATION_JSON)
assertEquals(
PasswordlessRequestData(
loginRequestId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived auth request response emits to passwordlessRequestFlow`() =
runTest {
pushManager.passwordlessRequestFlow.test {
pushManager.onMessageReceived(AUTH_REQUEST_RESPONSE_NOTIFICATION_JSON)
assertEquals(
PasswordlessRequestData(
loginRequestId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
),
awaitItem(),
)
}
}
@Nested
inner class MatchingUser {
@BeforeEach
fun setUp() {
val userId = "078966a2-93c2-4618-ae2a-0a2394c88d37"
authDiskSource.userState = UserStateJson(userId, mapOf(userId to mockk()))
}
@Test
fun `onMessageReceived sync cipher create emits to syncCipherUpsertFlow`() =
runTest {
pushManager.syncCipherUpsertFlow.test {
pushManager.onMessageReceived(SYNC_CIPHER_CREATE_NOTIFICATION_JSON)
assertEquals(
SyncCipherUpsertData(
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = false,
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync cipher delete emits to syncCipherDeleteFlow`() =
runTest {
pushManager.syncCipherDeleteFlow.test {
pushManager.onMessageReceived(SYNC_CIPHER_DELETE_NOTIFICATION_JSON)
assertEquals(
SyncCipherDeleteData(
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync cipher update emits to syncCipherUpsertFlow`() =
runTest {
pushManager.syncCipherUpsertFlow.test {
pushManager.onMessageReceived(SYNC_CIPHER_UPDATE_NOTIFICATION_JSON)
assertEquals(
SyncCipherUpsertData(
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = true,
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync folder create emits to syncFolderUpsertFlow`() =
runTest {
pushManager.syncFolderUpsertFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_CREATE_NOTIFICATION_JSON)
assertEquals(
SyncFolderUpsertData(
folderId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = false,
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync folder delete emits to syncFolderDeleteFlow`() =
runTest {
pushManager.syncFolderDeleteFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_DELETE_NOTIFICATION_JSON)
assertEquals(
SyncFolderDeleteData(
folderId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync folder update emits to syncFolderUpsertFlow`() =
runTest {
pushManager.syncFolderUpsertFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_UPDATE_NOTIFICATION_JSON)
assertEquals(
SyncFolderUpsertData(
folderId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = true,
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync login delete emits to syncCipherDeleteFlow`() =
runTest {
pushManager.syncCipherDeleteFlow.test {
pushManager.onMessageReceived(SYNC_LOGIN_DELETE_NOTIFICATION_JSON)
assertEquals(
SyncCipherDeleteData(
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync send create emits to syncSendUpsertFlow`() =
runTest {
pushManager.syncSendUpsertFlow.test {
pushManager.onMessageReceived(SYNC_SEND_CREATE_NOTIFICATION_JSON)
assertEquals(
SyncSendUpsertData(
sendId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = false,
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync send delete emits to syncSendDeleteFlow`() =
runTest {
pushManager.syncSendDeleteFlow.test {
pushManager.onMessageReceived(SYNC_SEND_DELETE_NOTIFICATION_JSON)
assertEquals(
SyncSendDeleteData(
sendId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync send update emits to syncSendUpsertFlow`() =
runTest {
pushManager.syncSendUpsertFlow.test {
pushManager.onMessageReceived(SYNC_SEND_UPDATE_NOTIFICATION_JSON)
assertEquals(
SyncSendUpsertData(
sendId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
revisionDate = ZonedDateTime.parse("2023-10-27T12:00:00.000Z"),
isUpdate = true,
),
awaitItem(),
)
}
}
}
@Nested
inner class NonMatchingUser {
@BeforeEach
fun setUp() {
val userId = "bad user ID"
authDiskSource.userState = UserStateJson(userId, mapOf(userId to mockk()))
}
@Test
fun `onMessageReceived sync cipher create does nothing`() = runTest {
pushManager.syncCipherUpsertFlow.test {
pushManager.onMessageReceived(SYNC_CIPHER_CREATE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync cipher delete does nothing`() = runTest {
pushManager.syncCipherDeleteFlow.test {
pushManager.onMessageReceived(SYNC_CIPHER_DELETE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync cipher update does nothing`() = runTest {
pushManager.syncCipherUpsertFlow.test {
pushManager.onMessageReceived(SYNC_CIPHER_UPDATE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync folder create does nothing`() = runTest {
pushManager.syncFolderUpsertFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_CREATE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync folder delete does nothing`() = runTest {
pushManager.syncFolderDeleteFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_DELETE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync folder update does nothing`() = runTest {
pushManager.syncFolderDeleteFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_UPDATE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync login delete does nothing`() = runTest {
pushManager.syncCipherDeleteFlow.test {
pushManager.onMessageReceived(SYNC_LOGIN_DELETE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync send create does nothing`() = runTest {
pushManager.syncSendUpsertFlow.test {
pushManager.onMessageReceived(SYNC_SEND_CREATE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync send delete does nothing`() = runTest {
pushManager.syncSendDeleteFlow.test {
pushManager.onMessageReceived(SYNC_SEND_DELETE_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync send update does nothing`() = runTest {
pushManager.syncSendUpsertFlow.test {
pushManager.onMessageReceived(SYNC_SEND_UPDATE_NOTIFICATION_JSON)
expectNoEvents()
}
}
}
@Nested
inner class NullUserState {
@BeforeEach
fun setUp() {
authDiskSource.userState = null
}
@Test
fun `onMessageReceived logout does nothing`() = runTest {
pushManager.logoutFlow.test {
pushManager.onMessageReceived(LOGOUT_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync ciphers does nothing`() = runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_CIPHERS_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync org keys does nothing`() = runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_ORG_KEYS_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync settings does nothing`() = runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_SETTINGS_NOTIFICATION_JSON)
expectNoEvents()
}
}
@Test
fun `onMessageReceived sync vault does nothing`() = runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_VAULT_NOTIFICATION_JSON)
expectNoEvents()
}
}
}
@Nested
inner class NonNullUserState {
@BeforeEach
fun setUp() {
val userId = "any user ID"
authDiskSource.userState = UserStateJson(userId, mapOf(userId to mockk()))
}
@Test
fun `onMessageReceived logout emits to logoutFlow`() = runTest {
pushManager.logoutFlow.test {
pushManager.onMessageReceived(LOGOUT_NOTIFICATION_JSON)
assertEquals(
Unit,
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync ciphers emits to fullSyncFlow`() = runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_CIPHERS_NOTIFICATION_JSON)
assertEquals(
Unit,
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync org keys emits to syncOrgKeysFlow`() = runTest {
pushManager.syncOrgKeysFlow.test {
pushManager.onMessageReceived(SYNC_ORG_KEYS_NOTIFICATION_JSON)
assertEquals(
Unit,
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync settings emits to fullSyncFlow`() = runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_SETTINGS_NOTIFICATION_JSON)
assertEquals(
Unit,
awaitItem(),
)
}
}
@Test
fun `onMessageReceived sync vault emits to fullSyncFlow`() = runTest {
pushManager.fullSyncFlow.test {
pushManager.onMessageReceived(SYNC_VAULT_NOTIFICATION_JSON)
assertEquals(
Unit,
awaitItem(),
)
}
}
}
}
@Nested
inner class PushNotificationRegistration {
@Nested
inner class NullUserState {
@BeforeEach
@ -161,10 +574,14 @@ class PushManagerTest {
fun `registerPushTokenIfNecessary should update registeredPushToken, lastPushTokenRegistrationDate and currentPushToken`() {
pushManager.registerPushTokenIfNecessary(newToken)
coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) }
coVerify(exactly = 1) {
pushService.putDeviceToken(PushTokenRequest(newToken))
}
assertEquals(
clock.instant().epochSecond,
pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toEpochSecond(),
pushDiskSource
.getLastPushTokenRegistrationDate(userId)
?.toEpochSecond(),
)
assertEquals(newToken, pushDiskSource.registeredPushToken)
assertEquals(newToken, pushDiskSource.getCurrentPushToken(userId))
@ -176,10 +593,14 @@ class PushManagerTest {
pushDiskSource.registeredPushToken = newToken
pushManager.registerStoredPushTokenIfNecessary()
coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) }
coVerify(exactly = 1) {
pushService.putDeviceToken(PushTokenRequest(newToken))
}
assertEquals(
clock.instant().epochSecond,
pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toEpochSecond(),
pushDiskSource
.getLastPushTokenRegistrationDate(userId)
?.toEpochSecond(),
)
assertEquals(newToken, pushDiskSource.registeredPushToken)
assertEquals(newToken, pushDiskSource.getCurrentPushToken(userId))
@ -199,7 +620,9 @@ class PushManagerTest {
fun `registerPushTokenIfNecessary should update registeredPushToken`() {
pushManager.registerPushTokenIfNecessary(newToken)
coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) }
coVerify(exactly = 1) {
pushService.putDeviceToken(PushTokenRequest(newToken))
}
assertNull(pushDiskSource.getLastPushTokenRegistrationDate(userId))
assertEquals(newToken, pushDiskSource.registeredPushToken)
assertEquals(existingToken, pushDiskSource.getCurrentPushToken(userId))
@ -210,7 +633,9 @@ class PushManagerTest {
pushDiskSource.registeredPushToken = newToken
pushManager.registerStoredPushTokenIfNecessary()
coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) }
coVerify(exactly = 1) {
pushService.putDeviceToken(PushTokenRequest(newToken))
}
assertNull(pushDiskSource.getLastPushTokenRegistrationDate(userId))
assertEquals(newToken, pushDiskSource.registeredPushToken)
assertEquals(existingToken, pushDiskSource.getCurrentPushToken(userId))
@ -219,3 +644,213 @@ class PushManagerTest {
}
}
}
}
private const val AUTH_REQUEST_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 15,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37"
}
}
"""
private const val AUTH_REQUEST_RESPONSE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 16,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37"
}
}
"""
private const val INVALID_NOTIFICATION_JSON = """
{}
"""
private const val LOGOUT_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 11,
"payload": {
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"date": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_CIPHER_CREATE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 1,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"organizationId": "6a41d965-ed95-4eae-98c3-5f1ec609c2c1",
"collectionIds": [],
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_CIPHER_DELETE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 9,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"organizationId": "6a41d965-ed95-4eae-98c3-5f1ec609c2c1",
"collectionIds": [],
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_CIPHER_UPDATE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 0,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"organizationId": "6a41d965-ed95-4eae-98c3-5f1ec609c2c1",
"collectionIds": [],
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_CIPHERS_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 4,
"payload": {
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"date": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_FOLDER_CREATE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 7,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_FOLDER_DELETE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 3,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_FOLDER_UPDATE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 8,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_LOGIN_DELETE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 2,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"organizationId": "6a41d965-ed95-4eae-98c3-5f1ec609c2c1",
"collectionIds": [],
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_ORG_KEYS_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 6,
"payload": {
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"date": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_SEND_CREATE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 12,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_SEND_DELETE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 14,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_SEND_UPDATE_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 13,
"payload": {
"id": "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"revisionDate": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_SETTINGS_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 10,
"payload": {
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"date": "2023-10-27T12:00:00.000Z"
}
}
"""
private const val SYNC_VAULT_NOTIFICATION_JSON = """
{
"contextId": "801f459d-8e51-47d0-b072-3f18c9f66f64",
"type": 5,
"payload": {
"userId": "078966a2-93c2-4618-ae2a-0a2394c88d37",
"date": "2023-10-27T12:00:00.000Z"
}
}
"""