Compare commits

...

6 Commits

Author SHA1 Message Date
anonymous
5d5cbdd178 fix: Address code review findings and add ViewModel tests
Code review fixes:
- Remove duplicate KeyConnectorUrl branch in InitUserCryptoMethodExtensions
- Fix CollectionManagerTest createCollection calls to include organizationUserId
- Prevent vault sync from overwriting user's in-progress edits in
  CollectionAddEditViewModel (early return if already in Content state)
- Add per-collection canManage permission check before allowing edit
  navigation, based on collection manage flag and org role
- Gitignore .claude/outputs/ to exclude plan documents from commits

New tests:
- CollectionsViewModelTest: 11 tests covering navigation, state updates,
  FAB visibility based on permissions, snackbar relay, and error states
- CollectionAddEditViewModelTest: 20 tests covering create/edit/delete
  flows, name validation, dialog states, snackbar relay, and the sync
  overwrite protection fix

Updated test fixtures:
- SyncResponseProfileUtil: add organizationUserId, limitCollectionCreation,
  limitCollectionDeletion fields
2026-03-25 14:02:03 -04:00
anonymous
5dcaf6e4a8 fix: Grant creating user manage access and fix permission checks
- Add organizationUserId to SyncResponseJson and Organization domain
  model to identify the current user's org membership ID
- Include creating user with manage access in collection create request,
  matching web client behavior
- Add limitCollectionCreation/limitCollectionDeletion to org model
- Fix FAB visibility: use canManageCollections computed property that
  checks role (Owner/Admin) in addition to permissions flags, matching
  web client logic: !limitCollectionCreation || isAdmin || permissions
2026-03-25 13:08:14 -04:00
Patrick Honkonen
d0809a7c07 fix: Include access permissions in collection update request
The PUT endpoint for updating a collection requires groups and users
access permissions in the request body. Previously only the encrypted
name was sent, causing the server to reject the request with "At least
one member or group must have can manage permission."

The update flow now fetches collection details via the new /details
endpoint before sending the PUT request, echoing back existing groups,
users, and externalId. Also fixes collection edit screen passing
organizationName instead of organizationId and resolves compile errors
from new parameters across tests.
2026-03-24 16:44:16 -04:00
anonymous
27eab5570f fix: Adapt to local SDK API changes for local development
Add vaultUrl parameter to SsoCookieVendorConfig and handle new
KeyConnectorUrl variant in InitUserCryptoMethod when expressions.
These changes are required for compatibility with the latest
sdk-internal build used for local collection encryption testing.
2026-03-24 15:09:33 -04:00
anonymous
f6435a0a1e feat: Replace encryptCollection stub with real SDK call
Remove the UnsupportedOperationException stub and delegate to the
actual SDK collections().encrypt() method. Requires SDK version with
collection encryption support (not yet in published 2.0.0-5451).
2026-03-24 15:09:33 -04:00
anonymous
d3e4dc854b feat: Add collection management (create, edit, delete) to Settings > Vault
Add full CRUD support for managing collections on Android, accessible
via Settings > Vault > Collections. Collections are organization-scoped
vault items available on paid plans.

Changes include:
- Network layer: CollectionsApi, CollectionService, request/response models
- Data layer: CollectionManager with encrypt > API > disk > decrypt flow
- Permission model: expanded SyncResponseJson.Permissions and Organization
  with collection-specific permission fields
- UI: CollectionsScreen (list with org subtitles, permission-gated FAB),
  CollectionAddEditScreen (name field, save, delete with confirmation)
- Navigation: type-safe routes wired through VaultSettings entry point
- VaultDiskSource.deleteCollection and VaultSdkSource.encryptCollection stub

Note: encryptCollection is stubbed pending SDK release (SDK changes are
implemented but not yet published). Create/update will fail at runtime
until the SDK is updated.
2026-03-24 15:09:29 -04:00
58 changed files with 4337 additions and 3 deletions

3
.gitignore vendored
View File

@@ -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__/

View File

@@ -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"))
}
}
}

View File

@@ -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
}

View File

@@ -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,
)
}

View File

@@ -45,6 +45,7 @@ class ServerCommunicationConfigRepositoryImpl(
idpLoginUrl = serverCommunicationConfig.bootstrap.idpLoginUrl,
cookieName = serverCommunicationConfig.bootstrap.cookieName,
cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain,
vaultUrl = null,
cookieValue = acquiredCookies,
),
),

View File

@@ -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].
*/

View File

@@ -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>> =

View File

@@ -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(

View File

@@ -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].

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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) },
)
}
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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"
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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())
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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) },

View File

@@ -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.
*/

View File

@@ -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() },

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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,
),
),
)

View File

@@ -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,
),
)

View File

@@ -129,6 +129,7 @@ class VaultRepositoryTest {
vaultSyncManager = vaultSyncManager,
credentialExchangeImportManager = credentialExchangeImportManager,
pinProtectedUserKeyManager = pinProtectedUserKeyManager,
collectionManager = mockk(),
)
@BeforeEach

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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 },
)
}
}

View File

@@ -64,6 +64,7 @@ class VaultUnlockedNavBarScreenTest : BitwardenComposeTest() {
onNavigateToFlightRecorder = {},
onNavigateToRecordedLogs = {},
onNavigateToAboutPrivilegedApps = {},
onNavigateToCollections = {},
)
}
}

View 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 |

View File

@@ -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.
*/

View File

@@ -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(),

View File

@@ -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>
}

View File

@@ -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,
)

View File

@@ -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>?,
)

View File

@@ -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,
)

View File

@@ -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,
)
}

View File

@@ -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()
}

View File

@@ -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>
}

View File

@@ -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()
}

View File

@@ -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
}
"""

View File

@@ -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,
)
/**

View File

@@ -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>