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.
This commit is contained in:
anonymous authored and Patrick Honkonen committed 2026-03-24 15:09:29 -04:00
1 parent b4917ceb95
commit d3e4dc854b
45 files changed
+2622

No files matched your search

@@ -0,0 +1,365 @@
# Collection Management — Design Document
**Feature**: Add collection CRUD (create, edit, delete) to Settings > Vault > Collections
**Date**: 2026-03-18
**Status**: Ready for Implementation
**Branch**: `android-collections`
**Sources**: User requirements, Bitwarden web client (`../clients/apps/web/`), Android folder feature (pattern reference), Android collection data layer (existing infrastructure)
---
## Requirements Specification
> **Status: DRAFT** — G1 (multi-org display) still pending team input. G2 (SDK encryption) resolved — changes implemented in SDK repo, awaiting publish. G3 (permission model expansion) resolved — confirmed in scope.
### 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 | Default: flat list with org name subtitle (G1 pending) |
| 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 | Show dialog explaining limit, suggest upgrading via web |
| FR7 | The FAB 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**: `CollectionJsonRequest` with encrypted `name` field | 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` — stubbed with TODO until SDK published | SDK repo changes done | SDK changes implemented, awaiting release |
| TR7 | **CollectionManager**: New `CollectionManager` interface + `CollectionManagerImpl` following `FolderManager` pattern | Folder pattern | Delegated from `VaultRepository` |
| TR8 | **Result types**: New sealed classes `CreateCollectionResult`, `UpdateCollectionResult`, `DeleteCollectionResult` | Folder pattern | In `data/vault/repository/model/` |
| TR9 | **VaultDiskSource**: Add `deleteCollection(userId, collectionId)` method | Gap analysis | `saveCollection` exists; delete does not. DAO method already exists. |
| TR10 | **Permission model expansion**: Add to `SyncResponseJson.Permissions`: `createNewCollections`, `editAnyCollection`, `deleteAnyCollection` | In scope (G3 resolved) | Fields exist in API JSON but are not parsed |
| TR11 | **Organization domain model expansion**: Add `maxCollections: Int?` to the Android `Organization` data class | In scope (G3 resolved) | `maxCollections` exists in `SyncResponseJson` but isn't mapped |
| TR12 | **Navigation**: Type-safe `@Serializable` routes: `CollectionsRoute`, `CollectionAddEditRoute` | Folder pattern | `organizationId` required for create |
| TR13 | **SnackbarRelay**: Add `COLLECTION_CREATED`, `COLLECTION_UPDATED`, `COLLECTION_DELETED` entries | Folder pattern | |
| TR14 | **Process death**: Collection name field persisted via `SavedStateHandle` | Folder pattern | |
| TR15 | **VaultSettingsScreen update**: Add "Collections" row between "Folders" and "Export Vault" | VaultSettingsScreen | New action + event in ViewModel |
| TR16 | **F-Droid**: No Google Play Services dependency | Requirement | Pure network + SDK |
### Security Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| SR1 | Collection names must be encrypted with the organization's key via `ScopedVaultSdkSource` before API transmission | 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 "Collections" title, back arrow, FAB for adding | Folder pattern | FAB hidden if no create permissions |
| UX2 | **Collection list item**: Decrypted name with org name subtitle; tap navigates to edit | Folder pattern | |
| UX3 | **Multi-org display**: Flat list with org name as subtitle on each item (default, G1 pending) | User | |
| UX4 | **Add/edit screen**: "New Collection" / "Edit Collection" title, name field, save button, delete in overflow | Folder pattern | |
| UX5 | **Delete confirmation**: Dialog with warning text and Cancel/Delete buttons | Folder pattern | |
| UX6 | **Loading state**: Full-screen loading spinner | Folder pattern | |
| UX7 | **Empty state**: Centered text "No collections available" | Folder pattern | |
| 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." | Web client | |
| UX11 | **Collection limit reached**: Dialog explaining limit, suggesting web upgrade | Web client | |
| UX12 | **Org selection for create**: If multiple orgs with create permission, select which org | Functional | |
| UX13 | **String resources**: All strings in `:ui` module `strings.xml` with typographic quotes | CLAUDE.md | |
### Open Questions
| ID | Category | Question | Status / Default |
|----|----------|----------|-----------------|
| **G1** | Functional / UX | Multi-organization display: flat list with org subtitle (Option A) vs. org selector (Option B)? | **Pending** — defaulting to Option A |
| **G2** | Technical | SDK `encryptCollection` availability | **Resolved** — implemented in SDK repo, awaiting publish. Stubbed in Android. |
| **G3** | Technical | Permission model expansion in scope? | **Resolved** — confirmed in scope |
| **G4** | Functional | Nested collection creation in V1? | Default: No, flat only |
| **G5** | Functional / UX | Free org at collection limit UX? | Default: Dialog suggesting web upgrade |
| **G6** | UX | Show externalId in edit view? | Default: No |
| **G7** | UX | Exact string values? | Default: Follow folder conventions |
| **G8** | Cross-cutting | Feature flag? | Default: No |
| **G9** | Functional | Offline behavior? | Default: Error snackbar, match folders |
| **G10** | Cross-cutting | Analytics events? | Default: None in V1 |
---
## Implementation Plan
### Classification
**Type: New Feature** — Entirely new screens, data layer managers, network services, and navigation. Multi-phase, touching `:network` and `:app` modules across data, domain, and UI layers.
### Architecture
```
┌─────────────────────────┐ ┌──────────────────────────────┐
│ CollectionsScreen │ │ CollectionAddEditScreen │
│ (list, FAB, org names) │ │ (name field, save, delete) │
└────────────┬────────────┘ └──────────────┬───────────────┘
│ │
┌────────────▼────────────┐ ┌──────────────▼───────────────┐
│ CollectionsViewModel │ │ CollectionAddEditViewModel │
│ DataState<List<CV>> │ │ create/update/delete actions │
└────────────┬────────────┘ └──────────────┬───────────────┘
│ │
└──────────┬───────────────────┘
│
┌─────────▼──────────┐
│ VaultRepository │ ← delegates to CollectionManager
│ (by collectionMgr)│
└─────────┬──────────┘
│
┌─────────▼──────────┐
│ CollectionManager │ ← encrypt → API → disk → decrypt
│ (CollectionMgrImpl)│
└──┬────┬────┬──────┘
│ │ │
┌───────┘ │ └────────┐
▼ ▼ ▼
VaultSdkSource CollectionSvc VaultDiskSource
(encrypt/decrypt) (Retrofit) (Room via DAO)
│
CollectionsApi
(POST/PUT/DELETE)
```
### Design Decisions
| Decision | Resolution | Rationale |
|----------|-----------|-----------|
| Multi-org display (G1 open) | Flat list with org name subtitle | Simpler; can refactor to org selector later |
| encryptCollection unavailable | Stub in VaultSdkSource with TODO | SDK not yet published; unblocks all other work |
| Collection nesting in V1 | Flat only, reject `/` in name input | Reduces scope; nesting display already works |
| Push notification sync | Defer to future phase | Not blocking for V1 |
| Collection API base path | `/organizations/{orgId}/collections` | Matches web client |
### File Inventory
#### Files to Create
| File Path | Type | Pattern Reference |
|-----------|------|-------------------|
| `network/.../api/CollectionsApi.kt` | Retrofit API | `FoldersApi.kt` |
| `network/.../service/CollectionService.kt` | Service interface | `FolderService.kt` |
| `network/.../service/CollectionServiceImpl.kt` | Service impl | `FolderServiceImpl.kt` |
| `network/.../model/CollectionJsonRequest.kt` | Request model | `FolderJsonRequest.kt` |
| `network/.../model/UpdateCollectionResponseJson.kt` | Response model | `UpdateFolderResponseJson.kt` |
| `app/.../vault/manager/CollectionManager.kt` | Manager interface | `FolderManager.kt` |
| `app/.../vault/manager/CollectionManagerImpl.kt` | Manager impl | `FolderManagerImpl.kt` |
| `app/.../vault/repository/model/CreateCollectionResult.kt` | Result type | `CreateFolderResult.kt` |
| `app/.../vault/repository/model/UpdateCollectionResult.kt` | Result type | `UpdateFolderResult.kt` |
| `app/.../vault/repository/model/DeleteCollectionResult.kt` | Result type | `DeleteFolderResult.kt` |
| `app/.../settings/collections/CollectionsScreen.kt` | Compose screen | `FoldersScreen.kt` |
| `app/.../settings/collections/CollectionsViewModel.kt` | ViewModel | `FoldersViewModel.kt` |
| `app/.../settings/collections/CollectionsNavigation.kt` | Navigation | `FoldersNavigation.kt` |
| `app/.../settings/collections/addedit/CollectionAddEditScreen.kt` | Compose screen | `FolderAddEditScreen.kt` |
| `app/.../settings/collections/addedit/CollectionAddEditViewModel.kt` | ViewModel | `FolderAddEditViewModel.kt` |
| `app/.../settings/collections/addedit/CollectionAddEditNavigation.kt` | Navigation | `FolderAddEditNavigation.kt` |
| `app/.../settings/collections/model/CollectionDisplayItem.kt` | UI model | `FolderDisplayItem.kt` |
| `app/.../settings/collections/model/CollectionAddEditType.kt` | UI model | `FolderAddEditType.kt` |
#### Files to Modify
| File Path | Change Description | Risk |
|-----------|-------------------|------|
| `network/.../model/SyncResponseJson.kt` | Add fields to `Permissions` | Medium |
| `network/.../BitwardenServiceClient.kt` | Add `collectionService` property | Low |
| `network/.../BitwardenServiceClientImpl.kt` | Add `collectionService` lazy impl | Low |
| `app/.../datasource/disk/VaultDiskSource.kt` | Add `deleteCollection` method | Low |
| `app/.../datasource/disk/VaultDiskSourceImpl.kt` | Implement `deleteCollection` | Low |
| `app/.../datasource/sdk/VaultSdkSource.kt` | Add `encryptCollection` stub | Low |
| `app/.../datasource/sdk/VaultSdkSourceImpl.kt` | Implement `encryptCollection` stub | Low |
| `app/.../auth/repository/model/Organization.kt` | Add `maxCollections` field | Low |
| `app/.../auth/repository/util/...Extensions.kt` | Map new `Organization` fields | Medium |
| `app/.../vault/manager/di/VaultManagerModule.kt` | Provide `CollectionManager` | Low |
| `app/.../vault/repository/VaultRepository.kt` | Extend `CollectionManager` | Medium |
| `app/.../vault/repository/VaultRepositoryImpl.kt` | Delegate `CollectionManager` | Medium |
| `app/.../vault/repository/di/VaultRepositoryModule.kt` | Add `CollectionManager` param | Low |
| `app/.../vault/datasource/network/di/VaultNetworkModule.kt` | Provide `CollectionService` | Low |
| `app/.../ui/platform/model/SnackbarRelay.kt` | Add collection relay entries | Low |
| `app/.../settings/vault/VaultSettingsScreen.kt` | Add Collections row | Low |
| `app/.../settings/vault/VaultSettingsViewModel.kt` | Add Collections action/event | Low |
| `app/.../settings/vault/VaultSettingsNavigation.kt` | Add `onNavigateToCollections` | Low |
| `app/.../vaultunlocked/VaultUnlockedNavigation.kt` | Register collections destinations | Low |
| `ui/.../res/values/strings.xml` | Add collection string resources | Low |
### Implementation Phases
#### Phase 1: Permission Model & Data Foundation
**Goal**: Expand the permission and organization models, add missing disk/SDK operations.
**Files**:
- Modify: `SyncResponseJson.kt`, `Organization.kt`, org mapping extensions, `VaultDiskSource.kt`, `VaultDiskSourceImpl.kt`, `VaultSdkSource.kt`, `VaultSdkSourceImpl.kt`
**Tasks**:
1. Add `createNewCollections`, `editAnyCollection`, `deleteAnyCollection` to `SyncResponseJson.Permissions`
2. Add `maxCollections: Int?` to `Organization` domain model and its mapping from `SyncResponseJson.Profile.Organization`
3. Add `deleteCollection(userId, collectionId)` to `VaultDiskSource`/`VaultDiskSourceImpl` (DAO method already exists)
4. Add `encryptCollection(userId, collectionView)` stub to `VaultSdkSource`/`VaultSdkSourceImpl` with TODO comment
**Verification**: `./gradlew app:compileStandardDebugKotlin`
**Skills**: `implementing-android-code`
#### Phase 2: Network Layer
**Goal**: Create the collections API, service, and request/response models in the `:network` module.
**Files**:
- Create: `CollectionsApi.kt`, `CollectionService.kt`, `CollectionServiceImpl.kt`, `CollectionJsonRequest.kt`, `UpdateCollectionResponseJson.kt`
- Modify: `BitwardenServiceClient.kt`, `BitwardenServiceClientImpl.kt`, `VaultNetworkModule.kt`
**Tasks**:
1. Create `CollectionsApi` Retrofit interface with `POST /organizations/{orgId}/collections`, `PUT /organizations/{orgId}/collections/{id}`, `DELETE /organizations/{orgId}/collections/{id}`
2. Create `CollectionJsonRequest` (name field)
3. Create `UpdateCollectionResponseJson` sealed type (Success/Invalid) following `UpdateFolderResponseJson`
4. Create `CollectionService` interface with `createCollection`, `updateCollection`, `deleteCollection`, `getCollection`
5. Create `CollectionServiceImpl` wrapping `CollectionsApi`
6. Add `collectionService: CollectionService` to `BitwardenServiceClient` and `BitwardenServiceClientImpl`
7. Provide `CollectionService` in `VaultNetworkModule`
**Verification**: `./gradlew network:compileDebugKotlin`
**Skills**: `implementing-android-code`
#### Phase 3: Collection Manager & Repository Wiring
**Goal**: Create the CollectionManager business logic layer and wire it into VaultRepository.
**Files**:
- Create: `CollectionManager.kt`, `CollectionManagerImpl.kt`, `CreateCollectionResult.kt`, `UpdateCollectionResult.kt`, `DeleteCollectionResult.kt`
- Modify: `VaultRepository.kt`, `VaultRepositoryImpl.kt`, `VaultRepositoryModule.kt`, `VaultManagerModule.kt`
**Tasks**:
1. Create sealed result classes for create/update/delete (following folder result patterns)
2. Create `CollectionManager` interface with `createCollection(organizationId, collectionView)`, `updateCollection(collectionId, organizationId, collectionView)`, `deleteCollection(collectionId, organizationId)`
3. Implement `CollectionManagerImpl` following `FolderManagerImpl` pattern: encrypt → API → disk → decrypt
4. Add `CollectionManager` provision to `VaultManagerModule`
5. Extend `VaultRepository` interface to include `CollectionManager`
6. Add `by collectionManager` delegation in `VaultRepositoryImpl`
7. Wire `CollectionManager` param in `VaultRepositoryModule`
**Verification**: `./gradlew app:compileStandardDebugKotlin`
**Skills**: `implementing-android-code`
#### Phase 4: UI Models, Navigation & String Resources
**Goal**: Create the UI models, navigation routes, and string resources needed before building screens.
**Files**:
- Create: `CollectionDisplayItem.kt`, `CollectionAddEditType.kt`, `CollectionsNavigation.kt`, `CollectionAddEditNavigation.kt`
- Modify: `SnackbarRelay.kt`, `strings.xml`
**Tasks**:
1. Add `COLLECTION_CREATED`, `COLLECTION_UPDATED`, `COLLECTION_DELETED` to `SnackbarRelay`
2. Create `CollectionDisplayItem` (id, name, organizationName)
3. Create `CollectionAddEditType` sealed class (AddItem with organizationId, EditItem with collectionId)
4. Create `CollectionsRoute` and navigation extension functions
5. Create `CollectionAddEditRoute` and navigation extension functions
6. Add string resources: "Collections", "New collection", "Edit collection", snackbar messages, delete confirmation, etc.
**Verification**: `./gradlew app:compileStandardDebugKotlin`
**Skills**: `implementing-android-code`
#### Phase 5: Collections List Screen
**Goal**: Build the CollectionsScreen and CollectionsViewModel showing all collections with org names.
**Files**:
- Create: `CollectionsScreen.kt`, `CollectionsViewModel.kt`
- Modify: `VaultSettingsScreen.kt`, `VaultSettingsViewModel.kt`, `VaultSettingsNavigation.kt`
**Tasks**:
1. Create `CollectionsViewModel` consuming `collectionsStateFlow` from VaultRepository, mapping to display items with org names
2. Create `CollectionsScreen` with Loading/Content/Error/Empty states, FAB (permission-gated), org name subtitles
3. Add "Collections" row to `VaultSettingsScreen` between Folders and Export Vault (update card styles)
4. Add `CollectionsButtonClick` action and `NavigateToCollections` event to `VaultSettingsViewModel`
5. Wire `onNavigateToCollections` in `VaultSettingsNavigation`
**Verification**: `./gradlew app:compileStandardDebugKotlin`
**Skills**: `implementing-android-code`
#### Phase 6: Collection Add/Edit Screen & Navigation Wiring
**Goal**: Build the CollectionAddEditScreen and ViewModel, wire full navigation graph.
**Files**:
- Create: `CollectionAddEditScreen.kt`, `CollectionAddEditViewModel.kt`
- Modify: `VaultUnlockedNavigation.kt`
**Tasks**:
1. Create `CollectionAddEditViewModel` with create/update/delete flows, name validation (reject `/`), snackbar relay
2. Create `CollectionAddEditScreen` with name field, save button, delete in overflow menu (edit only), loading/error dialogs
3. Register `collectionsDestination` and `collectionAddEditDestination` in `VaultUnlockedNavigation.kt`
4. Wire full navigation: VaultSettings → Collections → AddEdit
**Verification**: `./gradlew app:compileStandardDebugKotlin` and manual navigation test
**Skills**: `implementing-android-code`
### Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| SDK `encryptCollection` not available | Known | High | Stub with TODO; all other code testable |
| G1 multi-org answer changes approach | Medium | Medium | Flat list with org subtitle is easy to refactor |
| Collection API endpoints differ from web client | Low | High | Verified against web client source |
| Permission model fields not in server response | Low | High | Confirmed in SyncServiceTest JSON fixtures |
| VaultRepository delegation causes conflicts | Low | Medium | Follow exact FolderManager delegation pattern |
### Verification Plan
**Automated**:
- `./gradlew app:testStandardDebugUnitTest` after each phase
- `./gradlew app:assembleStandardDebug` for full build verification
- `./gradlew detekt` for lint checks
**Manual**:
- Navigate Settings → Vault → Collections → verify list loads
- Create a new collection → verify snackbar and list update
- Edit a collection name → verify save and snackbar
- Delete a collection → verify confirmation dialog, snackbar, and list update
- Verify FAB hidden for users without create permission
- Verify delete option hidden for users without delete permission
- Process death: enter name on add screen, rotate/kill process, verify name persists
---
## Executing This Plan
To implement this plan, run:
/work-on-android Collection Management feature
Reference this design document during implementation for architecture decisions,
file locations, and phase ordering.
@@ -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,8 @@ data class Organization(
val userIsClaimedByOrganization: Boolean,
val limitItemDeletion: Boolean,
val shouldUseEvents: Boolean,
val maxCollections: Int?,
val canCreateNewCollections: Boolean,
val canEditAnyCollection: Boolean,
val canDeleteAnyCollection: Boolean,
)
@@ -28,6 +28,10 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
shouldUseEvents = this.shouldUseEvents,
maxCollections = this.maxCollections,
canCreateNewCollections = this.permissions.canCreateNewCollections,
canEditAnyCollection = this.permissions.canEditAnyCollection,
canDeleteAnyCollection = this.permissions.canDeleteAnyCollection,
)
}
@@ -82,6 +82,11 @@ interface VaultDiskSource {
*/
fun getCollectionsFlow(userId: String): Flow<List<SyncResponseJson.Collection>>
/**
* Deletes a collection from the data source for the given [userId] and [collectionId].
*/
suspend fun deleteCollection(userId: String, collectionId: String)
/**
* Retrieves all domains from the data source for a given [userId].
*/
@@ -200,6 +200,10 @@ class VaultDiskSourceImpl(
)
}
override suspend fun deleteCollection(userId: String, collectionId: String) {
collectionsDao.deleteCollection(userId = userId, collectionId = collectionId)
}
override fun getCollectionsFlow(
userId: String,
): Flow<List<SyncResponseJson.Collection>> =
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.CollectionService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
@@ -25,6 +26,12 @@ object VaultNetworkModule {
bitwardenServiceClient: BitwardenServiceClient,
): CiphersService = bitwardenServiceClient.ciphersService
@Provides
@Singleton
fun providesCollectionService(
bitwardenServiceClient: BitwardenServiceClient,
): CollectionService = bitwardenServiceClient.collectionService
@Provides
@Singleton
fun providesFolderService(
@@ -239,6 +239,18 @@ interface VaultSdkSource {
collectionList: List<Collection>,
): Result<List<CollectionView>>
/**
* Encrypts a [CollectionView] for the user with the given [userId], returning a [Collection]
* wrapped in a [Result].
*
* This should only be called after a successful call to [initializeCrypto] for the associated
* user.
*/
suspend fun encryptCollection(
userId: String,
collectionView: CollectionView,
): Result<Collection>
/**
* Encrypts a [SendView] for the user with the given [userId], returning a [Send] wrapped
* in a [Result].
@@ -335,6 +335,16 @@ class VaultSdkSourceImpl(
.decryptList(collections = collectionList)
}
// TODO: Replace stub with actual SDK call once encryptCollection is available
// in the published Bitwarden SDK. The SDK changes have been implemented in the
// SDK repo but are not yet released. Once available, this should delegate to:
// getClient(userId).vault().collections().encrypt(collectionView = collectionView)
override suspend fun encryptCollection(
userId: String,
collectionView: CollectionView,
): Result<Collection> =
Result.failure(UnsupportedOperationException("Not yet implemented in SDK"))
override suspend fun decryptSend(
userId: String,
send: Send,
@@ -0,0 +1,36 @@
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].
*/
suspend fun createCollection(
organizationId: String,
collectionView: CollectionView,
): CreateCollectionResult
/**
* Attempt to delete a collection.
*/
suspend fun deleteCollection(
organizationId: String,
collectionId: String,
): DeleteCollectionResult
/**
* Attempt to update a collection.
*/
suspend fun updateCollection(
organizationId: String,
collectionId: String,
collectionView: CollectionView,
): UpdateCollectionResult
}
@@ -0,0 +1,131 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.util.flatMap
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,
collectionView: CollectionView,
): CreateCollectionResult {
val userId = activeUserId
?: return CreateCollectionResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptCollection(userId = userId, collectionView = collectionView)
.flatMap {
collectionService.createCollection(
organizationId = organizationId,
body = CollectionJsonRequest(name = it.name),
)
}
.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) },
)
}
override suspend fun updateCollection(
organizationId: String,
collectionId: String,
collectionView: CollectionView,
): UpdateCollectionResult {
val userId = activeUserId
?: return UpdateCollectionResult.Error(error = NoActiveUserException())
return vaultSdkSource
.encryptCollection(userId = userId, collectionView = collectionView)
.flatMap { collection ->
collectionService.updateCollection(
organizationId = organizationId,
collectionId = collectionId,
body = CollectionJsonRequest(name = collection.name),
)
}
.fold(
onSuccess = { response ->
when (response) {
is UpdateCollectionResponseJson.Success -> {
vaultDiskSource.saveCollection(
userId = userId,
collection = response.collection,
)
vaultSdkSource
.decryptCollection(
userId = userId,
collection = response.collection
.toEncryptedSdkCollection(),
)
.fold(
onSuccess = {
UpdateCollectionResult.Success(it)
},
onFailure = {
UpdateCollectionResult.Error(error = it)
},
)
}
is UpdateCollectionResponseJson.Invalid -> {
UpdateCollectionResult.Error(
errorMessage = response.message,
error = null,
)
}
}
},
onFailure = { UpdateCollectionResult.Error(error = it) },
)
}
}
@@ -7,6 +7,7 @@ import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.data.manager.appstate.AppStateManager
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.CollectionService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.SendsService
import com.bitwarden.network.service.SyncService
@@ -28,6 +29,8 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CipherManagerImpl
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManagerImpl
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManagerImpl
import com.x8bit.bitwarden.data.vault.manager.FolderManager
@@ -114,6 +117,20 @@ object VaultManagerModule {
pushManager = pushManager,
)
@Provides
@Singleton
fun provideCollectionManager(
collectionService: CollectionService,
vaultDiskSource: VaultDiskSource,
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
): CollectionManager = CollectionManagerImpl(
authDiskSource = authDiskSource,
collectionService = collectionService,
vaultDiskSource = vaultDiskSource,
vaultSdkSource = vaultSdkSource,
)
@Provides
@Singleton
fun provideFolderManager(
@@ -10,6 +10,7 @@ import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
@@ -32,6 +33,7 @@ import javax.crypto.Cipher
@Suppress("TooManyFunctions")
interface VaultRepository :
CipherManager,
CollectionManager,
FolderManager,
SendManager,
VaultLockManager,
@@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.PinProtectedUserKeyManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
@@ -74,6 +75,7 @@ class VaultRepositoryImpl(
private val vaultSdkSource: VaultSdkSource,
private val authDiskSource: AuthDiskSource,
private val cipherManager: CipherManager,
private val collectionManager: CollectionManager,
private val folderManager: FolderManager,
private val sendManager: SendManager,
private val vaultLockManager: VaultLockManager,
@@ -84,6 +86,7 @@ class VaultRepositoryImpl(
dispatcherManager: DispatcherManager,
) : VaultRepository,
CipherManager by cipherManager,
CollectionManager by collectionManager,
FolderManager by folderManager,
SendManager by sendManager,
VaultLockManager by vaultLockManager,
@@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.CipherManager
import com.x8bit.bitwarden.data.vault.manager.CredentialExchangeImportManager
import com.x8bit.bitwarden.data.vault.manager.CollectionManager
import com.x8bit.bitwarden.data.vault.manager.FolderManager
import com.x8bit.bitwarden.data.vault.manager.PinProtectedUserKeyManager
import com.x8bit.bitwarden.data.vault.manager.SendManager
@@ -34,6 +35,7 @@ object VaultRepositoryModule {
vaultSdkSource: VaultSdkSource,
authDiskSource: AuthDiskSource,
cipherManager: CipherManager,
collectionManager: CollectionManager,
folderManager: FolderManager,
sendManager: SendManager,
vaultLockManager: VaultLockManager,
@@ -47,6 +49,7 @@ object VaultRepositoryModule {
vaultSdkSource = vaultSdkSource,
authDiskSource = authDiskSource,
cipherManager = cipherManager,
collectionManager = collectionManager,
folderManager = folderManager,
sendManager = sendManager,
vaultLockManager = vaultLockManager,
@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.collections.CollectionView
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of creating a collection.
*/
sealed class CreateCollectionResult {
/**
* Collection created successfully.
*/
data class Success(val collectionView: CollectionView) : CreateCollectionResult()
/**
* Generic error while creating a collection. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : CreateCollectionResult()
}
@@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of deleting a collection.
*/
sealed class DeleteCollectionResult {
/**
* Collection deleted successfully.
*/
data object Success : DeleteCollectionResult()
/**
* Generic error while deleting a collection. The optional [errorMessage] may be displayed
* directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : DeleteCollectionResult()
}
@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.collections.CollectionView
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of updating a collection.
*/
sealed class UpdateCollectionResult {
/**
* Collection updated successfully.
*/
data class Success(val collectionView: CollectionView) : UpdateCollectionResult()
/**
* Generic error while updating a collection. The optional [errorMessage]
* may be displayed directly in the UI when present.
*/
data class Error(
val error: Throwable? = null,
val errorMessage: String? = error?.userFriendlyMessage,
) : UpdateCollectionResult()
}
@@ -106,6 +106,7 @@ fun SavedStateHandle.toSettingsArgs(): SettingsArgs {
@Suppress("LongParameterList")
fun NavGraphBuilder.settingsGraph(
navController: NavController,
onNavigateToCollections: () -> Unit,
onNavigateToDeleteAccount: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
@@ -163,6 +164,7 @@ fun NavGraphBuilder.settingsGraph(
)
vaultSettingsDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToCollections = onNavigateToCollections,
onNavigateToExportVault = onNavigateToExportVault,
onNavigateToFolders = onNavigateToFolders,
onNavigateToImportLogins = onNavigateToImportLogins,
@@ -0,0 +1,40 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.collections
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the collections screen.
*/
@Serializable
data object CollectionsRoute
/**
* Add collections destinations to the nav graph.
*/
fun NavGraphBuilder.collectionsDestination(
onNavigateBack: () -> Unit,
onNavigateToAddCollectionScreen: (organizationId: String) -> Unit,
onNavigateToEditCollectionScreen: (
collectionId: String,
organizationId: String,
) -> Unit,
) {
composableWithSlideTransitions<CollectionsRoute> {
CollectionsScreen(
onNavigateBack = onNavigateBack,
onNavigateToAddCollectionScreen = onNavigateToAddCollectionScreen,
onNavigateToEditCollectionScreen = onNavigateToEditCollectionScreen,
)
}
}
/**
* Navigate to the collections screen.
*/
fun NavController.navigateToCollections(navOptions: NavOptions? = null) {
this.navigate(route = CollectionsRoute, navOptions = navOptions)
}
@@ -0,0 +1,194 @@
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 = { collectionId, organizationId ->
viewModel.trySendAction(
CollectionsAction.CollectionClick(
collectionId = collectionId,
organizationId = organizationId,
),
)
},
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: (collectionId: String, organizationId: String) -> 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.id, it.organizationName) },
textTestTag = "CollectionName",
cardStyle = collectionsList.toListItemCardStyle(index = index),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.testTag(tag = "CollectionCell"),
)
}
item {
Spacer(modifier = Modifier.height(height = 88.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
@@ -0,0 +1,300 @@
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.x8bit.bitwarden.data.auth.repository.AuthRepository
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
/**
* 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) {
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.canCreateNewCollections
}
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.canCreateNewCollections
},
),
)
}
}
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.canCreateNewCollections
},
),
)
}
}
}
}
}
private fun List<CollectionView>.toDisplayItems(
organizations: List<com.x8bit.bitwarden.data.auth.repository.model.Organization>,
): List<CollectionDisplayItem> =
map { collection ->
CollectionDisplayItem(
id = collection.id.toString(),
name = collection.name,
organizationName = organizations
.find { it.id == collection.organizationId.toString() }
?.name
.orEmpty(),
)
}
/**
* 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,
) : CollectionsAction()
/**
* Indicates that the user clicked the close button.
*/
data object CloseButtonClick : CollectionsAction()
/**
* Actions for internal use by the ViewModel.
*/
sealed class Internal : CollectionsAction() {
/**
* Indicates that snackbar data has been received.
*/
data class SnackbarDataReceived(
val data: BitwardenSnackbarData,
) : Internal()
/**
* Indicates that the vault collections data has been received.
*/
data class VaultDataReceive(
val vaultDataState: DataState<List<CollectionView>>,
) : Internal()
}
}
@@ -0,0 +1,89 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionAddEditType
import kotlinx.serialization.Serializable
/**
* The type-safe route for the collection add & edit screen.
*/
@Serializable
data class CollectionAddEditRoute(
val actionType: CollectionActionType,
val collectionId: String?,
val organizationId: String,
)
/**
* Represents the action being done with a collection.
*/
@Serializable
enum class CollectionActionType {
ADD,
EDIT,
}
/**
* Class to retrieve collection add & edit arguments from the [SavedStateHandle].
*/
data class CollectionAddEditArgs(
val collectionAddEditType: CollectionAddEditType,
)
/**
* Constructs a [CollectionAddEditArgs] from the [SavedStateHandle] and internal route data.
*/
fun SavedStateHandle.toCollectionAddEditArgs(): CollectionAddEditArgs {
val route = this.toRoute<CollectionAddEditRoute>()
return CollectionAddEditArgs(
collectionAddEditType = when (route.actionType) {
CollectionActionType.ADD -> CollectionAddEditType.AddItem(
organizationId = route.organizationId,
)
CollectionActionType.EDIT -> CollectionAddEditType.EditItem(
collectionId = requireNotNull(route.collectionId),
organizationId = route.organizationId,
)
},
)
}
/**
* Add the collection add & edit screen to the nav graph.
*/
fun NavGraphBuilder.collectionAddEditDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions<CollectionAddEditRoute> {
CollectionAddEditScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Navigate to the collection add & edit screen.
*/
fun NavController.navigateToCollectionAddEdit(
collectionAddEditType: CollectionAddEditType,
navOptions: NavOptions? = null,
) {
this.navigate(
route = CollectionAddEditRoute(
actionType = collectionAddEditType.toCollectionActionType(),
collectionId = collectionAddEditType.collectionId,
organizationId = collectionAddEditType.organizationId,
),
navOptions = navOptions,
)
}
private fun CollectionAddEditType.toCollectionActionType(): CollectionActionType =
when (this) {
is CollectionAddEditType.AddItem -> CollectionActionType.ADD
is CollectionAddEditType.EditItem -> CollectionActionType.EDIT
}
@@ -0,0 +1,185 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem
import com.bitwarden.ui.platform.components.appbar.model.OverflowMenuItemData
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.collections.immutable.persistentListOf
/**
* Displays the screen for adding or editing a collection item.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
fun CollectionAddEditScreen(
onNavigateBack: () -> Unit,
viewModel: CollectionAddEditViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
var shouldShowConfirmationDialog by rememberSaveable { mutableStateOf(false) }
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is CollectionAddEditEvent.NavigateBack -> onNavigateBack.invoke()
}
}
CollectionAddEditItemDialogs(
dialogState = state.dialog,
onDismissRequest = {
viewModel.trySendAction(CollectionAddEditAction.DismissDialog)
},
)
if (shouldShowConfirmationDialog) {
BitwardenTwoButtonDialog(
title = null,
message = stringResource(
id = BitwardenString.do_you_really_want_to_delete_collection,
),
dismissButtonText = stringResource(id = BitwardenString.cancel),
confirmButtonText = stringResource(id = BitwardenString.delete),
onDismissClick = { shouldShowConfirmationDialog = false },
onConfirmClick = {
shouldShowConfirmationDialog = false
viewModel.trySendAction(CollectionAddEditAction.DeleteClick)
},
onDismissRequest = { shouldShowConfirmationDialog = false },
confirmTextColor = BitwardenTheme.colorScheme.status.error,
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = state.screenDisplayName.invoke(),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(
id = BitwardenDrawable.ic_close,
),
navigationIconContentDescription = stringResource(
id = BitwardenString.close,
),
onNavigationIconClick = {
viewModel.trySendAction(CollectionAddEditAction.CloseClick)
},
actions = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.save),
onClick = {
viewModel.trySendAction(CollectionAddEditAction.SaveClick)
},
modifier = Modifier.testTag("SaveButton"),
)
BitwardenOverflowActionItem(
isVisible = state.shouldShowOverflowMenu,
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = BitwardenString.delete),
onClick = { shouldShowConfirmationDialog = true },
),
),
)
},
)
},
) {
when (val viewState = state.viewState) {
is CollectionAddEditState.ViewState.Content -> {
Column(
modifier = Modifier.fillMaxSize(),
) {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenTextField(
label = stringResource(id = BitwardenString.name),
value = viewState.collectionName,
onValueChange = {
viewModel.trySendAction(
CollectionAddEditAction.NameTextChange(it),
)
},
textFieldTestTag = "CollectionNameField",
cardStyle = CardStyle.Full,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
is CollectionAddEditState.ViewState.Error -> {
BitwardenErrorContent(
message = viewState.message(),
modifier = Modifier.fillMaxSize(),
)
}
is CollectionAddEditState.ViewState.Loading -> {
BitwardenLoadingContent(
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@Composable
private fun CollectionAddEditItemDialogs(
dialogState: CollectionAddEditState.DialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is CollectionAddEditState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialogState.label())
}
is CollectionAddEditState.DialogState.Error -> BitwardenBasicDialog(
title = stringResource(id = BitwardenString.an_error_has_occurred),
message = dialogState.message(),
onDismissRequest = onDismissRequest,
throwable = dialogState.throwable,
)
null -> Unit
}
}
@@ -0,0 +1,567 @@
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.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 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)
}
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 result = vaultRepository.createCollection(
organizationId = collectionAddEditType.organizationId,
collectionView = CollectionView(
id = "",
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,
) {
when (val vaultDataState = action.vaultDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = CollectionAddEditState.ViewState.Error(
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState
.data
?.let { collection ->
CollectionAddEditState.ViewState.Content(
collectionName = collection.name,
)
}
?: CollectionAddEditState.ViewState.Error(
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
is DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = CollectionAddEditState.ViewState.Loading)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = CollectionAddEditState.ViewState.Error(
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState
.data
?.let { collection ->
CollectionAddEditState.ViewState.Content(
collectionName = collection.name,
)
}
?: CollectionAddEditState.ViewState.Error(
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
private fun handleCreateResultReceive(
action: CollectionAddEditAction.Internal.CreateCollectionResultReceive,
) {
mutableStateFlow.update { it.copy(dialog = null) }
when (val result = action.result) {
is CreateCollectionResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = CollectionAddEditState.DialogState.Error(
message = result
.errorMessage
?.asText()
?: BitwardenString.generic_error_message.asText(),
throwable = result.error,
),
)
}
}
is CreateCollectionResult.Success -> {
relayManager.sendSnackbarData(
data = BitwardenSnackbarData(
BitwardenString.collection_created.asText(),
),
relay = SnackbarRelay.COLLECTION_CREATED,
)
sendEvent(CollectionAddEditEvent.NavigateBack)
}
}
}
private fun handleUpdateResultReceive(
action: CollectionAddEditAction.Internal.UpdateCollectionResultReceive,
) {
mutableStateFlow.update { it.copy(dialog = null) }
when (val result = action.result) {
is UpdateCollectionResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = CollectionAddEditState.DialogState.Error(
message = result
.errorMessage
?.asText()
?: BitwardenString.generic_error_message.asText(),
throwable = result.error,
),
)
}
}
is UpdateCollectionResult.Success -> {
relayManager.sendSnackbarData(
data = BitwardenSnackbarData(
BitwardenString.collection_updated.asText(),
),
relay = SnackbarRelay.COLLECTION_UPDATED,
)
sendEvent(CollectionAddEditEvent.NavigateBack)
}
}
}
private fun handleDeleteResultReceive(
action: CollectionAddEditAction.Internal.DeleteCollectionResultReceive,
) {
when (val result = action.result) {
is DeleteCollectionResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = CollectionAddEditState.DialogState.Error(
message = result
.errorMessage
?.asText()
?: BitwardenString.generic_error_message.asText(),
throwable = result.error,
),
)
}
}
DeleteCollectionResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
relayManager.sendSnackbarData(
data = BitwardenSnackbarData(
BitwardenString.collection_deleted.asText(),
),
relay = SnackbarRelay.COLLECTION_DELETED,
)
sendEvent(event = CollectionAddEditEvent.NavigateBack)
}
}
}
private inline fun onContent(
crossinline block: (CollectionAddEditState.ViewState.Content) -> Unit,
) {
(state.viewState as? CollectionAddEditState.ViewState.Content)?.let(block)
}
}
/**
* Represents the state for adding or editing a collection.
*
* @property collectionAddEditType Indicates whether the VM is in add or edit mode.
* @property viewState indicates what view state the screen is in.
* @property dialog the state for the dialogs that can be displayed.
*/
@Parcelize
data class CollectionAddEditState(
val collectionAddEditType: CollectionAddEditType,
val viewState: ViewState,
val dialog: DialogState?,
) : Parcelable {
/**
* Helper to determine whether we show the overflow menu.
*/
val shouldShowOverflowMenu: Boolean
get() = collectionAddEditType is CollectionAddEditType.EditItem
/**
* Helper to determine the screen display name.
*/
val screenDisplayName: Text
get() = when (collectionAddEditType) {
is CollectionAddEditType.AddItem -> BitwardenString.new_collection.asText()
is CollectionAddEditType.EditItem -> BitwardenString.edit_collection.asText()
}
/**
* Represents the specific view states for the [CollectionAddEditScreen].
*/
sealed class ViewState : Parcelable {
/**
* Represents an error state for the [CollectionAddEditScreen].
*/
@Parcelize
data class Error(val message: Text) : ViewState()
/**
* Loading state for the [CollectionAddEditScreen].
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a loaded content state for the [CollectionAddEditScreen].
*/
@Parcelize
data class Content(val collectionName: String) : ViewState()
}
/**
* Displays a dialog.
*/
@Parcelize
sealed class DialogState : Parcelable {
/**
* Displays a loading dialog to the user.
*/
@Parcelize
data class Loading(val label: Text) : DialogState()
/**
* Displays an error dialog to the user.
*/
@Parcelize
data class Error(
val message: Text,
val throwable: Throwable? = null,
) : DialogState()
}
}
/**
* Represents a set of events that can be emitted during
* the process of adding or editing a collection.
*/
sealed class CollectionAddEditEvent {
/**
* Navigate back to previous screen.
*/
data object NavigateBack : CollectionAddEditEvent()
}
/**
* Represents a set of actions related to the process of adding or editing a collection.
*/
sealed class CollectionAddEditAction {
/**
* User clicked close.
*/
data object CloseClick : CollectionAddEditAction()
/**
* The user has clicked to delete the collection.
*/
data object DeleteClick : CollectionAddEditAction()
/**
* The user has clicked to dismiss the dialog.
*/
data object DismissDialog : CollectionAddEditAction()
/**
* Fired when the name text input is changed.
*
* @property name The name of the collection.
*/
data class NameTextChange(val name: String) : CollectionAddEditAction()
/**
* Represents the action when the save button is clicked.
*/
data object SaveClick : CollectionAddEditAction()
/**
* Actions for internal use by the ViewModel.
*/
sealed class Internal : CollectionAddEditAction() {
/**
* The result for deleting a collection has been received.
*/
data class DeleteCollectionResultReceive(
val result: DeleteCollectionResult,
) : Internal()
/**
* The result for updating a collection has been received.
*/
data class UpdateCollectionResultReceive(
val result: UpdateCollectionResult,
) : Internal()
/**
* The result for creating a collection has been received.
*/
data class CreateCollectionResultReceive(
val result: CreateCollectionResult,
) : Internal()
/**
* Indicates that the vault collection data has been received.
*/
data class VaultDataReceive(
val vaultDataState: DataState<CollectionView?>,
) : Internal()
}
}
@@ -0,0 +1,41 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.collections.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents the difference between creating a
* completely new collection and editing an existing one.
*/
sealed class CollectionAddEditType : Parcelable {
/**
* The ID of the collection (nullable).
*/
abstract val collectionId: String?
/**
* The ID of the organization this collection belongs to.
*/
abstract val organizationId: String
/**
* Indicates that we want to create a completely new collection.
*/
@Parcelize
data class AddItem(
override val organizationId: String,
) : CollectionAddEditType() {
override val collectionId: String?
get() = null
}
/**
* Indicates that we want to edit an existing collection.
*/
@Parcelize
data class EditItem(
override val collectionId: String,
override val organizationId: String,
) : CollectionAddEditType()
}
@@ -0,0 +1,18 @@
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.
*/
@Parcelize
data class CollectionDisplayItem(
val id: String,
val name: String,
val organizationName: String,
) : Parcelable
@@ -17,6 +17,7 @@ data object VaultSettingsRoute
*/
fun NavGraphBuilder.vaultSettingsDestination(
onNavigateBack: () -> Unit,
onNavigateToCollections: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToImportLogins: () -> Unit,
@@ -25,6 +26,7 @@ fun NavGraphBuilder.vaultSettingsDestination(
composableWithPushTransitions<VaultSettingsRoute> {
VaultSettingsScreen(
onNavigateBack = onNavigateBack,
onNavigateToCollections = onNavigateToCollections,
onNavigateToExportVault = onNavigateToExportVault,
onNavigateToFolders = onNavigateToFolders,
onNavigateToImportLogins = onNavigateToImportLogins,
@@ -49,6 +49,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
@Composable
fun VaultSettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToCollections: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToImportLogins: () -> Unit,
@@ -61,6 +62,7 @@ fun VaultSettingsScreen(
EventsEffect(viewModel = viewModel) { event ->
when (event) {
VaultSettingsEvent.NavigateBack -> onNavigateBack()
VaultSettingsEvent.NavigateToCollections -> onNavigateToCollections()
VaultSettingsEvent.NavigateToExportVault -> onNavigateToExportVault()
VaultSettingsEvent.NavigateToFolders -> onNavigateToFolders()
is VaultSettingsEvent.NavigateToImportVault -> onNavigateToImportLogins()
@@ -129,6 +131,19 @@ fun VaultSettingsScreen(
.fillMaxWidth(),
)
BitwardenTextRow(
text = stringResource(BitwardenString.collections),
onClick = {
viewModel.trySendAction(VaultSettingsAction.CollectionsButtonClick)
},
withDivider = false,
cardStyle = CardStyle.Middle(),
modifier = Modifier
.testTag("CollectionsLabel")
.standardHorizontalMargin()
.fillMaxWidth(),
)
BitwardenTextRow(
text = stringResource(BitwardenString.export_vault),
onClick = { viewModel.trySendAction(VaultSettingsAction.ExportVaultClick) },
@@ -77,6 +77,7 @@ class VaultSettingsViewModel @Inject constructor(
override fun handleAction(action: VaultSettingsAction): Unit = when (action) {
VaultSettingsAction.BackClick -> handleBackClicked()
VaultSettingsAction.CollectionsButtonClick -> handleCollectionsButtonClicked()
VaultSettingsAction.ExportVaultClick -> handleExportVaultClicked()
VaultSettingsAction.FoldersButtonClick -> handleFoldersButtonClicked()
VaultSettingsAction.ImportItemsClick -> handleImportItemsClicked()
@@ -134,6 +135,10 @@ class VaultSettingsViewModel @Inject constructor(
sendEvent(VaultSettingsEvent.NavigateBack)
}
private fun handleCollectionsButtonClicked() {
sendEvent(VaultSettingsEvent.NavigateToCollections)
}
private fun handleExportVaultClicked() {
sendEvent(VaultSettingsEvent.NavigateToExportVault)
}
@@ -181,6 +186,11 @@ sealed class VaultSettingsEvent {
*/
data object NavigateToImportItems : VaultSettingsEvent()
/**
* Navigate to the Collections screen.
*/
data object NavigateToCollections : VaultSettingsEvent()
/**
* Navigate to the Export Vault screen.
*/
@@ -206,6 +216,11 @@ sealed class VaultSettingsAction {
*/
data object BackClick : VaultSettingsAction()
/**
* Indicates that the user clicked the Collections button.
*/
data object CollectionsButtonClick : VaultSettingsAction()
/**
* Indicates that the user clicked the Export Vault button.
*/
@@ -36,6 +36,11 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.folderAd
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit.navigateToFolderAddEdit
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.foldersDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderAddEditType
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.collectionsDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit.collectionAddEditDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.addedit.navigateToCollectionAddEdit
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.model.CollectionAddEditType
import com.x8bit.bitwarden.ui.platform.feature.settings.collections.navigateToCollections
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.navigateToFolders
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VaultUnlockedNavbarRoute
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination
@@ -104,6 +109,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
},
)
vaultUnlockedNavBarDestination(
onNavigateToCollections = { navController.navigateToCollections() },
onNavigateToExportVault = { navController.navigateToExportVault() },
onNavigateToFolders = { navController.navigateToFolders() },
onNavigateToVaultAddItem = { navController.navigateToVaultAddEdit(it) },
@@ -247,6 +253,25 @@ fun NavGraphBuilder.vaultUnlockedGraph(
)
folderAddEditDestination(onNavigateBack = { navController.popBackStack() })
collectionsDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToAddCollectionScreen = { organizationId ->
navController.navigateToCollectionAddEdit(
CollectionAddEditType.AddItem(organizationId = organizationId),
)
},
onNavigateToEditCollectionScreen = { collectionId, organizationId ->
navController.navigateToCollectionAddEdit(
CollectionAddEditType.EditItem(
collectionId = collectionId,
organizationId = organizationId,
),
)
},
)
collectionAddEditDestination(
onNavigateBack = { navController.popBackStack() },
)
generatorModalDestination(onNavigateBack = { navController.popBackStack() })
searchDestination(
onNavigateBack = { navController.popBackStack() },
@@ -39,6 +39,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit,
onNavigateToViewSend: (ViewSendRoute) -> Unit,
onNavigateToCollections: () -> Unit,
onNavigateToDeleteAccount: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
@@ -62,6 +63,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToSearchSend = onNavigateToSearchSend,
onNavigateToSearchVault = onNavigateToSearchVault,
onNavigateToAddEditSend = onNavigateToAddEditSend,
onNavigateToCollections = onNavigateToCollections,
onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToExportVault = onNavigateToExportVault,
onNavigateToFolders = onNavigateToFolders,
@@ -56,6 +56,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit,
onNavigateToViewSend: (ViewSendRoute) -> Unit,
onNavigateToCollections: () -> Unit,
onNavigateToDeleteAccount: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
@@ -86,6 +87,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToSearchVault = onNavigateToSearchVault,
onNavigateToAddEditSend = onNavigateToAddEditSend,
onNavigateToViewSend = onNavigateToViewSend,
navigateToCollections = onNavigateToCollections,
navigateToDeleteAccount = onNavigateToDeleteAccount,
navigateToExportVault = onNavigateToExportVault,
navigateToFolders = onNavigateToFolders,
@@ -131,6 +133,7 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
onNavigateToAddEditSend: (route: AddEditSendRoute) -> Unit,
onNavigateToViewSend: (ViewSendRoute) -> Unit,
navigateToCollections: () -> Unit,
navigateToDeleteAccount: () -> Unit,
navigateToExportVault: () -> Unit,
navigateToFolders: () -> Unit,
@@ -215,6 +218,7 @@ private fun VaultUnlockedNavBarScaffold(
)
settingsGraph(
navController = navController,
onNavigateToCollections = navigateToCollections,
onNavigateToDeleteAccount = navigateToDeleteAccount,
onNavigateToExportVault = navigateToExportVault,
onNavigateToFolders = navigateToFolders,
@@ -24,6 +24,9 @@ enum class SnackbarRelay {
*/
CIPHER_UNARCHIVED_VIEW,
CIPHER_CREATED,
COLLECTION_CREATED,
COLLECTION_DELETED,
COLLECTION_UPDATED,
CIPHER_DELETED,
CIPHER_DELETED_SOFT,
CIPHER_MOVED_TO_ORGANIZATION,
@@ -17,6 +17,10 @@ fun createMockOrganization(
userIsClaimedByOrganization: Boolean = false,
limitItemDeletion: Boolean = false,
shouldUseEvents: Boolean = false,
maxCollections: Int? = null,
canCreateNewCollections: Boolean = false,
canEditAnyCollection: Boolean = false,
canDeleteAnyCollection: Boolean = false,
): Organization =
Organization(
id = id,
@@ -28,4 +32,8 @@ fun createMockOrganization(
userIsClaimedByOrganization = userIsClaimedByOrganization,
limitItemDeletion = limitItemDeletion,
shouldUseEvents = shouldUseEvents,
maxCollections = maxCollections,
canCreateNewCollections = canCreateNewCollections,
canEditAnyCollection = canEditAnyCollection,
canDeleteAnyCollection = canDeleteAnyCollection,
)
+180
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 |
@@ -16,6 +16,7 @@ import com.bitwarden.network.service.DevicesService
import com.bitwarden.network.service.DigitalAssetLinkService
import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.CollectionService
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
@@ -106,6 +107,11 @@ interface BitwardenServiceClient {
*/
val eventService: EventService
/**
* Provides access to the Collection service.
*/
val collectionService: CollectionService
/**
* Provides access to the Folder service.
*/
@@ -29,6 +29,8 @@ import com.bitwarden.network.service.DownloadService
import com.bitwarden.network.service.DownloadServiceImpl
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.EventServiceImpl
import com.bitwarden.network.service.CollectionService
import com.bitwarden.network.service.CollectionServiceImpl
import com.bitwarden.network.service.FolderService
import com.bitwarden.network.service.FolderServiceImpl
import com.bitwarden.network.service.HaveIBeenPwnedService
@@ -161,6 +163,13 @@ internal class BitwardenServiceClientImpl(
EventServiceImpl(eventApi = retrofits.authenticatedEventsRetrofit.create())
}
override val collectionService: CollectionService by lazy {
CollectionServiceImpl(
collectionsApi = retrofits.authenticatedApiRetrofit.create(),
json = clientJson,
)
}
override val folderService: FolderService by lazy {
FolderServiceImpl(
foldersApi = retrofits.authenticatedApiRetrofit.create(),
@@ -0,0 +1,54 @@
package com.bitwarden.network.api
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>
/**
* Updates a collection.
*/
@PUT("organizations/{orgId}/collections/{collectionId}")
suspend fun updateCollection(
@Path("orgId") organizationId: String,
@Path("collectionId") collectionId: String,
@Body body: CollectionJsonRequest,
): NetworkResult<SyncResponseJson.Collection>
/**
* Deletes a collection.
*/
@DELETE("organizations/{orgId}/collections/{collectionId}")
suspend fun deleteCollection(
@Path("orgId") organizationId: String,
@Path("collectionId") collectionId: String,
): NetworkResult<Unit>
}
@@ -0,0 +1,15 @@
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.
*/
@Serializable
data class CollectionJsonRequest(
@SerialName("name")
val name: String?,
)
@@ -418,6 +418,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 +429,15 @@ data class SyncResponseJson(
@SerialName("managePolicies")
val shouldManagePolicies: Boolean,
@SerialName("createNewCollections")
val canCreateNewCollections: Boolean = false,
@SerialName("editAnyCollection")
val canEditAnyCollection: Boolean = false,
@SerialName("deleteAnyCollection")
val canDeleteAnyCollection: Boolean = false,
)
}
@@ -0,0 +1,33 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models the responses from the collection update request.
*/
sealed class UpdateCollectionResponseJson {
/**
* The request completed successfully and returned the updated [collection].
*/
data class Success(
val collection: SyncResponseJson.Collection,
) : UpdateCollectionResponseJson()
/**
* Represents the json body of an invalid update request.
*
* @param message A general, user-displayable error message.
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
val message: String?,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : UpdateCollectionResponseJson()
}
@@ -0,0 +1,43 @@
package com.bitwarden.network.service
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>
}
@@ -0,0 +1,69 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.CollectionsApi
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()
}
@@ -149,10 +149,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,
)
/**
+7
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>