Refactor Send logic into SendManager (#5892)

This commit is contained in:
David Perez
2025-09-17 09:37:14 -05:00
committed by GitHub
parent f22f4399be
commit 8de465381e
8 changed files with 1317 additions and 1198 deletions

View File

@@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
/**
* Manages the creating, updating, and deleting sends.
*/
interface SendManager {
/**
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE].
*/
suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult
/**
* Attempt to delete a send.
*/
suspend fun deleteSend(sendId: String): DeleteSendResult
/**
* Attempt to remove the password from a send.
*/
suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult
/**
* Attempt to update a send.
*/
suspend fun updateSend(
sendId: String,
sendView: SendView,
): UpdateSendResult
}

View File

@@ -0,0 +1,296 @@
package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateSendJsonResponse
import com.bitwarden.network.model.UpdateSendResponseJson
import com.bitwarden.network.service.SendsService
import com.bitwarden.send.Send
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import retrofit2.HttpException
/**
* The default implementation of the [SendManager].
*/
@Suppress("LongParameterList")
class SendManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val sendsService: SendsService,
private val fileManager: FileManager,
private val reviewPromptManager: ReviewPromptManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
) : SendManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
private val ioScope = CoroutineScope(dispatcherManager.io)
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
init {
pushManager
.syncSendDeleteFlow
.onEach(::deleteSend)
.launchIn(unconfinedScope)
pushManager
.syncSendUpsertFlow
.onEach(::syncSendIfNecessary)
.launchIn(ioScope)
}
override suspend fun createSend(
sendView: SendView,
fileUri: Uri?,
): CreateSendResult {
val userId = activeUserId
?: return CreateSendResult.Error(message = null, error = NoActiveUserException())
return vaultSdkSource
.encryptSend(userId = userId, sendView = sendView)
.flatMap { send ->
when (send.type) {
SendType.TEXT -> sendsService.createTextSend(send.toEncryptedNetworkSend())
SendType.FILE -> createFileSend(uri = fileUri, userId = userId, send = send)
}
}
.map { createSendResponse ->
when (createSendResponse) {
is CreateSendJsonResponse.Invalid -> {
return CreateSendResult.Error(
message = createSendResponse.firstValidationErrorMessage,
error = null,
)
}
is CreateSendJsonResponse.Success -> {
// Save the send immediately, regardless of whether the decrypt succeeds
vaultDiskSource.saveSend(userId = userId, send = createSendResponse.send)
createSendResponse
}
}
}
.flatMap { createSendSuccessResponse ->
vaultSdkSource.decryptSend(
userId = userId,
send = createSendSuccessResponse.send.toEncryptedSdkSend(),
)
}
.fold(
onFailure = { CreateSendResult.Error(message = null, error = it) },
onSuccess = {
reviewPromptManager.registerCreateSendAction()
CreateSendResult.Success(sendView = it)
},
)
}
override suspend fun deleteSend(sendId: String): DeleteSendResult {
val userId = activeUserId ?: return DeleteSendResult.Error(error = NoActiveUserException())
return sendsService
.deleteSend(sendId)
.onSuccess { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) }
.fold(
onSuccess = { DeleteSendResult.Success },
onFailure = { DeleteSendResult.Error(error = it) },
)
}
override suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult {
val userId = activeUserId ?: return RemovePasswordSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return sendsService
.removeSendPassword(sendId = sendId)
.fold(
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
RemovePasswordSendResult.Error(
errorMessage = response.message,
error = null,
)
}
is UpdateSendResponseJson.Success -> {
vaultDiskSource.saveSend(userId = userId, send = response.send)
vaultSdkSource
.decryptSend(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.fold(
onSuccess = { RemovePasswordSendResult.Success(sendView = it) },
onFailure = {
RemovePasswordSendResult.Error(
errorMessage = null,
error = it,
)
},
)
}
}
},
onFailure = { RemovePasswordSendResult.Error(errorMessage = null, error = it) },
)
}
override suspend fun updateSend(
sendId: String,
sendView: SendView,
): UpdateSendResult {
val userId = activeUserId ?: return UpdateSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return vaultSdkSource
.encryptSend(userId = userId, sendView = sendView)
.flatMap { send ->
sendsService.updateSend(sendId = sendId, body = send.toEncryptedNetworkSend())
}
.fold(
onFailure = { UpdateSendResult.Error(errorMessage = null, error = it) },
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
UpdateSendResult.Error(errorMessage = response.message, error = null)
}
is UpdateSendResponseJson.Success -> {
vaultDiskSource.saveSend(userId = userId, send = response.send)
vaultSdkSource
.decryptSend(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.fold(
onSuccess = { UpdateSendResult.Success(sendView = it) },
onFailure = {
UpdateSendResult.Error(errorMessage = null, error = it)
},
)
}
}
},
)
}
private suspend fun createFileSend(
uri: Uri?,
userId: String,
send: Send,
): Result<CreateSendJsonResponse> {
uri ?: return IllegalArgumentException("File URI must be present to create a File Send.")
.asFailure()
return fileManager
.writeUriToCache(uri)
.flatMap { file ->
vaultSdkSource.encryptFile(
userId = userId,
send = send,
path = file.absolutePath,
destinationFilePath = file.absolutePath,
)
}
.flatMap { encryptedFile ->
sendsService
.createFileSend(
body = send.toEncryptedNetworkSend(fileLength = encryptedFile.length()),
)
.flatMap { sendFileResponse ->
when (sendFileResponse) {
is CreateFileSendResponse.Invalid -> {
CreateSendJsonResponse
.Invalid(
message = sendFileResponse.message,
validationErrors = sendFileResponse.validationErrors,
)
.asSuccess()
}
is CreateFileSendResponse.Success -> {
sendsService
.uploadFile(
sendFileResponse = sendFileResponse.createFileJsonResponse,
encryptedFile = encryptedFile,
)
.also {
// Delete encrypted file once it has been uploaded.
fileManager.delete(encryptedFile)
}
.map { CreateSendJsonResponse.Success(it) }
}
}
}
}
}
/**
* Deletes the send specified by [syncSendDeleteData] from disk.
*/
private suspend fun deleteSend(syncSendDeleteData: SyncSendDeleteData) {
vaultDiskSource.deleteSend(
userId = syncSendDeleteData.userId,
sendId = syncSendDeleteData.sendId,
)
}
/**
* Syncs an individual send contained in [syncSendUpsertData] to disk if certain criteria are
* met. If the resource cannot be found cloud-side, and it was updated, delete it from disk for
* now.
*/
private suspend fun syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) {
val userId = activeUserId ?: return
val sendId = syncSendUpsertData.sendId
val isUpdate = syncSendUpsertData.isUpdate
val revisionDate = syncSendUpsertData.revisionDate
val localSend = vaultDiskSource
.getSends(userId = userId)
.first()
.find { it.id == sendId }
val isValidCreate = !isUpdate && localSend == null
val isValidUpdate = isUpdate &&
localSend != null &&
localSend.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
sendsService
.getSend(sendId = sendId)
.fold(
onSuccess = { vaultDiskSource.saveSend(userId = userId, send = it) },
onFailure = {
// Delete any updates if it's missing from the server
val httpException = it as? HttpException
@Suppress("MagicNumber")
if (httpException?.code() == 404 && isUpdate) {
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
}
},
)
}
}

View File

@@ -6,12 +6,14 @@ import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.SyncService
import com.bitwarden.network.service.SendsService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
@@ -22,6 +24,8 @@ import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.SendManagerImpl
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
@@ -63,6 +67,28 @@ object VaultManagerModule {
reviewPromptManager = reviewPromptManager,
)
@Provides
@Singleton
fun provideSendManager(
sendsService: SendsService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
fileManager: FileManager,
reviewPromptManager: ReviewPromptManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
): SendManager = SendManagerImpl(
fileManager = fileManager,
authDiskSource = authDiskSource,
sendsService = sendsService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
reviewPromptManager = reviewPromptManager,
pushManager = pushManager,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideFileManager(

View File

@@ -1,13 +1,11 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherType
@@ -15,22 +13,19 @@ import com.bitwarden.vault.CipherView
import com.bitwarden.vault.DecryptCipherListResult
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
@@ -42,7 +37,7 @@ import javax.crypto.Cipher
* Responsible for managing vault data inside the network layer.
*/
@Suppress("TooManyFunctions")
interface VaultRepository : CipherManager, VaultLockManager {
interface VaultRepository : CipherManager, SendManager, VaultLockManager {
/**
* The [VaultFilterType] for the current user.
@@ -204,35 +199,11 @@ interface VaultRepository : CipherManager, VaultLockManager {
pin: String,
): VaultUnlockResult
/**
* Attempt to create a send. The [fileUri] _must_ be present when the given [SendView] has a
* [SendView.type] of [SendType.FILE].
*/
suspend fun createSend(sendView: SendView, fileUri: Uri?): CreateSendResult
/**
* Attempt to update a send.
*/
suspend fun updateSend(
sendId: String,
sendView: SendView,
): UpdateSendResult
/**
* Attempt to remove the password from a send.
*/
suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult
/**
* Attempt to get the verification code and the period.
*/
suspend fun generateTotp(cipherId: String, time: DateTime): GenerateTotpResult
/**
* Attempt to delete a send.
*/
suspend fun deleteSend(sendId: String): DeleteSendResult
/**
* Attempt to create a folder.
*/

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.vault.repository
import android.net.Uri
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DateTime
import com.bitwarden.core.InitUserCryptoMethod
@@ -11,22 +10,15 @@ import com.bitwarden.core.data.repository.util.map
import com.bitwarden.core.data.repository.util.mapNullable
import com.bitwarden.core.data.repository.util.updateToPendingOrLoading
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateSendJsonResponse
import com.bitwarden.network.model.UpdateFolderResponseJson
import com.bitwarden.network.model.UpdateSendResponseJson
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.util.isNoConnectionError
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.send.Send
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
@@ -43,20 +35,17 @@ import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
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.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
@@ -65,31 +54,25 @@ import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabetically
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabeticallyByTypeAndOrganization
import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSend
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
@@ -138,25 +121,24 @@ private const val STOP_TIMEOUT_DELAY_MS: Long = 1000L
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
class VaultRepositoryImpl(
private val ciphersService: CiphersService,
private val sendsService: SendsService,
private val folderService: FolderService,
private val vaultDiskSource: VaultDiskSource,
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val settingsDiskSource: SettingsDiskSource,
private val cipherManager: CipherManager,
private val fileManager: FileManager,
private val sendManager: SendManager,
private val vaultLockManager: VaultLockManager,
private val totpCodeManager: TotpCodeManager,
databaseSchemeManager: DatabaseSchemeManager,
pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
private val reviewPromptManager: ReviewPromptManager,
private val vaultSyncManager: VaultSyncManager,
private val credentialExchangeImportManager: CredentialExchangeImportManager,
) : VaultRepository,
CipherManager by cipherManager,
SendManager by sendManager,
VaultLockManager by vaultLockManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
@@ -314,16 +296,6 @@ class VaultRepositoryImpl(
.onEach(::syncCipherIfNecessary)
.launchIn(ioScope)
pushManager
.syncSendDeleteFlow
.onEach(::deleteSend)
.launchIn(unconfinedScope)
pushManager
.syncSendUpsertFlow
.onEach(::syncSendIfNecessary)
.launchIn(ioScope)
pushManager
.syncFolderDeleteFlow
.onEach(::deleteFolder)
@@ -657,153 +629,6 @@ class VaultRepositoryImpl(
)
}
override suspend fun createSend(
sendView: SendView,
fileUri: Uri?,
): CreateSendResult {
val userId = activeUserId
?: return CreateSendResult.Error(message = null, error = NoActiveUserException())
return vaultSdkSource
.encryptSend(
userId = userId,
sendView = sendView,
)
.flatMap { send ->
when (send.type) {
SendType.TEXT -> sendsService.createTextSend(send.toEncryptedNetworkSend())
SendType.FILE -> createFileSend(fileUri, userId, send)
}
}
.map { createSendResponse ->
when (createSendResponse) {
is CreateSendJsonResponse.Invalid -> {
return CreateSendResult.Error(
message = createSendResponse.firstValidationErrorMessage,
error = null,
)
}
is CreateSendJsonResponse.Success -> {
// Save the send immediately, regardless of whether the decrypt succeeds
vaultDiskSource.saveSend(userId = userId, send = createSendResponse.send)
createSendResponse
}
}
}
.flatMap { createSendSuccessResponse ->
vaultSdkSource.decryptSend(
userId = userId,
send = createSendSuccessResponse.send.toEncryptedSdkSend(),
)
}
.fold(
onFailure = { CreateSendResult.Error(message = null, error = it) },
onSuccess = {
reviewPromptManager.registerCreateSendAction()
CreateSendResult.Success(it)
},
)
}
override suspend fun updateSend(
sendId: String,
sendView: SendView,
): UpdateSendResult {
val userId = activeUserId
?: return UpdateSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return vaultSdkSource
.encryptSend(
userId = userId,
sendView = sendView,
)
.flatMap { send ->
sendsService.updateSend(
sendId = sendId,
body = send.toEncryptedNetworkSend(),
)
}
.fold(
onFailure = { UpdateSendResult.Error(errorMessage = null, error = it) },
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
UpdateSendResult.Error(errorMessage = response.message, error = null)
}
is UpdateSendResponseJson.Success -> {
vaultDiskSource.saveSend(userId = userId, send = response.send)
vaultSdkSource
.decryptSend(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.fold(
onSuccess = { UpdateSendResult.Success(sendView = it) },
onFailure = {
UpdateSendResult.Error(errorMessage = null, error = it)
},
)
}
}
},
)
}
override suspend fun removePasswordSend(sendId: String): RemovePasswordSendResult {
val userId = activeUserId
?: return RemovePasswordSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
)
return sendsService
.removeSendPassword(sendId = sendId)
.fold(
onSuccess = { response ->
when (response) {
is UpdateSendResponseJson.Invalid -> {
RemovePasswordSendResult.Error(
errorMessage = response.message,
error = null,
)
}
is UpdateSendResponseJson.Success -> {
vaultDiskSource.saveSend(userId = userId, send = response.send)
vaultSdkSource
.decryptSend(
userId = userId,
send = response.send.toEncryptedSdkSend(),
)
.fold(
onSuccess = { RemovePasswordSendResult.Success(sendView = it) },
onFailure = {
RemovePasswordSendResult.Error(
errorMessage = null,
error = it,
)
},
)
}
}
},
onFailure = { RemovePasswordSendResult.Error(errorMessage = null, error = it) },
)
}
override suspend fun deleteSend(sendId: String): DeleteSendResult {
val userId = activeUserId ?: return DeleteSendResult.Error(error = NoActiveUserException())
return sendsService
.deleteSend(sendId)
.onSuccess { vaultDiskSource.deleteSend(userId, sendId) }
.fold(
onSuccess = { DeleteSendResult.Success },
onFailure = { DeleteSendResult.Error(error = it) },
)
}
override suspend fun generateTotp(
cipherId: String,
time: DateTime,
@@ -1317,108 +1142,6 @@ class VaultRepositoryImpl(
)
}
private suspend fun createFileSend(
uri: Uri?,
userId: String,
send: Send,
): Result<CreateSendJsonResponse> {
uri ?: return IllegalArgumentException(
"File URI must be present to create a File Send.",
)
.asFailure()
return fileManager
.writeUriToCache(uri)
.flatMap { file ->
vaultSdkSource.encryptFile(
userId = userId,
send = send,
path = file.absolutePath,
destinationFilePath = file.absolutePath,
)
}
.flatMap { encryptedFile ->
sendsService
.createFileSend(
body = send.toEncryptedNetworkSend(
fileLength = encryptedFile.length(),
),
)
.flatMap { sendFileResponse ->
when (sendFileResponse) {
is CreateFileSendResponse.Invalid -> {
CreateSendJsonResponse
.Invalid(
message = sendFileResponse.message,
validationErrors = sendFileResponse.validationErrors,
)
.asSuccess()
}
is CreateFileSendResponse.Success -> {
sendsService
.uploadFile(
sendFileResponse = sendFileResponse.createFileJsonResponse,
encryptedFile = encryptedFile,
)
.also {
// Delete encrypted file once it has been uploaded.
fileManager.delete(encryptedFile)
}
.map { CreateSendJsonResponse.Success(it) }
}
}
}
}
}
/**
* Deletes the send specified by [syncSendDeleteData] from disk.
*/
private suspend fun deleteSend(syncSendDeleteData: SyncSendDeleteData) {
vaultDiskSource.deleteSend(
userId = syncSendDeleteData.userId,
sendId = syncSendDeleteData.sendId,
)
}
/**
* Syncs an individual send contained in [syncSendUpsertData] to disk if certain criteria are
* met. If the resource cannot be found cloud-side, and it was updated, delete it from disk for
* now.
*/
private suspend fun syncSendIfNecessary(syncSendUpsertData: SyncSendUpsertData) {
val userId = activeUserId ?: return
val sendId = syncSendUpsertData.sendId
val isUpdate = syncSendUpsertData.isUpdate
val revisionDate = syncSendUpsertData.revisionDate
val localSend = vaultDiskSource
.getSends(userId = userId)
.first()
.find { it.id == sendId }
val isValidCreate = !isUpdate && localSend == null
val isValidUpdate = isUpdate &&
localSend != null &&
localSend.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
sendsService
.getSend(sendId)
.fold(
onSuccess = { vaultDiskSource.saveSend(userId, it) },
onFailure = {
// Delete any updates if it's missing from the server
val httpException = it as? HttpException
@Suppress("MagicNumber")
if (httpException?.code() == 404 && isUpdate) {
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
}
},
)
}
/**
* Deletes the folder specified by [syncFolderDeleteData] from disk.
*/

View File

@@ -3,17 +3,15 @@ package com.x8bit.bitwarden.data.vault.repository.di
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
@@ -36,7 +34,6 @@ object VaultRepositoryModule {
@Provides
@Singleton
fun providesVaultRepository(
sendsService: SendsService,
ciphersService: CiphersService,
folderService: FolderService,
vaultDiskSource: VaultDiskSource,
@@ -44,18 +41,16 @@ object VaultRepositoryModule {
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
cipherManager: CipherManager,
fileManager: FileManager,
sendManager: SendManager,
vaultLockManager: VaultLockManager,
dispatcherManager: DispatcherManager,
totpCodeManager: TotpCodeManager,
pushManager: PushManager,
databaseSchemeManager: DatabaseSchemeManager,
clock: Clock,
reviewPromptManager: ReviewPromptManager,
vaultSyncManager: VaultSyncManager,
credentialExchangeImportManager: CredentialExchangeImportManager,
): VaultRepository = VaultRepositoryImpl(
sendsService = sendsService,
ciphersService = ciphersService,
folderService = folderService,
vaultDiskSource = vaultDiskSource,
@@ -63,14 +58,13 @@ object VaultRepositoryModule {
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
cipherManager = cipherManager,
fileManager = fileManager,
sendManager = sendManager,
vaultLockManager = vaultLockManager,
dispatcherManager = dispatcherManager,
totpCodeManager = totpCodeManager,
pushManager = pushManager,
databaseSchemeManager = databaseSchemeManager,
clock = clock,
reviewPromptManager = reviewPromptManager,
vaultSyncManager = vaultSyncManager,
credentialExchangeImportManager = credentialExchangeImportManager,
)

View File

@@ -0,0 +1,947 @@
package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.network.model.CreateFileSendResponse
import com.bitwarden.network.model.CreateSendJsonResponse
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.UpdateSendResponseJson
import com.bitwarden.network.model.createMockFileSendResponseJson
import com.bitwarden.network.model.createMockSend
import com.bitwarden.network.model.createMockSendJsonRequest
import com.bitwarden.network.service.SendsService
import com.bitwarden.send.SendType
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.platform.error.MissingPropertyException
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSend
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.HttpException
import java.io.File
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit
@Suppress("LargeClass")
class SendManagerTest {
private val fileManager: FileManager = mockk {
coEvery { delete(files = anyVararg()) } just runs
}
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val sendsService = mockk<SendsService>()
private val vaultDiskSource = mockk<VaultDiskSource>()
private val vaultSdkSource = mockk<VaultSdkSource>()
private val reviewPromptManager = mockk<ReviewPromptManager> {
every { registerCreateSendAction() } just runs
}
private val mutableSyncSendDeleteFlow = bufferedMutableSharedFlow<SyncSendDeleteData>()
private val mutableSyncSendUpsertFlow = bufferedMutableSharedFlow<SyncSendUpsertData>()
private val pushManager: PushManager = mockk {
every { syncSendDeleteFlow } returns mutableSyncSendDeleteFlow
every { syncSendUpsertFlow } returns mutableSyncSendUpsertFlow
}
private val sendManager: SendManager = SendManagerImpl(
sendsService = sendsService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
authDiskSource = fakeAuthDiskSource,
fileManager = fileManager,
reviewPromptManager = reviewPromptManager,
pushManager = pushManager,
dispatcherManager = FakeDispatcherManager(),
)
@BeforeEach
fun setup() {
mockkStatic(Uri::class)
mockkConstructor(NoActiveUserException::class, MissingPropertyException::class)
every {
anyConstructed<NoActiveUserException>() == any<NoActiveUserException>()
} returns true
every {
anyConstructed<MissingPropertyException>() == any<MissingPropertyException>()
} returns true
}
@AfterEach
fun tearDown() {
unmockkStatic(Uri::class)
unmockkConstructor(NoActiveUserException::class, MissingPropertyException::class)
}
@Test
fun `syncSendDeleteFlow should delete send from disk`() {
val userId = "mockId-1"
val sendId = "mockId-1"
coEvery { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) } just runs
mutableSyncSendDeleteFlow.tryEmit(SyncSendDeleteData(userId = userId, sendId = sendId))
coVerify { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) }
}
@Test
fun `syncSendUpsertFlow create with local send should do nothing`() = runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
val send = createMockSend(number = 1, id = sendId)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.getSends(userId = userId) } returns MutableStateFlow(listOf(send))
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(FIXED_CLOCK),
isUpdate = false,
),
)
coVerify(exactly = 0) {
sendsService.getSend(sendId = sendId)
vaultDiskSource.saveSend(userId = userId, send = any())
}
}
@Test
fun `syncSendUpsertFlow update with no local send should do nothing`() = runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.getSends(userId = userId) } returns MutableStateFlow(emptyList())
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(FIXED_CLOCK),
isUpdate = true,
),
)
coVerify(exactly = 0) {
sendsService.getSend(sendId = sendId)
vaultDiskSource.saveSend(userId = userId, send = any())
}
}
@Test
fun `syncSendUpsertFlow update with more recent local send should do nothing`() = runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val send = createMockSend(
number = number,
revisionDate = ZonedDateTime.now(FIXED_CLOCK),
)
val updatedSend = createMockSend(number = number)
coEvery { vaultDiskSource.getSends(userId = userId) } returns MutableStateFlow(listOf(send))
coEvery { sendsService.getSend(sendId = sendId) } returns updatedSend.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = updatedSend) } just runs
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(FIXED_CLOCK).minus(5, ChronoUnit.MINUTES),
isUpdate = true,
),
)
coVerify(exactly = 0) {
sendsService.getSend(sendId = sendId)
vaultDiskSource.saveSend(userId = userId, send = any())
}
}
@Suppress("MaxLineLength")
@Test
fun `syncSendUpsertFlow update failure with 404 code should make a request for a send and then delete it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { sendsService.getSend(sendId = sendId) } returns response.asFailure()
coEvery {
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
} just runs
val sendView = createMockSend(
number = number,
revisionDate = ZonedDateTime.now(FIXED_CLOCK).minus(5, ChronoUnit.MINUTES),
)
coEvery {
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(listOf(sendView))
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(FIXED_CLOCK),
isUpdate = true,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId = sendId)
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncSendUpsertFlow create failure with 404 code should make a request for a send and do nothing`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { sendsService.getSend(sendId = sendId) } returns response.asFailure()
coEvery {
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(emptyList())
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(FIXED_CLOCK),
isUpdate = false,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId = sendId)
}
coVerify(exactly = 0) {
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncSendUpsertFlow valid create success should make a request for a send and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(emptyList())
val send = mockk<SyncResponseJson.Send>()
coEvery { sendsService.getSend(sendId = sendId) } returns send.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = send) } just runs
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(FIXED_CLOCK),
isUpdate = false,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId = sendId)
vaultDiskSource.saveSend(userId = userId, send = send)
}
}
@Suppress("MaxLineLength")
@Test
fun `syncSendUpsertFlow valid update success should make a request for a send and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val sendView = createMockSend(
number = number,
revisionDate = ZonedDateTime.now(FIXED_CLOCK).minus(5, ChronoUnit.MINUTES),
)
coEvery {
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(listOf(sendView))
val send = mockk<SyncResponseJson.Send>()
coEvery { sendsService.getSend(sendId = sendId) } returns send.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = send) } just runs
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(FIXED_CLOCK),
isUpdate = true,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId = sendId)
vaultDiskSource.saveSend(userId = userId, send = send)
}
}
@Test
fun `createSend with no active user should return CreateSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = null
val result = sendManager.createSend(
sendView = mockk(),
fileUri = mockk(),
)
assertEquals(
CreateSendResult.Error(message = null, error = NoActiveUserException()),
result,
)
}
@Test
fun `createSend with encryptSend failure should return CreateSendResult failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSendView = createMockSendView(number = 1)
val error = IllegalStateException()
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns error.asFailure()
val result = sendManager.createSend(sendView = mockSendView, fileUri = null)
assertEquals(CreateSendResult.Error(message = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with TEXT and sendsService createTextSend failure should return CreateSendResult failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
val error = IllegalStateException()
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
coEvery {
sendsService.createTextSend(
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns error.asFailure()
val result = sendManager.createSend(sendView = mockSendView, fileUri = null)
assertEquals(CreateSendResult.Error(message = error.message, error = error), result)
}
@Suppress("MaxLineLength")
@Test
fun `createSend with TEXT and sendsService createTextSend success should return CreateSendResult success and increment send action count`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
val mockSdkSend = createMockSdkSend(number = 1, type = SendType.TEXT)
val mockSend = createMockSend(number = 1, type = SendTypeJson.TEXT)
val mockSendViewResult = createMockSendView(number = 2)
val sendTextResponse = CreateSendJsonResponse.Success(send = mockSend)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
coEvery {
sendsService.createTextSend(
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns sendTextResponse.asSuccess()
coEvery { vaultDiskSource.saveSend(userId, mockSend) } just runs
coEvery {
vaultSdkSource.decryptSend(userId, mockSdkSend)
} returns mockSendViewResult.asSuccess()
val result = sendManager.createSend(sendView = mockSendView, fileUri = null)
assertEquals(CreateSendResult.Success(mockSendViewResult), result)
verify(exactly = 1) { reviewPromptManager.registerCreateSendAction() }
}
@Test
@Suppress("MaxLineLength")
fun `createSend with FILE and sendsService createFileSend failure should return CreateSendResult failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val uri = setupMockUri(url = "www.test.com")
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val decryptedFile = mockk<File> {
every { length() } returns 1
every { absolutePath } returns "mockAbsolutePath"
}
val encryptedFile = mockk<File> {
every { length() } returns 1
every { absolutePath } returns "mockAbsolutePath"
}
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
coEvery { fileManager.writeUriToCache(any()) } returns decryptedFile.asSuccess()
coEvery {
vaultSdkSource.encryptFile(
userId = userId,
send = mockSdkSend,
path = "mockAbsolutePath",
destinationFilePath = "mockAbsolutePath",
)
} returns encryptedFile.asSuccess()
val error = IllegalStateException()
coEvery {
sendsService.createFileSend(body = createMockSendJsonRequest(number = 1))
} returns error.asFailure()
val result = sendManager.createSend(sendView = mockSendView, fileUri = uri)
assertEquals(CreateSendResult.Error(message = error.message, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with FILE and sendsService uploadFile failure should return CreateSendResult failure`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val url = "www.test.com"
val uri = setupMockUri(url = url)
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val decryptedFile = mockk<File> {
every { name } returns "mockFileName"
every { absolutePath } returns "mockAbsolutePath"
every { length() } returns 1
}
val encryptedFile = mockk<File> {
every { name } returns "mockFileName"
every { absolutePath } returns "mockAbsolutePath"
every { length() } returns 1
}
val sendFileResponse = CreateFileSendResponse.Success(
createMockFileSendResponseJson(number = 1),
)
val error = Throwable()
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
coEvery {
vaultSdkSource.decryptSend(userId, mockSdkSend)
} returns mockSendView.asSuccess()
every { fileManager.filesDirectory } returns "mockFilesDirectory"
coEvery { fileManager.writeUriToCache(any()) } returns decryptedFile.asSuccess()
coEvery {
vaultSdkSource.encryptFile(
userId = userId,
send = mockSdkSend,
path = "mockAbsolutePath",
destinationFilePath = "mockAbsolutePath",
)
} returns encryptedFile.asSuccess()
coEvery {
vaultDiskSource.saveSend(
userId,
sendFileResponse.createFileJsonResponse.sendResponse,
)
} just runs
coEvery {
sendsService.createFileSend(body = createMockSendJsonRequest(number = 1))
} returns sendFileResponse.asSuccess()
coEvery {
sendsService.uploadFile(
sendFileResponse = sendFileResponse.createFileJsonResponse,
encryptedFile = encryptedFile,
)
} returns error.asFailure()
val result = sendManager.createSend(sendView = mockSendView, fileUri = uri)
assertEquals(CreateSendResult.Error(message = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with FILE and fileManager uriToByteArray failure should return CreateSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val url = "www.test.com"
val uri = setupMockUri(url = url)
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val error = Throwable()
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
coEvery { fileManager.writeUriToCache(any()) } returns error.asFailure()
val result = sendManager.createSend(sendView = mockSendView, fileUri = uri)
assertEquals(CreateSendResult.Error(message = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `createSend with FILE and sendsService uploadFile success should return CreateSendResult success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val url = "www.test.com"
val uri = setupMockUri(url = url)
val mockSendView = createMockSendView(number = 1)
val mockSdkSend = createMockSdkSend(number = 1)
val decryptedFile = mockk<File> {
every { name } returns "mockFileName"
every { absolutePath } returns "mockAbsolutePath"
}
val encryptedFile = mockk<File> {
every { length() } returns 1
}
val sendFileResponse = CreateFileSendResponse.Success(
createMockFileSendResponseJson(number = 1),
)
val mockSendViewResult = createMockSendView(number = 1)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns mockSdkSend.asSuccess()
every { fileManager.filesDirectory } returns "mockFilesDirectory"
coEvery { fileManager.writeUriToCache(any()) } returns decryptedFile.asSuccess()
coEvery {
vaultSdkSource.encryptFile(
userId = userId,
send = mockSdkSend,
path = "mockAbsolutePath",
destinationFilePath = "mockAbsolutePath",
)
} returns encryptedFile.asSuccess()
coEvery {
sendsService.createFileSend(body = createMockSendJsonRequest(number = 1))
} returns sendFileResponse.asSuccess()
coEvery {
sendsService.uploadFile(
sendFileResponse = sendFileResponse.createFileJsonResponse,
encryptedFile = encryptedFile,
)
} returns sendFileResponse.createFileJsonResponse.sendResponse.asSuccess()
coEvery {
vaultDiskSource.saveSend(
userId,
sendFileResponse.createFileJsonResponse.sendResponse,
)
} just runs
coEvery {
vaultSdkSource.decryptSend(userId, mockSdkSend)
} returns mockSendViewResult.asSuccess()
val result = sendManager.createSend(sendView = mockSendView, fileUri = uri)
assertEquals(CreateSendResult.Success(mockSendViewResult), result)
}
@Test
fun `deleteSend with no active user should return DeleteSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = null
val result = sendManager.deleteSend(sendId = "sendId")
assertEquals(
DeleteSendResult.Error(error = NoActiveUserException()),
result,
)
}
@Test
fun `deleteSend with sendsService deleteSend failure should return DeleteSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val sendId = "mockId-1"
val error = Throwable("Fail")
coEvery {
sendsService.deleteSend(sendId = sendId)
} returns error.asFailure()
val result = sendManager.deleteSend(sendId)
assertEquals(DeleteSendResult.Error(error = error), result)
}
@Test
fun `deleteSend with sendsService deleteSend success should return DeleteSendResult success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "mockId-1"
coEvery { sendsService.deleteSend(sendId = sendId) } returns Unit.asSuccess()
coEvery { vaultDiskSource.deleteSend(userId, sendId) } just runs
val result = sendManager.deleteSend(sendId)
assertEquals(DeleteSendResult.Success, result)
}
@Test
fun `removePasswordSend with no active user should return RemovePasswordSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = null
val result = sendManager.removePasswordSend(sendId = "sendId")
assertEquals(
RemovePasswordSendResult.Error(
errorMessage = null,
error = NoActiveUserException(),
),
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `removePasswordSend with sendsService removeSendPassword Error should return RemovePasswordSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val sendId = "sendId1234"
val error = Throwable("Fail")
coEvery {
sendsService.removeSendPassword(sendId = sendId)
} returns error.asFailure()
val result = sendManager.removePasswordSend(sendId = sendId)
assertEquals(RemovePasswordSendResult.Error(errorMessage = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `removePasswordSend with sendsService removeSendPassword Success and vaultSdkSource decryptSend Failure should return RemovePasswordSendResult Error`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSend = createMockSend(number = 1)
val error = Throwable("Fail")
coEvery {
sendsService.removeSendPassword(sendId = sendId)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
coEvery {
vaultSdkSource.decryptSend(userId = userId, send = createMockSdkSend(number = 1))
} returns error.asFailure()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
val result = sendManager.removePasswordSend(sendId = sendId)
assertEquals(RemovePasswordSendResult.Error(errorMessage = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `removePasswordSend with sendsService removeSendPassword Success should return RemovePasswordSendResult success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
val mockSend = createMockSend(number = 1)
coEvery {
sendsService.removeSendPassword(sendId = sendId)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
coEvery {
vaultSdkSource.decryptSend(userId = userId, send = createMockSdkSend(number = 1))
} returns mockSendView.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
val result = sendManager.removePasswordSend(sendId = sendId)
assertEquals(RemovePasswordSendResult.Success(mockSendView), result)
}
@Test
fun `updateSend with no active user should return UpdateSendResult Error`() = runTest {
fakeAuthDiskSource.userState = null
val result = sendManager.updateSend(
sendId = "sendId",
sendView = mockk(),
)
assertEquals(
UpdateSendResult.Error(errorMessage = null, error = NoActiveUserException()),
result,
)
}
@Test
fun `updateSend with encryptSend failure should return UpdateSendResult failure`() = runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1)
val error = IllegalStateException()
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns error.asFailure()
val result = sendManager.updateSend(
sendId = sendId,
sendView = mockSendView,
)
assertEquals(UpdateSendResult.Error(errorMessage = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `updateSend with sendsService updateSend failure should return UpdateSendResult Error with a null message`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
val error = IllegalStateException()
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns error.asFailure()
val result = sendManager.updateSend(
sendId = sendId,
sendView = mockSendView,
)
assertEquals(UpdateSendResult.Error(errorMessage = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `updateSend with sendsService updateSend Invalid response should return UpdateSendResult Error with a non-null message`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns UpdateSendResponseJson
.Invalid(
message = "You do not have permission to edit this.",
validationErrors = null,
)
.asSuccess()
val result = sendManager.updateSend(
sendId = sendId,
sendView = mockSendView,
)
assertEquals(
UpdateSendResult.Error(
errorMessage = "You do not have permission to edit this.",
error = null,
),
result,
)
}
@Test
@Suppress("MaxLineLength")
fun `updateSend with sendsService updateSend success and decryption error should return UpdateSendResult Error with a null message`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
val mockSend = createMockSend(number = 1, type = SendTypeJson.TEXT)
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
val error = Throwable("Fail")
coEvery {
vaultSdkSource.decryptSend(
userId = userId, send = createMockSdkSend(number = 1, type = SendType.TEXT),
)
} returns error.asFailure()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
val result = sendManager.updateSend(
sendId = sendId,
sendView = mockSendView,
)
assertEquals(UpdateSendResult.Error(errorMessage = null, error = error), result)
}
@Test
@Suppress("MaxLineLength")
fun `updateSend with sendsService updateSend Success response should return UpdateSendResult success`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = "mockId-1"
val sendId = "sendId1234"
val mockSendView = createMockSendView(number = 1, type = SendType.TEXT)
coEvery {
vaultSdkSource.encryptSend(userId = userId, sendView = mockSendView)
} returns createMockSdkSend(number = 1, type = SendType.TEXT).asSuccess()
val mockSend = createMockSend(number = 1, type = SendTypeJson.TEXT)
coEvery {
sendsService.updateSend(
sendId = sendId,
body = createMockSendJsonRequest(number = 1, type = SendTypeJson.TEXT)
.copy(fileLength = null),
)
} returns UpdateSendResponseJson.Success(send = mockSend).asSuccess()
val mockSendViewResult = createMockSendView(number = 2, type = SendType.TEXT)
coEvery {
vaultSdkSource.decryptSend(
userId = userId,
send = createMockSdkSend(number = 1, type = SendType.TEXT),
)
} returns mockSendViewResult.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = mockSend) } just runs
val result = sendManager.updateSend(
sendId = sendId,
sendView = mockSendView,
)
assertEquals(UpdateSendResult.Success(mockSendViewResult), result)
}
//region Helper functions
private fun setupMockUri(
url: String,
queryParams: Map<String, String> = emptyMap(),
): Uri {
val mockUri = mockk<Uri> {
queryParams.forEach {
every { getQueryParameter(it.key) } returns it.value
}
}
every { Uri.parse(url) } returns mockUri
return mockUri
}
//endregion Helper functions
}
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val MOCK_PROFILE = AccountJson.Profile(
userId = "mockId-1",
email = "email",
isEmailVerified = true,
name = null,
stamp = "mockSecurityStamp-1",
organizationId = null,
avatarColorHex = null,
hasPremium = false,
forcePasswordResetReason = null,
kdfType = null,
kdfIterations = null,
kdfMemory = null,
kdfParallelism = null,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
)
private val MOCK_ACCOUNT = AccountJson(
profile = MOCK_PROFILE,
tokens = AccountTokensJson(
accessToken = "accessToken",
refreshToken = "refreshToken",
),
settings = AccountJson.Settings(
environmentUrlData = null,
),
)
private val MOCK_USER_STATE = UserStateJson(
activeUserId = "mockId-1",
accounts = mapOf(
"mockId-1" to MOCK_ACCOUNT,
),
)