mirror of
https://github.com/bitwarden/android.git
synced 2026-05-09 05:20:24 -05:00
Compare commits
6 Commits
sdlc/sdk-u
...
android-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d5cbdd178 | ||
|
|
5dcaf6e4a8 | ||
|
|
d0809a7c07 | ||
|
|
27eab5570f | ||
|
|
f6435a0a1e | ||
|
|
d3e4dc854b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,6 +36,9 @@ user.properties
|
||||
/app/src/standardRelease/google-services.json
|
||||
/authenticator/src/google-services.json
|
||||
|
||||
# Claude Code outputs
|
||||
.claude/outputs/
|
||||
|
||||
# Python
|
||||
.python-version
|
||||
__pycache__/
|
||||
|
||||
@@ -226,7 +226,7 @@ configurations.all {
|
||||
resolutionStrategy.dependencySubstitution {
|
||||
if ((userProperties["localSdk"] as String?).toBoolean()) {
|
||||
substitute(module("com.bitwarden:sdk-android"))
|
||||
.using(module("com.bitwarden:sdk-android:LOCAL"))
|
||||
.using(module("com.bitwarden:sdk-android.dev:LOCAL"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ import com.bitwarden.network.model.OrganizationType
|
||||
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
|
||||
* @property limitItemDeletion Indicates that the organization limits item deletion.
|
||||
* @property shouldUseEvents Indicates if the organization uses tracking events.
|
||||
* @property maxCollections The maximum number of collections allowed (nullable).
|
||||
* @property canCreateNewCollections Indicates if the user can create new collections.
|
||||
* @property canEditAnyCollection Indicates if the user can edit any collection.
|
||||
* @property canDeleteAnyCollection Indicates if the user can delete any collection.
|
||||
*/
|
||||
data class Organization(
|
||||
val id: String,
|
||||
@@ -26,4 +30,22 @@ data class Organization(
|
||||
val userIsClaimedByOrganization: Boolean,
|
||||
val limitItemDeletion: Boolean,
|
||||
val shouldUseEvents: Boolean,
|
||||
)
|
||||
val maxCollections: Int?,
|
||||
val limitCollectionCreation: Boolean,
|
||||
val limitCollectionDeletion: Boolean,
|
||||
val organizationUserId: String?,
|
||||
val canCreateNewCollections: Boolean,
|
||||
val canEditAnyCollection: Boolean,
|
||||
val canDeleteAnyCollection: Boolean,
|
||||
) {
|
||||
/**
|
||||
* Whether the user can create new collections in this organization, accounting for
|
||||
* the organization's role and limitCollectionCreation setting.
|
||||
* Matches web client logic: `!limitCollectionCreation || isAdmin || permissions.createNewCollections`
|
||||
*/
|
||||
val canManageCollections: Boolean
|
||||
get() = !limitCollectionCreation ||
|
||||
role == OrganizationType.ADMIN ||
|
||||
role == OrganizationType.OWNER ||
|
||||
canCreateNewCollections
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
|
||||
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
|
||||
limitItemDeletion = this.limitItemDeletion,
|
||||
shouldUseEvents = this.shouldUseEvents,
|
||||
maxCollections = this.maxCollections,
|
||||
organizationUserId = this.organizationUserId,
|
||||
limitCollectionCreation = this.limitCollectionCreation,
|
||||
limitCollectionDeletion = this.limitCollectionDeletion,
|
||||
canCreateNewCollections = this.permissions.canCreateNewCollections,
|
||||
canEditAnyCollection = this.permissions.canEditAnyCollection,
|
||||
canDeleteAnyCollection = this.permissions.canDeleteAnyCollection,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class ServerCommunicationConfigRepositoryImpl(
|
||||
idpLoginUrl = serverCommunicationConfig.bootstrap.idpLoginUrl,
|
||||
cookieName = serverCommunicationConfig.bootstrap.cookieName,
|
||||
cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain,
|
||||
vaultUrl = null,
|
||||
cookieValue = acquiredCookies,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -82,6 +82,11 @@ interface VaultDiskSource {
|
||||
*/
|
||||
fun getCollectionsFlow(userId: String): Flow<List<SyncResponseJson.Collection>>
|
||||
|
||||
/**
|
||||
* Deletes a collection from the data source for the given [userId] and [collectionId].
|
||||
*/
|
||||
suspend fun deleteCollection(userId: String, collectionId: String)
|
||||
|
||||
/**
|
||||
* Retrieves all domains from the data source for a given [userId].
|
||||
*/
|
||||
|
||||
@@ -200,6 +200,10 @@ class VaultDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun deleteCollection(userId: String, collectionId: String) {
|
||||
collectionsDao.deleteCollection(userId = userId, collectionId = collectionId)
|
||||
}
|
||||
|
||||
override fun getCollectionsFlow(
|
||||
userId: String,
|
||||
): Flow<List<SyncResponseJson.Collection>> =
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.di
|
||||
import com.bitwarden.network.BitwardenServiceClient
|
||||
import com.bitwarden.network.service.CiphersService
|
||||
import com.bitwarden.network.service.DownloadService
|
||||
import com.bitwarden.network.service.CollectionService
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.network.service.SendsService
|
||||
import com.bitwarden.network.service.SyncService
|
||||
@@ -25,6 +26,12 @@ object VaultNetworkModule {
|
||||
bitwardenServiceClient: BitwardenServiceClient,
|
||||
): CiphersService = bitwardenServiceClient.ciphersService
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesCollectionService(
|
||||
bitwardenServiceClient: BitwardenServiceClient,
|
||||
): CollectionService = bitwardenServiceClient.collectionService
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesFolderService(
|
||||
|
||||
@@ -239,6 +239,18 @@ interface VaultSdkSource {
|
||||
collectionList: List<Collection>,
|
||||
): Result<List<CollectionView>>
|
||||
|
||||
/**
|
||||
* Encrypts a [CollectionView] for the user with the given [userId], returning a [Collection]
|
||||
* wrapped in a [Result].
|
||||
*
|
||||
* This should only be called after a successful call to [initializeCrypto] for the associated
|
||||
* user.
|
||||
*/
|
||||
suspend fun encryptCollection(
|
||||
userId: String,
|
||||
collectionView: CollectionView,
|
||||
): Result<Collection>
|
||||
|
||||
/**
|
||||
* Encrypts a [SendView] for the user with the given [userId], returning a [Send] wrapped
|
||||
* in a [Result].
|
||||
|
||||
@@ -335,6 +335,17 @@ class VaultSdkSourceImpl(
|
||||
.decryptList(collections = collectionList)
|
||||
}
|
||||
|
||||
override suspend fun encryptCollection(
|
||||
userId: String,
|
||||
collectionView: CollectionView,
|
||||
): Result<Collection> =
|
||||
runCatchingWithLogs {
|
||||
getClient(userId = userId)
|
||||
.vault()
|
||||
.collections()
|
||||
.encrypt(collectionView = collectionView)
|
||||
}
|
||||
|
||||
override suspend fun decryptSend(
|
||||
userId: String,
|
||||
send: Send,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
|
||||
|
||||
/**
|
||||
* Manages the creating, updating, and deleting collections.
|
||||
*/
|
||||
interface CollectionManager {
|
||||
/**
|
||||
* Attempt to create a collection in the given [organizationId].
|
||||
* The [organizationUserId] is used to grant the creating user manage access.
|
||||
*/
|
||||
suspend fun createCollection(
|
||||
organizationId: String,
|
||||
organizationUserId: String?,
|
||||
collectionView: CollectionView,
|
||||
): CreateCollectionResult
|
||||
|
||||
/**
|
||||
* Attempt to delete a collection.
|
||||
*/
|
||||
suspend fun deleteCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): DeleteCollectionResult
|
||||
|
||||
/**
|
||||
* Attempt to update a collection.
|
||||
*/
|
||||
suspend fun updateCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
collectionView: CollectionView,
|
||||
): UpdateCollectionResult
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.network.model.CollectionAccessSelectionJson
|
||||
import com.bitwarden.network.model.CollectionJsonRequest
|
||||
import com.bitwarden.network.model.UpdateCollectionResponseJson
|
||||
import com.bitwarden.network.service.CollectionService
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
|
||||
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.CreateCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollection
|
||||
|
||||
/**
|
||||
* The default implementation of the [CollectionManager].
|
||||
*/
|
||||
class CollectionManagerImpl(
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val collectionService: CollectionService,
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
) : CollectionManager {
|
||||
private val activeUserId: String? get() = authDiskSource.userState?.activeUserId
|
||||
|
||||
override suspend fun createCollection(
|
||||
organizationId: String,
|
||||
organizationUserId: String?,
|
||||
collectionView: CollectionView,
|
||||
): CreateCollectionResult {
|
||||
val userId = activeUserId
|
||||
?: return CreateCollectionResult.Error(error = NoActiveUserException())
|
||||
// Grant the creating user manage access, matching web client behavior.
|
||||
val users = organizationUserId?.let {
|
||||
listOf(
|
||||
CollectionAccessSelectionJson(
|
||||
id = it,
|
||||
readOnly = false,
|
||||
hidePasswords = false,
|
||||
manage = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
return vaultSdkSource
|
||||
.encryptCollection(userId = userId, collectionView = collectionView)
|
||||
.flatMap {
|
||||
collectionService.createCollection(
|
||||
organizationId = organizationId,
|
||||
body = CollectionJsonRequest(
|
||||
name = it.name,
|
||||
users = users,
|
||||
),
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
vaultDiskSource.saveCollection(userId = userId, collection = it)
|
||||
}
|
||||
.flatMap {
|
||||
vaultSdkSource.decryptCollection(
|
||||
userId = userId,
|
||||
collection = it.toEncryptedSdkCollection(),
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { CreateCollectionResult.Success(collectionView = it) },
|
||||
onFailure = { CreateCollectionResult.Error(error = it) },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun deleteCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): DeleteCollectionResult {
|
||||
val userId = activeUserId
|
||||
?: return DeleteCollectionResult.Error(error = NoActiveUserException())
|
||||
return collectionService
|
||||
.deleteCollection(
|
||||
organizationId = organizationId,
|
||||
collectionId = collectionId,
|
||||
)
|
||||
.onSuccess {
|
||||
vaultDiskSource.deleteCollection(
|
||||
userId = userId,
|
||||
collectionId = collectionId,
|
||||
)
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { DeleteCollectionResult.Success },
|
||||
onFailure = { DeleteCollectionResult.Error(error = it) },
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
override suspend fun updateCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
collectionView: CollectionView,
|
||||
): UpdateCollectionResult {
|
||||
val userId = activeUserId
|
||||
?: return UpdateCollectionResult.Error(error = NoActiveUserException())
|
||||
return collectionService
|
||||
.getCollectionDetails(
|
||||
organizationId = organizationId,
|
||||
collectionId = collectionId,
|
||||
)
|
||||
.flatMap { details ->
|
||||
vaultSdkSource
|
||||
.encryptCollection(
|
||||
userId = userId,
|
||||
collectionView = collectionView,
|
||||
)
|
||||
.flatMap { collection ->
|
||||
collectionService.updateCollection(
|
||||
organizationId = organizationId,
|
||||
collectionId = collectionId,
|
||||
body = CollectionJsonRequest(
|
||||
name = collection.name,
|
||||
externalId = details.externalId,
|
||||
groups = details.groups,
|
||||
users = details.users,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { response ->
|
||||
when (response) {
|
||||
is UpdateCollectionResponseJson.Success -> {
|
||||
vaultDiskSource.saveCollection(
|
||||
userId = userId,
|
||||
collection = response.collection,
|
||||
)
|
||||
vaultSdkSource
|
||||
.decryptCollection(
|
||||
userId = userId,
|
||||
collection = response.collection
|
||||
.toEncryptedSdkCollection(),
|
||||
)
|
||||
.fold(
|
||||
onSuccess = {
|
||||
UpdateCollectionResult.Success(it)
|
||||
},
|
||||
onFailure = {
|
||||
UpdateCollectionResult.Error(error = it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is UpdateCollectionResponseJson.Invalid -> {
|
||||
UpdateCollectionResult.Error(
|
||||
errorMessage = response.message,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onFailure = { UpdateCollectionResult.Error(error = it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
|
||||
import com.bitwarden.data.manager.appstate.AppStateManager
|
||||
import com.bitwarden.data.manager.file.FileManager
|
||||
import com.bitwarden.network.service.CiphersService
|
||||
import com.bitwarden.network.service.CollectionService
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.network.service.SendsService
|
||||
import com.bitwarden.network.service.SyncService
|
||||
@@ -28,6 +29,8 @@ 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.CipherManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.CollectionManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl
|
||||
import com.x8bit.bitwarden.data.vault.manager.FolderManager
|
||||
@@ -114,6 +117,20 @@ object VaultManagerModule {
|
||||
pushManager = pushManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCollectionManager(
|
||||
collectionService: CollectionService,
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
): CollectionManager = CollectionManagerImpl(
|
||||
authDiskSource = authDiskSource,
|
||||
collectionService = collectionService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFolderManager(
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.bitwarden.vault.CipherType
|
||||
import com.bitwarden.vault.CipherView
|
||||
import com.bitwarden.vault.FolderView
|
||||
import com.x8bit.bitwarden.data.vault.manager.CipherManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.FolderManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.SendManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
|
||||
@@ -32,6 +33,7 @@ import javax.crypto.Cipher
|
||||
@Suppress("TooManyFunctions")
|
||||
interface VaultRepository :
|
||||
CipherManager,
|
||||
CollectionManager,
|
||||
FolderManager,
|
||||
SendManager,
|
||||
VaultLockManager,
|
||||
|
||||
@@ -26,6 +26,7 @@ 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.CollectionManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.FolderManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.PinProtectedUserKeyManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.SendManager
|
||||
@@ -74,6 +75,7 @@ class VaultRepositoryImpl(
|
||||
private val vaultSdkSource: VaultSdkSource,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
private val cipherManager: CipherManager,
|
||||
private val collectionManager: CollectionManager,
|
||||
private val folderManager: FolderManager,
|
||||
private val sendManager: SendManager,
|
||||
private val vaultLockManager: VaultLockManager,
|
||||
@@ -84,6 +86,7 @@ class VaultRepositoryImpl(
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : VaultRepository,
|
||||
CipherManager by cipherManager,
|
||||
CollectionManager by collectionManager,
|
||||
FolderManager by folderManager,
|
||||
SendManager by sendManager,
|
||||
VaultLockManager by vaultLockManager,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.CollectionManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.FolderManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.PinProtectedUserKeyManager
|
||||
import com.x8bit.bitwarden.data.vault.manager.SendManager
|
||||
@@ -34,6 +35,7 @@ object VaultRepositoryModule {
|
||||
vaultSdkSource: VaultSdkSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
cipherManager: CipherManager,
|
||||
collectionManager: CollectionManager,
|
||||
folderManager: FolderManager,
|
||||
sendManager: SendManager,
|
||||
vaultLockManager: VaultLockManager,
|
||||
@@ -47,6 +49,7 @@ object VaultRepositoryModule {
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
authDiskSource = authDiskSource,
|
||||
cipherManager = cipherManager,
|
||||
collectionManager = collectionManager,
|
||||
folderManager = folderManager,
|
||||
sendManager = sendManager,
|
||||
vaultLockManager = vaultLockManager,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
|
||||
|
||||
/**
|
||||
* Models result of creating a collection.
|
||||
*/
|
||||
sealed class CreateCollectionResult {
|
||||
|
||||
/**
|
||||
* Collection created successfully.
|
||||
*/
|
||||
data class Success(val collectionView: CollectionView) : CreateCollectionResult()
|
||||
|
||||
/**
|
||||
* Generic error while creating a collection. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = error.userFriendlyMessage,
|
||||
) : CreateCollectionResult()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
|
||||
|
||||
/**
|
||||
* Models result of deleting a collection.
|
||||
*/
|
||||
sealed class DeleteCollectionResult {
|
||||
|
||||
/**
|
||||
* Collection deleted successfully.
|
||||
*/
|
||||
data object Success : DeleteCollectionResult()
|
||||
|
||||
/**
|
||||
* Generic error while deleting a collection. The optional [errorMessage] may be displayed
|
||||
* directly in the UI when present.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val errorMessage: String? = error.userFriendlyMessage,
|
||||
) : DeleteCollectionResult()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
|
||||
|
||||
/**
|
||||
* Models result of updating a collection.
|
||||
*/
|
||||
sealed class UpdateCollectionResult {
|
||||
|
||||
/**
|
||||
* Collection updated successfully.
|
||||
*/
|
||||
data class Success(val collectionView: CollectionView) : UpdateCollectionResult()
|
||||
|
||||
/**
|
||||
* Generic error while updating a collection. The optional [errorMessage]
|
||||
* may be displayed directly in the UI when present.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable? = null,
|
||||
val errorMessage: String? = error?.userFriendlyMessage,
|
||||
) : UpdateCollectionResult()
|
||||
}
|
||||
@@ -12,8 +12,8 @@ val InitUserCryptoMethod.logTag: String
|
||||
is InitUserCryptoMethod.DecryptedKey -> "Decrypted Key (Never Lock/Biometrics)"
|
||||
is InitUserCryptoMethod.DeviceKey -> "Device Key"
|
||||
is InitUserCryptoMethod.KeyConnector -> "Key Connector"
|
||||
is InitUserCryptoMethod.KeyConnectorUrl -> "Key Connector URL"
|
||||
is InitUserCryptoMethod.Pin -> "Pin"
|
||||
is InitUserCryptoMethod.PinEnvelope -> "Pin Envelope"
|
||||
is InitUserCryptoMethod.KeyConnectorUrl -> "Key Connector Url"
|
||||
is InitUserCryptoMethod.MasterPasswordUnlock -> "Master Password Unlock"
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ fun SavedStateHandle.toSettingsArgs(): SettingsArgs {
|
||||
@Suppress("LongParameterList")
|
||||
fun NavGraphBuilder.settingsGraph(
|
||||
navController: NavController,
|
||||
onNavigateToCollections: () -> Unit,
|
||||
onNavigateToDeleteAccount: () -> Unit,
|
||||
onNavigateToExportVault: () -> Unit,
|
||||
onNavigateToFolders: () -> Unit,
|
||||
@@ -163,6 +164,7 @@ fun NavGraphBuilder.settingsGraph(
|
||||
)
|
||||
vaultSettingsDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToCollections = onNavigateToCollections,
|
||||
onNavigateToExportVault = onNavigateToExportVault,
|
||||
onNavigateToFolders = onNavigateToFolders,
|
||||
onNavigateToImportLogins = onNavigateToImportLogins,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the collections screen.
|
||||
*/
|
||||
@Serializable
|
||||
data object CollectionsRoute
|
||||
|
||||
/**
|
||||
* Add collections destinations to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.collectionsDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToAddCollectionScreen: (organizationId: String) -> Unit,
|
||||
onNavigateToEditCollectionScreen: (
|
||||
collectionId: String,
|
||||
organizationId: String,
|
||||
) -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<CollectionsRoute> {
|
||||
CollectionsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToAddCollectionScreen = onNavigateToAddCollectionScreen,
|
||||
onNavigateToEditCollectionScreen = onNavigateToEditCollectionScreen,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the collections screen.
|
||||
*/
|
||||
fun NavController.navigateToCollections(navOptions: NavOptions? = null) {
|
||||
this.navigate(route = CollectionsRoute, navOptions = navOptions)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
|
||||
import com.bitwarden.ui.platform.components.fab.BitwardenFloatingActionButton
|
||||
import com.bitwarden.ui.platform.components.row.BitwardenTextRow
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionDisplayItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Displays the collections list screen.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CollectionsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToAddCollectionScreen: (organizationId: String) -> Unit,
|
||||
onNavigateToEditCollectionScreen: (
|
||||
collectionId: String,
|
||||
organizationId: String,
|
||||
) -> Unit,
|
||||
viewModel: CollectionsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state = viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = rememberBitwardenSnackbarHostState()
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
is CollectionsEvent.NavigateBack -> onNavigateBack()
|
||||
is CollectionsEvent.NavigateToAddCollectionScreen -> {
|
||||
onNavigateToAddCollectionScreen(event.organizationId)
|
||||
}
|
||||
|
||||
is CollectionsEvent.NavigateToEditCollectionScreen -> {
|
||||
onNavigateToEditCollectionScreen(
|
||||
event.collectionId,
|
||||
event.organizationId,
|
||||
)
|
||||
}
|
||||
|
||||
is CollectionsEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.collections),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(
|
||||
id = BitwardenString.close,
|
||||
),
|
||||
onNavigationIconClick = {
|
||||
viewModel.trySendAction(CollectionsAction.CloseButtonClick)
|
||||
},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val viewState = state.value.viewState
|
||||
if (viewState is CollectionsState.ViewState.Content && viewState.showAddButton) {
|
||||
BitwardenFloatingActionButton(
|
||||
onClick = {
|
||||
viewModel.trySendAction(CollectionsAction.AddCollectionButtonClick)
|
||||
},
|
||||
painter = rememberVectorPainter(id = BitwardenDrawable.ic_plus_large),
|
||||
contentDescription = stringResource(id = BitwardenString.add_item),
|
||||
modifier = Modifier
|
||||
.testTag(tag = "AddItemButton")
|
||||
.navigationBarsPadding(),
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) },
|
||||
) {
|
||||
when (val viewState = state.value.viewState) {
|
||||
is CollectionsState.ViewState.Content -> {
|
||||
CollectionsContent(
|
||||
collectionsList = viewState.collectionList.toImmutableList(),
|
||||
onItemClick = { item ->
|
||||
viewModel.trySendAction(
|
||||
CollectionsAction.CollectionClick(
|
||||
collectionId = item.id,
|
||||
organizationId = item.organizationId,
|
||||
canManage = item.canManage,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
is CollectionsState.ViewState.Error -> {
|
||||
BitwardenErrorContent(
|
||||
message = viewState.message(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
is CollectionsState.ViewState.Loading -> {
|
||||
BitwardenLoadingContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollectionsContent(
|
||||
collectionsList: ImmutableList<CollectionDisplayItem>,
|
||||
onItemClick: (item: CollectionDisplayItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (collectionsList.isEmpty()) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = BitwardenString.no_collections_to_list),
|
||||
textAlign = TextAlign.Center,
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
modifier = Modifier.testTag("NoCollectionsLabel"),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
) {
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
}
|
||||
itemsIndexed(collectionsList) { index, it ->
|
||||
BitwardenTextRow(
|
||||
text = it.name,
|
||||
description = it.organizationName.toAnnotatedString(),
|
||||
onClick = { onItemClick(it) },
|
||||
textTestTag = "CollectionName",
|
||||
cardStyle = collectionsList.toListItemCardStyle(index = index),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.standardHorizontalMargin()
|
||||
.testTag(tag = "CollectionCell"),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(height = 88.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.ui.platform.base.BackgroundEvent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.ui.util.concat
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionDisplayItem
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
import kotlin.collections.find
|
||||
|
||||
/**
|
||||
* Handles [CollectionsAction],
|
||||
* and launches [CollectionsEvent] for the [CollectionsScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CollectionsViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
vaultRepository: VaultRepository,
|
||||
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
) : BaseViewModel<CollectionsState, CollectionsEvent, CollectionsAction>(
|
||||
initialState = CollectionsState(viewState = CollectionsState.ViewState.Loading),
|
||||
) {
|
||||
init {
|
||||
vaultRepository
|
||||
.collectionsStateFlow
|
||||
.onEach {
|
||||
sendAction(CollectionsAction.Internal.VaultDataReceive(it))
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
snackbarRelayManager
|
||||
.getSnackbarDataFlow(
|
||||
SnackbarRelay.COLLECTION_CREATED,
|
||||
SnackbarRelay.COLLECTION_DELETED,
|
||||
SnackbarRelay.COLLECTION_UPDATED,
|
||||
)
|
||||
.map { CollectionsAction.Internal.SnackbarDataReceived(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: CollectionsAction): Unit = when (action) {
|
||||
is CollectionsAction.AddCollectionButtonClick -> handleAddCollectionButtonClicked()
|
||||
is CollectionsAction.CloseButtonClick -> handleCloseButtonClicked()
|
||||
is CollectionsAction.Internal -> handleInternalAction(action)
|
||||
is CollectionsAction.CollectionClick -> handleCollectionClick(action)
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: CollectionsAction.Internal) {
|
||||
when (action) {
|
||||
is CollectionsAction.Internal.SnackbarDataReceived -> {
|
||||
handleSnackbarDataReceived(action)
|
||||
}
|
||||
|
||||
is CollectionsAction.Internal.VaultDataReceive -> {
|
||||
handleVaultDataReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCollectionClick(action: CollectionsAction.CollectionClick) {
|
||||
if (!action.canManage) return
|
||||
sendEvent(
|
||||
CollectionsEvent.NavigateToEditCollectionScreen(
|
||||
collectionId = action.collectionId,
|
||||
organizationId = action.organizationId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleAddCollectionButtonClicked() {
|
||||
// For now, use the first org with create permission.
|
||||
// TODO: If multiple orgs, show org picker (pending G1 decision).
|
||||
val org = authRepository.organizations.firstOrNull {
|
||||
it.canManageCollections
|
||||
}
|
||||
if (org != null) {
|
||||
sendEvent(CollectionsEvent.NavigateToAddCollectionScreen(org.id))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCloseButtonClicked() {
|
||||
sendEvent(CollectionsEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleSnackbarDataReceived(
|
||||
action: CollectionsAction.Internal.SnackbarDataReceived,
|
||||
) {
|
||||
sendEvent(CollectionsEvent.ShowSnackbar(action.data))
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun handleVaultDataReceive(
|
||||
action: CollectionsAction.Internal.VaultDataReceive,
|
||||
) {
|
||||
val organizations = authRepository.organizations
|
||||
when (val vaultDataState = action.vaultDataState) {
|
||||
is DataState.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = CollectionsState.ViewState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loaded -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = CollectionsState.ViewState.Content(
|
||||
collectionList = vaultDataState.data
|
||||
.toDisplayItems(organizations),
|
||||
showAddButton = organizations.any {
|
||||
org -> org.canManageCollections
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DataState.Loading -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = CollectionsState.ViewState.Loading)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.NoNetwork -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = CollectionsState.ViewState.Error(
|
||||
message = BitwardenString.internet_connection_required_title
|
||||
.asText()
|
||||
.concat(
|
||||
" ".asText(),
|
||||
BitwardenString
|
||||
.internet_connection_required_message
|
||||
.asText(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Pending -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = CollectionsState.ViewState.Content(
|
||||
collectionList = vaultDataState.data
|
||||
.toDisplayItems(organizations),
|
||||
showAddButton = organizations.any {
|
||||
org -> org.canManageCollections
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<CollectionView>.toDisplayItems(
|
||||
organizations: List<Organization>,
|
||||
): List<CollectionDisplayItem> =
|
||||
map { collection ->
|
||||
val org = organizations.find { it.id == collection.organizationId }
|
||||
CollectionDisplayItem(
|
||||
id = collection.id.toString(),
|
||||
name = collection.name,
|
||||
organizationName = org?.name.orEmpty(),
|
||||
organizationId = collection.organizationId,
|
||||
canManage = collection.manage ||
|
||||
org?.role == OrganizationType.OWNER ||
|
||||
org?.role == OrganizationType.ADMIN ||
|
||||
org?.canEditAnyCollection == true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state for the collections screen.
|
||||
*
|
||||
* @property viewState indicates what view state the screen is in.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CollectionsState(
|
||||
val viewState: ViewState,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Represents the specific view states for the [CollectionsScreen].
|
||||
*/
|
||||
sealed class ViewState : Parcelable {
|
||||
/**
|
||||
* Represents an error state for the [CollectionsScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(val message: Text) : ViewState()
|
||||
|
||||
/**
|
||||
* Loading state for the [CollectionsScreen], signifying that the content is being
|
||||
* processed.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : ViewState()
|
||||
|
||||
/**
|
||||
* Represents a loaded content state for the [CollectionsScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Content(
|
||||
val collectionList: List<CollectionDisplayItem>,
|
||||
val showAddButton: Boolean,
|
||||
) : ViewState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the collections screen.
|
||||
*/
|
||||
sealed class CollectionsEvent {
|
||||
/**
|
||||
* Navigates back to the previous screen.
|
||||
*/
|
||||
data object NavigateBack : CollectionsEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the screen to add a collection.
|
||||
*/
|
||||
data class NavigateToAddCollectionScreen(
|
||||
val organizationId: String,
|
||||
) : CollectionsEvent()
|
||||
|
||||
/**
|
||||
* Navigates to the screen to edit a collection.
|
||||
*/
|
||||
data class NavigateToEditCollectionScreen(
|
||||
val collectionId: String,
|
||||
val organizationId: String,
|
||||
) : CollectionsEvent()
|
||||
|
||||
/**
|
||||
* Show a snackbar.
|
||||
*/
|
||||
data class ShowSnackbar(
|
||||
val data: BitwardenSnackbarData,
|
||||
) : CollectionsEvent(), BackgroundEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the collections screen.
|
||||
*/
|
||||
sealed class CollectionsAction {
|
||||
/**
|
||||
* Indicates that the user clicked the add collection button.
|
||||
*/
|
||||
data object AddCollectionButtonClick : CollectionsAction()
|
||||
|
||||
/**
|
||||
* Indicates that the user clicked a collection.
|
||||
*/
|
||||
data class CollectionClick(
|
||||
val collectionId: String,
|
||||
val organizationId: String,
|
||||
val canManage: Boolean,
|
||||
) : CollectionsAction()
|
||||
|
||||
/**
|
||||
* Indicates that the user clicked the close button.
|
||||
*/
|
||||
data object CloseButtonClick : CollectionsAction()
|
||||
|
||||
/**
|
||||
* Actions for internal use by the ViewModel.
|
||||
*/
|
||||
sealed class Internal : CollectionsAction() {
|
||||
/**
|
||||
* Indicates that snackbar data has been received.
|
||||
*/
|
||||
data class SnackbarDataReceived(
|
||||
val data: BitwardenSnackbarData,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the vault collections data has been received.
|
||||
*/
|
||||
data class VaultDataReceive(
|
||||
val vaultDataState: DataState<List<CollectionView>>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionAddEditType
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* The type-safe route for the collection add & edit screen.
|
||||
*/
|
||||
@Serializable
|
||||
data class CollectionAddEditRoute(
|
||||
val actionType: CollectionActionType,
|
||||
val collectionId: String?,
|
||||
val organizationId: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the action being done with a collection.
|
||||
*/
|
||||
@Serializable
|
||||
enum class CollectionActionType {
|
||||
ADD,
|
||||
EDIT,
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to retrieve collection add & edit arguments from the [SavedStateHandle].
|
||||
*/
|
||||
data class CollectionAddEditArgs(
|
||||
val collectionAddEditType: CollectionAddEditType,
|
||||
)
|
||||
|
||||
/**
|
||||
* Constructs a [CollectionAddEditArgs] from the [SavedStateHandle] and internal route data.
|
||||
*/
|
||||
fun SavedStateHandle.toCollectionAddEditArgs(): CollectionAddEditArgs {
|
||||
val route = this.toRoute<CollectionAddEditRoute>()
|
||||
return CollectionAddEditArgs(
|
||||
collectionAddEditType = when (route.actionType) {
|
||||
CollectionActionType.ADD -> CollectionAddEditType.AddItem(
|
||||
organizationId = route.organizationId,
|
||||
)
|
||||
|
||||
CollectionActionType.EDIT -> CollectionAddEditType.EditItem(
|
||||
collectionId = requireNotNull(route.collectionId),
|
||||
organizationId = route.organizationId,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the collection add & edit screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.collectionAddEditDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<CollectionAddEditRoute> {
|
||||
CollectionAddEditScreen(onNavigateBack = onNavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the collection add & edit screen.
|
||||
*/
|
||||
fun NavController.navigateToCollectionAddEdit(
|
||||
collectionAddEditType: CollectionAddEditType,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = CollectionAddEditRoute(
|
||||
actionType = collectionAddEditType.toCollectionActionType(),
|
||||
collectionId = collectionAddEditType.collectionId,
|
||||
organizationId = collectionAddEditType.organizationId,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CollectionAddEditType.toCollectionActionType(): CollectionActionType =
|
||||
when (this) {
|
||||
is CollectionAddEditType.AddItem -> CollectionActionType.ADD
|
||||
is CollectionAddEditType.EditItem -> CollectionActionType.EDIT
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem
|
||||
import com.bitwarden.ui.platform.components.appbar.model.OverflowMenuItemData
|
||||
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
|
||||
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
|
||||
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
|
||||
import com.bitwarden.ui.platform.components.field.BitwardenTextField
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Displays the screen for adding or editing a collection item.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun CollectionAddEditScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: CollectionAddEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
var shouldShowConfirmationDialog by rememberSaveable { mutableStateOf(false) }
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
is CollectionAddEditEvent.NavigateBack -> onNavigateBack.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
CollectionAddEditItemDialogs(
|
||||
dialogState = state.dialog,
|
||||
onDismissRequest = {
|
||||
viewModel.trySendAction(CollectionAddEditAction.DismissDialog)
|
||||
},
|
||||
)
|
||||
|
||||
if (shouldShowConfirmationDialog) {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = null,
|
||||
message = stringResource(
|
||||
id = BitwardenString.do_you_really_want_to_delete_collection,
|
||||
),
|
||||
dismissButtonText = stringResource(id = BitwardenString.cancel),
|
||||
confirmButtonText = stringResource(id = BitwardenString.delete),
|
||||
onDismissClick = { shouldShowConfirmationDialog = false },
|
||||
onConfirmClick = {
|
||||
shouldShowConfirmationDialog = false
|
||||
viewModel.trySendAction(CollectionAddEditAction.DeleteClick)
|
||||
},
|
||||
onDismissRequest = { shouldShowConfirmationDialog = false },
|
||||
confirmTextColor = BitwardenTheme.colorScheme.status.error,
|
||||
)
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = state.screenDisplayName.invoke(),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(
|
||||
id = BitwardenDrawable.ic_close,
|
||||
),
|
||||
navigationIconContentDescription = stringResource(
|
||||
id = BitwardenString.close,
|
||||
),
|
||||
onNavigationIconClick = {
|
||||
viewModel.trySendAction(CollectionAddEditAction.CloseClick)
|
||||
},
|
||||
actions = {
|
||||
BitwardenTextButton(
|
||||
label = stringResource(id = BitwardenString.save),
|
||||
onClick = {
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
},
|
||||
modifier = Modifier.testTag("SaveButton"),
|
||||
)
|
||||
BitwardenOverflowActionItem(
|
||||
isVisible = state.shouldShowOverflowMenu,
|
||||
menuItemDataList = persistentListOf(
|
||||
OverflowMenuItemData(
|
||||
text = stringResource(id = BitwardenString.delete),
|
||||
onClick = { shouldShowConfirmationDialog = true },
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
when (val viewState = state.viewState) {
|
||||
is CollectionAddEditState.ViewState.Content -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(height = 12.dp))
|
||||
BitwardenTextField(
|
||||
label = stringResource(id = BitwardenString.name),
|
||||
value = viewState.collectionName,
|
||||
onValueChange = {
|
||||
viewModel.trySendAction(
|
||||
CollectionAddEditAction.NameTextChange(it),
|
||||
)
|
||||
},
|
||||
textFieldTestTag = "CollectionNameField",
|
||||
cardStyle = CardStyle.Full,
|
||||
modifier = Modifier
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
|
||||
is CollectionAddEditState.ViewState.Error -> {
|
||||
BitwardenErrorContent(
|
||||
message = viewState.message(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
is CollectionAddEditState.ViewState.Loading -> {
|
||||
BitwardenLoadingContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollectionAddEditItemDialogs(
|
||||
dialogState: CollectionAddEditState.DialogState?,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
when (dialogState) {
|
||||
is CollectionAddEditState.DialogState.Loading -> {
|
||||
BitwardenLoadingDialog(text = dialogState.label())
|
||||
}
|
||||
|
||||
is CollectionAddEditState.DialogState.Error -> BitwardenBasicDialog(
|
||||
title = stringResource(id = BitwardenString.an_error_has_occurred),
|
||||
message = dialogState.message(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
throwable = dialogState.throwable,
|
||||
)
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.collections.CollectionType
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.Text
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionAddEditType
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
private const val SLASH_CHAR = "/"
|
||||
|
||||
/**
|
||||
* Handles [CollectionAddEditAction],
|
||||
* and launches [CollectionAddEditEvent] for the [CollectionAddEditScreen].
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions", "LargeClass")
|
||||
class CollectionAddEditViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val authRepository: AuthRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val relayManager: SnackbarRelayManager<SnackbarRelay>,
|
||||
) : BaseViewModel<CollectionAddEditState, CollectionAddEditEvent, CollectionAddEditAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val args = savedStateHandle.toCollectionAddEditArgs()
|
||||
CollectionAddEditState(
|
||||
collectionAddEditType = args.collectionAddEditType,
|
||||
viewState = when (args.collectionAddEditType) {
|
||||
is CollectionAddEditType.AddItem -> {
|
||||
CollectionAddEditState.ViewState.Content("")
|
||||
}
|
||||
|
||||
is CollectionAddEditType.EditItem -> {
|
||||
CollectionAddEditState.ViewState.Loading
|
||||
}
|
||||
},
|
||||
dialog = null,
|
||||
)
|
||||
},
|
||||
) {
|
||||
init {
|
||||
state
|
||||
.collectionAddEditType
|
||||
.collectionId
|
||||
?.let { collectionId ->
|
||||
vaultRepository
|
||||
.collectionsStateFlow
|
||||
.map { dataState: DataState<List<CollectionView>> ->
|
||||
when (dataState) {
|
||||
is DataState.Error -> DataState.Error<CollectionView?>(
|
||||
data = dataState.data?.find {
|
||||
it.id.toString() == collectionId
|
||||
},
|
||||
error = dataState.error,
|
||||
)
|
||||
|
||||
is DataState.Loaded -> DataState.Loaded<CollectionView?>(
|
||||
data = dataState.data.find {
|
||||
it.id.toString() == collectionId
|
||||
},
|
||||
)
|
||||
|
||||
is DataState.Loading -> DataState.Loading
|
||||
is DataState.NoNetwork -> DataState.NoNetwork<CollectionView?>(
|
||||
data = dataState.data?.find {
|
||||
it.id.toString() == collectionId
|
||||
},
|
||||
)
|
||||
|
||||
is DataState.Pending -> DataState.Pending<CollectionView?>(
|
||||
data = dataState.data.find {
|
||||
it.id.toString() == collectionId
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.onEach {
|
||||
sendAction(CollectionAddEditAction.Internal.VaultDataReceive(it))
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleAction(action: CollectionAddEditAction) {
|
||||
when (action) {
|
||||
is CollectionAddEditAction.CloseClick -> handleCloseClick()
|
||||
is CollectionAddEditAction.DeleteClick -> handleDeleteClick()
|
||||
is CollectionAddEditAction.DismissDialog -> handleDismissDialog()
|
||||
is CollectionAddEditAction.NameTextChange -> handleNameTextChange(action)
|
||||
is CollectionAddEditAction.SaveClick -> handleSaveClick()
|
||||
is CollectionAddEditAction.Internal.VaultDataReceive -> {
|
||||
handleVaultDataReceive(action)
|
||||
}
|
||||
|
||||
is CollectionAddEditAction.Internal.CreateCollectionResultReceive -> {
|
||||
handleCreateResultReceive(action)
|
||||
}
|
||||
|
||||
is CollectionAddEditAction.Internal.UpdateCollectionResultReceive -> {
|
||||
handleUpdateResultReceive(action)
|
||||
}
|
||||
|
||||
is CollectionAddEditAction.Internal.DeleteCollectionResultReceive -> {
|
||||
handleDeleteResultReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCloseClick() {
|
||||
sendEvent(CollectionAddEditEvent.NavigateBack)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun handleSaveClick() = onContent { content ->
|
||||
if (content.collectionName.isEmpty()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.validation_field_required
|
||||
.asText(BitwardenString.name.asText()),
|
||||
),
|
||||
)
|
||||
}
|
||||
return@onContent
|
||||
}
|
||||
|
||||
if (content.collectionName.contains(SLASH_CHAR)) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.collection_name_slash_error.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
return@onContent
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Loading(
|
||||
BitwardenString.saving.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val collectionAddEditType = state.collectionAddEditType
|
||||
viewModelScope.launch {
|
||||
when (collectionAddEditType) {
|
||||
is CollectionAddEditType.AddItem -> {
|
||||
val org = authRepository.organizations.find {
|
||||
it.id == collectionAddEditType.organizationId
|
||||
}
|
||||
val result = vaultRepository.createCollection(
|
||||
organizationId = collectionAddEditType.organizationId,
|
||||
organizationUserId = org?.organizationUserId,
|
||||
collectionView = CollectionView(
|
||||
id = null,
|
||||
organizationId = collectionAddEditType.organizationId,
|
||||
name = content.collectionName,
|
||||
externalId = null,
|
||||
hidePasswords = false,
|
||||
readOnly = false,
|
||||
manage = true,
|
||||
type = CollectionType.SHARED_COLLECTION,
|
||||
),
|
||||
)
|
||||
sendAction(
|
||||
CollectionAddEditAction.Internal.CreateCollectionResultReceive(result),
|
||||
)
|
||||
}
|
||||
|
||||
is CollectionAddEditType.EditItem -> {
|
||||
val result = vaultRepository.updateCollection(
|
||||
organizationId = collectionAddEditType.organizationId,
|
||||
collectionId = collectionAddEditType.collectionId,
|
||||
collectionView = CollectionView(
|
||||
id = collectionAddEditType.collectionId,
|
||||
organizationId = collectionAddEditType.organizationId,
|
||||
name = content.collectionName,
|
||||
externalId = null,
|
||||
hidePasswords = false,
|
||||
readOnly = false,
|
||||
manage = true,
|
||||
type = CollectionType.SHARED_COLLECTION,
|
||||
),
|
||||
)
|
||||
sendAction(
|
||||
CollectionAddEditAction.Internal.UpdateCollectionResultReceive(result),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteClick() {
|
||||
val collectionAddEditType = state.collectionAddEditType
|
||||
val collectionId = collectionAddEditType.collectionId ?: return
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Loading(
|
||||
BitwardenString.deleting.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = vaultRepository.deleteCollection(
|
||||
organizationId = collectionAddEditType.organizationId,
|
||||
collectionId = collectionId,
|
||||
)
|
||||
sendAction(
|
||||
CollectionAddEditAction.Internal.DeleteCollectionResultReceive(result),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
private fun handleNameTextChange(action: CollectionAddEditAction.NameTextChange) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = action.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun handleVaultDataReceive(
|
||||
action: CollectionAddEditAction.Internal.VaultDataReceive,
|
||||
) {
|
||||
// Don't overwrite user's in-progress edits with sync data.
|
||||
if (state.viewState is CollectionAddEditState.ViewState.Content) return
|
||||
|
||||
when (val vaultDataState = action.vaultDataState) {
|
||||
is DataState.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loaded -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = vaultDataState
|
||||
.data
|
||||
?.let { collection ->
|
||||
CollectionAddEditState.ViewState.Content(
|
||||
collectionName = collection.name,
|
||||
)
|
||||
}
|
||||
?: CollectionAddEditState.ViewState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loading -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(viewState = CollectionAddEditState.ViewState.Loading)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.NoNetwork -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Pending -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = vaultDataState
|
||||
.data
|
||||
?.let { collection ->
|
||||
CollectionAddEditState.ViewState.Content(
|
||||
collectionName = collection.name,
|
||||
)
|
||||
}
|
||||
?: CollectionAddEditState.ViewState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreateResultReceive(
|
||||
action: CollectionAddEditAction.Internal.CreateCollectionResultReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
when (val result = action.result) {
|
||||
is CreateCollectionResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = result
|
||||
.errorMessage
|
||||
?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
throwable = result.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CreateCollectionResult.Success -> {
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_created.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_CREATED,
|
||||
)
|
||||
sendEvent(CollectionAddEditEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpdateResultReceive(
|
||||
action: CollectionAddEditAction.Internal.UpdateCollectionResultReceive,
|
||||
) {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
when (val result = action.result) {
|
||||
is UpdateCollectionResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = result
|
||||
.errorMessage
|
||||
?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
throwable = result.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is UpdateCollectionResult.Success -> {
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_updated.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_UPDATED,
|
||||
)
|
||||
sendEvent(CollectionAddEditEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDeleteResultReceive(
|
||||
action: CollectionAddEditAction.Internal.DeleteCollectionResultReceive,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is DeleteCollectionResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = result
|
||||
.errorMessage
|
||||
?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
throwable = result.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DeleteCollectionResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_deleted.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_DELETED,
|
||||
)
|
||||
sendEvent(event = CollectionAddEditEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun onContent(
|
||||
crossinline block: (CollectionAddEditState.ViewState.Content) -> Unit,
|
||||
) {
|
||||
(state.viewState as? CollectionAddEditState.ViewState.Content)?.let(block)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state for adding or editing a collection.
|
||||
*
|
||||
* @property collectionAddEditType Indicates whether the VM is in add or edit mode.
|
||||
* @property viewState indicates what view state the screen is in.
|
||||
* @property dialog the state for the dialogs that can be displayed.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CollectionAddEditState(
|
||||
val collectionAddEditType: CollectionAddEditType,
|
||||
val viewState: ViewState,
|
||||
val dialog: DialogState?,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Helper to determine whether we show the overflow menu.
|
||||
*/
|
||||
val shouldShowOverflowMenu: Boolean
|
||||
get() = collectionAddEditType is CollectionAddEditType.EditItem
|
||||
|
||||
/**
|
||||
* Helper to determine the screen display name.
|
||||
*/
|
||||
val screenDisplayName: Text
|
||||
get() = when (collectionAddEditType) {
|
||||
is CollectionAddEditType.AddItem -> BitwardenString.new_collection.asText()
|
||||
is CollectionAddEditType.EditItem -> BitwardenString.edit_collection.asText()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the specific view states for the [CollectionAddEditScreen].
|
||||
*/
|
||||
sealed class ViewState : Parcelable {
|
||||
/**
|
||||
* Represents an error state for the [CollectionAddEditScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(val message: Text) : ViewState()
|
||||
|
||||
/**
|
||||
* Loading state for the [CollectionAddEditScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : ViewState()
|
||||
|
||||
/**
|
||||
* Represents a loaded content state for the [CollectionAddEditScreen].
|
||||
*/
|
||||
@Parcelize
|
||||
data class Content(val collectionName: String) : ViewState()
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed class DialogState : Parcelable {
|
||||
|
||||
/**
|
||||
* Displays a loading dialog to the user.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Loading(val label: Text) : DialogState()
|
||||
|
||||
/**
|
||||
* Displays an error dialog to the user.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val message: Text,
|
||||
val throwable: Throwable? = null,
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a set of events that can be emitted during
|
||||
* the process of adding or editing a collection.
|
||||
*/
|
||||
sealed class CollectionAddEditEvent {
|
||||
|
||||
/**
|
||||
* Navigate back to previous screen.
|
||||
*/
|
||||
data object NavigateBack : CollectionAddEditEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a set of actions related to the process of adding or editing a collection.
|
||||
*/
|
||||
sealed class CollectionAddEditAction {
|
||||
|
||||
/**
|
||||
* User clicked close.
|
||||
*/
|
||||
data object CloseClick : CollectionAddEditAction()
|
||||
|
||||
/**
|
||||
* The user has clicked to delete the collection.
|
||||
*/
|
||||
data object DeleteClick : CollectionAddEditAction()
|
||||
|
||||
/**
|
||||
* The user has clicked to dismiss the dialog.
|
||||
*/
|
||||
data object DismissDialog : CollectionAddEditAction()
|
||||
|
||||
/**
|
||||
* Fired when the name text input is changed.
|
||||
*
|
||||
* @property name The name of the collection.
|
||||
*/
|
||||
data class NameTextChange(val name: String) : CollectionAddEditAction()
|
||||
|
||||
/**
|
||||
* Represents the action when the save button is clicked.
|
||||
*/
|
||||
data object SaveClick : CollectionAddEditAction()
|
||||
|
||||
/**
|
||||
* Actions for internal use by the ViewModel.
|
||||
*/
|
||||
sealed class Internal : CollectionAddEditAction() {
|
||||
|
||||
/**
|
||||
* The result for deleting a collection has been received.
|
||||
*/
|
||||
data class DeleteCollectionResultReceive(
|
||||
val result: DeleteCollectionResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The result for updating a collection has been received.
|
||||
*/
|
||||
data class UpdateCollectionResultReceive(
|
||||
val result: UpdateCollectionResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* The result for creating a collection has been received.
|
||||
*/
|
||||
data class CreateCollectionResultReceive(
|
||||
val result: CreateCollectionResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the vault collection data has been received.
|
||||
*/
|
||||
data class VaultDataReceive(
|
||||
val vaultDataState: DataState<CollectionView?>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents the difference between creating a
|
||||
* completely new collection and editing an existing one.
|
||||
*/
|
||||
sealed class CollectionAddEditType : Parcelable {
|
||||
|
||||
/**
|
||||
* The ID of the collection (nullable).
|
||||
*/
|
||||
abstract val collectionId: String?
|
||||
|
||||
/**
|
||||
* The ID of the organization this collection belongs to.
|
||||
*/
|
||||
abstract val organizationId: String
|
||||
|
||||
/**
|
||||
* Indicates that we want to create a completely new collection.
|
||||
*/
|
||||
@Parcelize
|
||||
data class AddItem(
|
||||
override val organizationId: String,
|
||||
) : CollectionAddEditType() {
|
||||
override val collectionId: String?
|
||||
get() = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that we want to edit an existing collection.
|
||||
*/
|
||||
@Parcelize
|
||||
data class EditItem(
|
||||
override val collectionId: String,
|
||||
override val organizationId: String,
|
||||
) : CollectionAddEditType()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* The data for the collection being displayed.
|
||||
*
|
||||
* @param id The id of the collection.
|
||||
* @param name The name of the collection.
|
||||
* @param organizationName The name of the organization the collection belongs to.
|
||||
* @param organizationId The id of the organization.
|
||||
* @param canManage Whether the user can manage (edit/delete) this collection.
|
||||
*/
|
||||
@Parcelize
|
||||
data class CollectionDisplayItem(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val organizationName: String,
|
||||
val organizationId: String,
|
||||
val canManage: Boolean,
|
||||
) : Parcelable
|
||||
@@ -17,6 +17,7 @@ data object VaultSettingsRoute
|
||||
*/
|
||||
fun NavGraphBuilder.vaultSettingsDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToCollections: () -> Unit,
|
||||
onNavigateToExportVault: () -> Unit,
|
||||
onNavigateToFolders: () -> Unit,
|
||||
onNavigateToImportLogins: () -> Unit,
|
||||
@@ -25,6 +26,7 @@ fun NavGraphBuilder.vaultSettingsDestination(
|
||||
composableWithPushTransitions<VaultSettingsRoute> {
|
||||
VaultSettingsScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
onNavigateToCollections = onNavigateToCollections,
|
||||
onNavigateToExportVault = onNavigateToExportVault,
|
||||
onNavigateToFolders = onNavigateToFolders,
|
||||
onNavigateToImportLogins = onNavigateToImportLogins,
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
@Composable
|
||||
fun VaultSettingsScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToCollections: () -> Unit,
|
||||
onNavigateToExportVault: () -> Unit,
|
||||
onNavigateToFolders: () -> Unit,
|
||||
onNavigateToImportLogins: () -> Unit,
|
||||
@@ -61,6 +62,7 @@ fun VaultSettingsScreen(
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
VaultSettingsEvent.NavigateBack -> onNavigateBack()
|
||||
VaultSettingsEvent.NavigateToCollections -> onNavigateToCollections()
|
||||
VaultSettingsEvent.NavigateToExportVault -> onNavigateToExportVault()
|
||||
VaultSettingsEvent.NavigateToFolders -> onNavigateToFolders()
|
||||
is VaultSettingsEvent.NavigateToImportVault -> onNavigateToImportLogins()
|
||||
@@ -129,6 +131,19 @@ fun VaultSettingsScreen(
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
BitwardenTextRow(
|
||||
text = stringResource(BitwardenString.collections),
|
||||
onClick = {
|
||||
viewModel.trySendAction(VaultSettingsAction.CollectionsButtonClick)
|
||||
},
|
||||
withDivider = false,
|
||||
cardStyle = CardStyle.Middle(),
|
||||
modifier = Modifier
|
||||
.testTag("CollectionsLabel")
|
||||
.standardHorizontalMargin()
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
|
||||
BitwardenTextRow(
|
||||
text = stringResource(BitwardenString.export_vault),
|
||||
onClick = { viewModel.trySendAction(VaultSettingsAction.ExportVaultClick) },
|
||||
|
||||
@@ -77,6 +77,7 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
|
||||
override fun handleAction(action: VaultSettingsAction): Unit = when (action) {
|
||||
VaultSettingsAction.BackClick -> handleBackClicked()
|
||||
VaultSettingsAction.CollectionsButtonClick -> handleCollectionsButtonClicked()
|
||||
VaultSettingsAction.ExportVaultClick -> handleExportVaultClicked()
|
||||
VaultSettingsAction.FoldersButtonClick -> handleFoldersButtonClicked()
|
||||
VaultSettingsAction.ImportItemsClick -> handleImportItemsClicked()
|
||||
@@ -134,6 +135,10 @@ class VaultSettingsViewModel @Inject constructor(
|
||||
sendEvent(VaultSettingsEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleCollectionsButtonClicked() {
|
||||
sendEvent(VaultSettingsEvent.NavigateToCollections)
|
||||
}
|
||||
|
||||
private fun handleExportVaultClicked() {
|
||||
sendEvent(VaultSettingsEvent.NavigateToExportVault)
|
||||
}
|
||||
@@ -181,6 +186,11 @@ sealed class VaultSettingsEvent {
|
||||
*/
|
||||
data object NavigateToImportItems : VaultSettingsEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the Collections screen.
|
||||
*/
|
||||
data object NavigateToCollections : VaultSettingsEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the Export Vault screen.
|
||||
*/
|
||||
@@ -206,6 +216,11 @@ sealed class VaultSettingsAction {
|
||||
*/
|
||||
data object BackClick : VaultSettingsAction()
|
||||
|
||||
/**
|
||||
* Indicates that the user clicked the Collections button.
|
||||
*/
|
||||
data object CollectionsButtonClick : VaultSettingsAction()
|
||||
|
||||
/**
|
||||
* Indicates that the user clicked the Export Vault button.
|
||||
*/
|
||||
|
||||
@@ -36,6 +36,11 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.folderAd
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.navigateToFolderAddEdit
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderAddEditType
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.collectionsDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit.collectionAddEditDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit.navigateToCollectionAddEdit
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionAddEditType
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.navigateToCollections
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders
|
||||
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VaultUnlockedNavbarRoute
|
||||
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
|
||||
@@ -104,6 +109,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
},
|
||||
)
|
||||
vaultUnlockedNavBarDestination(
|
||||
onNavigateToCollections = { navController.navigateToCollections() },
|
||||
onNavigateToExportVault = { navController.navigateToExportVault() },
|
||||
onNavigateToFolders = { navController.navigateToFolders() },
|
||||
onNavigateToVaultAddItem = { navController.navigateToVaultAddEdit(it) },
|
||||
@@ -247,6 +253,25 @@ fun NavGraphBuilder.vaultUnlockedGraph(
|
||||
)
|
||||
|
||||
folderAddEditDestination(onNavigateBack = { navController.popBackStack() })
|
||||
collectionsDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onNavigateToAddCollectionScreen = { organizationId ->
|
||||
navController.navigateToCollectionAddEdit(
|
||||
CollectionAddEditType.AddItem(organizationId = organizationId),
|
||||
)
|
||||
},
|
||||
onNavigateToEditCollectionScreen = { collectionId, organizationId ->
|
||||
navController.navigateToCollectionAddEdit(
|
||||
CollectionAddEditType.EditItem(
|
||||
collectionId = collectionId,
|
||||
organizationId = organizationId,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
collectionAddEditDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
)
|
||||
generatorModalDestination(onNavigateBack = { navController.popBackStack() })
|
||||
searchDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
|
||||
@@ -39,6 +39,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
|
||||
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
|
||||
onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit,
|
||||
onNavigateToViewSend: (ViewSendRoute) -> Unit,
|
||||
onNavigateToCollections: () -> Unit,
|
||||
onNavigateToDeleteAccount: () -> Unit,
|
||||
onNavigateToExportVault: () -> Unit,
|
||||
onNavigateToFolders: () -> Unit,
|
||||
@@ -62,6 +63,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
|
||||
onNavigateToSearchSend = onNavigateToSearchSend,
|
||||
onNavigateToSearchVault = onNavigateToSearchVault,
|
||||
onNavigateToAddEditSend = onNavigateToAddEditSend,
|
||||
onNavigateToCollections = onNavigateToCollections,
|
||||
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
|
||||
onNavigateToExportVault = onNavigateToExportVault,
|
||||
onNavigateToFolders = onNavigateToFolders,
|
||||
|
||||
@@ -56,6 +56,7 @@ fun VaultUnlockedNavBarScreen(
|
||||
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
|
||||
onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit,
|
||||
onNavigateToViewSend: (ViewSendRoute) -> Unit,
|
||||
onNavigateToCollections: () -> Unit,
|
||||
onNavigateToDeleteAccount: () -> Unit,
|
||||
onNavigateToExportVault: () -> Unit,
|
||||
onNavigateToFolders: () -> Unit,
|
||||
@@ -86,6 +87,7 @@ fun VaultUnlockedNavBarScreen(
|
||||
onNavigateToSearchVault = onNavigateToSearchVault,
|
||||
onNavigateToAddEditSend = onNavigateToAddEditSend,
|
||||
onNavigateToViewSend = onNavigateToViewSend,
|
||||
navigateToCollections = onNavigateToCollections,
|
||||
navigateToDeleteAccount = onNavigateToDeleteAccount,
|
||||
navigateToExportVault = onNavigateToExportVault,
|
||||
navigateToFolders = onNavigateToFolders,
|
||||
@@ -131,6 +133,7 @@ private fun VaultUnlockedNavBarScaffold(
|
||||
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
|
||||
onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit,
|
||||
onNavigateToViewSend: (ViewSendRoute) -> Unit,
|
||||
navigateToCollections: () -> Unit,
|
||||
navigateToDeleteAccount: () -> Unit,
|
||||
navigateToExportVault: () -> Unit,
|
||||
navigateToFolders: () -> Unit,
|
||||
@@ -215,6 +218,7 @@ private fun VaultUnlockedNavBarScaffold(
|
||||
)
|
||||
settingsGraph(
|
||||
navController = navController,
|
||||
onNavigateToCollections = navigateToCollections,
|
||||
onNavigateToDeleteAccount = navigateToDeleteAccount,
|
||||
onNavigateToExportVault = navigateToExportVault,
|
||||
onNavigateToFolders = navigateToFolders,
|
||||
|
||||
@@ -24,6 +24,9 @@ enum class SnackbarRelay {
|
||||
*/
|
||||
CIPHER_UNARCHIVED_VIEW,
|
||||
CIPHER_CREATED,
|
||||
COLLECTION_CREATED,
|
||||
COLLECTION_DELETED,
|
||||
COLLECTION_UPDATED,
|
||||
CIPHER_DELETED,
|
||||
CIPHER_DELETED_SOFT,
|
||||
CIPHER_MOVED_TO_ORGANIZATION,
|
||||
|
||||
@@ -17,6 +17,13 @@ fun createMockOrganization(
|
||||
userIsClaimedByOrganization: Boolean = false,
|
||||
limitItemDeletion: Boolean = false,
|
||||
shouldUseEvents: Boolean = false,
|
||||
maxCollections: Int? = null,
|
||||
organizationUserId: String? = null,
|
||||
limitCollectionCreation: Boolean = false,
|
||||
limitCollectionDeletion: Boolean = false,
|
||||
canCreateNewCollections: Boolean = false,
|
||||
canEditAnyCollection: Boolean = false,
|
||||
canDeleteAnyCollection: Boolean = false,
|
||||
): Organization =
|
||||
Organization(
|
||||
id = id,
|
||||
@@ -28,4 +35,11 @@ fun createMockOrganization(
|
||||
userIsClaimedByOrganization = userIsClaimedByOrganization,
|
||||
limitItemDeletion = limitItemDeletion,
|
||||
shouldUseEvents = shouldUseEvents,
|
||||
maxCollections = maxCollections,
|
||||
organizationUserId = organizationUserId,
|
||||
limitCollectionCreation = limitCollectionCreation,
|
||||
limitCollectionDeletion = limitCollectionDeletion,
|
||||
canCreateNewCollections = canCreateNewCollections,
|
||||
canEditAnyCollection = canEditAnyCollection,
|
||||
canDeleteAnyCollection = canDeleteAnyCollection,
|
||||
)
|
||||
|
||||
@@ -87,6 +87,7 @@ class ServerCommunicationConfigRepositoryTest {
|
||||
AcquiredCookie(name = "session", value = "abc123"),
|
||||
AcquiredCookie(name = "csrf", value = "def456"),
|
||||
),
|
||||
vaultUrl = null,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -140,6 +141,7 @@ class ServerCommunicationConfigRepositoryTest {
|
||||
AcquiredCookie(name = "session", value = "xyz789"),
|
||||
AcquiredCookie(name = "token", value = "uvw456"),
|
||||
),
|
||||
vaultUrl = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -185,6 +187,7 @@ class ServerCommunicationConfigRepositoryTest {
|
||||
cookieName = "session",
|
||||
cookieDomain = hostname,
|
||||
cookieValue = null,
|
||||
vaultUrl = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
package com.x8bit.bitwarden.data.vault.manager
|
||||
|
||||
import com.bitwarden.collections.CollectionType
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.network.model.CollectionAccessSelectionJson
|
||||
import com.bitwarden.network.model.CollectionDetailsResponseJson
|
||||
import com.bitwarden.network.model.CollectionJsonRequest
|
||||
import com.bitwarden.network.model.UpdateCollectionResponseJson
|
||||
import com.bitwarden.network.model.createMockCollection
|
||||
import com.bitwarden.network.service.CollectionService
|
||||
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.NoActiveUserException
|
||||
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.createMockSdkCollection
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollection
|
||||
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.runs
|
||||
import io.mockk.unmockkConstructor
|
||||
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 java.time.Instant
|
||||
|
||||
class CollectionManagerTest {
|
||||
private val fakeAuthDiskSource = FakeAuthDiskSource()
|
||||
private val collectionService = mockk<CollectionService>()
|
||||
private val vaultDiskSource = mockk<VaultDiskSource>()
|
||||
private val vaultSdkSource = mockk<VaultSdkSource>()
|
||||
|
||||
private val collectionManager: CollectionManager = CollectionManagerImpl(
|
||||
authDiskSource = fakeAuthDiskSource,
|
||||
collectionService = collectionService,
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
vaultSdkSource = vaultSdkSource,
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkConstructor(NoActiveUserException::class)
|
||||
every {
|
||||
anyConstructed<NoActiveUserException>() == any<NoActiveUserException>()
|
||||
} returns true
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkConstructor(NoActiveUserException::class)
|
||||
}
|
||||
|
||||
// region createCollection
|
||||
|
||||
@Test
|
||||
fun `createCollection with no active user should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = null
|
||||
val result = collectionManager.createCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
organizationUserId = null,
|
||||
collectionView = mockk(),
|
||||
)
|
||||
assertEquals(
|
||||
CreateCollectionResult.Error(
|
||||
error = NoActiveUserException(),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createCollection with encrypt failure should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val error = IllegalStateException()
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
} returns error.asFailure()
|
||||
|
||||
val result = collectionManager.createCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
organizationUserId = null,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(CreateCollectionResult.Error(error = error), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createCollection with service failure should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val sdkCollection = createMockSdkCollection(number = 1)
|
||||
val error = IllegalStateException()
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
} returns sdkCollection.asSuccess()
|
||||
|
||||
coEvery {
|
||||
collectionService.createCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
body = CollectionJsonRequest(name = sdkCollection.name),
|
||||
)
|
||||
} returns error.asFailure()
|
||||
|
||||
val result = collectionManager.createCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
organizationUserId = null,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(CreateCollectionResult.Error(error = error), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createCollection with success should return Success`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val sdkCollection = createMockSdkCollection(number = 1)
|
||||
val networkCollection = createMockCollection(number = 1)
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
} returns sdkCollection.asSuccess()
|
||||
|
||||
coEvery {
|
||||
collectionService.createCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
body = CollectionJsonRequest(name = sdkCollection.name),
|
||||
)
|
||||
} returns networkCollection.asSuccess()
|
||||
|
||||
coEvery {
|
||||
vaultDiskSource.saveCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collection = networkCollection,
|
||||
)
|
||||
} just runs
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collection = networkCollection
|
||||
.toEncryptedSdkCollection(),
|
||||
)
|
||||
} returns DEFAULT_COLLECTION_VIEW.asSuccess()
|
||||
|
||||
val result = collectionManager.createCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
organizationUserId = null,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(
|
||||
CreateCollectionResult.Success(
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
// endregion createCollection
|
||||
|
||||
// region deleteCollection
|
||||
|
||||
@Test
|
||||
fun `deleteCollection with no active user should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = null
|
||||
val result = collectionManager.deleteCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
assertEquals(
|
||||
DeleteCollectionResult.Error(
|
||||
error = NoActiveUserException(),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteCollection with service failure should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val error = Throwable("fail")
|
||||
coEvery {
|
||||
collectionService.deleteCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} returns error.asFailure()
|
||||
|
||||
val result = collectionManager.deleteCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
assertEquals(
|
||||
DeleteCollectionResult.Error(error = error),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteCollection with success should return Success`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
coEvery {
|
||||
collectionService.deleteCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} returns Unit.asSuccess()
|
||||
|
||||
coEvery {
|
||||
vaultDiskSource.deleteCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} just runs
|
||||
|
||||
val result = collectionManager.deleteCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
assertEquals(DeleteCollectionResult.Success, result)
|
||||
coVerify {
|
||||
vaultDiskSource.deleteCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion deleteCollection
|
||||
|
||||
// region updateCollection
|
||||
|
||||
@Test
|
||||
fun `updateCollection with no active user should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = null
|
||||
val result = collectionManager.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
collectionView = mockk(),
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResult.Error(
|
||||
error = NoActiveUserException(),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `updateCollection with getCollectionDetails failure should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val error = IllegalStateException()
|
||||
coEvery {
|
||||
collectionService.getCollectionDetails(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} returns error.asFailure()
|
||||
|
||||
val result = collectionManager.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResult.Error(error = error),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `updateCollection with encrypt failure should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val error = IllegalStateException()
|
||||
coEvery {
|
||||
collectionService.getCollectionDetails(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} returns DEFAULT_DETAILS_RESPONSE.asSuccess()
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
} returns error.asFailure()
|
||||
|
||||
val result = collectionManager.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResult.Error(error = error),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `updateCollection with service failure should return Error`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val sdkCollection = createMockSdkCollection(number = 1)
|
||||
val error = IllegalStateException()
|
||||
|
||||
coEvery {
|
||||
collectionService.getCollectionDetails(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} returns DEFAULT_DETAILS_RESPONSE.asSuccess()
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
} returns sdkCollection.asSuccess()
|
||||
|
||||
coEvery {
|
||||
collectionService.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
body = CollectionJsonRequest(
|
||||
name = sdkCollection.name,
|
||||
externalId = DEFAULT_DETAILS_RESPONSE.externalId,
|
||||
groups = DEFAULT_DETAILS_RESPONSE.groups,
|
||||
users = DEFAULT_DETAILS_RESPONSE.users,
|
||||
),
|
||||
)
|
||||
} returns error.asFailure()
|
||||
|
||||
val result = collectionManager.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResult.Error(error = error),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `updateCollection with Invalid response should return Error with message`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val sdkCollection = createMockSdkCollection(number = 1)
|
||||
|
||||
coEvery {
|
||||
collectionService.getCollectionDetails(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} returns DEFAULT_DETAILS_RESPONSE.asSuccess()
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
} returns sdkCollection.asSuccess()
|
||||
|
||||
coEvery {
|
||||
collectionService.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
body = CollectionJsonRequest(
|
||||
name = sdkCollection.name,
|
||||
externalId = DEFAULT_DETAILS_RESPONSE.externalId,
|
||||
groups = DEFAULT_DETAILS_RESPONSE.groups,
|
||||
users = DEFAULT_DETAILS_RESPONSE.users,
|
||||
),
|
||||
)
|
||||
} returns UpdateCollectionResponseJson
|
||||
.Invalid(
|
||||
message = "Permission denied.",
|
||||
validationErrors = null,
|
||||
)
|
||||
.asSuccess()
|
||||
|
||||
val result = collectionManager.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResult.Error(
|
||||
errorMessage = "Permission denied.",
|
||||
error = null,
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `updateCollection with success should return Success and include access permissions`() =
|
||||
runTest {
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val sdkCollection = createMockSdkCollection(number = 1)
|
||||
val networkCollection = createMockCollection(number = 1)
|
||||
|
||||
coEvery {
|
||||
collectionService.getCollectionDetails(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
)
|
||||
} returns DEFAULT_DETAILS_RESPONSE.asSuccess()
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.encryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
} returns sdkCollection.asSuccess()
|
||||
|
||||
coEvery {
|
||||
collectionService.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
body = CollectionJsonRequest(
|
||||
name = sdkCollection.name,
|
||||
externalId = DEFAULT_DETAILS_RESPONSE.externalId,
|
||||
groups = DEFAULT_DETAILS_RESPONSE.groups,
|
||||
users = DEFAULT_DETAILS_RESPONSE.users,
|
||||
),
|
||||
)
|
||||
} returns UpdateCollectionResponseJson
|
||||
.Success(collection = networkCollection)
|
||||
.asSuccess()
|
||||
|
||||
coEvery {
|
||||
vaultDiskSource.saveCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collection = networkCollection,
|
||||
)
|
||||
} just runs
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.decryptCollection(
|
||||
userId = ACTIVE_USER_ID,
|
||||
collection = networkCollection
|
||||
.toEncryptedSdkCollection(),
|
||||
)
|
||||
} returns DEFAULT_COLLECTION_VIEW.asSuccess()
|
||||
|
||||
val result = collectionManager.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
collectionView = DEFAULT_COLLECTION_VIEW,
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResult.Success(DEFAULT_COLLECTION_VIEW),
|
||||
result,
|
||||
)
|
||||
|
||||
coVerify {
|
||||
collectionService.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_COLLECTION_ID,
|
||||
body = CollectionJsonRequest(
|
||||
name = sdkCollection.name,
|
||||
externalId = DEFAULT_DETAILS_RESPONSE.externalId,
|
||||
groups = DEFAULT_DETAILS_RESPONSE.groups,
|
||||
users = DEFAULT_DETAILS_RESPONSE.users,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion updateCollection
|
||||
}
|
||||
|
||||
private const val ACTIVE_USER_ID: String = "mockId-1"
|
||||
private const val DEFAULT_ORG_ID = "orgId-1"
|
||||
private const val DEFAULT_COLLECTION_ID = "collectionId-1"
|
||||
|
||||
private val DEFAULT_COLLECTION_VIEW = CollectionView(
|
||||
id = DEFAULT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
name = "TestCollection",
|
||||
externalId = null,
|
||||
hidePasswords = false,
|
||||
readOnly = false,
|
||||
manage = true,
|
||||
type = CollectionType.SHARED_COLLECTION,
|
||||
)
|
||||
|
||||
private val DEFAULT_DETAILS_RESPONSE = CollectionDetailsResponseJson(
|
||||
id = DEFAULT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
name = "encryptedName",
|
||||
externalId = "externalId-1",
|
||||
groups = listOf(
|
||||
CollectionAccessSelectionJson(
|
||||
id = "groupId-1",
|
||||
readOnly = false,
|
||||
hidePasswords = false,
|
||||
manage = true,
|
||||
),
|
||||
),
|
||||
users = listOf(
|
||||
CollectionAccessSelectionJson(
|
||||
id = "userId-1",
|
||||
readOnly = false,
|
||||
hidePasswords = false,
|
||||
manage = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val MOCK_PROFILE = AccountJson.Profile(
|
||||
userId = ACTIVE_USER_ID,
|
||||
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 = Instant.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 = ACTIVE_USER_ID,
|
||||
accounts = mapOf(
|
||||
ACTIVE_USER_ID to MOCK_ACCOUNT,
|
||||
),
|
||||
)
|
||||
@@ -129,6 +129,7 @@ class VaultRepositoryTest {
|
||||
vaultSyncManager = vaultSyncManager,
|
||||
credentialExchangeImportManager = credentialExchangeImportManager,
|
||||
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
|
||||
collectionManager = mockk(),
|
||||
)
|
||||
|
||||
@BeforeEach
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.bitwarden.ui.util.concat
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionDisplayItem
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class CollectionsViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableCollectionsStateFlow =
|
||||
MutableStateFlow<DataState<List<com.bitwarden.collections.CollectionView>>>(
|
||||
DataState.Loaded(emptyList()),
|
||||
)
|
||||
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { collectionsStateFlow } returns mutableCollectionsStateFlow
|
||||
}
|
||||
|
||||
private val authRepository: AuthRepository = mockk {
|
||||
every { organizations } returns listOf(DEFAULT_ORGANIZATION)
|
||||
}
|
||||
|
||||
private val mutableSnackbarDataFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
|
||||
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
|
||||
every {
|
||||
getSnackbarDataFlow(relay = any(), relays = anyVararg())
|
||||
} returns mutableSnackbarDataFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on snackbar data received should emit ShowSnackbar`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
val data = BitwardenSnackbarData(message = "Snackbar!".asText())
|
||||
viewModel.eventFlow.test {
|
||||
mutableSnackbarDataFlow.emit(data)
|
||||
assertEquals(
|
||||
CollectionsEvent.ShowSnackbar(data = data),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CloseButtonClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CollectionsAction.CloseButtonClick)
|
||||
assertEquals(CollectionsEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CollectionClick with canManage should emit NavigateToEditCollectionScreen`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(
|
||||
CollectionsAction.CollectionClick(
|
||||
collectionId = COLLECTION_ID,
|
||||
organizationId = ORGANIZATION_ID,
|
||||
canManage = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
CollectionsEvent.NavigateToEditCollectionScreen(
|
||||
collectionId = COLLECTION_ID,
|
||||
organizationId = ORGANIZATION_ID,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CollectionClick without canManage should not emit`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(
|
||||
CollectionsAction.CollectionClick(
|
||||
collectionId = COLLECTION_ID,
|
||||
organizationId = ORGANIZATION_ID,
|
||||
canManage = false,
|
||||
),
|
||||
)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `AddCollectionButtonClick should emit NavigateToAddCollectionScreen when org has permission`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CollectionsAction.AddCollectionButtonClick)
|
||||
assertEquals(
|
||||
CollectionsEvent.NavigateToAddCollectionScreen(
|
||||
organizationId = ORGANIZATION_ID,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Loading should show Loading state`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(DataState.Loading)
|
||||
|
||||
assertEquals(
|
||||
createCollectionsState(),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Loaded should show Content with collections`() {
|
||||
val collectionView = createMockCollectionView(number = 1)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Loaded(listOf(collectionView)),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
createCollectionsState(
|
||||
viewState = CollectionsState.ViewState.Content(
|
||||
collectionList = listOf(DEFAULT_DISPLAY_ITEM),
|
||||
showAddButton = true,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Error should show Error state`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Error(
|
||||
data = emptyList(),
|
||||
error = IllegalStateException(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
createCollectionsState(
|
||||
viewState = CollectionsState.ViewState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive NoNetwork should show Error state`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.NoNetwork(data = emptyList()),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
createCollectionsState(
|
||||
viewState = CollectionsState.ViewState.Error(
|
||||
message = BitwardenString.internet_connection_required_title
|
||||
.asText()
|
||||
.concat(
|
||||
" ".asText(),
|
||||
BitwardenString
|
||||
.internet_connection_required_message
|
||||
.asText(),
|
||||
),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Pending should show Content with collections`() {
|
||||
val collectionView = createMockCollectionView(number = 1)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Pending(listOf(collectionView)),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
createCollectionsState(
|
||||
viewState = CollectionsState.ViewState.Content(
|
||||
collectionList = listOf(DEFAULT_DISPLAY_ITEM),
|
||||
showAddButton = true,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive should show FAB when user canManageCollections`() {
|
||||
every { authRepository.organizations } returns listOf(
|
||||
createMockOrganization(
|
||||
number = 1,
|
||||
id = ORGANIZATION_ID,
|
||||
role = OrganizationType.ADMIN,
|
||||
),
|
||||
)
|
||||
val collectionView = createMockCollectionView(number = 1)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Loaded(listOf(collectionView)),
|
||||
)
|
||||
|
||||
val content = viewModel.stateFlow.value.viewState
|
||||
as CollectionsState.ViewState.Content
|
||||
assertEquals(true, content.showAddButton)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive should hide FAB when user cannot manage collections`() {
|
||||
every { authRepository.organizations } returns listOf(
|
||||
createMockOrganization(
|
||||
number = 1,
|
||||
id = ORGANIZATION_ID,
|
||||
role = OrganizationType.USER,
|
||||
limitCollectionCreation = true,
|
||||
canCreateNewCollections = false,
|
||||
),
|
||||
)
|
||||
val collectionView = createMockCollectionView(number = 1)
|
||||
val viewModel = createViewModel()
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Loaded(listOf(collectionView)),
|
||||
)
|
||||
|
||||
val content = viewModel.stateFlow.value.viewState
|
||||
as CollectionsState.ViewState.Content
|
||||
assertEquals(false, content.showAddButton)
|
||||
}
|
||||
|
||||
private fun createViewModel(): CollectionsViewModel = CollectionsViewModel(
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
snackbarRelayManager = snackbarRelayManager,
|
||||
)
|
||||
|
||||
private fun createCollectionsState(
|
||||
viewState: CollectionsState.ViewState = CollectionsState.ViewState.Loading,
|
||||
) = CollectionsState(
|
||||
viewState = viewState,
|
||||
)
|
||||
}
|
||||
|
||||
private const val ORGANIZATION_ID = "mockId-1"
|
||||
private const val COLLECTION_ID = "mockId-1"
|
||||
|
||||
private val DEFAULT_ORGANIZATION = createMockOrganization(
|
||||
number = 1,
|
||||
id = ORGANIZATION_ID,
|
||||
role = OrganizationType.ADMIN,
|
||||
)
|
||||
|
||||
private val DEFAULT_DISPLAY_ITEM = CollectionDisplayItem(
|
||||
id = "mockId-1",
|
||||
name = "mockName-1",
|
||||
organizationName = "",
|
||||
organizationId = "mockOrganizationId-1",
|
||||
canManage = true,
|
||||
)
|
||||
@@ -0,0 +1,791 @@
|
||||
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.collections.CollectionType
|
||||
import com.bitwarden.collections.CollectionView
|
||||
import com.bitwarden.core.data.repository.model.DataState
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
|
||||
import com.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.util.asText
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.CreateCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCollectionResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCollectionResult
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionAddEditType
|
||||
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.runs
|
||||
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
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class CollectionAddEditViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableCollectionsStateFlow =
|
||||
MutableStateFlow<DataState<List<CollectionView>>>(DataState.Loading)
|
||||
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
every { collectionsStateFlow } returns mutableCollectionsStateFlow
|
||||
}
|
||||
|
||||
private val authRepository: AuthRepository = mockk {
|
||||
every { organizations } returns listOf(DEFAULT_ORGANIZATION)
|
||||
}
|
||||
|
||||
private val relayManager: SnackbarRelayManager<SnackbarRelay> = mockk {
|
||||
every { sendSnackbarData(data = any(), relay = any()) } just runs
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(SavedStateHandle::toCollectionAddEditArgs)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(SavedStateHandle::toCollectionAddEditArgs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial add state should be correct`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = DEFAULT_ADD_STATE,
|
||||
),
|
||||
)
|
||||
assertEquals(DEFAULT_ADD_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial edit state should be correct`() = runTest {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = DEFAULT_ADD_STATE.copy(
|
||||
collectionAddEditType = editType,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_ADD_STATE.copy(
|
||||
collectionAddEditType = editType,
|
||||
viewState = CollectionAddEditState.ViewState.Loading,
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CloseClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(CollectionAddEditAction.CloseClick)
|
||||
assertEquals(CollectionAddEditEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveClick with empty name should show error dialog`() = runTest {
|
||||
val stateWithEmptyName = DEFAULT_ADD_STATE.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = "",
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = stateWithEmptyName,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(stateWithEmptyName, viewModel.stateFlow.value)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
|
||||
assertEquals(
|
||||
stateWithEmptyName.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.validation_field_required
|
||||
.asText(BitwardenString.name.asText()),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SaveClick with slash in name should show slash error dialog`() = runTest {
|
||||
val stateWithSlash = DEFAULT_ADD_STATE.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = "test/collection",
|
||||
),
|
||||
)
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = stateWithSlash,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
|
||||
assertEquals(
|
||||
stateWithSlash.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.collection_name_slash_error.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in add mode, SaveClick createCollection success should show dialog and remove it once saved`() =
|
||||
runTest {
|
||||
val stateWithDialog = DEFAULT_ADD_STATE.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Loading(
|
||||
BitwardenString.saving.asText(),
|
||||
),
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val stateWithoutDialog = stateWithDialog.copy(dialog = null)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = stateWithoutDialog,
|
||||
),
|
||||
)
|
||||
|
||||
coEvery {
|
||||
vaultRepository.createCollection(
|
||||
organizationId = any(),
|
||||
organizationUserId = any(),
|
||||
collectionView = any(),
|
||||
)
|
||||
} returns CreateCollectionResult.Success(mockk())
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
assertEquals(stateWithoutDialog, awaitItem())
|
||||
assertEquals(stateWithDialog, awaitItem())
|
||||
assertEquals(stateWithoutDialog, awaitItem())
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_created.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_CREATED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in add mode, SaveClick createCollection error should show error dialog`() =
|
||||
runTest {
|
||||
val state = DEFAULT_ADD_STATE.copy(
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = state,
|
||||
),
|
||||
)
|
||||
|
||||
val error = Throwable("Oops")
|
||||
coEvery {
|
||||
vaultRepository.createCollection(
|
||||
organizationId = any(),
|
||||
organizationUserId = any(),
|
||||
collectionView = any(),
|
||||
)
|
||||
} returns CreateCollectionResult.Error(error = error)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
|
||||
assertEquals(
|
||||
state.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
throwable = error,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in edit mode, SaveClick should call updateCollection`() = runTest {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val stateWithDialog = CollectionAddEditState(
|
||||
collectionAddEditType = editType,
|
||||
dialog = CollectionAddEditState.DialogState.Loading(
|
||||
BitwardenString.saving.asText(),
|
||||
),
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val stateWithoutDialog = stateWithDialog.copy(dialog = null)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = stateWithoutDialog,
|
||||
),
|
||||
)
|
||||
|
||||
mutableCollectionsStateFlow.value = DataState.Loaded(
|
||||
listOf(createMockCollectionView(number = 1)),
|
||||
)
|
||||
|
||||
coEvery {
|
||||
vaultRepository.updateCollection(
|
||||
organizationId = any(),
|
||||
collectionId = any(),
|
||||
collectionView = any(),
|
||||
)
|
||||
} returns UpdateCollectionResult.Success(mockk())
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
assertEquals(stateWithoutDialog, awaitItem())
|
||||
assertEquals(stateWithDialog, awaitItem())
|
||||
assertEquals(stateWithoutDialog, awaitItem())
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_updated.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_UPDATED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `in edit mode, SaveClick updateCollection error should show error dialog`() =
|
||||
runTest {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val state = CollectionAddEditState(
|
||||
collectionAddEditType = editType,
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = state,
|
||||
),
|
||||
)
|
||||
|
||||
mutableCollectionsStateFlow.value = DataState.Loaded(
|
||||
listOf(createMockCollectionView(number = 1)),
|
||||
)
|
||||
|
||||
val error = Throwable("Oops")
|
||||
coEvery {
|
||||
vaultRepository.updateCollection(
|
||||
organizationId = any(),
|
||||
collectionId = any(),
|
||||
collectionView = any(),
|
||||
)
|
||||
} returns UpdateCollectionResult.Error(error = error)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
|
||||
assertEquals(
|
||||
state.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
throwable = error,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeleteClick should call deleteCollection and navigate back on success`() =
|
||||
runTest {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val state = CollectionAddEditState(
|
||||
collectionAddEditType = editType,
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = state,
|
||||
),
|
||||
)
|
||||
|
||||
mutableCollectionsStateFlow.value = DataState.Loaded(
|
||||
listOf(createMockCollectionView(number = 1)),
|
||||
)
|
||||
|
||||
coEvery {
|
||||
vaultRepository.deleteCollection(
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
)
|
||||
} returns DeleteCollectionResult.Success
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.DeleteClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
CollectionAddEditEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_deleted.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_DELETED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeleteClick with error should show error dialog`() = runTest {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val state = CollectionAddEditState(
|
||||
collectionAddEditType = editType,
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = state,
|
||||
),
|
||||
)
|
||||
|
||||
mutableCollectionsStateFlow.value = DataState.Loaded(
|
||||
listOf(createMockCollectionView(number = 1)),
|
||||
)
|
||||
|
||||
val error = Throwable("Oops")
|
||||
coEvery {
|
||||
vaultRepository.deleteCollection(
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
)
|
||||
} returns DeleteCollectionResult.Error(error = error)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.DeleteClick)
|
||||
|
||||
assertEquals(
|
||||
state.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
throwable = error,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeleteClick should not call deleteCollection if no collectionId`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = DEFAULT_ADD_STATE.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.DeleteClick)
|
||||
|
||||
coVerify(exactly = 0) {
|
||||
vaultRepository.deleteCollection(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DismissDialog should clear dialog state`() = runTest {
|
||||
val stateWithDialog = DEFAULT_ADD_STATE.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = "",
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = stateWithDialog,
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.DismissDialog)
|
||||
|
||||
assertEquals(
|
||||
stateWithDialog.copy(dialog = null),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NameTextChange should update content state`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = DEFAULT_ADD_STATE.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = "",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
CollectionAddEditAction.NameTextChange("NewName"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_ADD_STATE.copy(
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = "NewName",
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `CreateCollectionResultReceive Success should send snackbar and navigate back`() =
|
||||
runTest {
|
||||
val state = DEFAULT_ADD_STATE.copy(
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = state,
|
||||
),
|
||||
)
|
||||
|
||||
coEvery {
|
||||
vaultRepository.createCollection(
|
||||
organizationId = any(),
|
||||
organizationUserId = any(),
|
||||
collectionView = any(),
|
||||
)
|
||||
} returns CreateCollectionResult.Success(mockk())
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
CollectionAddEditEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_created.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_CREATED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CreateCollectionResultReceive Error should show error dialog`() =
|
||||
runTest {
|
||||
val state = DEFAULT_ADD_STATE.copy(
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = state,
|
||||
),
|
||||
)
|
||||
|
||||
val error = Throwable("Oops")
|
||||
coEvery {
|
||||
vaultRepository.createCollection(
|
||||
organizationId = any(),
|
||||
organizationUserId = any(),
|
||||
collectionView = any(),
|
||||
)
|
||||
} returns CreateCollectionResult.Error(error = error)
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
|
||||
|
||||
assertEquals(
|
||||
state.copy(
|
||||
dialog = CollectionAddEditState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
throwable = error,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeleteCollectionResultReceive Success should send snackbar and navigate back`() =
|
||||
runTest {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val state = CollectionAddEditState(
|
||||
collectionAddEditType = editType,
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = state,
|
||||
),
|
||||
)
|
||||
|
||||
mutableCollectionsStateFlow.value = DataState.Loaded(
|
||||
listOf(createMockCollectionView(number = 1)),
|
||||
)
|
||||
|
||||
coEvery {
|
||||
vaultRepository.deleteCollection(
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
)
|
||||
} returns DeleteCollectionResult.Success
|
||||
|
||||
viewModel.trySendAction(CollectionAddEditAction.DeleteClick)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
assertEquals(
|
||||
CollectionAddEditEvent.NavigateBack,
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
verify(exactly = 1) {
|
||||
relayManager.sendSnackbarData(
|
||||
data = BitwardenSnackbarData(
|
||||
BitwardenString.collection_deleted.asText(),
|
||||
),
|
||||
relay = SnackbarRelay.COLLECTION_DELETED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive should not overwrite Content state`() {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val contentState = CollectionAddEditState(
|
||||
collectionAddEditType = editType,
|
||||
dialog = null,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = "user-edited-name",
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = contentState,
|
||||
),
|
||||
)
|
||||
|
||||
// Emit new data from vault — should NOT overwrite the Content state.
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Loaded(
|
||||
listOf(
|
||||
createMockCollectionView(
|
||||
number = 1,
|
||||
name = "server-name",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// State should still have the user-edited name, not the server name.
|
||||
assertEquals(
|
||||
contentState,
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Loaded should update edit state to Content`() {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = DEFAULT_ADD_STATE.copy(
|
||||
collectionAddEditType = editType,
|
||||
viewState = CollectionAddEditState.ViewState.Loading,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Loaded(
|
||||
listOf(
|
||||
createMockCollectionView(
|
||||
number = 1,
|
||||
name = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_ADD_STATE.copy(
|
||||
collectionAddEditType = editType,
|
||||
viewState = CollectionAddEditState.ViewState.Content(
|
||||
collectionName = DEFAULT_COLLECTION_NAME,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `VaultDataReceive Error should update edit state to Error`() {
|
||||
val editType = CollectionAddEditType.EditItem(
|
||||
collectionId = DEFAULT_EDIT_COLLECTION_ID,
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
val viewModel = createViewModel(
|
||||
savedStateHandle = createSavedStateHandleWithState(
|
||||
state = DEFAULT_ADD_STATE.copy(
|
||||
collectionAddEditType = editType,
|
||||
viewState = CollectionAddEditState.ViewState.Loading,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
mutableCollectionsStateFlow.tryEmit(
|
||||
DataState.Error(
|
||||
data = emptyList(),
|
||||
error = IllegalStateException(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_ADD_STATE.copy(
|
||||
collectionAddEditType = editType,
|
||||
viewState = CollectionAddEditState.ViewState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSavedStateHandleWithState(
|
||||
state: CollectionAddEditState? = DEFAULT_ADD_STATE,
|
||||
) = SavedStateHandle().apply {
|
||||
val collectionAddEditType = state?.collectionAddEditType
|
||||
?: CollectionAddEditType.AddItem(
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
)
|
||||
set("state", state)
|
||||
every { toCollectionAddEditArgs() } returns CollectionAddEditArgs(
|
||||
collectionAddEditType = collectionAddEditType,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = createSavedStateHandleWithState(),
|
||||
): CollectionAddEditViewModel = CollectionAddEditViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
authRepository = authRepository,
|
||||
vaultRepository = vaultRepository,
|
||||
relayManager = relayManager,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DEFAULT_ORGANIZATION_ID = "mockId-1"
|
||||
private const val DEFAULT_EDIT_COLLECTION_ID = "mockId-1"
|
||||
private const val DEFAULT_COLLECTION_NAME = "test_collection"
|
||||
|
||||
private val DEFAULT_ORGANIZATION = createMockOrganization(
|
||||
number = 1,
|
||||
id = DEFAULT_ORGANIZATION_ID,
|
||||
role = OrganizationType.ADMIN,
|
||||
organizationUserId = "mockOrgUserId-1",
|
||||
)
|
||||
|
||||
private val DEFAULT_ADD_STATE = CollectionAddEditState(
|
||||
collectionAddEditType = CollectionAddEditType.AddItem(
|
||||
organizationId = DEFAULT_ORGANIZATION_ID,
|
||||
),
|
||||
viewState = CollectionAddEditState.ViewState.Loading,
|
||||
dialog = null,
|
||||
)
|
||||
@@ -28,6 +28,7 @@ class VaultSettingsScreenTest : BitwardenComposeTest() {
|
||||
private var onNavigateBackCalled = false
|
||||
private var onNavigateToExportVaultCalled = false
|
||||
private var onNavigateToFoldersCalled = false
|
||||
private var onNavigateToCollectionsCalled = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<VaultSettingsEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
||||
@@ -46,6 +47,7 @@ class VaultSettingsScreenTest : BitwardenComposeTest() {
|
||||
onNavigateToFolders = { onNavigateToFoldersCalled = true },
|
||||
onNavigateToImportLogins = { onNavigateToImportLoginsCalled = true },
|
||||
onNavigateToImportItems = { onNavigateToImportItemsCalled = true },
|
||||
onNavigateToCollections = { onNavigateToCollectionsCalled = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() {
|
||||
onNavigateToFlightRecorder = {},
|
||||
onNavigateToRecordedLogs = {},
|
||||
onNavigateToAboutPrivilegedApps = {},
|
||||
onNavigateToCollections = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
180
docs/COLLECTIONS_REQUIREMENTS.md
Normal file
180
docs/COLLECTIONS_REQUIREMENTS.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Collection Management on Android - Requirements Specification
|
||||
|
||||
> **Status: DRAFT** - Pending answers to blocking questions (see [Open Questions - Blocking](#blocking-questions))
|
||||
>
|
||||
> **Date:** 2026-03-17
|
||||
>
|
||||
> **Branch:** `android-collections`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This specification defines the requirements for adding collection management (create, edit, delete) to the Bitwarden Android Password Manager app. Collections are an organizational concept in Bitwarden that group vault items within an organization. They are available only on paid plans (Families, Teams, Teams Starter, Enterprise); free organizations are limited to a single collection.
|
||||
|
||||
The web client already supports full collection CRUD. This feature brings parity to the Android app, accessible via **Settings > Vault > Collections**. The implementation will follow the established folder management pattern (`FolderManager`, `FoldersScreen`, `FolderAddEditScreen`) as the primary architectural reference.
|
||||
|
||||
**Scope for V1:**
|
||||
- Create, edit (rename), and delete collections
|
||||
- Permission-gated: only users with appropriate org roles can perform these actions
|
||||
- No user/group access management UI (access is managed via the web admin console)
|
||||
- No nested collection creation (parent picker) in V1; existing nested collections display correctly
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| FR1 | Users can view a list of collections they have access to, grouped or filtered by organization | User, Web client | **See [G1]** for multi-org display decision |
|
||||
| FR2 | Users can create a new collection within an organization they have `createNewCollections` permission for | User, Web client | Requires org key encryption of collection name |
|
||||
| FR3 | Users can edit (rename) a collection they have `manage` or `editAnyCollection` permission for | User, Web client | Only the name field is editable on mobile |
|
||||
| FR4 | Users can delete a collection they have `manage` or `deleteAnyCollection` permission for | User, Web client | Confirmation dialog required before deletion |
|
||||
| FR5 | Collection name is required and must not contain `/` characters | Web client | `/` is the nesting delimiter; creation of nested collections deferred to future version |
|
||||
| FR6 | Free organizations are limited to `maxCollections` (typically 1) collections; creation is blocked when at limit | Web client | **See [G5]** for UX treatment |
|
||||
| FR7 | The FAB (floating action button) for creating a new collection is only visible when the user has permission to create collections in at least one organization | Web client | If user has no orgs or no create permission, FAB is hidden |
|
||||
| FR8 | The delete option is only visible when the user has permission to delete the specific collection | Web client | Shown in overflow menu, matching folder pattern |
|
||||
| FR9 | Collection list shows the decrypted display name of each collection | Android codebase | Uses existing `toCollectionDisplayName()` helper for nested names |
|
||||
| FR10 | After successful create/edit/delete, a snackbar confirmation is shown on the Collections list screen | Folder pattern | Uses `SnackbarRelayManager` relay pattern |
|
||||
| FR11 | Network errors during CRUD operations show a generic error snackbar | Folder pattern | No optimistic local write; server is source of truth |
|
||||
| FR12 | The Collections list screen shows Loading, Content (with items), Empty, and Error states | Folder pattern | Empty state shown when user has no collections across any org |
|
||||
| FR13 | Back navigation from CollectionAddEdit returns to the Collections list without saving | Folder pattern | Standard back-press behavior |
|
||||
| FR14 | The entry point is a new "Collections" row in the Settings > Vault screen | User | Added between "Folders" and "Export Vault" |
|
||||
|
||||
---
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| TR1 | **Module scope**: All new UI code lives in `:app` module under `ui/platform/feature/settings/collections/` | Folder pattern | Matches folder feature module structure |
|
||||
| TR2 | **Network API**: New `CollectionsApi` interface with endpoints: `POST /organizations/{orgId}/collections`, `PUT /organizations/{orgId}/collections/{id}`, `DELETE /organizations/{orgId}/collections/{id}` | Web client | Lives in `:network` module |
|
||||
| TR3 | **Network service**: New `CollectionService` / `CollectionServiceImpl` wrapping `CollectionsApi` | Folder pattern | Returns `Result<T>` types |
|
||||
| TR4 | **Request models**: `CreateCollectionJsonRequest` and `UpdateCollectionJsonRequest` with encrypted `name` field and `externalId` | Web client | Lives in `:network` module |
|
||||
| TR5 | **Response model**: Reuse existing `SyncResponseJson.Collection` for create/update responses | Web client | Already defined |
|
||||
| TR6 | **SDK encryption**: `VaultSdkSource` must expose `encryptCollection` to encrypt collection name with org key before API calls | **BLOCKER [G2]** | Only `decryptCollection`/`decryptCollectionList` exist today |
|
||||
| TR7 | **CollectionManager**: New `CollectionManager` interface + `CollectionManagerImpl` following `FolderManager` pattern; handles encrypt > API call > save to disk > decrypt flow | Folder pattern | Delegated from `VaultRepository` |
|
||||
| TR8 | **Result types**: New sealed classes `CreateCollectionResult`, `UpdateCollectionResult`, `DeleteCollectionResult` with `Success`/`Error` variants | Folder pattern | In `data/vault/repository/model/` |
|
||||
| TR9 | **VaultDiskSource**: Add `deleteCollection(userId, collectionId)` method | Gap analysis | `saveCollection` exists; delete does not |
|
||||
| TR10 | **Permission model expansion**: Add to `SyncResponseJson.Permissions`: `createNewCollections: Boolean`, `editAnyCollection: Boolean`, `deleteAnyCollection: Boolean` | **BLOCKER [G3]** | Fields exist in API JSON but are not parsed |
|
||||
| TR11 | **Organization domain model expansion**: Add `maxCollections: Int?` and `limitCollectionCreation: Boolean` to the Android `Organization` data class (and its mapping from `SyncResponseJson.Profile.Organization`) | **BLOCKER [G3]** | `maxCollections` exists in `SyncResponseJson` but isn't mapped to domain model |
|
||||
| TR12 | **Navigation**: Type-safe `@Serializable` routes: `CollectionsRoute`, `CollectionAddEditRoute(actionType, collectionId?, organizationId)` | Folder pattern | `organizationId` required for both create and edit |
|
||||
| TR13 | **SnackbarRelay**: Add `COLLECTION_CREATED`, `COLLECTION_UPDATED`, `COLLECTION_DELETED` entries to `SnackbarRelay` | Folder pattern | |
|
||||
| TR14 | **Process death**: Collection name field persisted via `SavedStateHandle` in `CollectionAddEditViewModel` | Folder pattern | |
|
||||
| TR15 | **VaultSettingsScreen update**: Add "Collections" `BitwardenTextRow` between "Folders" and "Export Vault" with `CardStyle.Middle()` and update surrounding card styles | VaultSettingsScreen | Requires new `CollectionsButtonClick` action and `NavigateToCollections` event |
|
||||
| TR16 | **F-Droid**: No Google Play Services dependency | Requirement | Feature is pure network + SDK |
|
||||
|
||||
---
|
||||
|
||||
## Security Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| SR1 | Collection names must be encrypted with the organization's encryption key (via `ScopedVaultSdkSource`) before transmission to the API | Web client, Zero-knowledge architecture | Never transmit plaintext collection names |
|
||||
| SR2 | Use `ScopedVaultSdkSource` for all encryption/decryption to prevent cross-user crypto context leakage | CLAUDE.md Security Rules | Critical for multi-account safety |
|
||||
| SR3 | On logout, all collection data is cleared via existing `CollectionsDao` user-scoped cleanup | Existing behavior | Already handled by `UserLogoutManager` |
|
||||
| SR4 | Validate collection name input (non-empty, no `/` characters) before processing | Web client | Input sanitization at UI boundary |
|
||||
| SR5 | Permission checks must be enforced client-side before showing create/edit/delete UI affordances | Web client | Do not show actions the user cannot perform |
|
||||
|
||||
---
|
||||
|
||||
## UX Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| UX1 | **Collections list screen**: Top app bar with title "Collections", back navigation arrow, FAB with `+` icon for creating new collections | Folder pattern | FAB hidden if user has no create permissions |
|
||||
| UX2 | **Collection list item**: Shows decrypted collection name; tap navigates to edit screen | Folder pattern | Nested collection names shown using `toCollectionDisplayName()` |
|
||||
| UX3 | **Multi-org display**: **See [G1]** - collections must indicate which organization they belong to | User | Organization name shown as subtitle or section header |
|
||||
| UX4 | **Add/edit screen**: Top app bar with "New Collection" or "Edit Collection" title, single text field for name, save button in top bar, delete in overflow menu (edit only) | Folder pattern | |
|
||||
| UX5 | **Delete confirmation**: Dialog with "Do you really want to delete? This collection will be permanently deleted." and Cancel/Delete buttons | Folder pattern | |
|
||||
| UX6 | **Loading state**: Full-screen loading spinner | Folder pattern | |
|
||||
| UX7 | **Empty state**: Centered text indicating no collections are available | Folder pattern | Exact copy TBD |
|
||||
| UX8 | **Error state**: Generic error with retry option | Folder pattern | |
|
||||
| UX9 | **Snackbar messages**: "Collection created", "Collection updated", "Collection deleted" | Folder pattern | |
|
||||
| UX10 | **Permission error**: "You don\u2019t have permission to perform this action." snackbar if server returns 403 | Web client | |
|
||||
| UX11 | **Collection limit reached**: **See [G5]** - message when free org is at max | Web client | |
|
||||
| UX12 | **Org selection for create**: If user belongs to multiple orgs with create permission, must select which org to create in | Functional | **See [G1]** for approach |
|
||||
| UX13 | **String resources**: All user-facing strings added to `:ui` module `strings.xml` with typographic quotes/apostrophes | CLAUDE.md | |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Blocking Questions
|
||||
|
||||
These must be answered before implementation can begin.
|
||||
|
||||
| ID | Category | Question | Impact |
|
||||
|----|----------|----------|--------|
|
||||
| **G1** | Functional / UX | **Multi-organization display**: When a user belongs to multiple organizations, how should the Collections screen present their collections? **Option A**: Flat list with organization name as a subtitle on each item or as section headers grouping collections by org. **Option B**: Organization selector/filter at the top of the screen (user picks which org to manage). | Affects `CollectionsViewModel` state model, `CollectionAddEditRoute` parameters, and whether an org picker component is needed on the create screen. Option A is simpler but may be noisy for users in many orgs. Option B is more focused but adds a selector component. |
|
||||
| **G2** | Technical | **SDK `encryptCollection`**: Confirmed that `encryptCollection` does **not** exist anywhere in the Bitwarden Rust SDK. The `Collection` type in `crates/bitwarden/src/vault/collection.rs` only implements `KeyDecryptable`, not `KeyEncryptable`. The UniFFI bindings (`crates/bitwarden-uniffi/src/vault/collections.rs`) only expose `decrypt` and `decrypt_list`. By contrast, the folder equivalent has both `encrypt` and `decrypt` at every layer. **The SDK needs a small, well-scoped addition**: (1) `KeyEncryptable<SymmetricCryptoKey, Collection>` impl for `CollectionView` in `collection.rs`, (2) `encrypt` method in `client_collection.rs`, (3) `encrypt` UniFFI export in `collections.rs`. This requires a **new SDK release** before the Android feature can be fully implemented. | Hard blocker for the data layer. Requires coordination with the SDK team for a new release. The change is small and well-patterned (mirrors the existing folder encryption exactly), but it is a cross-repo dependency. |
|
||||
| **G3** | Technical | **Permission model expansion**: **RESOLVED — In scope.** The Android `SyncResponseJson.Permissions` model will be expanded to parse `createNewCollections`, `editAnyCollection`, and `deleteAnyCollection` from the API JSON. The `Organization` domain model will be expanded to include `limitCollectionCreation` and map `maxCollections`. This enables correct permission gating for all org roles including custom roles. | Low implementation risk. Fields already exist in the API response; only parsing and domain mapping need to be added. |
|
||||
|
||||
### Non-Blocking Questions
|
||||
|
||||
These have reasonable defaults and can be resolved during implementation.
|
||||
|
||||
| ID | Category | Question | Default Assumption |
|
||||
|----|----------|----------|--------------------|
|
||||
| **G4** | Functional | Should users be able to create nested collections (via parent picker) in V1? | **No**. V1 supports flat collection creation only. Name input rejects `/` characters. Existing nested collections display correctly in the list using `toCollectionDisplayName()`. A parent picker can be added in a future iteration. |
|
||||
| **G5** | Functional / UX | When a free org is at its collection limit, what should happen when the user taps the FAB? | Show a dialog explaining the limit has been reached, suggesting the user upgrade via the web vault. No in-app purchase or deep-link needed in V1. |
|
||||
| **G6** | UX | Should `externalId` be shown (read-only) in the collection edit view? | **No**. `externalId` is an admin-console-only concept. Showing it on mobile adds noise with no actionable benefit. |
|
||||
| **G7** | UX | What exact strings should appear for screen titles, labels, and messages? | Follow folder conventions: "Collections" (list title), "New Collection" / "Edit Collection" (add/edit titles), "Name" (field label), "Save" (button), standard delete confirmation and snackbar messages as described in UX requirements. |
|
||||
| **G8** | Cross-cutting | Should this feature be behind a server-side feature flag for staged rollout? | **No feature flag**. This is a purely additive UI feature using existing stable API endpoints (same endpoints the web client has used for years). If PM wants a rollout gate, a `FlagKey` entry can be added. |
|
||||
| **G9** | Functional | What should happen if the user attempts CRUD while offline? | Match folder behavior: the API call fails and a generic network error snackbar is shown. No optimistic local writes. |
|
||||
| **G10** | Cross-cutting | Are there analytics events to emit for collection CRUD? | **No analytics in V1**, consistent with folder management. If `OrganizationEventManager` tracking is desired, it can be scoped separately. |
|
||||
|
||||
---
|
||||
|
||||
## Existing Infrastructure (What We Can Reuse)
|
||||
|
||||
The following components already exist in the Android codebase and will be leveraged:
|
||||
|
||||
| Component | Location | What It Provides |
|
||||
|-----------|----------|------------------|
|
||||
| `CollectionEntity` | `data/vault/datasource/disk/entity/` | Room entity for collection storage |
|
||||
| `CollectionsDao` | `data/vault/datasource/disk/dao/` | Room DAO with insert/query operations |
|
||||
| `VaultDiskSource.saveCollection()` | `data/vault/datasource/disk/` | Save collection to disk (needs `deleteCollection` added) |
|
||||
| `VaultSdkSource.decryptCollection()` | `data/vault/datasource/sdk/` | Decrypt collection with org key (needs `encryptCollection` added) |
|
||||
| `VaultSyncManager.collectionsStateFlow` | `data/vault/manager/` | Streaming `DataState<List<CollectionView>>` from sync |
|
||||
| `CollectionViewExtensions` | `ui/vault/feature/util/` | `toCollectionDisplayName()`, `getFilteredCollections()`, permission helpers |
|
||||
| `CollectionPermission` enum | `ui/vault/feature/util/model/` | VIEW, VIEW_EXCEPT_PASSWORDS, EDIT, EDIT_EXCEPT_PASSWORD, MANAGE |
|
||||
| `SyncResponseJson.Collection` | `:network` module | API response model for collections |
|
||||
| `SyncResponseJson.Organization.maxCollections` | `:network` module | Collection limit field (exists in JSON model) |
|
||||
| `VaultSdkCollectionExtensions` | `data/vault/repository/util/` | `toEncryptedSdkCollection()` conversion, sorting utilities |
|
||||
|
||||
---
|
||||
|
||||
## New Components Required
|
||||
|
||||
| Component | Module | Pattern Reference |
|
||||
|-----------|--------|-------------------|
|
||||
| `CollectionsApi` (Retrofit interface) | `:network` | `FoldersApi` |
|
||||
| `CollectionService` / `CollectionServiceImpl` | `:network` | `FolderService` / `FolderServiceImpl` |
|
||||
| `CreateCollectionJsonRequest` | `:network` | `FolderJsonRequest` |
|
||||
| `UpdateCollectionJsonRequest` | `:network` | `FolderJsonRequest` |
|
||||
| `CollectionManager` / `CollectionManagerImpl` | `:app` data layer | `FolderManager` / `FolderManagerImpl` |
|
||||
| `CreateCollectionResult` | `:app` repository model | `CreateFolderResult` |
|
||||
| `UpdateCollectionResult` | `:app` repository model | `UpdateFolderResult` |
|
||||
| `DeleteCollectionResult` | `:app` repository model | `DeleteFolderResult` |
|
||||
| `CollectionsScreen` + `CollectionsViewModel` | `:app` UI | `FoldersScreen` + `FoldersViewModel` |
|
||||
| `CollectionAddEditScreen` + `CollectionAddEditViewModel` | `:app` UI | `FolderAddEditScreen` + `FolderAddEditViewModel` |
|
||||
| `CollectionsNavigation` / `CollectionAddEditNavigation` | `:app` UI | `FoldersNavigation` / `FolderAddEditNavigation` |
|
||||
| `CollectionsRoute` / `CollectionAddEditRoute` | `:app` UI | `FoldersRoute` / `FolderAddEditRoute` |
|
||||
| `CollectionDisplayItem` | `:app` UI model | `FolderDisplayItem` |
|
||||
| `CollectionAddEditType` | `:app` UI model | `FolderAddEditType` |
|
||||
| `SnackbarRelay` entries for collection CRUD | `:app` UI model | Existing `SnackbarRelay` enum |
|
||||
| Permission fields on `SyncResponseJson.Permissions` | `:network` | Extend existing model |
|
||||
| Permission/limit fields on `Organization` domain model | `:app` data layer | Extend existing model |
|
||||
|
||||
---
|
||||
|
||||
## Source Documentation
|
||||
|
||||
| Source | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| Bitwarden Web Client | Codebase reference | `../clients/apps/web/` - `CollectionDialogComponent`, `CollectionAdminService`, collection models |
|
||||
| Bitwarden Android - Folder Feature | Codebase reference | `ui/platform/feature/settings/folders/` - full CRUD pattern reference |
|
||||
| Bitwarden Android - VaultSettings | Codebase reference | `ui/platform/feature/settings/vault/` - entry point screen |
|
||||
| Bitwarden Android - Collection Data Layer | Codebase reference | `data/vault/datasource/disk/entity/CollectionEntity.kt`, `CollectionsDao`, `VaultSdkCollectionExtensions` |
|
||||
| User requirements | User-provided | Create/edit/delete collections via Settings > Vault > Collections; paid plans only |
|
||||
@@ -16,6 +16,7 @@ import com.bitwarden.network.service.DevicesService
|
||||
import com.bitwarden.network.service.DigitalAssetLinkService
|
||||
import com.bitwarden.network.service.DownloadService
|
||||
import com.bitwarden.network.service.EventService
|
||||
import com.bitwarden.network.service.CollectionService
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.network.service.HaveIBeenPwnedService
|
||||
import com.bitwarden.network.service.IdentityService
|
||||
@@ -106,6 +107,11 @@ interface BitwardenServiceClient {
|
||||
*/
|
||||
val eventService: EventService
|
||||
|
||||
/**
|
||||
* Provides access to the Collection service.
|
||||
*/
|
||||
val collectionService: CollectionService
|
||||
|
||||
/**
|
||||
* Provides access to the Folder service.
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,8 @@ import com.bitwarden.network.service.DownloadService
|
||||
import com.bitwarden.network.service.DownloadServiceImpl
|
||||
import com.bitwarden.network.service.EventService
|
||||
import com.bitwarden.network.service.EventServiceImpl
|
||||
import com.bitwarden.network.service.CollectionService
|
||||
import com.bitwarden.network.service.CollectionServiceImpl
|
||||
import com.bitwarden.network.service.FolderService
|
||||
import com.bitwarden.network.service.FolderServiceImpl
|
||||
import com.bitwarden.network.service.HaveIBeenPwnedService
|
||||
@@ -161,6 +163,13 @@ internal class BitwardenServiceClientImpl(
|
||||
EventServiceImpl(eventApi = retrofits.authenticatedEventsRetrofit.create())
|
||||
}
|
||||
|
||||
override val collectionService: CollectionService by lazy {
|
||||
CollectionServiceImpl(
|
||||
collectionsApi = retrofits.authenticatedApiRetrofit.create(),
|
||||
json = clientJson,
|
||||
)
|
||||
}
|
||||
|
||||
override val folderService: FolderService by lazy {
|
||||
FolderServiceImpl(
|
||||
foldersApi = retrofits.authenticatedApiRetrofit.create(),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.bitwarden.network.api
|
||||
|
||||
import com.bitwarden.network.model.CollectionDetailsResponseJson
|
||||
import com.bitwarden.network.model.CollectionJsonRequest
|
||||
import com.bitwarden.network.model.NetworkResult
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Defines raw calls under the /organizations/{orgId}/collections API with authentication applied.
|
||||
*/
|
||||
internal interface CollectionsApi {
|
||||
|
||||
/**
|
||||
* Create a collection.
|
||||
*/
|
||||
@POST("organizations/{orgId}/collections")
|
||||
suspend fun createCollection(
|
||||
@Path("orgId") organizationId: String,
|
||||
@Body body: CollectionJsonRequest,
|
||||
): NetworkResult<SyncResponseJson.Collection>
|
||||
|
||||
/**
|
||||
* Gets a collection.
|
||||
*/
|
||||
@GET("organizations/{orgId}/collections/{collectionId}")
|
||||
suspend fun getCollection(
|
||||
@Path("orgId") organizationId: String,
|
||||
@Path("collectionId") collectionId: String,
|
||||
): NetworkResult<SyncResponseJson.Collection>
|
||||
|
||||
/**
|
||||
* Gets a collection with access details (groups and users).
|
||||
*/
|
||||
@GET("organizations/{orgId}/collections/{collectionId}/details")
|
||||
suspend fun getCollectionDetails(
|
||||
@Path("orgId") organizationId: String,
|
||||
@Path("collectionId") collectionId: String,
|
||||
): NetworkResult<CollectionDetailsResponseJson>
|
||||
|
||||
/**
|
||||
* Updates a collection.
|
||||
*/
|
||||
@PUT("organizations/{orgId}/collections/{collectionId}")
|
||||
suspend fun updateCollection(
|
||||
@Path("orgId") organizationId: String,
|
||||
@Path("collectionId") collectionId: String,
|
||||
@Body body: CollectionJsonRequest,
|
||||
): NetworkResult<SyncResponseJson.Collection>
|
||||
|
||||
/**
|
||||
* Deletes a collection.
|
||||
*/
|
||||
@DELETE("organizations/{orgId}/collections/{collectionId}")
|
||||
suspend fun deleteCollection(
|
||||
@Path("orgId") organizationId: String,
|
||||
@Path("collectionId") collectionId: String,
|
||||
): NetworkResult<Unit>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a group or user access selection for a collection.
|
||||
*
|
||||
* This model is used in both request and response contexts for
|
||||
* collection access management.
|
||||
*
|
||||
* @property id The ID of the group or user.
|
||||
* @property readOnly Whether the group or user has read-only access.
|
||||
* @property hidePasswords Whether passwords are hidden from the group or user.
|
||||
* @property manage Whether the group or user can manage the collection.
|
||||
*/
|
||||
@Serializable
|
||||
data class CollectionAccessSelectionJson(
|
||||
@SerialName("id")
|
||||
val id: String,
|
||||
|
||||
@SerialName("readOnly")
|
||||
val readOnly: Boolean,
|
||||
|
||||
@SerialName("hidePasswords")
|
||||
val hidePasswords: Boolean,
|
||||
|
||||
@SerialName("manage")
|
||||
val manage: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response model for collection details including access permissions.
|
||||
*
|
||||
* Corresponds to the server's CollectionAccessDetailsResponseModel returned
|
||||
* from GET /organizations/{orgId}/collections/{id}/details endpoint.
|
||||
*
|
||||
* @property id The collection ID.
|
||||
* @property organizationId The organization ID.
|
||||
* @property name The encrypted collection name.
|
||||
* @property externalId The external ID of the collection.
|
||||
* @property groups The group access selections for this collection.
|
||||
* @property users The user access selections for this collection.
|
||||
*/
|
||||
@Serializable
|
||||
data class CollectionDetailsResponseJson(
|
||||
@SerialName("id")
|
||||
val id: String,
|
||||
|
||||
@SerialName("organizationId")
|
||||
val organizationId: String,
|
||||
|
||||
@SerialName("name")
|
||||
val name: String,
|
||||
|
||||
@SerialName("externalId")
|
||||
val externalId: String?,
|
||||
|
||||
@SerialName("groups")
|
||||
val groups: List<CollectionAccessSelectionJson>?,
|
||||
|
||||
@SerialName("users")
|
||||
val users: List<CollectionAccessSelectionJson>?,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a collection request.
|
||||
*
|
||||
* @property name The encrypted name of the collection.
|
||||
* @property externalId The external ID of the collection.
|
||||
* @property groups The group access selections for this collection.
|
||||
* @property users The user access selections for this collection.
|
||||
*/
|
||||
@Serializable
|
||||
data class CollectionJsonRequest(
|
||||
@SerialName("name")
|
||||
val name: String?,
|
||||
|
||||
@SerialName("externalId")
|
||||
val externalId: String? = null,
|
||||
|
||||
@SerialName("groups")
|
||||
val groups: List<CollectionAccessSelectionJson>? = null,
|
||||
|
||||
@SerialName("users")
|
||||
val users: List<CollectionAccessSelectionJson>? = null,
|
||||
)
|
||||
@@ -337,6 +337,9 @@ data class SyncResponseJson(
|
||||
@SerialName("userId")
|
||||
val userId: String?,
|
||||
|
||||
@SerialName("organizationUserId")
|
||||
val organizationUserId: String?,
|
||||
|
||||
@SerialName("useEvents")
|
||||
val shouldUseEvents: Boolean,
|
||||
|
||||
@@ -368,6 +371,12 @@ data class SyncResponseJson(
|
||||
|
||||
@SerialName("limitItemDeletion")
|
||||
val limitItemDeletion: Boolean = false,
|
||||
|
||||
@SerialName("limitCollectionCreation")
|
||||
val limitCollectionCreation: Boolean = false,
|
||||
|
||||
@SerialName("limitCollectionDeletion")
|
||||
val limitCollectionDeletion: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -418,6 +427,9 @@ data class SyncResponseJson(
|
||||
*
|
||||
* @property shouldManageResetPassword If reset password should be managed.
|
||||
* @property shouldManagePolicies If policies should be managed.
|
||||
* @property canCreateNewCollections If the user can create new collections.
|
||||
* @property canEditAnyCollection If the user can edit any collection.
|
||||
* @property canDeleteAnyCollection If the user can delete any collection.
|
||||
*/
|
||||
@Serializable
|
||||
data class Permissions(
|
||||
@@ -426,6 +438,15 @@ data class SyncResponseJson(
|
||||
|
||||
@SerialName("managePolicies")
|
||||
val shouldManagePolicies: Boolean,
|
||||
|
||||
@SerialName("createNewCollections")
|
||||
val canCreateNewCollections: Boolean = false,
|
||||
|
||||
@SerialName("editAnyCollection")
|
||||
val canEditAnyCollection: Boolean = false,
|
||||
|
||||
@SerialName("deleteAnyCollection")
|
||||
val canDeleteAnyCollection: Boolean = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.bitwarden.network.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Models the responses from the collection update request.
|
||||
*/
|
||||
sealed class UpdateCollectionResponseJson {
|
||||
/**
|
||||
* The request completed successfully and returned the updated [collection].
|
||||
*/
|
||||
data class Success(
|
||||
val collection: SyncResponseJson.Collection,
|
||||
) : UpdateCollectionResponseJson()
|
||||
|
||||
/**
|
||||
* Represents the json body of an invalid update request.
|
||||
*
|
||||
* @param message A general, user-displayable error message.
|
||||
* @param validationErrors a map where each value is a list of error messages for each key.
|
||||
* The values in the array should be used for display to the user, since the keys tend to come
|
||||
* back as nonsense. (eg: empty string key)
|
||||
*/
|
||||
@Serializable
|
||||
data class Invalid(
|
||||
@SerialName("message")
|
||||
val message: String?,
|
||||
|
||||
@SerialName("validationErrors")
|
||||
val validationErrors: Map<String, List<String>>?,
|
||||
) : UpdateCollectionResponseJson()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.network.model.CollectionDetailsResponseJson
|
||||
import com.bitwarden.network.model.CollectionJsonRequest
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.UpdateCollectionResponseJson
|
||||
|
||||
/**
|
||||
* Provides an API for querying collection endpoints.
|
||||
*/
|
||||
interface CollectionService {
|
||||
/**
|
||||
* Attempt to create a collection in the given organization.
|
||||
*/
|
||||
suspend fun createCollection(
|
||||
organizationId: String,
|
||||
body: CollectionJsonRequest,
|
||||
): Result<SyncResponseJson.Collection>
|
||||
|
||||
/**
|
||||
* Attempt to update a collection in the given organization.
|
||||
*/
|
||||
suspend fun updateCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
body: CollectionJsonRequest,
|
||||
): Result<UpdateCollectionResponseJson>
|
||||
|
||||
/**
|
||||
* Attempt to hard delete a collection from the given organization.
|
||||
*/
|
||||
suspend fun deleteCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
* Attempt to retrieve a collection from the given organization.
|
||||
*/
|
||||
suspend fun getCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): Result<SyncResponseJson.Collection>
|
||||
|
||||
/**
|
||||
* Attempt to retrieve a collection with access details (groups and users)
|
||||
* from the given organization.
|
||||
*/
|
||||
suspend fun getCollectionDetails(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): Result<CollectionDetailsResponseJson>
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.network.api.CollectionsApi
|
||||
import com.bitwarden.network.model.CollectionDetailsResponseJson
|
||||
import com.bitwarden.network.model.CollectionJsonRequest
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.UpdateCollectionResponseJson
|
||||
import com.bitwarden.network.model.toBitwardenError
|
||||
import com.bitwarden.network.util.NetworkErrorCode
|
||||
import com.bitwarden.network.util.parseErrorBodyOrNull
|
||||
import com.bitwarden.network.util.toResult
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
internal class CollectionServiceImpl(
|
||||
private val collectionsApi: CollectionsApi,
|
||||
private val json: Json,
|
||||
) : CollectionService {
|
||||
override suspend fun createCollection(
|
||||
organizationId: String,
|
||||
body: CollectionJsonRequest,
|
||||
): Result<SyncResponseJson.Collection> =
|
||||
collectionsApi
|
||||
.createCollection(organizationId = organizationId, body = body)
|
||||
.toResult()
|
||||
|
||||
override suspend fun updateCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
body: CollectionJsonRequest,
|
||||
): Result<UpdateCollectionResponseJson> =
|
||||
collectionsApi
|
||||
.updateCollection(
|
||||
organizationId = organizationId,
|
||||
collectionId = collectionId,
|
||||
body = body,
|
||||
)
|
||||
.toResult()
|
||||
.map { UpdateCollectionResponseJson.Success(collection = it) }
|
||||
.recoverCatching { throwable ->
|
||||
throwable
|
||||
.toBitwardenError()
|
||||
.parseErrorBodyOrNull<UpdateCollectionResponseJson.Invalid>(
|
||||
code = NetworkErrorCode.BAD_REQUEST,
|
||||
json = json,
|
||||
)
|
||||
?: throw throwable
|
||||
}
|
||||
|
||||
override suspend fun deleteCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): Result<Unit> =
|
||||
collectionsApi
|
||||
.deleteCollection(
|
||||
organizationId = organizationId,
|
||||
collectionId = collectionId,
|
||||
)
|
||||
.toResult()
|
||||
|
||||
override suspend fun getCollection(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): Result<SyncResponseJson.Collection> =
|
||||
collectionsApi
|
||||
.getCollection(
|
||||
organizationId = organizationId,
|
||||
collectionId = collectionId,
|
||||
)
|
||||
.toResult()
|
||||
|
||||
override suspend fun getCollectionDetails(
|
||||
organizationId: String,
|
||||
collectionId: String,
|
||||
): Result<CollectionDetailsResponseJson> =
|
||||
collectionsApi
|
||||
.getCollectionDetails(
|
||||
organizationId = organizationId,
|
||||
collectionId = collectionId,
|
||||
)
|
||||
.toResult()
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.bitwarden.network.service
|
||||
|
||||
import com.bitwarden.network.api.CollectionsApi
|
||||
import com.bitwarden.network.base.BaseServiceTest
|
||||
import com.bitwarden.network.model.CollectionAccessSelectionJson
|
||||
import com.bitwarden.network.model.CollectionDetailsResponseJson
|
||||
import com.bitwarden.network.model.CollectionJsonRequest
|
||||
import com.bitwarden.network.model.CollectionTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.UpdateCollectionResponseJson
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import retrofit2.create
|
||||
|
||||
class CollectionServiceTest : BaseServiceTest() {
|
||||
private val collectionsApi: CollectionsApi = retrofit.create()
|
||||
|
||||
private val collectionService: CollectionService = CollectionServiceImpl(
|
||||
collectionsApi = collectionsApi,
|
||||
json = json,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `createCollection should return the correct response`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(COLLECTION_SUCCESS_JSON))
|
||||
val result = collectionService.createCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
body = CollectionJsonRequest(name = DEFAULT_NAME),
|
||||
)
|
||||
assertEquals(DEFAULT_COLLECTION, result.getOrThrow())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCollection should return the correct response`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(COLLECTION_SUCCESS_JSON))
|
||||
val result = collectionService.getCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_ID,
|
||||
)
|
||||
assertEquals(DEFAULT_COLLECTION, result.getOrThrow())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getCollectionDetails should return the correct response`() =
|
||||
runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(COLLECTION_DETAILS_JSON),
|
||||
)
|
||||
val result = collectionService.getCollectionDetails(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_ID,
|
||||
)
|
||||
assertEquals(DEFAULT_COLLECTION_DETAILS, result.getOrThrow())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateCollection with success should return Success`() =
|
||||
runTest {
|
||||
server.enqueue(MockResponse().setBody(COLLECTION_SUCCESS_JSON))
|
||||
val result = collectionService.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_ID,
|
||||
body = CollectionJsonRequest(name = DEFAULT_NAME),
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResponseJson.Success(
|
||||
collection = DEFAULT_COLLECTION,
|
||||
),
|
||||
result.getOrThrow(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateCollection with invalid response should return Invalid`() =
|
||||
runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(400)
|
||||
.setBody(UPDATE_COLLECTION_INVALID_JSON),
|
||||
)
|
||||
val result = collectionService.updateCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_ID,
|
||||
body = CollectionJsonRequest(name = DEFAULT_NAME),
|
||||
)
|
||||
assertEquals(
|
||||
UpdateCollectionResponseJson.Invalid(
|
||||
message = "At least one member or group must have can manage permission.",
|
||||
validationErrors = null,
|
||||
),
|
||||
result.getOrThrow(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteCollection should return success`() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
val result = collectionService.deleteCollection(
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
collectionId = DEFAULT_ID,
|
||||
)
|
||||
assertEquals(Unit, result.getOrThrow())
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_ID = "collectionId"
|
||||
private const val DEFAULT_ORG_ID = "orgId"
|
||||
private const val DEFAULT_NAME = "mockName"
|
||||
|
||||
private val DEFAULT_COLLECTION = SyncResponseJson.Collection(
|
||||
id = DEFAULT_ID,
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
name = DEFAULT_NAME,
|
||||
externalId = "externalId",
|
||||
shouldHidePasswords = false,
|
||||
isReadOnly = false,
|
||||
canManage = true,
|
||||
defaultUserCollectionEmail = null,
|
||||
type = CollectionTypeJson.SHARED_COLLECTION,
|
||||
)
|
||||
|
||||
private val DEFAULT_COLLECTION_DETAILS = CollectionDetailsResponseJson(
|
||||
id = DEFAULT_ID,
|
||||
organizationId = DEFAULT_ORG_ID,
|
||||
name = DEFAULT_NAME,
|
||||
externalId = "externalId",
|
||||
groups = listOf(
|
||||
CollectionAccessSelectionJson(
|
||||
id = "groupId-1",
|
||||
readOnly = false,
|
||||
hidePasswords = false,
|
||||
manage = true,
|
||||
),
|
||||
),
|
||||
users = listOf(
|
||||
CollectionAccessSelectionJson(
|
||||
id = "userId-1",
|
||||
readOnly = false,
|
||||
hidePasswords = false,
|
||||
manage = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private const val COLLECTION_SUCCESS_JSON = """
|
||||
{
|
||||
"id": "collectionId",
|
||||
"organizationId": "orgId",
|
||||
"name": "mockName",
|
||||
"externalId": "externalId",
|
||||
"hidePasswords": false,
|
||||
"readOnly": false,
|
||||
"manage": true,
|
||||
"type": 0
|
||||
}
|
||||
"""
|
||||
|
||||
private const val COLLECTION_DETAILS_JSON = """
|
||||
{
|
||||
"id": "collectionId",
|
||||
"organizationId": "orgId",
|
||||
"name": "mockName",
|
||||
"externalId": "externalId",
|
||||
"groups": [
|
||||
{
|
||||
"id": "groupId-1",
|
||||
"readOnly": false,
|
||||
"hidePasswords": false,
|
||||
"manage": true
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"id": "userId-1",
|
||||
"readOnly": false,
|
||||
"hidePasswords": false,
|
||||
"manage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
private const val UPDATE_COLLECTION_INVALID_JSON = """
|
||||
{
|
||||
"message": "At least one member or group must have can manage permission.",
|
||||
"validationErrors": null
|
||||
}
|
||||
"""
|
||||
@@ -86,6 +86,7 @@ fun createMockOrganizationNetwork(
|
||||
use2fa: Boolean = false,
|
||||
familySponsorshipToDelete: Boolean? = false,
|
||||
userId: String? = "mockUserId-$number",
|
||||
organizationUserId: String? = "mockOrgUserId-$number",
|
||||
shouldUseEvents: Boolean = false,
|
||||
familySponsorshipFriendlyName: String? = "mockFamilySponsorshipFriendlyName-$number",
|
||||
shouldUseTotp: Boolean = false,
|
||||
@@ -96,6 +97,8 @@ fun createMockOrganizationNetwork(
|
||||
status: OrganizationStatusType = OrganizationStatusType.ACCEPTED,
|
||||
userIsClaimedByOrganization: Boolean = false,
|
||||
limitItemDeletion: Boolean = false,
|
||||
limitCollectionCreation: Boolean = false,
|
||||
limitCollectionDeletion: Boolean = false,
|
||||
): SyncResponseJson.Profile.Organization =
|
||||
SyncResponseJson.Profile.Organization(
|
||||
shouldUsePolicies = shouldUsePolicies,
|
||||
@@ -120,6 +123,7 @@ fun createMockOrganizationNetwork(
|
||||
use2fa = use2fa,
|
||||
familySponsorshipToDelete = familySponsorshipToDelete,
|
||||
userId = userId,
|
||||
organizationUserId = organizationUserId,
|
||||
shouldUseEvents = shouldUseEvents,
|
||||
familySponsorshipFriendlyName = familySponsorshipFriendlyName,
|
||||
shouldUseTotp = shouldUseTotp,
|
||||
@@ -130,6 +134,8 @@ fun createMockOrganizationNetwork(
|
||||
status = status,
|
||||
userIsClaimedByOrganization = userIsClaimedByOrganization,
|
||||
limitItemDeletion = limitItemDeletion,
|
||||
limitCollectionCreation = limitCollectionCreation,
|
||||
limitCollectionDeletion = limitCollectionDeletion,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -149,10 +155,16 @@ fun createMockOrganizationKeys(
|
||||
fun createMockPermissions(
|
||||
shouldManageResetPassword: Boolean = false,
|
||||
shouldManagePolicies: Boolean = false,
|
||||
canCreateNewCollections: Boolean = false,
|
||||
canEditAnyCollection: Boolean = false,
|
||||
canDeleteAnyCollection: Boolean = false,
|
||||
): SyncResponseJson.Profile.Permissions =
|
||||
SyncResponseJson.Profile.Permissions(
|
||||
shouldManageResetPassword = shouldManageResetPassword,
|
||||
shouldManagePolicies = shouldManagePolicies,
|
||||
canCreateNewCollections = canCreateNewCollections,
|
||||
canEditAnyCollection = canEditAnyCollection,
|
||||
canDeleteAnyCollection = canDeleteAnyCollection,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
<string name="preview">Preview</string>
|
||||
<string name="deleting">Deleting…</string>
|
||||
<string name="do_you_really_want_to_delete">Do you really want to delete? This cannot be undone.</string>
|
||||
<string name="do_you_really_want_to_delete_collection">Do you really want to delete this collection? This cannot be undone.</string>
|
||||
<string name="edit">Edit</string>
|
||||
<string name="edit_collection">Edit collection</string>
|
||||
<string name="edit_folder">Edit folder</string>
|
||||
<string name="email">Email</string>
|
||||
<string name="email_address">Email address</string>
|
||||
@@ -119,6 +121,7 @@
|
||||
<string name="never">Never</string>
|
||||
<string name="new_item_created">Item added</string>
|
||||
<string name="no_cards">There are no cards in your vault.</string>
|
||||
<string name="new_collection">New collection</string>
|
||||
<string name="new_card">New card</string>
|
||||
<string name="no_logins">There are no logins in your vault.</string>
|
||||
<string name="no_identities">There are no identities in your vault.</string>
|
||||
@@ -278,6 +281,10 @@ Scanning will happen automatically.</string>
|
||||
<string name="icons_url">Icons server URL</string>
|
||||
<string name="vault_is_locked">Vault is locked</string>
|
||||
<string name="go_to_my_vault">Go to my vault</string>
|
||||
<string name="collection_created">New collection created.</string>
|
||||
<string name="collection_deleted">Collection deleted.</string>
|
||||
<string name="collection_name_slash_error">Collection name cannot contain the \u201c/\u201d character.</string>
|
||||
<string name="collection_updated">Collection saved</string>
|
||||
<string name="collections">Collections</string>
|
||||
<string name="no_items_collection">There are no items in this collection.</string>
|
||||
<string name="no_items_folder">There are no items in this folder.</string>
|
||||
|
||||
Reference in New Issue
Block a user