diff --git a/.claude/outputs/plans/COLLECTION-MANAGEMENT-PLAN.md b/.claude/outputs/plans/COLLECTION-MANAGEMENT-PLAN.md new file mode 100644 index 0000000000..c69505d2b0 --- /dev/null +++ b/.claude/outputs/plans/COLLECTION-MANAGEMENT-PLAN.md @@ -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` 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> │ │ 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. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt index 9103d3b116..4f82532550 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/Organization.kt @@ -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, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt index dddb46dffa..e6caf6b70f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/util/SyncResponseJsonExtensions.kt @@ -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, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt index e46db66e09..6476ff0707 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt @@ -82,6 +82,11 @@ interface VaultDiskSource { */ fun getCollectionsFlow(userId: String): Flow> + /** + * 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]. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt index 032ee65b53..b87c860135 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt @@ -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> = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt index 293d7f2505..c0fbbddf63 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt @@ -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( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index cf585c82f0..abd51c71df 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -239,6 +239,18 @@ interface VaultSdkSource { collectionList: List, ): Result> + /** + * 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 + /** * Encrypts a [SendView] for the user with the given [userId], returning a [Send] wrapped * in a [Result]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 0072b84d3a..2fcea16dfa 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -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 = + Result.failure(UnsupportedOperationException("Not yet implemented in SDK")) + override suspend fun decryptSend( userId: String, send: Send, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CollectionManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CollectionManager.kt new file mode 100644 index 0000000000..7e96e625f5 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CollectionManager.kt @@ -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 +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CollectionManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CollectionManagerImpl.kt new file mode 100644 index 0000000000..a29bb52b5a --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/CollectionManagerImpl.kt @@ -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) }, + ) + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index e2804dfee3..b5b83ca8de 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -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( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index cb0273f73c..0b965843f7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 940f36e4b2..bc3e5be559 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt index 245f111d62..f6875065a1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/CreateCollectionResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/CreateCollectionResult.kt new file mode 100644 index 0000000000..a4f1bb11c5 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/CreateCollectionResult.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/DeleteCollectionResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/DeleteCollectionResult.kt new file mode 100644 index 0000000000..c62e1d1929 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/DeleteCollectionResult.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/UpdateCollectionResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/UpdateCollectionResult.kt new file mode 100644 index 0000000000..a18f95a03f --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/vault/repository/model/UpdateCollectionResult.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt index 47b308ef5e..8cd130de6d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsNavigation.kt new file mode 100644 index 0000000000..d94868a125 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsNavigation.kt @@ -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 { + CollectionsScreen( + onNavigateBack = onNavigateBack, + onNavigateToAddCollectionScreen = onNavigateToAddCollectionScreen, + onNavigateToEditCollectionScreen = onNavigateToEditCollectionScreen, + ) + } +} + +/** + * Navigate to the collections screen. + */ +fun NavController.navigateToCollections(navOptions: NavOptions? = null) { + this.navigate(route = CollectionsRoute, navOptions = navOptions) +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsScreen.kt new file mode 100644 index 0000000000..64018133db --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsScreen.kt @@ -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, + 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()) + } + } + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsViewModel.kt new file mode 100644 index 0000000000..70805fb64e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/CollectionsViewModel.kt @@ -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, +) : BaseViewModel( + 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.toDisplayItems( + organizations: List, +): List = + 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, + 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>, + ) : Internal() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditNavigation.kt new file mode 100644 index 0000000000..878d039bfc --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditNavigation.kt @@ -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() + 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 { + 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 + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditScreen.kt new file mode 100644 index 0000000000..dfac46f6ad --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditScreen.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditViewModel.kt new file mode 100644 index 0000000000..a99f88308a --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/addedit/CollectionAddEditViewModel.kt @@ -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, +) : BaseViewModel( + 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> -> + when (dataState) { + is DataState.Error -> DataState.Error( + data = dataState.data?.find { + it.id.toString() == collectionId + }, + error = dataState.error, + ) + + is DataState.Loaded -> DataState.Loaded( + data = dataState.data.find { + it.id.toString() == collectionId + }, + ) + + is DataState.Loading -> DataState.Loading + is DataState.NoNetwork -> DataState.NoNetwork( + data = dataState.data?.find { + it.id.toString() == collectionId + }, + ) + + is DataState.Pending -> DataState.Pending( + 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, + ) : Internal() + } +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/model/CollectionAddEditType.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/model/CollectionAddEditType.kt new file mode 100644 index 0000000000..8b1b52c0a3 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/model/CollectionAddEditType.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/model/CollectionDisplayItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/model/CollectionDisplayItem.kt new file mode 100644 index 0000000000..c065f809f9 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/collections/model/CollectionDisplayItem.kt @@ -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 diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt index ab941e6c9d..84463e71d4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsNavigation.kt @@ -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 { VaultSettingsScreen( onNavigateBack = onNavigateBack, + onNavigateToCollections = onNavigateToCollections, onNavigateToExportVault = onNavigateToExportVault, onNavigateToFolders = onNavigateToFolders, onNavigateToImportLogins = onNavigateToImportLogins, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt index 666c623465..f66fb9aec1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsScreen.kt @@ -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) }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt index 3f72c8b5e9..b8f25e813f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/vault/VaultSettingsViewModel.kt @@ -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. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index bd07d88816..21c4cb3bc1 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -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() }, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index eb24c800f1..8d94c411dd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index e1a843c05d..b74914a930 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt index 11659c1a4e..a9f93f1b19 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/model/SnackbarRelay.kt @@ -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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/model/OrganizationUtil.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/model/OrganizationUtil.kt index 6c72c80d16..3f421fc1f9 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/model/OrganizationUtil.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/auth/repository/model/OrganizationUtil.kt @@ -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, ) diff --git a/docs/COLLECTIONS_REQUIREMENTS.md b/docs/COLLECTIONS_REQUIREMENTS.md new file mode 100644 index 0000000000..fd3d7dc8f8 --- /dev/null +++ b/docs/COLLECTIONS_REQUIREMENTS.md @@ -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` 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` 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>` 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 | diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt index b5bfab1dd6..74ff89410b 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt @@ -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. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index 229cfcd6e8..7e557057bc 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -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(), diff --git a/network/src/main/kotlin/com/bitwarden/network/api/CollectionsApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/CollectionsApi.kt new file mode 100644 index 0000000000..c7068af1d2 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/api/CollectionsApi.kt @@ -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 + + /** + * Gets a collection. + */ + @GET("organizations/{orgId}/collections/{collectionId}") + suspend fun getCollection( + @Path("orgId") organizationId: String, + @Path("collectionId") collectionId: String, + ): NetworkResult + + /** + * Updates a collection. + */ + @PUT("organizations/{orgId}/collections/{collectionId}") + suspend fun updateCollection( + @Path("orgId") organizationId: String, + @Path("collectionId") collectionId: String, + @Body body: CollectionJsonRequest, + ): NetworkResult + + /** + * Deletes a collection. + */ + @DELETE("organizations/{orgId}/collections/{collectionId}") + suspend fun deleteCollection( + @Path("orgId") organizationId: String, + @Path("collectionId") collectionId: String, + ): NetworkResult +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/CollectionJsonRequest.kt b/network/src/main/kotlin/com/bitwarden/network/model/CollectionJsonRequest.kt new file mode 100644 index 0000000000..115f96f1f3 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/CollectionJsonRequest.kt @@ -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?, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt index 1ac96dcce2..c3bf4abeca 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/SyncResponseJson.kt @@ -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, ) } diff --git a/network/src/main/kotlin/com/bitwarden/network/model/UpdateCollectionResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/UpdateCollectionResponseJson.kt new file mode 100644 index 0000000000..0b3a15b86a --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/UpdateCollectionResponseJson.kt @@ -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>?, + ) : UpdateCollectionResponseJson() +} diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CollectionService.kt b/network/src/main/kotlin/com/bitwarden/network/service/CollectionService.kt new file mode 100644 index 0000000000..6aafed9dbb --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/CollectionService.kt @@ -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 + + /** + * Attempt to update a collection in the given organization. + */ + suspend fun updateCollection( + organizationId: String, + collectionId: String, + body: CollectionJsonRequest, + ): Result + + /** + * Attempt to hard delete a collection from the given organization. + */ + suspend fun deleteCollection( + organizationId: String, + collectionId: String, + ): Result + + /** + * Attempt to retrieve a collection from the given organization. + */ + suspend fun getCollection( + organizationId: String, + collectionId: String, + ): Result +} diff --git a/network/src/main/kotlin/com/bitwarden/network/service/CollectionServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/CollectionServiceImpl.kt new file mode 100644 index 0000000000..0e214ed0e5 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/CollectionServiceImpl.kt @@ -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 = + collectionsApi + .createCollection(organizationId = organizationId, body = body) + .toResult() + + override suspend fun updateCollection( + organizationId: String, + collectionId: String, + body: CollectionJsonRequest, + ): Result = + collectionsApi + .updateCollection( + organizationId = organizationId, + collectionId = collectionId, + body = body, + ) + .toResult() + .map { UpdateCollectionResponseJson.Success(collection = it) } + .recoverCatching { throwable -> + throwable + .toBitwardenError() + .parseErrorBodyOrNull( + code = NetworkErrorCode.BAD_REQUEST, + json = json, + ) + ?: throw throwable + } + + override suspend fun deleteCollection( + organizationId: String, + collectionId: String, + ): Result = + collectionsApi + .deleteCollection( + organizationId = organizationId, + collectionId = collectionId, + ) + .toResult() + + override suspend fun getCollection( + organizationId: String, + collectionId: String, + ): Result = + collectionsApi + .getCollection( + organizationId = organizationId, + collectionId = collectionId, + ) + .toResult() +} diff --git a/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseProfileUtil.kt b/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseProfileUtil.kt index c4c44eadc2..59ea6586f1 100644 --- a/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseProfileUtil.kt +++ b/network/src/testFixtures/kotlin/com/bitwarden/network/model/SyncResponseProfileUtil.kt @@ -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, ) /** diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index e0d138e743..27be09f21f 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -14,7 +14,9 @@ Preview Deleting… Do you really want to delete? This cannot be undone. + Do you really want to delete this collection? This cannot be undone. Edit + Edit collection Edit folder Email Email address @@ -119,6 +121,7 @@ Never Item added There are no cards in your vault. + New collection New card There are no logins in your vault. There are no identities in your vault. @@ -278,6 +281,10 @@ Scanning will happen automatically. Icons server URL Vault is locked Go to my vault + New collection created. + Collection deleted. + Collection name cannot contain the \u201c/\u201d character. + Collection saved Collections There are no items in this collection. There are no items in this folder.