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

@@ -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
@@ -98,7 +283,11 @@ class PushManagerImpl @Inject constructor(
onFailure = {
// Silently fail. This call will be attempted again the next time the token
// registration is done.
},
},
)
}
}
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,
)