Compare commits

...

90 Commits

Author SHA1 Message Date
Patrick Honkonen
e31fa46a73
[PM-30279] Extract credential provider handling to dedicated activity (#6472)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:05:28 +00:00
David Perez
aff8b0347b
Update test tools (#6468) 2026-02-03 21:54:42 +00:00
renovate[bot]
f4d34e4649
[deps]: Update androidx.credentials:credentials to v1.6.0-rc01 (#6455)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 21:35:42 +00:00
David Perez
2e18b079f8
Update Androidx dependencies (#6467) 2026-02-03 18:29:31 +00:00
aj-rosado
b0eea88af2
[PM-31613] Add send email verification feature flag (#6470) 2026-02-03 17:09:15 +00:00
Patrick Honkonen
4cac4d6a6e
Add comprehensive tests for Unlock feature (#6426)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 14:33:58 +00:00
David Perez
a2ec99fb05
Remove the configuration cache to avoid play store build issues (#6466) 2026-02-02 19:10:20 +00:00
Patrick Honkonen
d49629de9e
Add Android testing skill for Claude (#6370)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 18:42:01 +00:00
renovate[bot]
c85cbb70a1
[deps]: Lock file maintenance (#6460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-02 18:33:13 +00:00
github-actions[bot]
e482820201
Update Google privileged browsers list (#6452)
Co-authored-by: GitHub Actions Bot <actions@github.com>
Co-authored-by: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com>
2026-02-02 17:59:57 +00:00
Shamim Shahrier Emon
74d45c3906
[PM-31393] Sends: UI/UX inconsistency of the password field (#6435) 2026-02-02 17:58:19 +00:00
Lucas
12eb42097c
[PM-30259] Add iodéOS browser to community FIDO2 privileged list (#6298) 2026-02-02 17:34:29 +00:00
David Perez
0811d14606
PM-31603: Add toast when resetpassword succeeds (#6465) 2026-02-02 17:26:01 +00:00
Ruyut
365067e5be
[PM-31583] Fix typos in authentication-related KDoc comments (#6461) 2026-02-02 15:29:31 +00:00
bw-ghapp[bot]
9652c7e049
Crowdin Pull (#6453)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-02-02 14:14:55 +00:00
bw-ghapp[bot]
6cc519bc3f
Update SDK to 2.0.0-4835-5285d3fc (#6446)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-02-02 14:07:02 +00:00
aj-rosado
9f82b42e36
[BWA-182] Add mTLS support for Glide image loading (#6125)
Co-authored-by: David Perez <david@livefront.com>
2026-01-30 19:57:59 +00:00
Patrick Honkonen
5531b478d3
Add comprehensive tests for FileManagerImpl (#6425)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 19:18:53 +00:00
Ruyut
fe5b61bf25
[PM-31445] Fix minor KDoc typos and wording issues. (#6441) 2026-01-30 19:15:03 +00:00
Patrick Honkonen
92ba38c831
[PM-31446] fix:Append assetlinks.json path to DAL URLs (#6447) 2026-01-30 18:22:00 +00:00
Patrick Honkonen
675b346666
Add comprehensive tests for ExportViewModel (#6442)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:16:24 +00:00
Patrick Honkonen
0f087b7d15
Add comprehensive tests for AuthenticatorRepositoryImpl (#6424)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 15:41:21 +00:00
Álison Fernandes
99a6dd7647
[PM-31436] Consolidate Feature categories in release notes and add labels (#6439) 2026-01-30 14:01:08 +00:00
bw-ghapp[bot]
ea4df7dde9
Update SDK to 2.0.0-4818-c1e4bb66 (#6444)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-30 12:08:39 +00:00
Álison Fernandes
f541919d39
[PM-31292] ci: update renovate config to remove gradle group and ignore sdk updates (#6437) 2026-01-29 21:06:49 +00:00
Amy Galles
3d1f46983a
use option to determine if release will be marked latest (#6417) 2026-01-29 18:41:36 +00:00
David Perez
b0084d2f1f
Set cache problem to warning (#6436) 2026-01-29 16:35:10 +00:00
David Perez
0d0a5cb292
Item migration flow has been moved into a graph (#6427) 2026-01-29 15:16:02 +00:00
bw-ghapp[bot]
ebfe293c81
Update SDK to 2.0.0-4800-bed92cae (#6431)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-29 13:49:01 +00:00
Patrick Honkonen
254b2cd25b
Add comprehensive tests for Import Parsers and UuidManager (#6423)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 21:55:06 +00:00
Patrick Honkonen
3d974d710c
[PM-31370] Refactor stringToUri and consolidate FileManager (#6432)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 21:37:24 +00:00
bw-ghapp[bot]
7717a09c06
Update SDK to 2.0.0-4772-490c1be4 (#6395)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-28 18:55:02 +00:00
David Perez
674cff1c3c
PM-31363: Fix crash caused by a duplicate ID (#6428) 2026-01-28 18:45:57 +00:00
Álison Fernandes
ca9ec45548
[PM-31343] Fix dependencies listed under Maintenance by adding a new fallback section to release.yml (#6420) 2026-01-28 14:59:34 +00:00
David Perez
009136ce1e
Minor cleanup of the MigrateToMyItemsScreen (#6421) 2026-01-28 14:59:22 +00:00
David Perez
19a3697605
Remove intialization of NetworkConnectionManager from application class (#6419) 2026-01-28 14:57:10 +00:00
David Perez
954571ff4a
Optimize build times (#6418) 2026-01-27 19:01:20 +00:00
David Perez
66316e4bd2
Cleanup organizations (#6391) 2026-01-27 17:28:09 +00:00
David Perez
9463cf646b
Update Kotlin and associated dependencies (#6408) 2026-01-27 17:14:39 +00:00
David Perez
e81710c24f
GradlewWrapper updates (#6415) 2026-01-27 17:14:24 +00:00
David Perez
71466405fa
Update testing tools (#6407) 2026-01-27 15:21:52 +00:00
David Perez
618bdc7424
Update protobufs to v4.33.4 (#6414) 2026-01-27 15:21:30 +00:00
David Perez
0f05e30997
Update the Compose BOM to v2026.01.00 (#6401) 2026-01-27 15:21:13 +00:00
David Perez
006a13d5ac
Update Sonarqube to v7.2.2.6593 (#6406) 2026-01-26 21:48:01 +00:00
David Perez
1d35004999
Update the Gradle Wrapper to the latest version (#6405) 2026-01-26 17:42:53 +00:00
David Perez
85249987aa
Update app version name to 2026.2.0 (#6409) 2026-01-26 17:42:27 +00:00
bw-ghapp[bot]
f05cf773fb
Crowdin Pull (#6412)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-26 14:54:34 +00:00
Patrick Honkonen
2e311b6c4a
[PM-30899] Store account keys upon SSO user creation (#6384) 2026-01-23 19:51:25 +00:00
David Perez
ee5ed77bc1
Update to Junit v6.0.2 (#6402) 2026-01-23 18:43:33 +00:00
aj-rosado
04a3cd227e
[PM-30644] Removing special circumstance validation from MigrateToMyItems route (#6358) 2026-01-23 17:06:31 +00:00
aj-rosado
ec28dde6d2
[PM-31081] Added snackbar when items are successfully migrated (#6394) 2026-01-23 16:51:41 +00:00
David Perez
319872ccf9
PM-29693: Add introducing archive action card to vault screen (#6390) 2026-01-23 16:50:43 +00:00
aj-rosado
9f1fad8be0
[PM-28990] Skipping vault migration on Network or Timeout error (#6393) 2026-01-23 16:06:17 +00:00
aj-rosado
0395d489c2
[PM-31069] Add OrganizationId support for Vault Migration operations (#6397) 2026-01-23 16:05:55 +00:00
David Perez
2acf429f67
PM-29696: Add action card for lapsed premium subscription (#6389) 2026-01-23 15:24:00 +00:00
David Perez
721fbbb82c
PM-31162: Update copy on the snackbar for archive feature (#6399) 2026-01-23 15:07:27 +00:00
David Perez
6d198bd8c9
Update to Firebase v34.8.0 (#6396) 2026-01-23 15:07:09 +00:00
Álison Fernandes
8658f1d42c
[PM-14880] ci: Address automated PR labeling workflow feedback (#6400) 2026-01-22 21:25:09 +00:00
Shamim Shahrier Emon
acc3e24d65
[PM-30664] Unlock with PIN doesn’t appear as enabled after enabling ‘Require master password on app restart’ (#6344) 2026-01-21 18:42:02 +00:00
bw-ghapp[bot]
40c8346bf7
Update SDK to 2.0.0-4676-0544ddec (#6388)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-21 17:24:18 +00:00
aj-rosado
a7badf8b0b
[PM-28470] Implement revoke from organization (#6383)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 16:53:26 +00:00
David Perez
c52910e74a
PM-31043: Add unarchive button to overflow menus (#6387) 2026-01-21 16:50:30 +00:00
David Perez
afc1ff4d7a
PM-31042: Add overflow archive button (#6385) 2026-01-21 14:50:00 +00:00
bw-ghapp[bot]
8cb4fab1de
Update SDK to 2.0.0-4672-b3e4ea24 (#6371)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-21 11:24:07 +00:00
David Perez
f79113aa7f
Fix minor typos (#6386) 2026-01-20 21:49:38 +00:00
renovate[bot]
7d814df04e
[deps]: Lock file maintenance (#6382)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 18:45:36 +00:00
David Perez
49b208f013
PM-29697: Finish View and Edit Cipher UI for archive (#6377) 2026-01-20 18:43:14 +00:00
bw-ghapp[bot]
8d33e6660a
Crowdin Pull (#6380)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-20 15:48:58 +00:00
David Perez
27a0f5172c
Move Vault Listing Dialog clicks to VaultItemListingHandlers (#6375) 2026-01-16 19:50:26 +00:00
David Perez
3e470ebc25
PM-30868: Archive Banner on Edit Item Screen (#6367) 2026-01-16 19:48:15 +00:00
aj-rosado
eb18ca04a0
[PM-28471] Migrate individual vault to organization (#6352) 2026-01-16 19:11:43 +00:00
David Perez
759e0563a9
PM-30897: Add archive and unarchive button on Edit Cipher Screen (#6372) 2026-01-16 17:17:19 +00:00
David Perez
757f444493
PM-29694: Update archive empty state (#6369) 2026-01-15 18:29:16 +00:00
David Perez
98ba1690bf
PM-30807: Add archived header to ViewItem Screen (#6362) 2026-01-15 15:45:01 +00:00
David Perez
44274a888e
PM-30795: Update cipher filtering logic for archive (#6359) 2026-01-15 15:14:26 +00:00
bw-ghapp[bot]
77cc0d5fba
Update SDK to 2.0.0-4524-513f18bf (#6361)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-15 15:13:40 +00:00
Álison Fernandes
026393384b
[PM-30823] ci: Fix BWA Play Store publishing for rc cherry picks and update upload step names (#6360) 2026-01-15 14:42:45 +00:00
Patrick Honkonen
7daeaca63e
refactor(claude): Refine reviewing-changes skill description for clarity and usage (#6366) 2026-01-15 13:59:58 +00:00
Gavin Gui
353e7e9a4e
[PM-30394] PM-29960: Skip biometric prompt on Xiaomi HyperOS (#6316) 2026-01-14 16:08:57 +00:00
bw-ghapp[bot]
2d824f96f5
Update SDK to 2.0.0-4505-df9bd639 (#6355)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-14 16:00:14 +00:00
David Perez
a9b1623f8b
PM-30774: Add archiving and unarchiving network requests (#6356) 2026-01-14 14:49:01 +00:00
David Perez
6d72d3a1c9
PM-30767: Add archive row to Vault Screen (#6354) 2026-01-13 21:41:45 +00:00
David Perez
f6edc19595
Remove the unused showDivider flag from BitwardenGroupItem (#6353) 2026-01-13 16:42:51 +00:00
David Perez
45125a94c2
Update archive string with noun suffix (#6351) 2026-01-13 16:38:05 +00:00
David Perez
66900f71df
End subtext and end icon support to BitwardenGroupItem (#6349) 2026-01-13 15:27:32 +00:00
bw-ghapp[bot]
d12c546c9a
Update SDK to 2.0.0-4498-7681828f (#6350)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-01-13 13:08:36 +00:00
David Perez
be365eec1c
PM-30708: Add archive item navigation (#6348) 2026-01-12 21:30:04 +00:00
Álison Fernandes
d86959b375
[PM-14880] ci: Update feature labels (#6346) 2026-01-12 18:45:23 +00:00
bw-ghapp[bot]
282cce8ce0
Update SDK to 2.0.0-4479-ad9fb51d (#6345)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2026-01-12 17:00:34 +00:00
bw-ghapp[bot]
e8eaf4e68c
Crowdin Pull (#6342)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-01-12 15:06:13 +00:00
337 changed files with 20132 additions and 2354 deletions

View File

@ -1,7 +1,7 @@
---
name: reviewing-changes
version: 3.0.0
description: Android-specific code review workflow additions for Bitwarden Android. Provides change type refinements, checklist loading, and reference material organization. Complements bitwarden-code-reviewer agent's base review standards.
description: Guides Android code reviews with type-specific checklists and MVVM/Compose pattern validation. Use when reviewing Android PRs, pull requests, diffs, or local changes involving Kotlin, ViewModel, Composable, Repository, or Gradle files. Triggered by "review PR", "review changes", "check this code", "Android review", or code review requests mentioning bitwarden/android. Loads specialized checklists for feature additions, bug fixes, UI refinements, refactoring, dependency updates, and infrastructure changes.
---
# Reviewing Changes - Android Additions

View File

@ -0,0 +1,44 @@
# Testing Android Code Skill
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
## Purpose
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
## When This Skill Activates
The skill automatically loads when you ask questions like:
- "How do I test this ViewModel?"
- "Why is my Bitwarden test failing?"
- "Write tests for this repository"
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
## What's Included
| File | Purpose |
|------|---------|
| `SKILL.md` | Core testing patterns and base class locations |
| `references/test-base-classes.md` | Detailed base class documentation |
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
| `examples/repository-test-example.md` | Complete repository test with mocks |
## Patterns Covered
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
3. **BaseServiceTest** - MockWebServer setup for network testing
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
## Quick Start
For comprehensive architecture and testing philosophy, see:
- `docs/ARCHITECTURE.md`

View File

@ -0,0 +1,319 @@
---
name: testing-android-code
description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden.
version: 1.0.0
---
# Testing Android Code - Bitwarden Testing Patterns
**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`.
## Test Framework Configuration
**Required Dependencies:**
- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine)
- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test**
**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales.
---
## A. ViewModel Testing Patterns
### Base Class: BaseViewModelTest
**Always extend `BaseViewModelTest` for ViewModel tests.**
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
**Benefits:**
- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing
**Pattern:**
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
private val mockRepository: ExampleRepository = mockk()
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
@Test
fun `ButtonClick should fetch data and update state`() = runTest {
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.ButtonClick)
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
}
coVerify { mockRepository.fetchData(any()) }
}
}
```
**For complete examples:** See `references/test-base-classes.md`
### StateFlow vs EventFlow (Critical Distinction)
| Flow Type | Replay | First Action | Pattern |
|-----------|--------|--------------|---------|
| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new |
| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event |
**For detailed patterns:** See `references/flow-testing-patterns.md`
---
## B. Compose UI Testing Patterns
### Base Class: BitwardenComposeTest
**Always extend `BitwardenComposeTest` for Compose screen tests.**
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
**Benefits:**
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
- Provides fixed Clock for deterministic time-based tests
**Pattern:**
```kotlin
class ExampleScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
ExampleScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel,
)
}
}
@Test
fun `on back click should send BackClick action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) }
}
}
```
**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed.
**For complete base class details:** See `references/test-base-classes.md`
---
## C. Repository and Service Testing
### Service Testing with MockWebServer
**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`)
```kotlin
class ExampleServiceTest : BaseServiceTest() {
private val api: ExampleApi = retrofit.create()
private val service = ExampleServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
val result = service.getConfig()
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
}
```
### Repository Testing Pattern
```kotlin
class ExampleRepositoryTest {
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val dispatcherManager = FakeDispatcherManager()
private val mockDiskSource: ExampleDiskSource = mockk()
private val mockService: ExampleService = mockk()
private val repository = ExampleRepositoryImpl(
clock = fixedClock,
exampleDiskSource = mockDiskSource,
exampleService = mockService,
dispatcherManager = dispatcherManager,
)
@Test
fun `fetchData should return success when service succeeds`() = runTest {
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
val result = repository.fetchData(userId)
assertTrue(result.isSuccess)
}
}
```
**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers.
---
## D. Test Data Builders
### Builder Pattern with Number Parameter
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/`
```kotlin
fun createMockCipher(
number: Int,
id: String = "mockId-$number",
name: String? = "mockName-$number",
// ... more parameters with defaults
): SyncResponseJson.Cipher
// Usage:
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
val custom = createMockCipher(number = 3, name = "Custom")
```
**Available Builders (35+):**
- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()`
- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()`
- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()`
- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()`
- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()`
See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list.
---
## E. Result Type Testing
**Locations:**
- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt`
- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt`
```kotlin
// Create results
"data".asSuccess() // Result.success("data")
throwable.asFailure() // Result.failure<T>(throwable)
// Assertions
assertTrue(result.isSuccess)
assertEquals(expectedValue, result.getOrNull())
```
---
## F. Test Utilities and Helpers
### Fake Implementations
| Fake | Location | Purpose |
|------|----------|---------|
| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution |
| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage |
| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences |
### Exception Testing (CRITICAL)
```kotlin
// CORRECT - Call directly, NOT inside runTest
@Test
fun `test exception`() {
assertCoroutineThrows<IllegalStateException> {
repository.throwingFunction()
}
}
```
**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern.
---
## G. Critical Gotchas
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
**Core Patterns:**
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first
- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl`
- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests
**Assertion Patterns:**
- **Complete state assertions** - Assert entire state objects, not individual fields
- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()`
- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions
**Test Design:**
- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths
- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic
- **Null stream testing** - Test null returns from ContentResolver operations
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
- **Test factory methods** - Accept domain state types, not SavedStateHandle
---
## H. Test File Organization
### Directory Structure
```
module/src/test/kotlin/com/bitwarden/.../
├── ui/*ScreenTest.kt, *ViewModelTest.kt
├── data/repository/*RepositoryTest.kt
└── network/service/*ServiceTest.kt
module/src/testFixtures/kotlin/com/bitwarden/.../
├── util/TestHelpers.kt
├── base/Base*Test.kt
└── model/*Util.kt
```
### Test Naming
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`
- Functions: `` `given state when action should result` ``
---
## Summary
Key Bitwarden-specific testing patterns:
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
3. **BaseServiceTest** - MockWebServer setup for network testing
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
5. **Test Data Builders** - Consistent `number: Int` parameter pattern
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`
**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations.
---
## Reference Documentation
For detailed information, see:
- `references/test-base-classes.md` - Detailed base class documentation and usage patterns
- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow
- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips
**Complete Examples:**
- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow
- `examples/compose-screen-test-example.md` - Full Compose screen test
- `examples/repository-test-example.md` - Full repository test with mocks and fakes

View File

@ -0,0 +1,337 @@
/**
* Complete Compose Screen Test Example
*
* Key patterns demonstrated:
* - Extending BitwardenComposeTest
* - Mocking ViewModel with flows
* - Testing UI interactions
* - Testing navigation callbacks
* - Using bufferedMutableSharedFlow for events
* - Testing dialogs with isDialog() and hasAnyAncestor()
*/
package com.bitwarden.example.feature
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.util.assertNoDialogExists
import com.bitwarden.ui.util.isProgressBar
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import junit.framework.TestCase.assertTrue
import org.junit.Before
import org.junit.Test
class ExampleScreenTest : BitwardenComposeTest() {
// Track navigation callbacks
private var haveCalledNavigateBack = false
private var haveCalledNavigateToNext = false
// Use bufferedMutableSharedFlow for events (default replay = 0)
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
// Mock ViewModel with relaxed = true
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
haveCalledNavigateBack = false
haveCalledNavigateToNext = false
setContent {
ExampleScreen(
onNavigateBack = { haveCalledNavigateBack = true },
onNavigateToNext = { haveCalledNavigateToNext = true },
viewModel = viewModel,
)
}
}
/**
* Test: Back button sends action to ViewModel
*/
@Test
fun `on back click should send BackClick action`() {
composeTestRule
.onNodeWithContentDescription("Back")
.performClick()
verify { viewModel.trySendAction(ExampleAction.BackClick) }
}
/**
* Test: Submit button sends action to ViewModel
*/
@Test
fun `on submit click should send SubmitClick action`() {
composeTestRule
.onNodeWithText("Submit")
.performClick()
verify { viewModel.trySendAction(ExampleAction.SubmitClick) }
}
/**
* Test: Loading state shows progress indicator
*/
@Test
fun `loading state should display progress indicator`() {
mutableStateFlow.update { it.copy(isLoading = true) }
composeTestRule
.onNode(isProgressBar)
.assertIsDisplayed()
}
/**
* Test: Data state shows content
*/
@Test
fun `data state should display content`() {
mutableStateFlow.update { it.copy(data = "Test Data") }
composeTestRule
.onNodeWithText("Test Data")
.assertIsDisplayed()
}
/**
* Test: Error state shows error message
*/
@Test
fun `error state should display error message`() {
mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
composeTestRule
.onNodeWithText("Something went wrong")
.assertIsDisplayed()
}
/**
* Test: NavigateBack event triggers navigation callback
*/
@Test
fun `NavigateBack event should call onNavigateBack`() {
mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
assertTrue(haveCalledNavigateBack)
}
/**
* Test: NavigateToNext event triggers navigation callback
*/
@Test
fun `NavigateToNext event should call onNavigateToNext`() {
mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
assertTrue(haveCalledNavigateToNext)
}
/**
* Test: Item in list can be clicked
*/
@Test
fun `on item click should send ItemClick action`() {
val itemId = "item-123"
mutableStateFlow.update {
it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item")))
}
composeTestRule
.onNodeWithText("Test Item")
.performClick()
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) }
}
// ==================== DIALOG TESTS ====================
/**
* Test: No dialog exists when dialogState is null
*/
@Test
fun `no dialog should exist when dialogState is null`() {
mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists()
}
/**
* Test: Loading dialog displays when state updates
* PATTERN: Use isDialog() to check dialog exists
*/
@Test
fun `loading dialog should display when dialogState is Loading`() {
mutableStateFlow.update {
it.copy(dialogState = ExampleState.DialogState.Loading("Please wait..."))
}
composeTestRule
.onNode(isDialog())
.assertIsDisplayed()
// Verify loading text within dialog using hasAnyAncestor(isDialog())
composeTestRule
.onAllNodesWithText("Please wait...")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* Test: Error dialog displays title and message
* PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
*/
@Test
fun `error dialog should display title and message`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Error(
title = "An error has occurred",
message = "Something went wrong. Please try again.",
),
)
}
// Verify dialog exists
composeTestRule
.onNode(isDialog())
.assertIsDisplayed()
// Verify title within dialog
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
// Verify message within dialog
composeTestRule
.onAllNodesWithText("Something went wrong. Please try again.")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
/**
* Test: Dialog button click sends action
* PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
*/
@Test
fun `error dialog dismiss button should send DismissDialog action`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Error(
title = "Error",
message = "An error occurred",
),
)
}
// Click dismiss button within dialog
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
}
/**
* Test: Confirmation dialog with multiple buttons
* PATTERN: Test both confirm and cancel actions
*/
@Test
fun `confirmation dialog confirm button should send ConfirmAction`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Confirmation(
title = "Confirm Action",
message = "Are you sure you want to proceed?",
),
)
}
// Click confirm button
composeTestRule
.onAllNodesWithText("Confirm")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) }
}
@Test
fun `confirmation dialog cancel button should send DismissDialog action`() {
mutableStateFlow.update {
it.copy(
dialogState = ExampleState.DialogState.Confirmation(
title = "Confirm Action",
message = "Are you sure?",
),
)
}
// Click cancel button
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
}
}
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
errorMessage = null,
items = emptyList(),
dialogState = null,
)
// Example types (normally in separate files)
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
val errorMessage: String? = null,
val items: List<ExampleItem> = emptyList(),
val dialogState: DialogState? = null,
) {
/**
* PATTERN: Nested sealed class for dialog states.
* Common dialog types: Loading, Error, Confirmation
*/
sealed class DialogState {
data class Loading(val message: String) : DialogState()
data class Error(val title: String, val message: String) : DialogState()
data class Confirmation(val title: String, val message: String) : DialogState()
}
}
data class ExampleItem(val id: String, val name: String)
sealed class ExampleAction {
data object BackClick : ExampleAction()
data object SubmitClick : ExampleAction()
data class ItemClick(val itemId: String) : ExampleAction()
data object DismissDialog : ExampleAction()
data object ConfirmAction : ExampleAction()
}
sealed class ExampleEvent {
data object NavigateBack : ExampleEvent()
data object NavigateToNext : ExampleEvent()
}

View File

@ -0,0 +1,255 @@
/**
* Complete Repository Test Example
*
* Key patterns demonstrated:
* - Fake for disk sources, Mock for network services
* - Using FakeDispatcherManager for deterministic coroutines
* - Using fixed Clock for deterministic time
* - Testing Result types with .asSuccess() / .asFailure()
* - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
* - Testing Flow emissions with Turbine
*/
package com.bitwarden.example.data.repository
import app.cash.turbine.test
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class ExampleRepositoryTest {
// Fixed clock for deterministic time-based tests
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
// Use FakeDispatcherManager for deterministic coroutine execution
private val dispatcherManager = FakeDispatcherManager()
// Mock service (network layer is always mocked)
private val mockService: ExampleService = mockk()
/**
* PATTERN: Use Fake for disk source in happy path tests.
* This is the Bitwarden convention for repository testing.
*/
private val fakeDiskSource = FakeExampleDiskSource()
private lateinit var repository: ExampleRepositoryImpl
@BeforeEach
fun setup() {
repository = ExampleRepositoryImpl(
clock = fixedClock,
service = mockService,
diskSource = fakeDiskSource,
dispatcherManager = dispatcherManager,
)
}
// ==================== HAPPY PATH TESTS (use Fake) ====================
/**
* Test: Successful fetch returns data and saves to disk
*/
@Test
fun `fetchData should return success and save to disk when service succeeds`() = runTest {
val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
coEvery { mockService.getData() } returns expectedData.asSuccess()
val result = repository.fetchData()
assertEquals(expectedData, result.getOrThrow())
// Fake automatically stores the data - verify it's there
assertEquals(expectedData, fakeDiskSource.storedData)
}
/**
* Test: Service failure returns failure without saving
*/
@Test
fun `fetchData should return failure when service fails`() = runTest {
val exception = Exception("Network error")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull())
// Fake was not updated
assertNull(fakeDiskSource.storedData)
}
/**
* Test: Repository flow emits when disk source updates
*/
@Test
fun `dataFlow should emit when disk source updates`() = runTest {
val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant())
val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
repository.dataFlow.test {
// Initial null value from Fake
assertNull(awaitItem())
// Update via Fake property setter (triggers emission)
fakeDiskSource.storedData = data1
assertEquals(data1, awaitItem())
// Another update
fakeDiskSource.storedData = data2
assertEquals(data2, awaitItem())
}
}
/**
* Test: Refresh fetches and saves new data
*/
@Test
fun `refresh should fetch new data and update disk source`() = runTest {
val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant())
coEvery { mockService.getData() } returns newData.asSuccess()
val result = repository.refresh()
assertEquals(Unit, result.getOrThrow())
coVerify { mockService.getData() }
assertEquals(newData, fakeDiskSource.storedData)
}
/**
* Test: Delete clears data from disk
*/
@Test
fun `deleteData should clear disk source`() = runTest {
// Pre-populate the fake
fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
repository.deleteData()
assertNull(fakeDiskSource.storedData)
}
/**
* Test: Cached data returns from disk when available
*/
@Test
fun `getCachedData should return disk data without network call`() = runTest {
val cachedData = ExampleData(
id = "cached",
name = "Cached",
updatedAt = fixedClock.instant(),
)
fakeDiskSource.storedData = cachedData
val result = repository.getCachedData()
assertEquals(cachedData, result)
coVerify(exactly = 0) { mockService.getData() }
}
// ==================== ERROR PATH TESTS ====================
/**
* PATTERN: For error paths, reconfigure the class-level mock per-test.
* Use coEvery to change mock behavior for each specific test case.
*/
@Test
fun `fetchData should return failure when service returns error`() = runTest {
val exception = Exception("Server unavailable")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.fetchData()
assertEquals(exception, result.exceptionOrNull())
// Fake state unchanged on failure
assertNull(fakeDiskSource.storedData)
}
@Test
fun `refresh should return failure and preserve cached data when service fails`() = runTest {
// Pre-populate cache via Fake
val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant())
fakeDiskSource.storedData = cachedData
// Reconfigure mock to return failure
coEvery { mockService.getData() } returns Exception("Network error").asFailure()
val result = repository.refresh()
assertTrue(result.isFailure)
// Cached data preserved on failure
assertEquals(cachedData, fakeDiskSource.storedData)
}
}
// Example types (normally in separate files)
data class ExampleData(
val id: String,
val name: String,
val updatedAt: Instant,
)
interface ExampleService {
suspend fun getData(): Result<ExampleData>
}
interface ExampleDiskSource {
val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?>
fun getData(): ExampleData?
fun saveData(data: ExampleData)
fun clearData()
}
/**
* PATTERN: Fake implementation for happy path testing.
*
* Key characteristics:
* - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
* - Uses .onSubscription { emit(state) } for immediate state emission
* - Private storage with override property setter that emits to flow
* - Test assertions done via the override property getter
*/
class FakeExampleDiskSource : ExampleDiskSource {
private var storedDataValue: ExampleData? = null
private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
/**
* Override property with getter/setter. Setter emits to flow automatically.
* Tests can read this property for assertions and write to trigger emissions.
*/
var storedData: ExampleData?
get() = storedDataValue
set(value) {
storedDataValue = value
mutableDataFlow.tryEmit(value)
}
override val dataFlow: Flow<ExampleData?>
get() = mutableDataFlow.onSubscription { emit(storedData) }
override fun getData(): ExampleData? = storedData
override fun saveData(data: ExampleData) {
storedData = data
}
override fun clearData() {
storedData = null
}
}

View File

@ -0,0 +1,161 @@
/**
* Complete ViewModel Test Example
*
* Key patterns demonstrated:
* - Extending BaseViewModelTest
* - Testing StateFlow with Turbine
* - Testing EventFlow with Turbine
* - Using stateEventFlow() for simultaneous testing
* - MockK mocking patterns
* - Test factory method design (accepts domain state, not SavedStateHandle)
* - Complete state assertions (assert entire state objects)
*/
package com.bitwarden.example.feature
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies
private val mockRepository: ExampleRepository = mockk()
private val mockAuthDiskSource: AuthDiskSource = mockk {
every { userStateFlow } returns MutableStateFlow(null)
}
/**
* StateFlow has replay=1, so first awaitItem() returns current state
*/
@Test
fun `initial state should be default state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
/**
* Test state transitions: initial -> loading -> success
*/
@Test
fun `LoadData action should update state from idle to loading to success`() = runTest {
val expectedData = "loaded data"
coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.LoadData)
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
}
coVerify { mockRepository.fetchData(any()) }
}
/**
* EventFlow has no replay - MUST call expectNoEvents() first
*/
@Test
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
val viewModel = createViewModel()
viewModel.eventFlow.test {
expectNoEvents() // CRITICAL for EventFlow
viewModel.trySendAction(ExampleAction.SubmitClick)
assertEquals(ExampleEvent.NavigateToNext, awaitItem())
}
}
/**
* Use stateEventFlow() helper for simultaneous testing
*/
@Test
fun `complex action should update state and emit event`() = runTest {
coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
eventFlow.expectNoEvents()
viewModel.trySendAction(ExampleAction.ComplexAction)
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
}
}
/**
* Test state restoration from saved state.
* Note: Use initialState parameter, NOT SavedStateHandle directly.
*/
@Test
fun `initial state from saved state should be preserved`() = runTest {
// Build complete expected state - always assert full objects
val savedState = ExampleState(
isLoading = false,
data = "restored data",
errorMessage = null,
)
val viewModel = createViewModel(initialState = savedState)
viewModel.stateFlow.test {
assertEquals(savedState, awaitItem())
}
}
/**
* Factory method accepts domain state, NOT SavedStateHandle.
* This hides Android framework details from test logic.
*/
private fun createViewModel(
initialState: ExampleState? = null,
): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
repository = mockRepository,
authDiskSource = mockAuthDiskSource,
)
}
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
errorMessage = null,
)
// Example types (normally in separate files)
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
val errorMessage: String? = null,
)
sealed class ExampleAction {
data object LoadData : ExampleAction()
data object SubmitClick : ExampleAction()
data object ComplexAction : ExampleAction()
}
sealed class ExampleEvent {
data object NavigateToNext : ExampleEvent()
data class ShowToast(val message: String) : ExampleEvent()
}

View File

@ -0,0 +1,698 @@
# Critical Gotchas and Anti-Patterns
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
## ❌ NEVER wrap assertCoroutineThrows in runTest
### The Problem
`runTest` catches exceptions and rethrows them, which breaks the `assertCoroutineThrows` assertion pattern.
### Wrong
```kotlin
@Test
fun `test exception`() = runTest {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Won't work - exception is caught by runTest!
}
```
### Correct
```kotlin
@Test
fun `test exception`() {
assertCoroutineThrows<Exception> {
repository.throwingFunction()
} // Works correctly
}
```
### Why This Happens
`runTest` provides a coroutine scope and catches exceptions to provide better error messages. However, `assertCoroutineThrows` needs to catch the exception itself to verify it was thrown. When wrapped in `runTest`, the exception is caught twice, breaking the assertion.
## ❌ ALWAYS unmock static functions
### The Problem
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
### Wrong
```kotlin
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
// Forgot @After - subsequent tests will fail mysteriously!
```
### Correct
```kotlin
@Before
fun setup() {
mockkStatic(::isBuildVersionAtLeast)
every { isBuildVersionAtLeast(any()) } returns true
}
@After
fun tearDown() {
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
}
```
### Common Static Functions to Watch
```kotlin
// Platform version checks
mockkStatic(::isBuildVersionAtLeast)
unmockkStatic(::isBuildVersionAtLeast)
// URI parsing
mockkStatic(Uri::class)
unmockkStatic(Uri::class)
// Static utility functions
mockkStatic(MyUtilClass::class)
unmockkStatic(MyUtilClass::class)
```
### Debugging Tip
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
## ❌ Don't confuse StateFlow and EventFlow testing
### StateFlow (replay = 1)
```kotlin
// CORRECT - StateFlow always has current value
viewModel.stateFlow.test {
val initial = awaitItem() // Gets current state immediately
viewModel.trySendAction(action)
val updated = awaitItem() // Gets new state
}
```
### EventFlow (no replay)
```kotlin
// CORRECT - EventFlow has no initial value
viewModel.eventFlow.test {
expectNoEvents() // MUST do this first
viewModel.trySendAction(action)
val event = awaitItem() // Gets emitted event
}
```
### Common Mistake
```kotlin
// WRONG - Forgetting expectNoEvents() on EventFlow
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May cause flaky tests
assertEquals(event, awaitItem())
}
```
## ❌ Don't mix real and test dispatchers
### Wrong
```kotlin
private val repository = ExampleRepositoryImpl(
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
)
@Test
fun `test repository`() = runTest {
// Test will have timing issues - real dispatcher != test dispatcher
}
```
### Correct
```kotlin
private val repository = ExampleRepositoryImpl(
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
)
@Test
fun `test repository`() = runTest {
// Test runs deterministically
}
```
### Why This Matters
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
- Non-deterministic test failures
- Real delays in tests (slow test suite)
- Race conditions
### Always Use
- `FakeDispatcherManager()` for repositories
- `UnconfinedTestDispatcher()` when manually creating dispatchers
- `runTest` for coroutine tests (provides TestDispatcher automatically)
## ❌ Don't forget to use runTest for coroutine tests
### Wrong
```kotlin
@Test
fun `test coroutine`() {
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
}
```
This causes:
- Test completes before coroutines finish
- False positives (test passes but assertions never run)
- Mysterious failures
### Correct
```kotlin
@Test
fun `test coroutine`() = runTest {
viewModel.stateFlow.test { /* ... */ }
}
```
### When runTest is Required
- Testing ViewModels (they use `viewModelScope`)
- Testing Flows with Turbine `.test {}`
- Testing repositories with suspend functions
- Any test calling suspend functions
### Exception: assertCoroutineThrows
As noted above, `assertCoroutineThrows` should NOT be wrapped in `runTest`.
## ❌ Don't forget relaxed = true for complex mocks
### Without relaxed
```kotlin
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
// Error: "no answer found for: stateFlow"
```
### With relaxed
```kotlin
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
// Only mock what you care about
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
}
```
### When to Use relaxed
- Mocking ViewModels in Compose tests
- Mocking complex objects with many methods
- When you only care about specific method calls
### When NOT to Use relaxed
- Mocking repository interfaces (be explicit about behavior)
- When you want to verify NO unexpected calls
- Testing error paths (want test to fail if unexpected method called)
## ❌ Don't assert individual fields when complete state is available
### The Problem
Asserting individual state fields can miss unintended side effects on other fields.
### Wrong
```kotlin
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val state = viewModel.stateFlow.value
assertEquals(null, state.dialog) // Only checks one field!
}
```
### Correct
```kotlin
@Test
fun `action should update state`() = runTest {
viewModel.trySendAction(SomeAction.DoThing)
val expected = SomeState(
isLoading = false,
data = "result",
dialog = null,
)
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
}
```
### Why This Matters
- Catches unintended mutations to other state fields
- Makes expected state explicit and readable
- Prevents silent regressions when state structure changes
---
## ❌ Don't use Kotlin assert() for boolean checks
### The Problem
Kotlin's `assert()` doesn't follow JUnit conventions and provides poor failure messages.
### Wrong
```kotlin
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assert(onNavigateCalled) // Kotlin assert - bad failure messages
}
```
### Correct
```kotlin
@Test
fun `event should trigger callback`() {
mutableEventFlow.tryEmit(SomeEvent.Navigate)
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
}
```
### Always Use JUnit Assertions
- `assertTrue()` / `assertFalse()` for booleans
- `assertEquals()` for value comparisons
- `assertNotNull()` / `assertNull()` for nullability
- `assertThrows<T>()` for exceptions
---
## ❌ Don't pass SavedStateHandle to test factory methods
### The Problem
Exposing `SavedStateHandle` in test factory methods leaks Android framework details into test logic.
### Wrong
```kotlin
private fun createViewModel(
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
): MyViewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
// ...
}
```
### Correct
```kotlin
private fun createViewModel(
initialState: MyState? = null, // Domain type only
): MyViewModel = MyViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
repository = mockRepository,
)
@Test
fun `initial state from saved state`() = runTest {
val savedState = MyState(isLoading = true)
val viewModel = createViewModel(initialState = savedState)
// ...
}
```
### Why This Matters
- Cleaner, more intuitive test code
- Hides SavedStateHandle implementation details
- Follows Bitwarden conventions
---
## ❌ Don't test SavedStateHandle persistence in unit tests
### The Problem
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
### Wrong
```kotlin
@Test
fun `state should persist to SavedStateHandle`() = runTest {
val savedStateHandle = SavedStateHandle()
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
viewModel.trySendAction(SomeAction)
val savedState = savedStateHandle.get<MyState>("state")
assertEquals(expectedState, savedState) // Testing framework, not logic!
}
```
### Correct
Focus on testing business logic and state transformations:
```kotlin
@Test
fun `action should update state correctly`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(SomeAction)
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
}
```
---
## ❌ Don't use static mocking when DI pattern is available
### The Problem
Static mocking (`mockkStatic`) is harder to maintain and less testable than dependency injection.
### Wrong
```kotlin
class ParserTest {
@BeforeEach
fun setup() {
mockkStatic(UUID::class)
every { UUID.randomUUID() } returns mockk {
every { toString() } returns "fixed-uuid"
}
}
@AfterEach
fun tearDown() {
unmockkStatic(UUID::class)
}
}
```
### Correct
Extract an interface and inject it:
```kotlin
// Production code
interface UuidManager {
fun generateUuid(): String
}
class UuidManagerImpl : UuidManager {
override fun generateUuid(): String = UUID.randomUUID().toString()
}
class Parser(private val uuidManager: UuidManager) { ... }
// Test code
class ParserTest {
private val mockUuidManager = mockk<UuidManager>()
@BeforeEach
fun setup() {
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
}
// No tearDown needed - no static mocking!
}
```
### When to Use This Pattern
- UUID generation
- Timestamp/Clock operations
- System property access
- Any static function that needs deterministic testing
---
## ❌ Don't forget to test null stream returns from Android APIs
### The Problem
Android's `ContentResolver.openOutputStream()` and `openInputStream()` can return null, not just throw exceptions.
### Wrong
```kotlin
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
// Missing: test for null return!
}
```
### Correct
```kotlin
class FileManagerTest {
@Test
fun `stringToUri with exception should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } throws IOException()
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result)
}
@Test
fun `stringToUri with null stream should return false`() = runTest {
every { mockContentResolver.openOutputStream(any()) } returns null
val result = fileManager.stringToUri(mockUri, "data")
assertFalse(result) // CRITICAL: must handle null!
}
}
```
### Common Android APIs That Return Null
- `ContentResolver.openOutputStream()` / `openInputStream()`
- `Context.getExternalFilesDir()`
- `PackageManager.getApplicationInfo()` (can throw)
---
## Bitwarden Mocking Guidelines
**Mock at architectural boundaries:**
- Repository → ViewModel (mock repository)
- Service → Repository (mock service)
- API → Service (use MockWebServer, not mocks)
- DiskSource → Repository (mock disk source)
**Fake vs Mock Strategy (IMPORTANT):**
- **Happy paths**: Use Fake implementations (`FakeAuthenticatorDiskSource`, `FakeVaultDiskSource`)
- **Error paths**: Use MockK with isolated repository instances
```kotlin
// Happy path - use Fake
private val fakeDiskSource = FakeAuthenticatorDiskSource()
@Test
fun `createItem should return Success`() = runTest {
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Success, result)
}
// Error path - use isolated Mock
@Test
fun `createItem with exception should return Error`() = runTest {
val mockDiskSource = mockk<AuthenticatorDiskSource> {
coEvery { saveItem(any()) } throws RuntimeException()
}
val repository = RepositoryImpl(diskSource = mockDiskSource)
val result = repository.createItem(mockItem)
assertEquals(CreateItemResult.Error, result)
}
```
**Use Fakes for:**
- `FakeDispatcherManager` - deterministic coroutines
- `FakeConfigDiskSource` - in-memory config storage
- `FakeSharedPreferences` - memory-backed preferences
- `FakeAuthenticatorDiskSource` - in-memory authenticator storage
**Create real instances for:**
- Data classes, value objects (User, Config, CipherView)
- Test data builders (`createMockCipher(number = 1)`)
## ❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
### The Problem
Fake data sources using `MutableSharedFlow` won't emit cached state to new subscribers without explicit handling.
### Wrong
```kotlin
class FakeDataSource : DataSource {
private val mutableFlow = MutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems)
}
}
// Test: Initial collection gets nothing!
repository.dataFlow.test {
// Hangs or fails - no initial emission
}
```
### Correct
```kotlin
class FakeDataSource : DataSource {
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
private val storedItems = mutableListOf<Item>()
override fun getItems(): Flow<List<Item>> = mutableFlow
.onSubscription { emit(storedItems.toList()) }
override suspend fun saveItem(item: Item) {
storedItems.add(item)
mutableFlow.emit(storedItems.toList())
}
}
// Test: Initial collection receives current state
repository.dataFlow.test {
assertEquals(emptyList(), awaitItem()) // Works!
}
```
### Key Points
- Use `bufferedMutableSharedFlow()` from `core/data/repository/util/`
- Add `.onSubscription { emit(currentState) }` for immediate state emission
- This ensures new collectors receive the current cached state
---
## ✅ Use Result extension functions for assertions
### The Pattern
Use `asSuccess()` and `asFailure()` extensions from `com.bitwarden.core.data.util` for cleaner Result assertions.
### Success Path
```kotlin
@Test
fun `getData should return success`() = runTest {
val result = repository.getData()
val expected = expectedData.asSuccess()
assertEquals(expected.getOrNull(), result.getOrNull())
}
```
### Failure Path
```kotlin
@Test
fun `getData with error should return failure`() = runTest {
val exception = IOException("Network error")
coEvery { mockService.getData() } returns exception.asFailure()
val result = repository.getData()
assertTrue(result.isFailure)
assertEquals(exception, result.exceptionOrNull())
}
```
### Avoid Redundant Assertions
```kotlin
// WRONG - redundant success checks
assertTrue(result.isSuccess)
assertTrue(expected.isSuccess)
assertArrayEquals(expected.getOrNull(), result.getOrNull())
// CORRECT - final assertion is sufficient
assertArrayEquals(expected.getOrNull(), result.getOrNull())
```
---
## Summary Checklist
Before submitting tests, verify:
**Core Patterns:**
- [ ] No `assertCoroutineThrows` inside `runTest`
- [ ] All static mocks have `unmockk` in `@After`
- [ ] EventFlow tests start with `expectNoEvents()`
- [ ] Using FakeDispatcherManager, not real dispatchers
- [ ] All coroutine tests use `runTest`
**Assertion Patterns:**
- [ ] Assert complete state objects, not individual fields
- [ ] Use JUnit `assertTrue()`, not Kotlin `assert()`
- [ ] Use `asSuccess()` for Result type assertions
- [ ] Avoid redundant assertion patterns
**Test Design:**
- [ ] Test factory methods accept domain types, not SavedStateHandle
- [ ] Use Fakes for happy paths, Mocks for error paths
- [ ] Prefer DI patterns over static mocking
- [ ] Test null returns from Android APIs (streams, files)
- [ ] Fakes use `bufferedMutableSharedFlow()` with `.onSubscription`
**General:**
- [ ] Tests don't depend on execution order
- [ ] Complex mocks use `relaxed = true`
- [ ] Test data is created fresh for each test
- [ ] Mocking behavior, not value objects
- [ ] Testing observable behavior, not implementation
When tests fail mysteriously, check these gotchas first.

View File

@ -0,0 +1,274 @@
# Flow Testing with Turbine
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
## StateFlow vs EventFlow
### StateFlow (Replayed)
**Characteristics:**
- `replay = 1` - Always emits current value to new collectors
- First `awaitItem()` returns the current/initial state
- Survives configuration changes
- Used for UI state that needs to be immediately available
**Test Pattern:**
```kotlin
@Test
fun `action should update state`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateFlow.test {
// First awaitItem() gets CURRENT state
assertEquals(INITIAL_STATE, awaitItem())
// Trigger action
viewModel.trySendAction(MyAction.LoadData)
// Next awaitItem() gets UPDATED state
assertEquals(LOADING_STATE, awaitItem())
assertEquals(SUCCESS_STATE, awaitItem())
}
}
```
### EventFlow (No Replay)
**Characteristics:**
- `replay = 0` - Only emits new events after subscription
- No initial value emission
- One-time events (navigation, toasts, dialogs)
- Does not survive configuration changes
**Test Pattern:**
```kotlin
@Test
fun `action should emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.eventFlow.test {
// MUST call expectNoEvents() first - nothing emitted yet
expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.Submit)
// Now expect the event
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}
```
**Critical:** Always call `expectNoEvents()` before triggering actions on EventFlow. Forgetting this causes flaky tests.
## Testing State and Events Simultaneously
Use the `stateEventFlow()` helper from `BaseViewModelTest`:
```kotlin
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = MyViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(MyAction.ComplexAction)
// Verify state progression
assertEquals(LOADING_STATE, stateFlow.awaitItem())
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
// Verify event emission
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
}
}
```
## Repository Flow Testing
### Testing Database Flows
```kotlin
@Test
fun `dataFlow should emit when database updates`() = runTest {
val dataFlow = MutableStateFlow(initialData)
every { mockDiskSource.dataFlow } returns dataFlow
repository.dataFlow.test {
// Initial value
assertEquals(initialData, awaitItem())
// Update disk source
dataFlow.value = updatedData
// Verify emission
assertEquals(updatedData, awaitItem())
}
}
```
### Testing Transformed Flows
```kotlin
@Test
fun `flow transformation should map correctly`() = runTest {
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
every { mockDao.observeUser() } returns sourceFlow
// Repository transforms entity to domain model
repository.userFlow.test {
val expectedUser = User(id = "1", name = "John")
assertEquals(expectedUser, awaitItem())
}
}
```
## Common Patterns
### Pattern 1: Testing Initial State + Action
```kotlin
@Test
fun `load data should update from idle to loading to success`() = runTest {
coEvery { repository.getData() } returns "data".asSuccess()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
}
}
```
### Pattern 2: Testing Error States
```kotlin
@Test
fun `load data with error should emit failure state`() = runTest {
val error = Exception("Network error")
coEvery { repository.getData() } returns error.asFailure()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.loadData()
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
assertEquals(
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
awaitItem(),
)
}
}
```
### Pattern 3: Testing Event Sequences
```kotlin
@Test
fun `submit should emit validation then navigation events`() = runTest {
viewModel.eventFlow.test {
expectNoEvents()
viewModel.trySendAction(MyAction.Submit)
assertEquals(MyEvent.ShowValidation, awaitItem())
assertEquals(MyEvent.NavigateToNext, awaitItem())
}
}
```
### Pattern 4: Testing Cancellation
```kotlin
@Test
fun `cancelling collection should stop emissions`() = runTest {
val flow = flow {
repeat(100) {
emit(it)
delay(100)
}
}
flow.test {
assertEquals(0, awaitItem())
assertEquals(1, awaitItem())
// Cancel after 2 items
cancel()
// No more items received
}
}
```
## Anti-Patterns
### ❌ Forgetting expectNoEvents() on EventFlow
```kotlin
// WRONG
viewModel.eventFlow.test {
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
assertEquals(event, awaitItem())
}
// CORRECT
viewModel.eventFlow.test {
expectNoEvents() // ALWAYS do this first
viewModel.trySendAction(action)
assertEquals(event, awaitItem())
}
```
### ❌ Not Using runTest
```kotlin
// WRONG - Missing runTest
@Test
fun `test flow`() {
flow.test { /* ... */ }
}
// CORRECT
@Test
fun `test flow`() = runTest {
flow.test { /* ... */ }
}
```
### ❌ Mixing StateFlow and EventFlow Patterns
```kotlin
// WRONG - Treating StateFlow like EventFlow
stateFlow.test {
expectNoEvents() // Unnecessary - StateFlow always has value
/* ... */
}
// WRONG - Treating EventFlow like StateFlow
eventFlow.test {
val item = awaitItem() // Will hang - no initial value!
/* ... */
}
```
## Reference Implementations
**ViewModel with StateFlow and EventFlow:**
`app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
**Repository Flow Testing:**
`data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt`
**Complex Flow Transformations:**
`data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt`

View File

@ -0,0 +1,259 @@
# Test Base Classes Reference
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
## BaseViewModelTest
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
### Purpose
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
### Automatic Configuration
- Registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
- Ensures deterministic coroutine execution in tests
- All coroutines complete immediately without real delays
### Key Feature: stateEventFlow() Helper
**Use Case:** When you need to test both StateFlow and EventFlow simultaneously.
```kotlin
@Test
fun `complex action should update state and emit event`() = runTest {
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// Verify initial state
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
// No events yet
eventFlow.expectNoEvents()
// Trigger action
viewModel.trySendAction(ExampleAction.ComplexAction)
// Verify state updated
assertEquals(LOADING_STATE, stateFlow.awaitItem())
// Verify event emitted
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
}
}
```
### Usage Pattern
```kotlin
class MyViewModelTest : BaseViewModelTest() {
private val mockRepository: MyRepository = mockk()
private val savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to INITIAL_STATE)
)
@Test
fun `test action`() = runTest {
val viewModel = MyViewModel(
savedStateHandle = savedStateHandle,
repository = mockRepository
)
// Test with automatic dispatcher setup
viewModel.stateFlow.test {
assertEquals(INITIAL_STATE, awaitItem())
}
}
}
```
## BitwardenComposeTest
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
### Purpose
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
### Automatic Configuration
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
- Provides fixed `Clock` for deterministic time-based tests
- Extends `BaseComposeTest` for Robolectric and dispatcher setup
### Key Features
**Pre-configured Managers:**
- `FeatureFlagManager` - Controls feature flag behavior
- `AuthTabManager` - Manages auth tab state
- `BiometricsManager` - Handles biometric authentication
- `ClipboardManager` - Clipboard operations
- `NotificationManager` - Notification display
**Fixed Clock:**
All tests use a fixed clock for deterministic time-based testing:
```kotlin
// Tests use consistent time: 2023-10-27T12:00:00Z
val fixedClock: Clock
```
### Usage Pattern
```kotlin
class MyScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateBack = false
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<MyViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
setContent {
MyScreen(
onNavigateBack = { haveCalledNavigateBack = true },
viewModel = viewModel
)
}
}
@Test
fun `on back click should send action`() {
composeTestRule.onNodeWithContentDescription("Back").performClick()
verify { viewModel.trySendAction(MyAction.BackClick) }
}
@Test
fun `loading state should show progress`() {
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
}
}
```
### Important: bufferedMutableSharedFlow for Events
In Compose tests, use `bufferedMutableSharedFlow` instead of regular `MutableSharedFlow` (default replay is 0):
```kotlin
// Correct for Compose tests
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
// This allows triggering events and having the UI react
mutableEventFlow.tryEmit(MyEvent.NavigateBack)
```
## BaseServiceTest
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt`
### Purpose
Provides MockWebServer setup for testing API service implementations.
### Automatic Configuration
- `server: MockWebServer` - Auto-started before each test, stopped after
- `retrofit: Retrofit` - Pre-configured with:
- JSON converter (kotlinx.serialization)
- NetworkResultCallAdapter for Result<T> responses
- Base URL pointing to MockWebServer
- `json: Json` - kotlinx.serialization JSON instance
### Usage Pattern
```kotlin
class MyServiceTest : BaseServiceTest() {
private val api: MyApi = retrofit.create()
private val service = MyServiceImpl(api)
@Test
fun `getConfig should return success when API succeeds`() = runTest {
// Enqueue mock response
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
// Call service
val result = service.getConfig()
// Verify result
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
}
@Test
fun `getConfig should return failure when API fails`() = runTest {
// Enqueue error response
server.enqueue(MockResponse().setResponseCode(500))
// Call service
val result = service.getConfig()
// Verify failure
assertTrue(result.isFailure)
}
}
```
### MockWebServer Patterns
**Enqueue successful response:**
```kotlin
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))
```
**Enqueue error response:**
```kotlin
server.enqueue(MockResponse().setResponseCode(404))
server.enqueue(MockResponse().setResponseCode(500))
```
**Enqueue delayed response:**
```kotlin
server.enqueue(
MockResponse()
.setBody("""{"key": "value"}""")
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
)
```
**Verify request details:**
```kotlin
val request = server.takeRequest()
assertEquals("/api/config", request.path)
assertEquals("GET", request.method)
assertEquals("Bearer token", request.getHeader("Authorization"))
```
## BaseComposeTest
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt`
### Purpose
Base class for Compose tests that extends `BaseRobolectricTest` and provides `setTestContent()` helper.
### Features
- Robolectric configuration for Compose
- Proper dispatcher setup
- `composeTestRule` for UI testing
- `setTestContent()` helper wraps content in theme
### Usage
Typically you'll extend `BitwardenComposeTest` which extends this class. Use `BaseComposeTest` directly only for tests that don't need Bitwarden-specific manager configuration.
## When to Use Each Base Class
| Test Type | Base Class | Use When |
|-----------|------------|----------|
| ViewModel tests | `BaseViewModelTest` | Testing ViewModel state and events |
| Compose screen tests | `BitwardenComposeTest` | Testing Compose UI with Bitwarden components |
| API service tests | `BaseServiceTest` | Testing network layer with MockWebServer |
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
## Complete Examples
**ViewModel Test:**
`../examples/viewmodel-test-example.md`
**Compose Screen Test:**
`../examples/compose-screen-test-example.md`
**Repository Test:**
`../examples/repository-test-example.md`

22
.github/label-pr.json vendored
View File

@ -1,15 +1,14 @@
{
"catch_all_label": "t:misc",
"title_patterns": {
"t:new-feature": ["feat", "feature"],
"t:enhancement": ["enhancement", "enh", "impr"],
"t:feature": ["feat", "feature", "tool"],
"t:bug": ["fix", "bug", "bugfix"],
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
"t:docs": ["docs"],
"t:ci": ["ci", "build", "chore(ci)"],
"t:deps": ["deps"],
"t:breaking-change": ["breaking", "breaking-change"],
"t:misc": ["misc"]
"t:misc": ["misc"],
"t:llm": ["llm"]
},
"path_patterns": {
"app:shared": [
@ -29,16 +28,21 @@
"app:authenticator": [
"authenticator/"
],
"t:enhancement": [
"t:feature": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json"
"app/src/main/assets/fido2_privileged_google.json",
"testharness/"
],
"t:tech-debt": [
"gradle.properties",
"keystore/"
],
"t:ci": [
".checkmarx/",
".github/",
"scripts/",
"fastlane/",
".gradle/",
".claude/",
"detekt-config.yml"
],
"t:docs": [
@ -47,8 +51,8 @@
"t:deps": [
"gradle/"
],
"t:misc": [
"keystore/"
"t:llm": [
".claude/"
]
}
}

11
.github/release.yml vendored
View File

@ -6,14 +6,13 @@ changelog:
- title: '✨ Community Highlight'
labels:
- community-pr
- title: '🚀 New Features & Enhancements'
- title: ':shipit: Feature Development'
labels:
- t:feature
- t:feature-app
- t:feature-tool
- t:new-feature
- t:enhancement
- title: ':shipit: Tools'
labels:
- t:feature-tool
- title: '❗ Breaking Changes'
labels:
- t:breaking-change
@ -26,8 +25,10 @@ changelog:
- t:ci
- t:docs
- t:misc
- '*'
- title: '📦 Dependency Updates'
labels:
- dependencies
- t:deps
- title: '🎨 Other'
labels:
- '*'

15
.github/renovate.json vendored
View File

@ -3,6 +3,7 @@
"extends": [
"github>bitwarden/renovate-config"
],
"ignoreDeps": ["com.bitwarden:sdk-android"],
"enabledManagers": [
"github-actions",
"gradle",
@ -19,20 +20,6 @@
"patch"
]
},
{
"groupName": "gradle minor",
"matchUpdateTypes": [
"minor",
"patch"
],
"matchManagers": [
"gradle"
],
"excludePackageNames": [
"com.github.bumptech.glide:compose",
"com.bitwarden:sdk-android"
]
},
{
"groupName": "kotlin",
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",

View File

@ -4,21 +4,22 @@
Label pull requests based on changed file paths and PR title patterns (conventional commit format).
Usage:
python label-pr.py <pr-number> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
python label-pr.py <pr-number> <pr-labels> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
Arguments:
pr-number: The pull request number
pr-labels: Current PR labels as JSON array string
-a, --add: Add labels without removing existing ones (default)
-r, --replace: Replace all existing labels
-d, --dry-run: Run without actually applying labels
-c, --config: Path to JSON config file (default: .github/label-pr.json)
Examples:
python label-pr.py 1234
python label-pr.py 1234 -a
python label-pr.py 1234 --replace
python label-pr.py 1234 -r -d
python label-pr.py 1234 --config custom-config.json
python label-pr.py 1234 '[]'
python label-pr.py 1234 '[{"name":"label1"}]' -a
python label-pr.py 1234 '[{"name":"label1"}]' --replace
python label-pr.py 1234 '[{"name":"label1"}]' -r -d
python label-pr.py 1234 '[]' --config custom-config.json
"""
import argparse
@ -42,9 +43,6 @@ def load_config_json(config_file: str) -> dict:
print(f"✅ Loaded config from: {config_file}")
valid_config = True
if not config.get("catch_all_label"):
print("❌ Missing 'catch_all_label' in config file")
valid_config = False
if not config.get("title_patterns"):
print("❌ Missing 'title_patterns' in config file")
valid_config = False
@ -155,6 +153,27 @@ def label_title(pr_title: str, title_patterns: dict) -> list[str]:
return list(labels_to_apply)
def parse_pr_labels(pr_labels_str: str) -> list[str]:
"""Parse PR labels from JSON array string."""
try:
labels = json.loads(pr_labels_str)
if not isinstance(labels, list):
print("::warning::Failed to parse PR labels: not a list")
return []
return [item.get("name") for item in labels if item.get("name")]
except (json.JSONDecodeError, TypeError) as e:
print(f"::error::Error parsing PR labels: {e}")
return []
def get_preserved_labels(pr_labels_str: str) -> list[str]:
"""Get existing PR labels that should be preserved (exclude app: and t: labels)."""
existing_labels = parse_pr_labels(pr_labels_str)
print(f"🔍 Parsed PR labels: {existing_labels}")
preserved_labels = [label for label in existing_labels if not (label.startswith("app:") or label.startswith("t:"))]
if preserved_labels:
print(f"🔍 Preserving existing labels: {', '.join(preserved_labels)}")
return preserved_labels
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
@ -165,6 +184,11 @@ def parse_args():
help="The pull request number"
)
parser.add_argument(
"pr_labels",
help="Current PR labels (JSON array)"
)
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument(
"-a", "--add",
@ -194,7 +218,6 @@ def parse_args():
def main():
args = parse_args()
config = load_config_json(args.config)
CATCH_ALL_LABEL = config["catch_all_label"]
LABEL_TITLE_PATTERNS = config["title_patterns"]
LABEL_PATH_PATTERNS = config["path_patterns"]
@ -216,16 +239,18 @@ def main():
title_labels = label_title(pr_title, LABEL_TITLE_PATTERNS)
all_labels = set(filepath_labels + title_labels)
if not any(label.startswith("t:") for label in all_labels):
all_labels.add(CATCH_ALL_LABEL)
if all_labels:
print("--------------------------------")
labels_str = ', '.join(sorted(all_labels))
if mode == "add":
print(f"::notice::🏷️ Adding labels: {labels_str}")
if not args.dry_run:
gh_add_labels(pr_number, list(all_labels))
else:
preserved_labels = get_preserved_labels(args.pr_labels)
if preserved_labels:
all_labels.update(preserved_labels)
labels_str = ', '.join(sorted(all_labels))
print(f"::notice::🏷️ Replacing labels with: {labels_str}")
if not args.dry_run:
gh_replace_labels(pr_number, list(all_labels))

View File

@ -21,17 +21,19 @@ on:
distribute-to-firebase:
description: "Optional. Distribute artifacts to Firebase."
required: false
default: false
default: true
type: boolean
publish-to-play-store:
description: "Optional. Deploy bundle artifact to Google Play Store"
required: false
default: false
default: true
type: boolean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
permissions:
contents: read
@ -171,7 +173,7 @@ jobs:
--name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none
- name: Download Firebase credentials
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@ -182,7 +184,7 @@ jobs:
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
- name: Download Play Store credentials
if: ${{ inputs.publish-to-play-store }}
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@ -196,7 +198,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Verify Play Store credentials
if: ${{ inputs.publish-to-play-store }}
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
run: |
bundle exec fastlane run validate_play_store_json_key \
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
@ -281,7 +283,7 @@ jobs:
keyAlias:"bitwardenauthenticator" \
keyPassword:"$KEY_PASSWORD"
- name: Upload release Play Store .aab artifact
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -289,7 +291,7 @@ jobs:
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
if-no-files-found: error
- name: Upload release .apk artifact
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -309,7 +311,7 @@ jobs:
sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \
> ./authenticator-android-apk-sha256.txt
- name: Upload .apk SHA file for release
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ matrix.variant == 'apk' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -317,7 +319,7 @@ jobs:
path: ./authenticator-android-apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ matrix.variant == 'aab' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -326,21 +328,19 @@ jobs:
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release bundle to Firebase
if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
- name: Distribute to Firebase - prod.aab
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
env:
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
run: |
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
serviceCredentialsFile:"$FIREBASE_CREDS_PATH"
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
# bundles
- name: Publish release bundle to Google Play Store
if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }}
- name: Publish to Play Store - prod.aab
if: ${{ matrix.variant == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
env:
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
run: |

View File

@ -33,6 +33,8 @@ env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
permissions:
contents: read
@ -184,7 +186,7 @@ jobs:
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
- name: Download Firebase credentials
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
if: ${{ matrix.variant == 'prod' && env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@ -295,7 +297,7 @@ jobs:
run: |
bundle exec fastlane assembleDebugApks
- name: Upload release Play Store .aab artifact
- name: Upload to GitHub Artifacts - prod.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -303,7 +305,7 @@ jobs:
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
if-no-files-found: error
- name: Upload beta Play Store .aab artifact
- name: Upload to GitHub Artifacts - beta.aab
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -311,7 +313,7 @@ jobs:
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
if-no-files-found: error
- name: Upload release .apk artifact
- name: Upload to GitHub Artifacts - prod.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -319,7 +321,7 @@ jobs:
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
if-no-files-found: error
- name: Upload beta .apk artifact
- name: Upload to GitHub Artifacts - beta.apk
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -328,7 +330,7 @@ jobs:
if-no-files-found: error
# When building variants other than 'prod'
- name: Upload debug .apk artifact
- name: Upload to GitHub Artifacts - dev.apk
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -366,7 +368,7 @@ jobs:
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Upload .apk SHA file for release
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -374,7 +376,7 @@ jobs:
path: ./com.x8bit.bitwarden.apk-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for beta
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -382,7 +384,7 @@ jobs:
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -390,7 +392,7 @@ jobs:
path: ./com.x8bit.bitwarden.aab-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for beta
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -398,7 +400,7 @@ jobs:
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for debug
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
@ -407,11 +409,11 @@ jobs:
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release artifacts to Firebase
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
- name: Distribute to Firebase - prod.apk
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
@ -419,8 +421,8 @@ jobs:
actionUrl:$GITHUB_ACTION_RUN_URL \
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Publish beta artifacts to Firebase
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && (inputs.distribute-to-firebase || github.event_name == 'push') }}
- name: Distribute to Firebase - beta.apk
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
@ -429,12 +431,12 @@ jobs:
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
- name: Verify Play Store credentials
if: ${{ matrix.variant == 'prod' && inputs.publish-to-play-store }}
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
run: |
bundle exec fastlane run validate_play_store_json_key
- name: Publish Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.event_name == 'push') }}
- name: Publish to Play Store - prod.aab
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
run: |
bundle exec fastlane publishProdToPlayStore
bundle exec fastlane publishBetaToPlayStore
@ -488,7 +490,7 @@ jobs:
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
- name: Download Firebase credentials
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@ -575,7 +577,7 @@ jobs:
keyAlias:bitwarden-beta \
keyPassword:$FDROID_BETA_KEY_PASSWORD
- name: Upload F-Droid .apk artifact
- name: Upload to GitHub Artifacts - fdroid.apk
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk
@ -587,14 +589,14 @@ jobs:
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk" \
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload F-Droid SHA file
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload F-Droid Beta .apk artifact
- name: Upload to GitHub Artifacts - beta.fdroid.apk
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
@ -606,7 +608,7 @@ jobs:
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk" \
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload F-Droid Beta SHA file
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
@ -614,11 +616,11 @@ jobs:
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release F-Droid artifacts to Firebase
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
- name: Distribute to Firebase - fdroid.apk
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
env:
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
run: |

View File

@ -18,6 +18,7 @@ jobs:
workflow_name: "publish-github-release-bwa.yml"
credentials_filename: "authenticator_play_store-creds.json"
project_type: android
make_latest: false
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
secrets: inherit

View File

@ -19,6 +19,7 @@ jobs:
workflow_name: "publish-github-release-bwpm.yml"
credentials_filename: "play_creds.json"
project_type: android
make_latest: true
check_release_command: >
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
secrets: inherit

View File

@ -1,5 +1,5 @@
name: SDLC / Label PR by Files
name: SDLC / Label PR
run-name: Label PR ${{ github.event.pull_request.number || inputs.pr-number }}${{ github.event_name == 'workflow_dispatch' && format(' / mode "{0}" dry-run "{1}"', inputs.mode, inputs.dry-run) || '' }}
on:
pull_request:
types: [opened, synchronize]
@ -63,7 +63,7 @@ jobs:
exit 0
fi
if [ "$_PR_USER" = "renovate[bot]" ] || [ "$_PR_USER" = "bw-ghapp[bot]" ]; then
if [[ "$_PR_USER" == app/* || "$_PR_USER" == *\[bot\] ]]; then
echo "➡️ Bot PR ($_PR_USER). Label mode: --add"
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
exit 0
@ -77,7 +77,14 @@ jobs:
GH_TOKEN: ${{ github.token }}
_LABEL_MODE: ${{ inputs.mode && format('--{0}', inputs.mode) || steps.label-mode.outputs.label_mode }}
_DRY_RUN: ${{ inputs.dry-run == true && '--dry-run' || '' }}
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
run: |
echo "🔍 Labeling PR #$_PR_NUMBER with mode: $_LABEL_MODE and dry-run: $_DRY_RUN"
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_LABEL_MODE" "$_DRY_RUN"
if [ -z "$_PR_LABELS" ] || [ "$_PR_LABELS" = "null" ] || [ "$_PR_LABELS" = "[]" ]; then
echo "🔍 No current PR labels found, retrieving PR data for PR #$_PR_NUMBER..."
_PR_LABELS=$(gh pr view "$_PR_NUMBER" --json labels --jq '.labels')
fi
echo "🔍 Labeling PR #$_PR_NUMBER with mode: \"$_LABEL_MODE\" and dry-run: \"$_DRY_RUN\" and current PR labels: \"$_PR_LABELS\"..."
echo "🐍 Running label-pr.py script..."
echo ""
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_PR_LABELS" "$_LABEL_MODE" "$_DRY_RUN"

View File

@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1200.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1211.0)
aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@ -17,11 +17,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.209.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)

View File

@ -260,6 +260,8 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.bitwarden.sdk)
implementation(libs.bumptech.glide)
implementation(libs.bumptech.glide.okhttp)
ksp(libs.bumptech.glide.compiler)
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.collections.immutable)
@ -299,16 +301,16 @@ dependencies {
testImplementation(libs.square.turbine)
}
tasks {
withType<Test> {
useJUnitPlatform()
maxHeapSize = "2g"
maxParallelForks = Runtime.getRuntime().availableProcessors()
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
// Explicitly setting the user Country and Language because tests assume en-US
"-Duser.country=US" +
"-Duser.language=en"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
@Suppress("MagicNumber")
forkEvery = 100
maxHeapSize = "2g"
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
// Explicitly setting the user Country and Language because tests assume en-US
"-Duser.country=US" +
"-Duser.language=en"
}
afterEvaluate {

View File

@ -0,0 +1,70 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "2835802f9de260f6f5109c81081e9b46",
"entities": [
{
"tableName": "organization_events",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL, `organization_id` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "organizationEventType",
"columnName": "organization_event_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "cipherId",
"columnName": "cipher_id",
"affinity": "TEXT"
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "organizationId",
"columnName": "organization_id",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_organization_events_user_id",
"unique": false,
"columnNames": [
"user_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2835802f9de260f6f5109c81081e9b46')"
]
}
}

View File

@ -84,15 +84,6 @@
<data android:host="*.bitwarden.eu" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<intent-filter>
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
@ -120,6 +111,35 @@
</intent-filter>
</activity>
<!-- Credential Provider Activity for handling passkey and password credential operations.
This activity is NOT exported to protect against external apps attempting to extract
vault credentials by sending malicious intents. Only our own PendingIntents can
launch this activity.
This is a transparent trampoline activity that launches MainActivity for credential
operations and forwards results back to the Credential Manager framework.
Uses Theme.Translucent.NoTitleBar for invisibility while allowing normal lifecycle
(Theme.NoDisplay requires finish() before onResume(), incompatible with ActivityResult).
Note: Unlike AuthCallbackActivity, this does NOT use noHistory="true" because it
must remain in the back stack to receive the ActivityResult callback from
MainActivity. -->
<activity
android:name=".CredentialProviderActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name=".AccessibilityActivity"
android:exported="false"

View File

@ -1,5 +1,17 @@
{
"apps": [
{
"type": "android",
"info": {
"package_name": "com.iode.firefox",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "C9:96:DA:AB:86:A8:CD:32:53:77:49:A5:EE:1D:C2:F9:84:F2:9D:43:F3:06:7D:2C:0A:54:BF:8B:BF:AB:62:C0"
}
]
}
},
{
"type": "android",
"info": {

View File

@ -815,6 +815,18 @@
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.zoho.primeum.stable",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "A9:D6:D0:A2:AF:DB:15:84:9B:8C:D3:1D:51:FE:73:B8:E1:B1:70:BA:A5:70:C2:F8:F2:A3:F8:65:28:29:CB:BD"
}
]
}
}
]
}

View File

@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.hilt.android.HiltAndroidApp
@ -24,9 +23,6 @@ class BitwardenApplication : Application() {
@Inject
lateinit var logsManager: LogsManager
@Inject
lateinit var networkConnectionManager: NetworkConnectionManager
@Inject
lateinit var networkConfigManager: NetworkConfigManager

View File

@ -0,0 +1,89 @@
package com.x8bit.bitwarden
import android.app.ComponentCaller
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.util.validate
import com.x8bit.bitwarden.data.credentials.BitwardenCredentialProviderService
import dagger.hilt.android.AndroidEntryPoint
/**
* Transparent trampoline activity for handling credential provider operations.
*
* This activity is declared as `exported="false"` in the manifest to ensure only
* our own PendingIntents can launch it. This protects against external apps attempting
* to extract vault credentials by sending malicious intents via CredentialManager.
*
* All credential flows (FIDO2 passkeys, password credentials) are routed through this
* activity when triggered by the Android CredentialManager framework via our
* [BitwardenCredentialProviderService].
*
* ## Architecture
*
* This activity does not host any UI itself. It acts as a trampoline that:
* 1. Receives the credential intent from the CredentialManager framework
* 2. Sets the pending credential request via [CredentialProviderViewModel], which stores
* it in `CredentialProviderRequestManager` for secure relay to [MainViewModel]
* 3. Launches [MainActivity] to handle the actual credential UI
* 4. Forwards the result back to the CredentialManager framework
*
* This preserves the single-Activity architecture where all UI is hosted by MainActivity,
* while still allowing the CredentialManager framework to receive results properly.
*/
@OmitFromCoverage
@AndroidEntryPoint
class CredentialProviderActivity : ComponentActivity() {
private val viewModel: CredentialProviderViewModel by viewModels()
/**
* Launcher for MainActivity that forwards the result back to Credential Manager.
*/
private val mainActivityLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
// Forward result back to Credential Manager framework
setResult(result.resultCode, result.data)
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
intent = intent.validate()
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
// Process credential intent (sets pending request on CredentialProviderRequestManager)
viewModel.trySendAction(CredentialProviderAction.ReceiveFirstIntent(intent))
launchMainActivityForResult()
}
// On restoration (process death), result comes via mainActivityLauncher callback
}
private fun launchMainActivityForResult() {
val mainIntent = Intent(this, MainActivity::class.java).apply {
// Pending credential request is retrieved by MainViewModel from
// CredentialProviderRequestManager, triggering appropriate navigation.
// CredentialProviderCompletionManager handles setResult/finish.
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
mainActivityLauncher.launch(mainIntent)
}
override fun onNewIntent(intent: Intent) {
val newIntent = intent.validate()
super.onNewIntent(newIntent)
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
launchMainActivityForResult()
}
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
val newIntent = intent.validate()
super.onNewIntent(newIntent, caller)
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
launchMainActivityForResult()
}
}

View File

@ -0,0 +1,110 @@
package com.x8bit.bitwarden
import android.content.Intent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* A view model that handles credential provider operations for [CredentialProviderActivity].
*
* This ViewModel processes credential-related intents and sets the pending credential request
* on [CredentialProviderRequestManager] for relay to [MainViewModel]. This ensures credential
* data is never passed through intent extras to exported activities, providing security
* hardening against malicious intent attacks.
*
* Since [CredentialProviderActivity] is a transparent trampoline with no UI, this ViewModel only
* handles intent processing. All UI state management (theme, feature flags, auth flows) is
* handled by [MainActivity].
*
* @see RootNavViewModel for navigation based on SpecialCircumstance.
*/
@HiltViewModel
class CredentialProviderViewModel @Inject constructor(
private val credentialProviderRequestManager: CredentialProviderRequestManager,
private val authRepository: AuthRepository,
private val bitwardenCredentialManager: BitwardenCredentialManager,
) : BaseViewModel<Unit, Unit, CredentialProviderAction>(initialState = Unit) {
override fun handleAction(action: CredentialProviderAction) {
when (action) {
is CredentialProviderAction.ReceiveFirstIntent -> handleIntent(action.intent)
is CredentialProviderAction.ReceiveNewIntent -> handleIntent(action.intent)
}
}
private fun handleIntent(intent: Intent) {
intent.getCreateCredentialRequestOrNull()?.let { handleCreateCredential(it) }
?: intent.getFido2AssertionRequestOrNull()?.let { handleFido2Assertion(it) }
?: intent.getProviderGetPasswordRequestOrNull()?.let { handlePasswordGet(it) }
?: intent.getGetCredentialsRequestOrNull()?.let { handleGetCredentials(it) }
}
private fun handleCreateCredential(request: CreateCredentialRequest) {
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
// Switch accounts if the selected user is not the active user
if (authRepository.activeUserId != null &&
authRepository.activeUserId != request.userId
) {
authRepository.switchAccount(request.userId)
}
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.CreateCredential(request),
)
}
private fun handleFido2Assertion(request: Fido2CredentialAssertionRequest) {
// Set the user's verification status when a new FIDO 2 request is received
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.Fido2Assertion(request),
)
}
private fun handlePasswordGet(request: ProviderGetPasswordCredentialRequest) {
// Set the user's verification status when a new GetPassword request is received
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.GetPassword(request),
)
}
private fun handleGetCredentials(request: GetCredentialsRequest) {
credentialProviderRequestManager.setPendingCredentialRequest(
CredentialProviderRequest.GetCredentials(request),
)
}
}
/**
* Models actions for the [CredentialProviderViewModel].
*/
sealed class CredentialProviderAction {
/**
* Receive the first intent when the activity is created.
*/
data class ReceiveFirstIntent(val intent: Intent) : CredentialProviderAction()
/**
* Receive a new intent when the activity receives onNewIntent.
*/
data class ReceiveNewIntent(val intent: Intent) : CredentialProviderAction()
}

View File

@ -12,9 +12,11 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.browser.auth.AuthTabIntent
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
@ -120,6 +122,8 @@ class MainActivity : AppCompatActivity() {
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Both root navigation and debug menu exist at this top level.
// The debug menu can appear on top of the rest of the app without

View File

@ -26,11 +26,8 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
@ -42,6 +39,7 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@ -80,7 +78,7 @@ class MainViewModel @Inject constructor(
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val credentialProviderRequestManager: CredentialProviderRequestManager,
private val shareManager: ShareManager,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
@ -314,11 +312,9 @@ class MainViewModel @Inject constructor(
val hasVaultShortcut = intent.isMyVaultShortcut
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val createCredentialRequest = intent.getCreateCredentialRequestOrNull()
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
val credentialProviderRequest =
credentialProviderRequestManager.getPendingCredentialRequest()
when {
passwordlessRequestData != null -> {
authRepository.activeUserId?.let {
@ -376,59 +372,6 @@ class MainViewModel @Inject constructor(
)
}
createCredentialRequest != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
bitwardenCredentialManager.isUserVerified =
createCredentialRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = createCredentialRequest,
)
// Switch accounts if the selected user is not the active user.
if (authRepository.activeUserId != null &&
authRepository.activeUserId != createCredentialRequest.userId
) {
authRepository.switchAccount(createCredentialRequest.userId)
}
}
fido2AssertCredentialRequest != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
bitwardenCredentialManager.isUserVerified =
fido2AssertCredentialRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = fido2AssertCredentialRequest,
)
}
providerGetPasswordRequest != null -> {
// Set the user's verification status when a new GetPassword request is
// received to force explicit verification if the user's vault is
// unlocked when the request is received.
bitwardenCredentialManager.isUserVerified =
providerGetPasswordRequest.isUserPreVerified
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetPasswordRequest(
passwordGetRequest = providerGetPasswordRequest,
)
}
getCredentialsRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.ProviderGetCredentials(
getCredentialsRequest = getCredentialsRequest,
)
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut
@ -452,6 +395,47 @@ class MainViewModel @Inject constructor(
),
)
}
credentialProviderRequest != null -> {
handleCredentialRequest(credentialProviderRequest)
}
}
}
/**
* Handles a credential request relayed from [CredentialProviderActivity] via
* [CredentialProviderRequestManager].
*
* This method converts the [CredentialProviderRequest] into the appropriate
* [SpecialCircumstance] for routing by [RootNavViewModel]. The credential data is trusted
* because it was set by our own [CredentialProviderActivity] through the internal manager,
* not parsed from intent extras.
*/
private fun handleCredentialRequest(request: CredentialProviderRequest) {
specialCircumstanceManager.specialCircumstance = when (request) {
is CredentialProviderRequest.CreateCredential -> {
SpecialCircumstance.ProviderCreateCredential(
createCredentialRequest = request.request,
)
}
is CredentialProviderRequest.Fido2Assertion -> {
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = request.request,
)
}
is CredentialProviderRequest.GetPassword -> {
SpecialCircumstance.ProviderGetPasswordRequest(
passwordGetRequest = request.request,
)
}
is CredentialProviderRequest.GetCredentials -> {
SpecialCircumstance.ProviderGetCredentials(
getCredentialsRequest = request.request,
)
}
}
}

View File

@ -373,7 +373,10 @@ class AuthDiskSourceImpl(
inMemoryOnly: Boolean,
) {
inMemoryPinProtectedUserKeyEnvelopes[userId] = pinProtectedUserKeyEnvelope
if (inMemoryOnly) return
if (inMemoryOnly) {
getMutablePinProtectedUserKeyEnvelopeFlow(userId).tryEmit(pinProtectedUserKeyEnvelope)
return
}
putString(
key = PIN_PROTECTED_USER_KEY_KEY_ENVELOPE.appendIdentifier(userId),
value = pinProtectedUserKeyEnvelope,

View File

@ -12,7 +12,7 @@ sealed class CreateAuthRequestResult {
) : CreateAuthRequestResult()
/**
* Models the data returned when a auth request has been approved.
* Models the data returned when an auth request has been approved.
*/
data class Success(
val authRequest: AuthRequest,
@ -21,7 +21,7 @@ sealed class CreateAuthRequestResult {
) : CreateAuthRequestResult()
/**
* There was a generic error getting the user's auth requests.
* There was a generic error creating the auth request.
*/
data class Error(
val error: Throwable,

View File

@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
@ -17,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@ -43,7 +44,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing an modifying authentication state.
* Provides an API for observing and modifying authentication state.
*/
@Suppress("TooManyFunctions")
interface AuthRepository :
@ -125,7 +126,7 @@ interface AuthRepository :
/**
* The organization for the active user.
*/
val organizations: List<SyncResponseJson.Profile.Organization>
val organizations: List<Organization>
/**
* Whether or not the welcome carousel should be displayed, based on the feature flag and
@ -282,7 +283,7 @@ interface AuthRepository :
): PasswordHintResult
/**
* Removes the users password from the account. This used used when migrating from master
* Removes the users password from the account. This is used when migrating from master
* password login to key connector login.
*/
suspend fun removePassword(masterPassword: String): RemovePasswordResult
@ -384,7 +385,7 @@ interface AuthRepository :
): SendVerificationEmailResult
/**
* Validates the given [token] for the given [email]. Part of th new account registration flow.
* Validates the given [token] for the given [email]. Part of the new account registration flow.
*/
suspend fun validateEmailToken(
email: String,
@ -402,4 +403,11 @@ interface AuthRepository :
suspend fun leaveOrganization(
organizationId: String,
): LeaveOrganizationResult
/**
* Revokes self from the organization that matches the given [organizationId]
*/
suspend fun revokeFromOrganization(
organizationId: String,
): RevokeFromOrganizationResult
}

View File

@ -2,8 +2,10 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.repository.error.MissingPropertyException
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
@ -14,6 +16,7 @@ import com.bitwarden.crypto.Kdf
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.data.repository.util.toEnvironmentUrls
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.CreateAccountKeysResponseJson
import com.bitwarden.network.model.DeleteAccountResponseJson
import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
@ -45,6 +48,7 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.util.isSslHandShakeError
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@ -70,6 +74,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
@ -79,6 +84,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RevokeFromOrganizationResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
@ -93,6 +99,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@ -167,6 +174,7 @@ class AuthRepositoryImpl(
private val policyManager: PolicyManager,
private val userStateManager: UserStateManager,
private val kdfManager: KdfManager,
private val toastManager: ToastManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
@ -285,8 +293,11 @@ class AuthRepositoryImpl(
?.profile
?.forcePasswordResetReason
override val organizations: List<SyncResponseJson.Profile.Organization>
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
override val organizations: List<Organization>
get() = activeUserId
?.let { authDiskSource.getOrganizations(it) }
.orEmpty()
.toOrganizations()
override val showWelcomeCarousel: Boolean
get() = !settingsRepository.hasUserLoggedInOrCreatedAccount
@ -459,42 +470,32 @@ class AuthRepositoryImpl(
.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { keys ->
.flatMap { registerTdeKeyResponse ->
accountsService
.createAccountKeys(
publicKey = keys.publicKey,
encryptedPrivateKey = keys.privateKey,
publicKey = registerTdeKeyResponse.publicKey,
encryptedPrivateKey = registerTdeKeyResponse.privateKey,
)
.map { keys }
.map { createAccountKeysResponse ->
registerTdeKeyResponse to createAccountKeysResponse
}
}
.flatMap { keys ->
.flatMap { (registerTdeKeyResponse, createAccountKeysResponse) ->
organizationService
.organizationResetPasswordEnroll(
organizationId = orgAutoEnrollStatus.organizationId,
userId = userId,
passwordHash = null,
resetPasswordKey = keys.adminReset,
resetPasswordKey = registerTdeKeyResponse.adminReset,
)
.map { keys }
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { keys ->
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
createNewSsoUserSuccess(
userId = userId,
privateKey = keys.privateKey,
createAccountKeysResponse = createAccountKeysResponse,
registerTdeKeyResponse = registerTdeKeyResponse,
)
// Order matters here, we need to make sure that the vault is unlocked
// before we trust the device, to avoid state-base navigation issues.
vaultRepository.syncVaultState(userId = userId)
keys.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
}
}
.fold(
@ -503,6 +504,37 @@ class AuthRepositoryImpl(
)
}
/**
* Stores all the relevant data from a successful creation of an SSO user. The data is stored
* while in an [UserStateManager.userStateTransaction] to ensure the `UserState` is only
* updated once after data stored.
*/
private suspend fun createNewSsoUserSuccess(
userId: String,
createAccountKeysResponse: CreateAccountKeysResponseJson,
registerTdeKeyResponse: RegisterTdeKeyResponse,
): Unit = userStateManager.userStateTransaction {
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { trustDeviceResponse ->
trustedDeviceManager.trustThisDevice(
userId = userId,
trustDeviceResponse = trustDeviceResponse,
)
}
}
override suspend fun completeTdeLogin(
requestPrivateKey: String,
asymmetricalKey: String,
@ -951,8 +983,8 @@ class AuthRepositoryImpl(
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.type != OrganizationType.OWNER &&
it.type != OrganizationType.ADMIN
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
}
?.keyConnectorUrl
?: return RemovePasswordResult.Error(
@ -1014,9 +1046,10 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
}
val userId = activeAccount.profile.userId
return vaultSdkSource
.updatePassword(
userId = activeAccount.profile.userId,
userId = userId,
newPassword = newPassword,
)
.flatMap { updatePasswordResponse ->
@ -1042,14 +1075,15 @@ class AuthRepositoryImpl(
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = activeAccount.profile.userId,
userId = userId,
passwordHash = passwordHash,
)
}
toastManager.show(BitwardenString.updated_master_password)
// Log out the user after successful password reset.
// This clears all user state including forcePasswordResetReason.
logout(reason = LogoutReason.PasswordReset)
logout(reason = LogoutReason.PasswordReset, userId = userId)
// Return the success.
ResetPasswordResult.Success
@ -1393,6 +1427,14 @@ class AuthRepositoryImpl(
onFailure = { LeaveOrganizationResult.Error(error = it) },
)
override suspend fun revokeFromOrganization(
organizationId: String,
): RevokeFromOrganizationResult =
organizationService.revokeFromOrganization(organizationId).fold(
onSuccess = { RevokeFromOrganizationResult.Success },
onFailure = { RevokeFromOrganizationResult.Error(error = it) },
)
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.network.service.AccountsService
import com.bitwarden.network.service.DevicesService
@ -71,6 +72,7 @@ object AuthRepositoryModule {
logsManager: LogsManager,
userStateManager: UserStateManager,
kdfManager: KdfManager,
toastManager: ToastManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@ -97,6 +99,7 @@ object AuthRepositoryModule {
logsManager = logsManager,
userStateManager = userStateManager,
kdfManager = kdfManager,
toastManager = toastManager,
)
@Provides

View File

@ -14,14 +14,16 @@ import com.bitwarden.network.model.OrganizationType
* @property keyConnectorUrl The key connector domain (if applicable).
* @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.
*/
data class Organization(
val id: String,
val name: String?,
val name: String,
val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
val keyConnectorUrl: String?,
val userIsClaimedByOrganization: Boolean,
val limitItemDeletion: Boolean = false,
val limitItemDeletion: Boolean,
val shouldUseEvents: Boolean,
)

View File

@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of leaving an organization.
*/
sealed class RevokeFromOrganizationResult {
/**
* Revoke from organization succeeded.
*/
data object Success : RevokeFromOrganizationResult()
/**
* There was an error revoking from the organization.
*/
data class Error(
val error: Throwable?,
) : RevokeFromOrganizationResult()
}

View File

@ -9,7 +9,7 @@ data class UserAccountTokens(
val refreshToken: String?,
) {
/**
* Returns `true` if the user is logged in, `false otherwise.
* Returns `true` if the user is logged in, `false` otherwise.
*/
val isLoggedIn: Boolean get() = accessToken != null
}

View File

@ -13,26 +13,30 @@ private val JSON = Json {
}
/**
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization].
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization] or `null` if the
* [SyncResponseJson.Profile.Organization.name] is not present.
*/
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
Organization(
id = this.id,
name = this.name,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
)
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
this.name?.let {
Organization(
id = this.id,
name = it,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
limitItemDeletion = this.limitItemDeletion,
shouldUseEvents = this.shouldUseEvents,
)
}
/**
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
* [Organization]s.
*/
fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organization> =
this.map { it.toOrganization() }
this.mapNotNull { it.toOrganization() }
/**
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.

View File

@ -68,6 +68,8 @@ class AutofillCipherProviderImpl(
it.type is CipherListViewType.Card &&
// Must not be deleted.
it.deletedDate == null &&
// Must not be archived.
it.archivedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE &&
// Must not be restricted by organization.
@ -106,6 +108,8 @@ class AutofillCipherProviderImpl(
it.type is CipherListViewType.Login &&
// Must not be deleted.
it.deletedDate == null &&
// Must not be archived.
it.archivedDate == null &&
// Must not require a reprompt.
it.reprompt == CipherRepromptType.NONE
}

View File

@ -54,7 +54,7 @@ val CipherView.isActiveWithFido2Credentials: Boolean
get() = deletedDate == null && !(login?.fido2Credentials.isNullOrEmpty())
/**
* Returns true when the cipher is not deleted and contains at least one Pasword credential.
* Returns true when the cipher is not deleted and contains at least one Password credential.
*/
val CipherView.isActiveWithPasswordCredentials: Boolean
get() = deletedDate == null && !(login?.password.isNullOrEmpty())

View File

@ -14,6 +14,8 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManagerImpl
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser
@ -145,4 +147,9 @@ object CredentialProviderModule {
@Singleton
fun providePasskeyAttestationOptionsSanitizer(): PasskeyAttestationOptionsSanitizer =
PasskeyAttestationOptionsSanitizerImpl
@Provides
@Singleton
fun provideCredentialProviderRequestManager(): CredentialProviderRequestManager =
CredentialProviderRequestManagerImpl()
}

View File

@ -50,6 +50,8 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import timber.log.Timber
private const val DAL_ROUTE = ".well-known/assetlinks.json"
/**
* Primary implementation of [BitwardenCredentialManager].
*/
@ -123,7 +125,7 @@ class BitwardenCredentialManagerImpl(
.getSignatureFingerprintAsHexString()
.orEmpty(),
host = hostUrl,
assetLinkUrl = hostUrl,
assetLinkUrl = hostUrl.toDigitalAssetLinkUrl(),
),
)
}
@ -316,7 +318,7 @@ class BitwardenCredentialManagerImpl(
packageName = callingAppInfo.packageName,
sha256CertFingerprint = signatureFingerprint,
host = host,
assetLinkUrl = host,
assetLinkUrl = host.toDigitalAssetLinkUrl(),
),
)
@ -428,6 +430,13 @@ class BitwardenCredentialManagerImpl(
?.relyingParty
?.id
?.prefixHttpsIfNecessaryOrNull()
private fun String.toDigitalAssetLinkUrl(): String =
when {
this.endsWith(DAL_ROUTE) -> this
this.endsWith("/") -> "$this$DAL_ROUTE"
else -> "$this/$DAL_ROUTE"
}
}
private const val MAX_AUTHENTICATION_ATTEMPTS = 5

View File

@ -23,6 +23,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(CREATE_PASSKEY_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
@ -44,6 +45,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(GET_PASSKEY_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
.putExtra(EXTRA_KEY_CREDENTIAL_ID, credentialId)
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
@ -65,6 +67,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(UNLOCK_ACCOUNT_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
@ -83,6 +86,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(CREATE_PASSWORD_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
return PendingIntent.getActivity(
@ -103,6 +107,7 @@ class CredentialManagerPendingIntentManagerImpl(
): PendingIntent {
val intent = Intent(GET_PASSWORD_ACTION)
.setPackage(context.packageName)
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
.putExtra(EXTRA_KEY_USER_ID, userId)
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
.putExtra(EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK, isUserVerified)
@ -116,6 +121,7 @@ class CredentialManagerPendingIntentManagerImpl(
}
}
private val CREDENTIAL_ACTIVITY_CLASS = com.x8bit.bitwarden.CredentialProviderActivity::class.java
private const val CREATE_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY"
private const val UNLOCK_ACCOUNT_ACTION = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT"
private const val GET_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY"

View File

@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.credentials.manager
import com.x8bit.bitwarden.CredentialProviderActivity
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.MainViewModel
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
/**
* Manages pending credential provider requests, relaying them from [CredentialProviderActivity]
* to [MainActivity] via a pull-based pattern.
*
* This approach ensures credential data is never passed through intent extras to
* exported activities. [CredentialProviderActivity] sets the request, then [MainViewModel]
* retrieves it once when handling the incoming intent.
*/
interface CredentialProviderRequestManager {
/**
* Set a pending credential request.
*/
fun setPendingCredentialRequest(request: CredentialProviderRequest)
/**
* Get and clear the pending credential request. Returns null if no request is pending.
*/
fun getPendingCredentialRequest(): CredentialProviderRequest?
}

View File

@ -0,0 +1,25 @@
package com.x8bit.bitwarden.data.credentials.manager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Singleton
/**
* Primary implementation of [CredentialProviderRequestManager].
*
* Uses an [AtomicReference] for thread-safe get-and-clear semantics, ensuring
* the pending request is only processed once.
*/
@Singleton
class CredentialProviderRequestManagerImpl : CredentialProviderRequestManager {
private val pendingRequest = AtomicReference<CredentialProviderRequest?>(null)
override fun setPendingCredentialRequest(request: CredentialProviderRequest) {
pendingRequest.set(request)
}
override fun getPendingCredentialRequest(): CredentialProviderRequest? {
return pendingRequest.getAndSet(null)
}
}

View File

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.data.credentials.manager.model
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
/**
* Represents a pending credential provider request to be processed by MainActivity.
*/
sealed class CredentialProviderRequest {
/**
* Request to create a new FIDO2 passkey credential.
*/
data class CreateCredential(
val request: CreateCredentialRequest,
) : CredentialProviderRequest()
/**
* Request to assert (authenticate with) an existing FIDO2 passkey.
*/
data class Fido2Assertion(
val request: Fido2CredentialAssertionRequest,
) : CredentialProviderRequest()
/**
* Request to retrieve a password credential.
*/
data class GetPassword(
val request: ProviderGetPasswordCredentialRequest,
) : CredentialProviderRequest()
/**
* Request to get available credentials (BeginGetCredential flow).
*/
data class GetCredentials(
val request: GetCredentialsRequest,
) : CredentialProviderRequest()
}

View File

@ -7,6 +7,7 @@ import androidx.credentials.provider.PasswordCredentialEntry
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.core.util.isHyperOS
import javax.crypto.Cipher
/**
@ -15,7 +16,7 @@ import javax.crypto.Cipher
fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
): PublicKeyCredentialEntry.Builder =
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
if (isBiometricPromptDataSupported() && cipher != null) {
setBiometricPromptData(
biometricPromptData = buildPromptDataWithCipher(cipher),
)
@ -29,10 +30,19 @@ fun PublicKeyCredentialEntry.Builder.setBiometricPromptDataIfSupported(
fun PasswordCredentialEntry.Builder.setBiometricPromptDataIfSupported(
cipher: Cipher?,
): PasswordCredentialEntry.Builder =
if (isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) && cipher != null) {
if (isBiometricPromptDataSupported() && cipher != null) {
setBiometricPromptData(
biometricPromptData = buildPromptDataWithCipher(cipher),
)
} else {
this
}
/**
* Returns whether biometric prompt data is supported on this device.
* Note: Xiaomi HyperOS is known to be incompatible.
*/
private fun isBiometricPromptDataSupported(): Boolean {
return isBuildVersionAtLeast(Build.VERSION_CODES.VANILLA_ICE_CREAM) &&
!isHyperOS()
}

View File

@ -30,6 +30,7 @@ class EventDiskSourceImpl(
},
cipherId = event.cipherId,
date = event.date,
organizationId = event.organizationId,
),
)
}
@ -48,6 +49,7 @@ class EventDiskSourceImpl(
},
cipherId = it.cipherId,
date = it.date,
organizationId = it.organizationId,
)
}
}

View File

@ -105,6 +105,24 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun clearData(userId: String)
/**
* Retrieves the stored value of whether the introducing archive action card has been dismissed.
*/
fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean?
/**
* Stores whether the introducing archive action card has been dismissed.
*/
fun storeIntroducingArchiveActionCardDismissed(
userId: String,
isDismissed: Boolean?,
)
/**
* Emits updates that track [getIntroducingArchiveActionCardDismissed] for the given [userId].
*/
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].

View File

@ -49,6 +49,8 @@ private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMa
private const val RESUME_SCREEN = "resumeScreen"
private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
"introducingArchiveActionCardDismissed"
/**
* Primary implementation of [SettingsDiskSource].
@ -87,6 +89,9 @@ class SettingsDiskSourceImpl(
private val mutableShowImportLoginsSettingBadgeFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@ -240,8 +245,29 @@ class SettingsDiskSourceImpl(
// - show unlock setting badge
// - should show add login coach mark
// - should show generator coach mark
// - should show introducing archive action card dismissed
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
getBoolean(
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
)
override fun storeIntroducingArchiveActionCardDismissed(
userId: String,
isDismissed: Boolean?,
) {
putBoolean(
key = INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED.appendIdentifier(identifier = userId),
value = isDismissed,
)
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed)
}
override fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?> =
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@ -579,6 +605,13 @@ class SettingsDiskSourceImpl(
override fun getAppResumeScreen(userId: String): AppResumeScreenData? =
getString(RESUME_SCREEN.appendIdentifier(userId))?.let { json.decodeFromStringOrNull(it) }
private fun getMutableIntroducingArchiveActionCardDismissedFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableIntroducingArchiveActionCardDismissedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.database
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@ -14,8 +15,11 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTyp
entities = [
OrganizationEventEntity::class,
],
version = 1,
version = 2,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 1, to = 2),
],
)
@TypeConverters(ZonedDateTimeTypeConverter::class)
abstract class PlatformDatabase : RoomDatabase() {

View File

@ -25,4 +25,7 @@ data class OrganizationEventEntity(
@ColumnInfo(name = "date")
val date: ZonedDateTime,
@ColumnInfo(name = "organization_id")
val organizationId: String?,
)

View File

@ -79,6 +79,7 @@ class OrganizationEventManagerImpl(
type = event.type,
cipherId = event.cipherId,
date = ZonedDateTime.now(clock),
organizationId = event.organizationId,
),
)
}

View File

@ -16,11 +16,17 @@ sealed class OrganizationEvent {
*/
abstract val cipherId: String?
/**
* The optional organization ID.
*/
abstract val organizationId: String?
/**
* Tracks when a value is successfully auto-filled
*/
data class CipherClientAutoFilled(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_AUTO_FILLED
@ -31,6 +37,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientCopiedCardCode(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_CARD_CODE
@ -41,6 +48,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientCopiedHiddenField(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_HIDDEN_FIELD
@ -51,6 +59,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientCopiedPassword(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_COPIED_PASSWORD
@ -61,6 +70,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledCardCodeVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_CARD_CODE_VISIBLE
@ -71,6 +81,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledCardNumberVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_CARD_NUMBER_VISIBLE
@ -81,6 +92,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledHiddenFieldVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_HIDDEN_FIELD_VISIBLE
@ -91,6 +103,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientToggledPasswordVisible(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_TOGGLED_PASSWORD_VISIBLE
@ -101,6 +114,7 @@ sealed class OrganizationEvent {
*/
data class CipherClientViewed(
override val cipherId: String,
override val organizationId: String? = null,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.CIPHER_CLIENT_VIEWED
@ -111,6 +125,7 @@ sealed class OrganizationEvent {
*/
data object UserClientExportedVault : OrganizationEvent() {
override val cipherId: String? = null
override val organizationId: String? = null
override val type: OrganizationEventType
get() = OrganizationEventType.USER_CLIENT_EXPORTED_VAULT
}
@ -119,8 +134,10 @@ sealed class OrganizationEvent {
* Tracks when a user's personal ciphers have been migrated to their organization's My Items
* folder as required by the organization's personal vault ownership policy.
*/
data object ItemOrganizationAccepted : OrganizationEvent() {
override val cipherId: String? = null
data class ItemOrganizationAccepted(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_ACCEPTED
}
@ -129,8 +146,10 @@ sealed class OrganizationEvent {
* Tracks when a user chooses to leave an organization instead of migrating their personal
* ciphers to their organization's My Items folder.
*/
data object ItemOrganizationDeclined : OrganizationEvent() {
override val cipherId: String? = null
data class ItemOrganizationDeclined(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_DECLINED
}

View File

@ -242,6 +242,16 @@ interface SettingsRepository : FlightRecorderManager {
*/
fun storePullToRefreshEnabled(isPullToRefreshEnabled: Boolean)
/**
* Gets updates for whether the introducing archive action card is dismissed.
*/
fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean>
/**
* Stores that the introducing archive action card has been dismissed for the active user.
*/
fun dismissIntroducingArchiveActionCard()
/**
* Stores the encrypted user key for biometrics, allowing it to be used to unlock the current
* user's vault.

View File

@ -500,6 +500,29 @@ class SettingsRepositoryImpl(
}
}
override fun getIntroducingArchiveActionCardDismissedFlow(): StateFlow<Boolean> {
val userId = activeUserId ?: return MutableStateFlow(value = false)
return settingsDiskSource
.getIntroducingArchiveActionCardDismissedFlow(userId = userId)
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource
.getIntroducingArchiveActionCardDismissed(userId = userId)
?: false,
)
}
override fun dismissIntroducingArchiveActionCard() {
activeUserId?.let {
settingsDiskSource.storeIntroducingArchiveActionCardDismissed(
userId = it,
isDismissed = true,
)
}
}
override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult {
val userId = activeUserId
?: return BiometricsKeyResult.Error(error = NoActiveUserException())

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionId
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.EnrollPinResponse
import com.bitwarden.core.InitOrgCryptoRequest
@ -389,6 +390,16 @@ interface VaultSdkSource {
cipherView: CipherView,
): Result<CipherView>
/**
* Re-encrypts the [cipherViews] with the organizations encryption key into the respective [collectionIds]
*/
suspend fun bulkMoveToOrganization(
userId: String,
organizationId: String,
cipherViews: List<CipherView>,
collectionIds: List<CollectionId>,
): Result<List<EncryptionContext>>
/**
* Validates that the given password matches the password hash.
*/

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk
import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionId
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.DeriveKeyConnectorException
import com.bitwarden.core.DeriveKeyConnectorRequest
@ -451,6 +452,22 @@ class VaultSdkSourceImpl(
.moveToOrganization(cipher = cipherView, organizationId = organizationId)
}
override suspend fun bulkMoveToOrganization(
userId: String,
organizationId: String,
cipherViews: List<CipherView>,
collectionIds: List<CollectionId>,
): Result<List<EncryptionContext>> = runCatchingWithLogs {
getClient(userId = userId)
.vault()
.ciphers()
.prepareCiphersForBulkShare(
organizationId = organizationId,
ciphers = cipherViews,
collectionIds = collectionIds,
)
}
override suspend fun validatePassword(
userId: String,
password: String,

View File

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.vault.manager
import android.net.Uri
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
@ -10,6 +11,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
/**
@ -57,6 +59,22 @@ interface CipherManager {
*/
suspend fun getCipher(cipherId: String): GetCipherResult
/**
* Attempt to archive a cipher.
*/
suspend fun archiveCipher(
cipherId: String,
cipherView: CipherView,
): ArchiveCipherResult
/**
* Attempt to unarchive a cipher.
*/
suspend fun unarchiveCipher(
cipherId: String,
cipherView: CipherView,
): UnarchiveCipherResult
/**
* Attempt to delete a cipher.
*/
@ -115,4 +133,12 @@ interface CipherManager {
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult
/**
* Migrate the attachments if they don't have their own key
*/
suspend fun migrateAttachments(
userId: String,
cipherView: CipherView,
): Result<CipherView>
}

View File

@ -28,6 +28,7 @@ import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
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.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult
@ -35,6 +36,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.ShareCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipherResponse
@ -161,6 +163,76 @@ class CipherManagerImpl(
)
}
override suspend fun archiveCipher(
cipherId: String,
cipherView: CipherView,
): ArchiveCipherResult {
val userId = activeUserId ?: return ArchiveCipherResult.Error(NoActiveUserException())
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { encryptionContext ->
ciphersService
.archiveCipher(cipherId = cipherId)
.flatMap {
vaultSdkSource.decryptCipher(
userId = userId,
cipher = encryptionContext.cipher,
)
}
}
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = it.copy(archivedDate = clock.instant()),
)
}
.onSuccess {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { ArchiveCipherResult.Success },
onFailure = { ArchiveCipherResult.Error(error = it) },
)
}
override suspend fun unarchiveCipher(
cipherId: String,
cipherView: CipherView,
): UnarchiveCipherResult {
val userId = activeUserId ?: return UnarchiveCipherResult.Error(NoActiveUserException())
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { encryptionContext ->
ciphersService
.unarchiveCipher(cipherId = cipherId)
.flatMap {
vaultSdkSource.decryptCipher(
userId = userId,
cipher = encryptionContext.cipher,
)
}
}
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = it.copy(archivedDate = null),
)
}
.onSuccess {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { UnarchiveCipherResult.Success },
onFailure = { UnarchiveCipherResult.Error(error = it) },
)
}
override suspend fun hardDeleteCipher(cipherId: String): DeleteCipherResult {
val userId = activeUserId
?: return DeleteCipherResult.Error(error = NoActiveUserException())
@ -613,7 +685,7 @@ class CipherManagerImpl(
}
}
private suspend fun migrateAttachments(
override suspend fun migrateAttachments(
userId: String,
cipherView: CipherView,
): Result<CipherView> {

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.vault.manager
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import kotlinx.coroutines.flow.StateFlow
/**
@ -16,4 +17,22 @@ interface VaultMigrationManager {
* Automatically updated when cipher data, policies, or feature flags change.
*/
val vaultMigrationDataStateFlow: StateFlow<VaultMigrationData>
/**
* Migrates all personal vault items to the specified organization.
*
* @param userId The ID of the user performing the migration.
* @param organizationId The ID of the organization to migrate items to.
* @return Result indicating success or failure of the migration operation.
*/
suspend fun migratePersonalVault(
userId: String,
organizationId: String,
): MigratePersonalVaultResult
/**
* Clears the migration state, setting it to [VaultMigrationData.NoMigrationRequired].
* This should be called when the user declines migration or leaves the organization.
*/
fun clearMigrationState()
}

View File

@ -1,8 +1,18 @@
package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.collections.CollectionType
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.toCipherWithIdJsonRequest
import com.bitwarden.network.service.CiphersService
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
@ -10,7 +20,14 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndUnlocked
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.manager.model.VaultMigrationData
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.MigratePersonalVaultResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import com.x8bit.bitwarden.data.vault.repository.util.updateFromMiniResponse
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -20,6 +37,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import timber.log.Timber
/**
* Default implementation of [VaultMigrationManager].
@ -34,6 +52,9 @@ import kotlinx.coroutines.flow.update
class VaultMigrationManagerImpl(
private val authDiskSource: AuthDiskSource,
private val vaultDiskSource: VaultDiskSource,
private val vaultRepository: VaultRepository,
private val vaultSdkSource: VaultSdkSource,
private val ciphersService: CiphersService,
private val settingsDiskSource: SettingsDiskSource,
private val policyManager: PolicyManager,
private val featureFlagManager: FeatureFlagManager,
@ -152,4 +173,153 @@ class VaultMigrationManagerImpl(
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
isNetworkConnected &&
hasPersonalCiphers
override suspend fun migratePersonalVault(
userId: String,
organizationId: String,
): MigratePersonalVaultResult {
val vaultData = vaultRepository.vaultDataStateFlow.value.data
?: return MigratePersonalVaultResult.Failure(
IllegalStateException("Vault data not available"),
)
val defaultUserCollection = getDefaultUserCollection(vaultData, organizationId)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
val personalCiphers = getPersonalCipherViews(vaultData)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
if (personalCiphers.isEmpty()) {
clearMigrationState()
return MigratePersonalVaultResult.Success
}
val cipherIds = personalCiphers.mapNotNull { it.id }
val encryptedCiphers = vaultDiskSource.getSelectedCiphers(
userId = userId,
cipherIds = cipherIds,
)
val encryptedCiphersMap = encryptedCiphers.associateBy { it.id }
val processedCipherViews = migrateAttachments(userId, personalCiphers)
.getOrElse { return MigratePersonalVaultResult.Failure(it) }
encryptAndShareCiphers(
userId = userId,
organizationId = organizationId,
processedCipherViews = processedCipherViews,
encryptedCiphersMap = encryptedCiphersMap,
collectionIds = listOfNotNull(defaultUserCollection.id),
).getOrElse { return MigratePersonalVaultResult.Failure(it) }
clearMigrationState()
return MigratePersonalVaultResult.Success
}
override fun clearMigrationState() {
mutableVaultMigrationDataStateFlow.update { VaultMigrationData.NoMigrationRequired }
}
private fun getDefaultUserCollection(
vaultData: VaultData,
organizationId: String,
): Result<CollectionView> {
val collection = vaultData.collectionViewList.find {
it.type == CollectionType.DEFAULT_USER_COLLECTION && it.organizationId == organizationId
}
return collection?.asSuccess()
?: IllegalStateException("Default user collection not found for organization")
.asFailure()
}
private suspend fun getPersonalCipherViews(
vaultData: VaultData,
): Result<List<CipherView>> = runCatching {
vaultData.decryptCipherListResult.successes
.filter { it.organizationId == null }
.mapNotNull { cipherListView ->
cipherListView.id?.let { cipherId ->
vaultRepository
.getCipher(cipherId = cipherId)
.toCipherViewOrFailure()
?.getOrElse { error ->
return error.asFailure()
}
}
}
}
private suspend fun migrateAttachments(
userId: String,
personalCiphers: List<CipherView>,
): Result<List<CipherView>> = runCatching {
personalCiphers.map { cipherView ->
vaultRepository
.migrateAttachments(userId = userId, cipherView = cipherView)
.getOrElse { error ->
return error.asFailure()
}
}
}
private suspend fun encryptAndShareCiphers(
userId: String,
organizationId: String,
processedCipherViews: List<CipherView>,
encryptedCiphersMap: Map<String, SyncResponseJson.Cipher>,
collectionIds: List<String>,
): Result<Unit> {
return vaultSdkSource
.bulkMoveToOrganization(
userId = userId,
organizationId = organizationId,
cipherViews = processedCipherViews,
collectionIds = collectionIds,
)
.map { encryptionContexts ->
encryptionContexts.mapNotNull { context ->
context.cipher.id?.let { cipherId ->
context
.toEncryptedNetworkCipher()
.toCipherWithIdJsonRequest(id = cipherId)
}
}
}
.flatMap { cipherRequests ->
ciphersService.bulkShareCiphers(
body = BulkShareCiphersJsonRequest(
ciphers = cipherRequests,
collectionIds = collectionIds,
),
)
}
.map { bulkShareResponse ->
bulkShareResponse.cipherMiniResponse.forEach { miniResponse ->
encryptedCiphersMap[miniResponse.id]?.let {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.updateFromMiniResponse(
miniResponse = miniResponse,
collectionIds = collectionIds,
),
)
}
}
}
}
private fun GetCipherResult.toCipherViewOrFailure(): Result<CipherView>? =
when (this) {
GetCipherResult.CipherNotFound -> {
Timber.e("Cipher not found for vault migration.")
null
}
is GetCipherResult.Failure -> {
Timber.e(this.error, "Failed to decrypt cipher for vault migration.")
this.error.asFailure()
}
is GetCipherResult.Success -> this.cipherView.asSuccess()
}
}

View File

@ -43,6 +43,7 @@ import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManager
import com.x8bit.bitwarden.data.vault.manager.VaultMigrationManagerImpl
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManager
import com.x8bit.bitwarden.data.vault.manager.VaultSyncManagerImpl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -64,6 +65,9 @@ object VaultManagerModule {
fun provideVaultMigrationManager(
authDiskSource: AuthDiskSource,
vaultDiskSource: VaultDiskSource,
vaultRepository: VaultRepository,
vaultSdkSource: VaultSdkSource,
ciphersService: CiphersService,
settingsDiskSource: SettingsDiskSource,
vaultLockManager: VaultLockManager,
policyManager: PolicyManager,
@ -73,6 +77,9 @@ object VaultManagerModule {
): VaultMigrationManager = VaultMigrationManagerImpl(
authDiskSource = authDiskSource,
vaultDiskSource = vaultDiskSource,
vaultRepository = vaultRepository,
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
settingsDiskSource = settingsDiskSource,
vaultLockManager = vaultLockManager,
policyManager = policyManager,

View File

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of archiving a cipher.
*/
sealed class ArchiveCipherResult {
/**
* Cipher archived successfully.
*/
data object Success : ArchiveCipherResult()
/**
* Generic error while archiving a cipher.
*/
data class Error(val error: Throwable) : ArchiveCipherResult()
}

View File

@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of migrating the personal vault.
*/
sealed class MigratePersonalVaultResult {
/**
* Personal vault migrated successfully.
*/
data object Success : MigratePersonalVaultResult()
/**
* Generic error while migrating personal vault
*/
data class Failure(val error: Throwable?) : MigratePersonalVaultResult()
}

View File

@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.vault.repository.model
/**
* Models result of unarchiving a cipher.
*/
sealed class UnarchiveCipherResult {
/**
* Cipher unarchived successfully.
*/
data object Success : UnarchiveCipherResult()
/**
* Generic error while unarchiving a cipher.
*/
data class Error(val error: Throwable) : UnarchiveCipherResult()
}

View File

@ -5,6 +5,7 @@ package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparator
import com.bitwarden.network.model.AttachmentJsonRequest
import com.bitwarden.network.model.CipherJsonRequest
import com.bitwarden.network.model.CipherMiniResponseJson
import com.bitwarden.network.model.CipherRepromptTypeJson
import com.bitwarden.network.model.CipherTypeJson
import com.bitwarden.network.model.FieldTypeJson
@ -109,6 +110,32 @@ fun Cipher.toEncryptedNetworkCipherResponse(
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
)
/**
* Updates a [SyncResponseJson.Cipher] with metadata from a
* [CipherMiniResponseJson.CipherMiniResponse].
* This is useful for updating local cipher data after bulk operations that return mini responses.
*
* @param miniResponse The mini response containing updated cipher metadata.
* @param collectionIds Optional list of collection IDs to update.
* If null, keeps existing collection IDs.
* @return A new [SyncResponseJson.Cipher] with updated fields from the mini response.
*/
fun SyncResponseJson.Cipher.updateFromMiniResponse(
miniResponse: CipherMiniResponseJson.CipherMiniResponse,
collectionIds: List<String>? = null,
): SyncResponseJson.Cipher = copy(
organizationId = miniResponse.organizationId,
collectionIds = collectionIds ?: this.collectionIds,
revisionDate = miniResponse.revisionDate,
key = miniResponse.key,
attachments = miniResponse.attachments,
archivedDate = miniResponse.archivedDate,
deletedDate = miniResponse.deletedDate,
reprompt = miniResponse.reprompt,
shouldOrganizationUseTotp = miniResponse.shouldOrganizationUseTotp,
type = miniResponse.type,
)
/**
* Converts a Bitwarden SDK [Card] object to a corresponding
* [SyncResponseJson.Cipher.Card] object.

View File

@ -4,6 +4,7 @@ import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparat
import com.bitwarden.network.model.SendJsonRequest
import com.bitwarden.network.model.SendTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.send.AuthType
import com.bitwarden.send.Send
import com.bitwarden.send.SendFile
import com.bitwarden.send.SendText
@ -92,6 +93,8 @@ fun SyncResponseJson.Send.toEncryptedSdkSend(): Send =
revisionDate = revisionDate.toInstant(),
deletionDate = deletionDate.toInstant(),
expirationDate = expirationDate?.toInstant(),
emails = null,
authType = AuthType.NONE,
)
/**

View File

@ -1,47 +1,49 @@
package com.x8bit.bitwarden.ui.platform.components.listitem
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.nullableTestTag
import com.bitwarden.ui.platform.components.icon.BitwardenIcon
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* A reusable composable function that displays a group item.
* The list item consists of a start icon, a label, a supporting label and an optional divider.
* The list item consists of a start icon, label, sub-label, supporting label, and an end icon.
*
* @param label The main text label to be displayed in the group item.
* @param supportingLabel The secondary supporting text label to be displayed beside the label.
* @param startIcon The [Painter] object used to draw the icon at the start of the group item.
* @param startIcon The [IconData] object used to draw the icon at the start of the group item.
* @param onClick A lambda function that is invoked when the group is clicked.
* @param cardStyle Indicates the type of card style to be applied.
* @param modifier The [Modifier] to be applied to the [Row] composable that holds the list item.
* @param showDivider Indicates whether the divider should be shown or not.
* @param startIconTestTag The optional test tag for the [startIcon].
* @param subLabel The secondary text label to be displayed in the group item.
* @param endIcon The [IconData] object used to draw the icon at the end of the group item.
*/
@Composable
fun BitwardenGroupItem(
label: String,
supportingLabel: String,
startIcon: Painter,
startIcon: IconData.Local,
onClick: () -> Unit,
cardStyle: CardStyle?,
modifier: Modifier = Modifier,
showDivider: Boolean = true,
startIconTestTag: String? = null,
subLabel: String? = null,
endIcon: IconData.Local? = null,
) {
Row(
modifier = modifier
@ -54,28 +56,46 @@ fun BitwardenGroupItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
painter = startIcon,
contentDescription = null,
BitwardenIcon(
iconData = startIcon,
tint = BitwardenTheme.colorScheme.icon.primary,
modifier = Modifier
.defaultMinSize(minHeight = 36.dp)
.nullableTestTag(tag = startIconTestTag)
.size(size = 24.dp),
)
Text(
text = label,
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.weight(1f),
)
Column(modifier = Modifier.weight(weight = 1f)) {
Text(
text = label,
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.fillMaxWidth(),
)
subLabel?.let {
Spacer(modifier = Modifier.height(height = 2.dp))
Text(
text = it,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.fillMaxWidth(),
)
}
}
Text(
text = supportingLabel,
style = BitwardenTheme.typography.labelSmall,
color = BitwardenTheme.colorScheme.text.primary,
)
endIcon?.let {
BitwardenIcon(
iconData = it,
tint = BitwardenTheme.colorScheme.icon.primary,
modifier = Modifier
.defaultMinSize(minHeight = 36.dp)
.size(size = 24.dp),
)
}
}
}
@ -86,9 +106,18 @@ private fun BitwardenGroupItem_preview() {
BitwardenGroupItem(
label = "Sample Label",
supportingLabel = "5",
startIcon = rememberVectorPainter(id = BitwardenDrawable.ic_file_text),
startIconTestTag = "Test Tag",
startIcon = IconData.Local(
iconRes = BitwardenDrawable.ic_file_text,
contentDescription = null,
testTag = "Test Tag 1",
),
endIcon = IconData.Local(
iconRes = BitwardenDrawable.ic_locked,
contentDescription = null,
testTag = "Test Tag 2",
),
onClick = {},
subLabel = "Sample Subtext",
cardStyle = CardStyle.Full,
)
}

View File

@ -70,8 +70,9 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.exportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.navigateToExportItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.exportitems.verifypassword.navigateToVerifyPassword
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsRoute
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.navigateToMigrateToMyItems
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.MigrateToMyItemsGraphRoute
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.migrateToMyItemsGraph
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.navigateToMigrateToMyItemsGraph
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@ -119,6 +120,7 @@ fun RootNavScreen(
setupAutoFillDestinationAsRoot()
setupCompleteDestination()
exportItemsGraph(navController)
migrateToMyItemsGraph(navController)
}
val targetRoute = when (state) {
@ -155,13 +157,7 @@ fun RootNavScreen(
RootNavState.OnboardingAutoFillSetup -> SetupAutofillRoute.AsRoot
RootNavState.OnboardingBrowserAutofillSetup -> SetupBrowserAutofillRoute.AsRoot
RootNavState.OnboardingStepsComplete -> SetupCompleteRoute
is RootNavState.MigrateToMyItems -> {
val migrateState = state as RootNavState.MigrateToMyItems
MigrateToMyItemsRoute(
organizationId = migrateState.organizationId,
organizationName = migrateState.organizationName,
)
}
is RootNavState.MigrateToMyItems -> MigrateToMyItemsGraphRoute
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
@ -214,11 +210,7 @@ fun RootNavScreen(
}
is RootNavState.MigrateToMyItems -> {
navController.navigateToMigrateToMyItems(
organizationName = currentState.organizationName,
organizationId = currentState.organizationId,
navOptions = rootNavOptions,
)
navController.navigateToMigrateToMyItemsGraph(rootNavOptions)
}
RootNavState.RemovePassword -> navController.navigateToRemovePassword(rootNavOptions)
@ -354,31 +346,57 @@ private fun NavDestination?.rootLevelRoute(): String? {
* Define the enter transition for each route.
*/
@Suppress("MaxLineLength")
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toEnterTransition(): NonNullEnterTransitionProvider =
when (targetState.destination.rootLevelRoute()) {
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.slideUp
else -> when (initialState.destination.rootLevelRoute()) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.none
// The RESET_PASSWORD_ROUTE animation should be stay but due to an issue when combining
// certain animations, we are just using a fadeIn instead.
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.fadeIn
else -> RootTransitionProviders.Enter.fadeIn
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toEnterTransition(): NonNullEnterTransitionProvider {
val initialRoute = initialState.destination.rootLevelRoute()
val targetRoute = targetState.destination.rootLevelRoute()
return if (initialRoute == targetRoute) {
RootTransitionProviders.Enter.none
} else {
when (targetState.destination.rootLevelRoute()) {
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Enter.slideUp
else -> when (initialState.destination.rootLevelRoute()) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.none
// The MigrateToMyItemsGraphRoute and ResetPasswordRoute animation should be stay
// but due to an issue when combining certain animations, we are just using a
// fadeIn instead.
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Enter.fadeIn
else -> RootTransitionProviders.Enter.fadeIn
}
}
}
}
/**
* Define the exit transition for each route.
*/
@Suppress("MaxLineLength")
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toExitTransition(): NonNullExitTransitionProvider {
return when (initialState.destination.rootLevelRoute()) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.none
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.slideDown
else -> when (targetState.destination.rootLevelRoute()) {
ResetPasswordRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.stay
else -> RootTransitionProviders.Exit.fadeOut
val initialRoute = initialState.destination.rootLevelRoute()
val targetRoute = targetState.destination.rootLevelRoute()
return if (initialRoute == targetRoute) {
RootTransitionProviders.Exit.none
} else {
when (initialRoute) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.none
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Exit.slideDown
else -> when (targetRoute) {
MigrateToMyItemsGraphRoute.toObjectNavigationRoute(),
ResetPasswordRoute.toObjectNavigationRoute(),
-> RootTransitionProviders.Exit.stay
else -> RootTransitionProviders.Exit.fadeOut
}
}
}
}

View File

@ -55,7 +55,7 @@ class RootNavViewModel @Inject constructor(
vaultMigrationData = vaultMigrationData,
)
}
.onEach(::handleAction)
.onEach(::sendAction)
.launchIn(viewModelScope)
}
@ -117,31 +117,24 @@ class RootNavViewModel @Inject constructor(
}
userState.activeAccount.isVaultUnlocked &&
specialCircumstance is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
)
}
userState.activeAccount.isVaultUnlocked &&
specialCircumstance is SpecialCircumstance.AutofillSelection -> {
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = userState.activeAccount.userId,
type = specialCircumstance.autofillSelectionData.type,
)
}
userState.activeAccount.isVaultUnlocked &&
specialCircumstance == null &&
action.vaultMigrationData is VaultMigrationData.MigrationRequired -> {
RootNavState.MigrateToMyItems(
organizationId = action.vaultMigrationData.organizationId,
organizationName = action.vaultMigrationData.organizationName,
)
}
action.vaultMigrationData is VaultMigrationData.MigrationRequired &&
shouldShowVaultMigration(specialCircumstance) -> RootNavState.MigrateToMyItems
userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) {
is SpecialCircumstance.AutofillSave -> {
RootNavState.VaultUnlockedForAutofillSave(
autofillSaveItem = specialCircumstance.autofillSaveItem,
)
}
is SpecialCircumstance.AutofillSelection -> {
RootNavState.VaultUnlockedForAutofillSelection(
activeUserId = userState.activeAccount.userId,
type = specialCircumstance.autofillSelectionData.type,
)
}
is SpecialCircumstance.AddTotpLoginItem -> {
RootNavState.VaultUnlockedForNewTotp(
activeUserId = userState.activeAccount.userId,
@ -224,8 +217,6 @@ class RootNavViewModel @Inject constructor(
is SpecialCircumstance.CredentialExchangeExport,
is SpecialCircumstance.RegistrationEvent,
is SpecialCircumstance.AutofillSave,
is SpecialCircumstance.AutofillSelection,
-> {
throw IllegalStateException(
"Special circumstance should have been already handled.",
@ -282,6 +273,36 @@ class RootNavViewModel @Inject constructor(
val userIsNotUsingKeyConnector = !this.activeAccount.isUsingKeyConnector
return isLoggedInUsingSso && usesKeyConnectorAndNotAdmin && userIsNotUsingKeyConnector
}
/**
* Determines whether the vault migration screen should be shown based on the special
* circumstance. Returns true for circumstances that are shortcuts not blocking user from
* essential operations like autofill, passkeys or Credential Manager
*/
private fun shouldShowVaultMigration(specialCircumstance: SpecialCircumstance?): Boolean =
when (specialCircumstance) {
is SpecialCircumstance.AccountSecurityShortcut,
is SpecialCircumstance.GeneratorShortcut,
is SpecialCircumstance.SearchShortcut,
is SpecialCircumstance.SendShortcut,
is SpecialCircumstance.ShareNewSend,
is SpecialCircumstance.VerificationCodeShortcut,
is SpecialCircumstance.VaultShortcut,
null,
-> true
is SpecialCircumstance.AddTotpLoginItem,
is SpecialCircumstance.AutofillSave,
is SpecialCircumstance.AutofillSelection,
is SpecialCircumstance.CredentialExchangeExport,
is SpecialCircumstance.Fido2Assertion,
is SpecialCircumstance.PasswordlessRequest,
is SpecialCircumstance.ProviderGetCredentials,
is SpecialCircumstance.ProviderGetPasswordRequest,
is SpecialCircumstance.ProviderCreateCredential,
is SpecialCircumstance.RegistrationEvent,
-> false
}
}
/**
@ -337,13 +358,10 @@ sealed class RootNavState : Parcelable {
data object VaultLocked : RootNavState()
/**
* App should show MigrateToMyItems screen.
* App should show MigrateToMyItems graph.
*/
@Parcelize
data class MigrateToMyItems(
val organizationId: String,
val organizationName: String,
) : RootNavState()
data object MigrateToMyItems : RootNavState()
/**
* App should show vault unlocked nav graph for the given [activeUserId].

View File

@ -74,6 +74,8 @@ fun SearchContent(
is ListingItemOverflowAction.VaultAction.EditClick,
is ListingItemOverflowAction.VaultAction.LaunchClick,
is ListingItemOverflowAction.VaultAction.ViewClick,
is ListingItemOverflowAction.VaultAction.ArchiveClick,
is ListingItemOverflowAction.VaultAction.UnarchiveClick,
null,
-> Unit
}

View File

@ -31,6 +31,7 @@ enum class SearchableItemType {
SENDS_TEXTS,
SENDS_FILES,
VAULT_ALL,
VAULT_ARCHIVE,
VAULT_LOGINS,
VAULT_CARDS,
VAULT_IDENTITIES,
@ -62,6 +63,7 @@ fun SavedStateHandle.toSearchArgs(): SearchArgs {
SearchableItemType.SENDS_TEXTS -> SearchType.Sends.Texts
SearchableItemType.SENDS_FILES -> SearchType.Sends.Files
SearchableItemType.VAULT_ALL -> SearchType.Vault.All
SearchableItemType.VAULT_ARCHIVE -> SearchType.Vault.Archive
SearchableItemType.VAULT_LOGINS -> SearchType.Vault.Logins
SearchableItemType.VAULT_CARDS -> SearchType.Vault.Cards
SearchableItemType.VAULT_IDENTITIES -> SearchType.Vault.Identities
@ -134,6 +136,7 @@ private fun SearchType.toSearchableItemType(): SearchableItemType =
SearchType.Vault.Trash -> SearchableItemType.VAULT_TRASH
SearchType.Vault.VerificationCodes -> SearchableItemType.VAULT_VERIFICATION_CODES
SearchType.Vault.SshKeys -> SearchableItemType.VAULT_SSH_KEYS
SearchType.Vault.Archive -> SearchableItemType.VAULT_ARCHIVE
}
private fun SearchType.toIdOrNull(): String? =
@ -152,4 +155,5 @@ private fun SearchType.toIdOrNull(): String? =
SearchType.Vault.Trash -> null
SearchType.Vault.VerificationCodes -> null
SearchType.Vault.SshKeys -> null
SearchType.Vault.Archive -> null
}

View File

@ -23,6 +23,7 @@ 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.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
@ -118,6 +119,7 @@ fun SearchScreen(
SearchDialogs(
dialogState = state.dialogState,
onDismissRequest = searchHandlers.onDismissRequest,
onUpgradeToPremiumClick = searchHandlers.onUpgradeToPremiumClick,
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -192,8 +194,21 @@ fun SearchScreen(
private fun SearchDialogs(
dialogState: SearchState.DialogState?,
onDismissRequest: () -> Unit,
onUpgradeToPremiumClick: () -> Unit,
) {
when (dialogState) {
SearchState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onUpgradeToPremiumClick,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
)
}
is SearchState.DialogState.Error -> BitwardenBasicDialog(
title = dialogState.title?.invoke(),
message = dialogState.message(),

View File

@ -4,9 +4,11 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.util.baseIconUrl
import com.bitwarden.data.repository.util.baseWebSendUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.send.SendType
import com.bitwarden.ui.platform.base.BackgroundEvent
@ -28,6 +30,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@ -40,9 +43,11 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption
@ -95,10 +100,11 @@ class SearchViewModel @Inject constructor(
private val organizationEventManager: OrganizationEventManager,
private val vaultRepo: VaultRepository,
private val authRepo: AuthRepository,
environmentRepo: EnvironmentRepository,
private val environmentRepo: EnvironmentRepository,
settingsRepo: SettingsRepository,
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
specialCircumstanceManager: SpecialCircumstanceManager,
featureFlagManager: FeatureFlagManager,
) : BaseViewModel<SearchState, SearchEvent, SearchAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
@ -134,6 +140,7 @@ class SearchViewModel @Inject constructor(
hasMasterPassword = userState.activeAccount.hasMasterPassword,
isPremium = userState.activeAccount.isPremium,
restrictItemTypesPolicyOrgIds = persistentListOf(),
isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems),
)
},
) {
@ -159,6 +166,8 @@ class SearchViewModel @Inject constructor(
snackbarRelayManager
.getSnackbarDataFlow(
SnackbarRelay.CIPHER_ARCHIVED,
SnackbarRelay.CIPHER_UNARCHIVED,
SnackbarRelay.CIPHER_DELETED,
SnackbarRelay.CIPHER_DELETED_SOFT,
SnackbarRelay.CIPHER_RESTORED,
@ -169,6 +178,12 @@ class SearchViewModel @Inject constructor(
.map { SearchAction.Internal.SnackbarDataReceived(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.ArchiveItems)
.map { SearchAction.Internal.ArchiveItemsFlagUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: SearchAction) {
@ -185,6 +200,7 @@ class SearchViewModel @Inject constructor(
is SearchAction.SearchTermChange -> handleSearchTermChange(action)
is SearchAction.VaultFilterSelect -> handleVaultFilterSelect(action)
is SearchAction.OverflowOptionClick -> handleOverflowItemClick(action)
SearchAction.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
is SearchAction.Internal -> handleInternalAction(action)
}
}
@ -298,6 +314,13 @@ class SearchViewModel @Inject constructor(
recalculateViewState()
}
private fun handleUpgradeToPremiumClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
val baseUrl = environmentRepo.environment.environmentUrlData.baseWebVaultUrlOrDefault
val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium"
sendEvent(SearchEvent.NavigateToUrl(url = url))
}
private fun handleOverflowItemClick(action: SearchAction.OverflowOptionClick) {
when (val overflowAction = action.overflowAction) {
is ListingItemOverflowAction.SendAction.CopyUrlClick -> {
@ -353,6 +376,14 @@ class SearchViewModel @Inject constructor(
is ListingItemOverflowAction.VaultAction.CopyTotpClick -> {
handleCopyTotpClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.ArchiveClick -> {
handleArchiveClick(overflowAction)
}
is ListingItemOverflowAction.VaultAction.UnarchiveClick -> {
handleUnarchiveClick(overflowAction)
}
}
}
@ -402,6 +433,56 @@ class SearchViewModel @Inject constructor(
}
}
private fun handleArchiveClick(action: ListingItemOverflowAction.VaultAction.ArchiveClick) {
if (!state.isPremium) {
mutableStateFlow.update {
it.copy(dialogState = SearchState.DialogState.ArchiveRequiresPremium)
}
return
}
mutableStateFlow.update {
it.copy(
dialogState = SearchState.DialogState.Loading(
message = BitwardenString.archiving.asText(),
),
)
}
viewModelScope.launch {
decryptCipherViewOrNull(cipherId = action.cipherId)?.let {
sendAction(
SearchAction.Internal.ArchiveCipherReceive(
result = vaultRepo.archiveCipher(
cipherId = action.cipherId,
cipherView = it,
),
),
)
}
}
}
private fun handleUnarchiveClick(action: ListingItemOverflowAction.VaultAction.UnarchiveClick) {
mutableStateFlow.update {
it.copy(
dialogState = SearchState.DialogState.Loading(
message = BitwardenString.unarchiving.asText(),
),
)
}
viewModelScope.launch {
decryptCipherViewOrNull(cipherId = action.cipherId)?.let {
sendAction(
SearchAction.Internal.UnarchiveCipherReceive(
result = vaultRepo.unarchiveCipher(
cipherId = action.cipherId,
cipherView = it,
),
),
)
}
}
}
private fun handleRemovePasswordClick(
action: ListingItemOverflowAction.SendAction.RemovePasswordClick,
) {
@ -556,6 +637,13 @@ class SearchViewModel @Inject constructor(
is SearchAction.Internal.DecryptCipherErrorReceive -> {
handleDecryptCipherErrorReceive(action)
}
is SearchAction.Internal.ArchiveItemsFlagUpdateReceive -> {
handleArchiveItemsFlagUpdateReceive(action)
}
is SearchAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action)
is SearchAction.Internal.UnarchiveCipherReceive -> handleUnarchiveCipherReceive(action)
}
}
@ -573,6 +661,54 @@ class SearchViewModel @Inject constructor(
}
}
private fun handleArchiveItemsFlagUpdateReceive(
action: SearchAction.Internal.ArchiveItemsFlagUpdateReceive,
) {
mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) }
}
private fun handleArchiveCipherReceive(action: SearchAction.Internal.ArchiveCipherReceive) {
when (val result = action.result) {
is ArchiveCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_archive_selected_item.asText(),
throwable = result.error,
),
)
}
}
ArchiveCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(SearchEvent.ShowSnackbar(BitwardenString.item_moved_to_archived.asText()))
}
}
}
private fun handleUnarchiveCipherReceive(action: SearchAction.Internal.UnarchiveCipherReceive) {
when (val result = action.result) {
is UnarchiveCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = SearchState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
throwable = result.error,
),
)
}
}
UnarchiveCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(SearchEvent.ShowSnackbar(BitwardenString.item_moved_to_vault.asText()))
}
}
}
private fun handleIconLoadingSettingReceive(
action: SearchAction.Internal.IconLoadingSettingReceive,
) {
@ -877,6 +1013,7 @@ class SearchViewModel @Inject constructor(
isIconLoadingDisabled = state.isIconLoadingDisabled,
isAutofill = state.isAutofill,
isPremiumUser = state.isPremium,
isArchiveEnabled = state.isArchiveEnabled,
)
}
@ -943,6 +1080,7 @@ data class SearchState(
val hasMasterPassword: Boolean,
val isPremium: Boolean,
val restrictItemTypesPolicyOrgIds: ImmutableList<String>,
val isArchiveEnabled: Boolean,
) : Parcelable {
/**
@ -1025,6 +1163,12 @@ data class SearchState(
data class Loading(
val message: Text,
) : DialogState()
/**
* Displays a dialog to the user indicating that archiving requires a premium account.
*/
@Parcelize
data object ArchiveRequiresPremium : DialogState()
}
/**
@ -1114,6 +1258,13 @@ sealed class SearchTypeData : Parcelable {
override val title: Text get() = BitwardenString.search_vault.asText()
}
/**
* Indicates that we should be searching all archived vault items.
*/
data object Archive : Vault() {
override val title: Text get() = BitwardenString.search_archive.asText()
}
/**
* Indicates that we should be searching only login ciphers.
*/
@ -1287,6 +1438,11 @@ sealed class SearchAction {
val overflowAction: ListingItemOverflowAction,
) : SearchAction()
/**
* User clicked the upgrade to premium button.
*/
data object UpgradeToPremiumClick : SearchAction()
/**
* Models actions that the [SearchViewModel] itself might send.
*/
@ -1312,6 +1468,20 @@ sealed class SearchAction {
val result: GenerateTotpResult,
) : Internal()
/**
* Indicates that the archive cipher result has been received.
*/
data class ArchiveCipherReceive(
val result: ArchiveCipherResult,
) : Internal()
/**
* Indicates that the unarchive cipher result has been received.
*/
data class UnarchiveCipherReceive(
val result: UnarchiveCipherResult,
) : Internal()
/**
* Indicates a result for removing the password protection from a send has been received.
*/
@ -1363,6 +1533,13 @@ sealed class SearchAction {
data class DecryptCipherErrorReceive(
val error: Throwable?,
) : Internal()
/**
* Indicates that the Archive Items flag has been updated.
*/
data class ArchiveItemsFlagUpdateReceive(
val isEnabled: Boolean,
) : Internal()
}
}

View File

@ -20,6 +20,7 @@ data class SearchHandlers(
val onSearchTermChange: (String) -> Unit,
val onVaultFilterSelect: (VaultFilterType) -> Unit,
val onOverflowItemClick: (ListingItemOverflowAction) -> Unit,
val onUpgradeToPremiumClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@ -55,6 +56,9 @@ data class SearchHandlers(
onOverflowItemClick = {
viewModel.trySendAction(SearchAction.OverflowOptionClick(it))
},
onUpgradeToPremiumClick = {
viewModel.trySendAction(SearchAction.UpgradeToPremiumClick)
},
)
}
}

View File

@ -38,6 +38,11 @@ sealed class SearchType : Parcelable {
*/
data object All : Vault()
/**
* Indicates that we should be searching all archived vault items.
*/
data object Archive : Vault()
/**
* Indicates that we should be searching only login ciphers.
*/

View File

@ -58,6 +58,7 @@ fun SearchTypeData.updateWithAdditionalDataIfNecessary(
SearchTypeData.Sends.Files -> this
SearchTypeData.Sends.Texts -> this
SearchTypeData.Vault.All -> this
SearchTypeData.Vault.Archive -> this
SearchTypeData.Vault.Cards -> this
SearchTypeData.Vault.Identities -> this
SearchTypeData.Vault.Logins -> this
@ -107,25 +108,46 @@ private fun CipherListView.filterBySearchType(
searchTypeData: SearchTypeData.Vault,
): Boolean =
when (searchTypeData) {
SearchTypeData.Vault.All -> deletedDate == null
is SearchTypeData.Vault.Cards -> type is CipherListViewType.Card && deletedDate == null
SearchTypeData.Vault.All -> deletedDate == null && archivedDate == null
SearchTypeData.Vault.Archive -> archivedDate != null && deletedDate == null
is SearchTypeData.Vault.Cards -> {
type is CipherListViewType.Card && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Collection -> {
searchTypeData.collectionId in this.collectionIds && deletedDate == null
searchTypeData.collectionId in this.collectionIds &&
deletedDate == null &&
archivedDate == null
}
is SearchTypeData.Vault.Folder -> {
folderId == searchTypeData.folderId && deletedDate == null && archivedDate == null
}
SearchTypeData.Vault.NoFolder -> {
folderId == null && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Folder -> folderId == searchTypeData.folderId && deletedDate == null
SearchTypeData.Vault.NoFolder -> folderId == null && deletedDate == null
is SearchTypeData.Vault.Identities -> {
type is CipherListViewType.Identity && deletedDate == null
type is CipherListViewType.Identity && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Logins -> {
type is CipherListViewType.Login && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.Logins -> type is CipherListViewType.Login && deletedDate == null
is SearchTypeData.Vault.SecureNotes -> {
type is CipherListViewType.SecureNote && deletedDate == null
type is CipherListViewType.SecureNote && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.SshKeys -> {
type is CipherListViewType.SshKey && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.VerificationCodes -> {
login?.totp != null && deletedDate == null && archivedDate == null
}
is SearchTypeData.Vault.SshKeys -> type is CipherListViewType.SshKey && deletedDate == null
is SearchTypeData.Vault.VerificationCodes -> login?.totp != null && deletedDate == null
is SearchTypeData.Vault.Trash -> deletedDate != null
}
@ -164,6 +186,7 @@ fun List<CipherListView>.toViewState(
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isPremiumUser: Boolean,
isArchiveEnabled: Boolean,
): SearchState.ViewState =
when {
searchTerm.isEmpty() -> SearchState.ViewState.Empty(message = null)
@ -175,6 +198,7 @@ fun List<CipherListView>.toViewState(
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = isAutofill,
isPremiumUser = isPremiumUser,
isArchiveEnabled = isArchiveEnabled,
)
.sortAlphabetically(),
)
@ -187,12 +211,14 @@ fun List<CipherListView>.toViewState(
}
}
@Suppress("LongParameterList")
private fun List<CipherListView>.toDisplayItemList(
baseIconUrl: String,
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isPremiumUser: Boolean,
isArchiveEnabled: Boolean,
): List<SearchState.DisplayItem> =
this.map {
it.toDisplayItem(
@ -201,15 +227,18 @@ private fun List<CipherListView>.toDisplayItemList(
isIconLoadingDisabled = isIconLoadingDisabled,
isAutofill = isAutofill,
isPremiumUser = isPremiumUser,
isArchiveEnabled = isArchiveEnabled,
)
}
@Suppress("LongParameterList")
private fun CipherListView.toDisplayItem(
baseIconUrl: String,
hasMasterPassword: Boolean,
isIconLoadingDisabled: Boolean,
isAutofill: Boolean,
isPremiumUser: Boolean,
isArchiveEnabled: Boolean,
): SearchState.DisplayItem =
SearchState.DisplayItem(
id = id.orEmpty(),
@ -225,6 +254,7 @@ private fun CipherListView.toDisplayItem(
overflowOptions = toOverflowActions(
hasMasterPassword = hasMasterPassword,
isPremiumUser = isPremiumUser,
isArchiveEnabled = isArchiveEnabled,
),
overflowTestTag = "CipherOptionsButton",
totpCode = login?.totp,

View File

@ -12,6 +12,7 @@ fun SearchType.toSearchTypeData(): SearchTypeData =
SearchType.Sends.Files -> SearchTypeData.Sends.Files
SearchType.Sends.Texts -> SearchTypeData.Sends.Texts
SearchType.Vault.All -> SearchTypeData.Vault.All
SearchType.Vault.Archive -> SearchTypeData.Vault.Archive
SearchType.Vault.Cards -> SearchTypeData.Vault.Cards
is SearchType.Vault.Collection -> SearchTypeData.Vault.Collection(collectionId)
is SearchType.Vault.Folder -> SearchTypeData.Vault.Folder(folderId)

View File

@ -59,7 +59,6 @@ import com.x8bit.bitwarden.ui.vault.feature.item.vaultItemDestination
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.vaultItemListingDestinationAsRoot
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.navigateToManualCodeEntryScreen
import com.x8bit.bitwarden.ui.vault.feature.manualcodeentry.vaultManualCodeEntryDestination
import com.x8bit.bitwarden.ui.vault.feature.migratetomyitems.migrateToMyItemsDestination
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.navigateToVaultMoveToOrganization
import com.x8bit.bitwarden.ui.vault.feature.movetoorganization.vaultMoveToOrganizationDestination
import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.navigateToQrCodeScanScreen
@ -218,6 +217,7 @@ fun NavGraphBuilder.vaultUnlockedGraph(
addEditSendDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateUpToSearchOrRoot = { navController.navigateUpToSearchOrVaultUnlockedRoot() },
onNavigateToGeneratorModal = { navController.navigateToGeneratorModal(mode = it) },
)
viewSendDestination(
onNavigateBack = { navController.popBackStack() },
@ -264,11 +264,6 @@ fun NavGraphBuilder.vaultUnlockedGraph(
importLoginsScreenDestination(
onNavigateBack = { navController.popBackStack() },
)
migrateToMyItemsDestination(
onNavigateToVault = { navController.popBackStack() },
onNavigateToLeaveOrganization = { },
)
}
}

View File

@ -0,0 +1,59 @@
package com.x8bit.bitwarden.ui.platform.glide
import android.content.Context
import com.bitwarden.network.ssl.createMtlsOkHttpClient
import com.bumptech.glide.Glide
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.io.InputStream
/**
* Custom Glide module for the Bitwarden app that configures Glide to use an OkHttpClient
* with mTLS (mutual TLS) support.
*
* This ensures that all icon/image loading requests through Glide present the client certificate
* for mutual TLS authentication, allowing them to pass through Cloudflare's mTLS checks.
*
* The configuration mirrors the SSL setup used in RetrofitsImpl for API calls.
*/
@GlideModule
class BitwardenAppGlideModule : AppGlideModule() {
/**
* Entry point to access Hilt-provided dependencies from non-Hilt managed classes.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
interface BitwardenGlideEntryPoint {
/**
* Provides access to the [CertificateManager] for mTLS certificate management.
*/
fun certificateManager(): CertificateManager
}
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
// Get CertificateManager from Hilt
val entryPoint = EntryPointAccessors.fromApplication(
context = context.applicationContext,
entryPoint = BitwardenGlideEntryPoint::class.java,
)
val certificateManager = entryPoint.certificateManager()
// Register OkHttpUrlLoader that uses our mTLS OkHttpClient
registry.replace(
GlideUrl::class.java,
InputStream::class.java,
OkHttpUrlLoader.Factory(certificateManager.createMtlsOkHttpClient()),
)
}
override fun isManifestParsingEnabled(): Boolean = false
}

View File

@ -9,6 +9,8 @@ import kotlinx.serialization.Serializable
*/
@Serializable
enum class SnackbarRelay {
CIPHER_ARCHIVED,
CIPHER_UNARCHIVED,
CIPHER_CREATED,
CIPHER_DELETED,
CIPHER_DELETED_SOFT,
@ -25,4 +27,5 @@ enum class SnackbarRelay {
SEND_DELETED,
SEND_UPDATED,
LEFT_ORGANIZATION,
VAULT_MIGRATED_TO_MY_ITEMS,
}

View File

@ -18,7 +18,6 @@ import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem
@ -70,7 +69,7 @@ fun SendContent(
BitwardenGroupItem(
label = stringResource(id = BitwardenString.type_text),
supportingLabel = state.textTypeCount.toString(),
startIcon = rememberVectorPainter(id = BitwardenDrawable.ic_file_text),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_file_text),
onClick = sendHandlers.onTextTypeClick,
cardStyle = CardStyle.Top(dividerPadding = 56.dp),
modifier = Modifier
@ -84,7 +83,7 @@ fun SendContent(
BitwardenGroupItem(
label = stringResource(id = BitwardenString.type_file),
supportingLabel = state.fileTypeCount.toString(),
startIcon = rememberVectorPainter(id = BitwardenDrawable.ic_file),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_file),
onClick = sendHandlers.onFileTypeClick,
cardStyle = CardStyle.Bottom,
modifier = Modifier

View File

@ -37,6 +37,7 @@ import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.animation.AnimateNullableContentVisibility
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedErrorButton
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
@ -367,6 +368,7 @@ private fun AddEditSendOptions(
addSendHandlers: AddEditSendHandlers,
) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
var shouldShowDialog by rememberSaveable { mutableStateOf(false) }
BitwardenExpandingHeader(
isExpanded = isExpanded,
onClick = { isExpanded = !isExpanded },
@ -437,7 +439,47 @@ private fun AddEditSendOptions(
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_generate,
contentDescription = stringResource(id = BitwardenString.generate_password),
onClick = {
if (state.common.passwordInput.isEmpty()) {
addSendHandlers.onOpenPasswordGeneratorClick()
} else {
shouldShowDialog = true
}
},
modifier = Modifier.testTag(tag = "RegeneratePasswordButton"),
)
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_copy,
contentDescription = stringResource(id = BitwardenString.copy_password),
isEnabled = state.common.passwordInput.isNotEmpty(),
onClick = {
addSendHandlers.onPasswordCopyClick(state.common.passwordInput)
},
modifier = Modifier.testTag(tag = "CopyPasswordButton"),
)
}
if (shouldShowDialog) {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.password),
message = stringResource(id = BitwardenString.password_override_alert),
confirmButtonText = stringResource(id = BitwardenString.yes),
dismissButtonText = stringResource(id = BitwardenString.no),
onConfirmClick = {
shouldShowDialog = false
addSendHandlers.onOpenPasswordGeneratorClick()
},
onDismissClick = {
shouldShowDialog = false
},
onDismissRequest = {
shouldShowDialog = false
},
)
}
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenSwitch(
modifier = Modifier

View File

@ -6,6 +6,7 @@ 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.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AddEditSendType
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import kotlinx.serialization.Serializable
@ -57,11 +58,13 @@ fun SavedStateHandle.toAddEditSendArgs(): AddEditSendArgs {
fun NavGraphBuilder.addEditSendDestination(
onNavigateBack: () -> Unit,
onNavigateUpToSearchOrRoot: () -> Unit,
onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit,
) {
composableWithSlideTransitions<AddEditSendRoute> {
AddEditSendScreen(
onNavigateBack = onNavigateBack,
onNavigateUpToSearchOrRoot = onNavigateUpToSearchOrRoot,
onNavigateToGeneratorModal = onNavigateToGeneratorModal,
)
}
}

View File

@ -38,6 +38,7 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.handlers.AddEditSendHandlers
/**
@ -53,6 +54,7 @@ fun AddEditSendScreen(
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
onNavigateBack: () -> Unit,
onNavigateUpToSearchOrRoot: () -> Unit,
onNavigateToGeneratorModal: (GeneratorMode.Modal) -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val addSendHandlers = remember(viewModel) { AddEditSendHandlers.create(viewModel) }
@ -88,6 +90,10 @@ fun AddEditSendScreen(
}
is AddEditSendEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
is AddEditSendEvent.NavigateToGeneratorModal -> {
onNavigateToGeneratorModal(event.generatorMode)
}
}
}

View File

@ -26,12 +26,15 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.model.AddEditSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.shouldFinishOnComplete
import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendName
@ -41,6 +44,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toViewState
import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
@ -62,11 +66,12 @@ private const val MAX_FILE_SIZE_BYTES: Long = 100 * 1024 * 1024
/**
* View model for the add/edit send screen.
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Suppress("TooManyFunctions", "LongParameterList", "LargeClass")
@HiltViewModel
class AddEditSendViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
authRepo: AuthRepository,
generatorRepository: GeneratorRepository,
private val clock: Clock,
private val clipboardManager: BitwardenClipboardManager,
private val environmentRepo: EnvironmentRepository,
@ -151,6 +156,18 @@ class AddEditSendViewModel @Inject constructor(
.launchIn(viewModelScope)
}
}
generatorRepository
.generatorResultFlow
.map { result ->
// Wait until we have a Content screen to update
mutableStateFlow.first {
it.viewState is AddEditSendState.ViewState.Content
}
AddEditSendAction.Internal.GeneratorResultReceive(generatorResult = result)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: AddEditSendAction): Unit = when (action) {
@ -173,6 +190,10 @@ class AddEditSendViewModel @Inject constructor(
is AddEditSendAction.DeactivateThisSendToggle -> handleDeactivateThisSendToggle(action)
is AddEditSendAction.HideMyEmailToggle -> handleHideMyEmailToggle(action)
is AddEditSendAction.Internal -> handleInternalAction(action)
is AddEditSendAction.OpenPasswordGeneratorClick -> handleAddEditOpenPasswordGeneratorClick()
is AddEditSendAction.PasswordCopyClick -> {
handleCopyClick(password = action.password)
}
}
private fun handleInternalAction(action: AddEditSendAction.Internal): Unit = when (action) {
@ -193,6 +214,17 @@ class AddEditSendViewModel @Inject constructor(
}
is AddEditSendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
is AddEditSendAction.Internal.GeneratorResultReceive -> {
handleGeneratorResultReceive(action)
}
}
private fun handleCopyClick(password: String) {
clipboardManager.setText(
text = password,
toastDescriptorOverride = BitwardenString.password.asText(),
)
}
private fun handleCreateSendResultReceive(
@ -319,6 +351,16 @@ class AddEditSendViewModel @Inject constructor(
}
}
private fun handleGeneratorResultReceive(
action: AddEditSendAction.Internal.GeneratorResultReceive,
) {
(action.generatorResult as? GeneratorResult.Password)?.let { passwordData ->
updateCommonContent {
it.copy(passwordInput = passwordData.password)
}
}
}
@Suppress("LongMethod")
private fun handleSendDataReceive(action: AddEditSendAction.Internal.SendDataReceive) {
when (val sendDataState = action.sendDataState) {
@ -619,6 +661,10 @@ class AddEditSendViewModel @Inject constructor(
}
}
private fun handleAddEditOpenPasswordGeneratorClick() {
sendEvent(event = AddEditSendEvent.NavigateToGeneratorModal(GeneratorMode.Modal.Password))
}
private fun navigateBack(isDeleted: Boolean = false) {
specialCircumstanceManager.specialCircumstance = null
sendEvent(
@ -856,6 +902,13 @@ sealed class AddEditSendEvent {
*/
data object NavigateBack : AddEditSendEvent()
/**
* Navigate to the generator modal.
*/
data class NavigateToGeneratorModal(
val generatorMode: GeneratorMode.Modal,
) : AddEditSendEvent()
/**
* Navigate up to the search screen or the root screen depending where you came from.
*/
@ -886,6 +939,11 @@ sealed class AddEditSendEvent {
*/
sealed class AddEditSendAction {
/**
* Represents the action to open the password generator.
*/
data object OpenPasswordGeneratorClick : AddEditSendAction()
/**
* User has chosen a file to be part of the send.
*/
@ -901,6 +959,13 @@ sealed class AddEditSendAction {
*/
data object CopyLinkClick : AddEditSendAction()
/**
* Represents the action triggered when a password copy button is clicked.
*
* @param password The [String] to be copied.
*/
data class PasswordCopyClick(val password: String) : AddEditSendAction()
/**
* User clicked the share link button.
*/
@ -987,6 +1052,13 @@ sealed class AddEditSendAction {
*/
data class CreateSendResultReceive(val result: CreateSendResult) : Internal()
/**
* Indicates that the vault totp code result has been received.
*/
data class GeneratorResultReceive(
val generatorResult: GeneratorResult,
) : Internal()
/**
* Indicates a result for updating a send has been received.
*/

View File

@ -22,6 +22,8 @@ data class AddEditSendHandlers(
val onDeactivateSendToggle: (Boolean) -> Unit,
val onDeletionDateChange: (ZonedDateTime) -> Unit,
val onDeleteClick: () -> Unit,
val onOpenPasswordGeneratorClick: () -> Unit,
val onPasswordCopyClick: (String) -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@ -59,6 +61,16 @@ data class AddEditSendHandlers(
viewModel.trySendAction(AddEditSendAction.DeletionDateChange(it))
},
onDeleteClick = { viewModel.trySendAction(AddEditSendAction.DeleteClick) },
onOpenPasswordGeneratorClick = {
viewModel.trySendAction(AddEditSendAction.OpenPasswordGeneratorClick)
},
onPasswordCopyClick = {
viewModel.trySendAction(
AddEditSendAction.PasswordCopyClick(
password = it,
),
)
},
)
}
}

View File

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addedit.util
import com.bitwarden.send.AuthType
import com.bitwarden.send.SendFileView
import com.bitwarden.send.SendTextView
import com.bitwarden.send.SendType
@ -36,6 +37,8 @@ fun AddEditSendState.ViewState.Content.toSendView(
// we just update it to match the deletion date.
common.deletionDate.toInstant()
},
emails = emptyList(),
authType = AuthType.NONE,
)
private fun AddEditSendState.ViewState.Content.SendType.toSendType(): SendType =

View File

@ -24,6 +24,7 @@ import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.coachmark.scope.CoachMarkScope
import com.bitwarden.ui.platform.components.field.BitwardenTextField
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
@ -85,7 +86,21 @@ fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent(
text = stringResource(BitwardenString.personal_ownership_policy_in_effect),
modifier = Modifier
.standardHorizontalMargin()
.testTag("PersonalOwnershipPolicyLabel")
.testTag(tag = "PersonalOwnershipPolicyLabel")
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
}
}
state.common.archiveCalloutText?.let {
item {
BitwardenInfoCalloutCard(
text = it(),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_archive_small),
modifier = Modifier
.standardHorizontalMargin()
.testTag(tag = "ArchivedPolicyLabel")
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
@ -118,11 +133,11 @@ fun CoachMarkScope<AddEditItemCoachMark>.VaultAddEditContent(
.standardHorizontalMargin()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
}
item {
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenTextField(
label = stringResource(id = BitwardenString.item_name_required),
value = state.common.name,

View File

@ -190,6 +190,10 @@ fun VaultAddEditScreen(
VaultAddEditEvent.NavigateToLearnMore -> {
intentManager.launchUri("https://bitwarden.com/help/uri-match-detection/".toUri())
}
is VaultAddEditEvent.NavigateToPremium -> {
intentManager.launchUri(uri = event.uri.toUri())
}
}
}
@ -213,6 +217,14 @@ fun VaultAddEditScreen(
VaultAddEditSshKeyTypeHandlers.create(viewModel = viewModel)
}
val archiveClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.ArchiveClick) }
}
val unarchiveClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.UnarchiveClick) }
}
val confirmDeleteClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) }
}
@ -294,6 +306,9 @@ fun VaultAddEditScreen(
)
}
},
onUpgradeToPremiumClick = {
viewModel.trySendAction(VaultAddEditAction.Common.UpgradeToPremiumClick)
},
)
if (pendingDeleteCipher) {
@ -391,6 +406,16 @@ fun VaultAddEditScreen(
!state.isCipherInCollection ||
!state.canAssociateToCollections
},
OverflowMenuItemData(
text = stringResource(id = BitwardenString.archive_verb),
onClick = archiveClickAction,
)
.takeIf { state.displayArchiveButton },
OverflowMenuItemData(
text = stringResource(id = BitwardenString.unarchive),
onClick = unarchiveClickAction,
)
.takeIf { state.displayUnarchiveButton },
OverflowMenuItemData(
text = stringResource(id = BitwardenString.delete),
onClick = { pendingDeleteCipher = true },
@ -476,8 +501,21 @@ private fun VaultAddEditItemDialogs(
onSubmitPinSetUpFido2Verification: (pin: String) -> Unit,
onRetryPinSetUpFido2Verification: () -> Unit,
onDismissFido2Verification: () -> Unit,
onUpgradeToPremiumClick: () -> Unit,
) {
when (dialogState) {
is VaultAddEditState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onUpgradeToPremiumClick,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
)
}
is VaultAddEditState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialogState.label())
}

View File

@ -5,9 +5,11 @@ import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.provider.CallingAppInfo
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.takeUntilLoaded
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
@ -32,6 +34,7 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
import com.x8bit.bitwarden.data.credentials.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.credentials.model.UserVerificationRequirement
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
@ -44,16 +47,19 @@ import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toCreateCredentialRequestOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.credentials.manager.model.CreateCredentialResult
@ -113,6 +119,7 @@ private const val KEY_STATE = "state"
@Suppress("TooManyFunctions", "LargeClass", "LongParameterList", "LongMethod")
class VaultAddEditViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
featureFlagManager: FeatureFlagManager,
generatorRepository: GeneratorRepository,
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
private val toastManager: ToastManager,
@ -128,6 +135,7 @@ class VaultAddEditViewModel @Inject constructor(
private val organizationEventManager: OrganizationEventManager,
private val networkConnectionManager: NetworkConnectionManager,
private val firstTimeActionManager: FirstTimeActionManager,
private val environmentRepository: EnvironmentRepository,
) : BaseViewModel<VaultAddEditState, VaultAddEditEvent, VaultAddEditAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE]
@ -169,6 +177,7 @@ class VaultAddEditViewModel @Inject constructor(
}
VaultAddEditState(
isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems),
vaultAddEditType = vaultAddEditType,
cipherType = vaultCipherType,
viewState = when (vaultAddEditType) {
@ -205,6 +214,7 @@ class VaultAddEditViewModel @Inject constructor(
shouldShowCoachMarkTour = false,
shouldClearSpecialCircumstance = autofillSelectionData == null,
defaultUriMatchType = settingsRepository.defaultUriMatchType,
hasPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true,
)
},
) {
@ -263,6 +273,12 @@ class VaultAddEditViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.ArchiveItems)
.map { VaultAddEditAction.Internal.ArchiveItemsFlagUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION)
.map { VaultAddEditAction.Internal.SnackbarDataReceived(it) }
@ -303,6 +319,9 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Common.AttachmentsClick -> handleAttachmentsClick()
is VaultAddEditAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
is VaultAddEditAction.Common.CollectionsClick -> handleCollectionsClick()
is VaultAddEditAction.Common.ArchiveClick -> handleArchiveClick()
is VaultAddEditAction.Common.UnarchiveClick -> handleUnarchiveClick()
VaultAddEditAction.Common.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
is VaultAddEditAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick()
is VaultAddEditAction.Common.CloseClick -> handleCloseClick()
is VaultAddEditAction.Common.DismissDialog -> handleDismissDialog()
@ -546,6 +565,73 @@ class VaultAddEditViewModel @Inject constructor(
onEdit { sendEvent(VaultAddEditEvent.NavigateToCollections(it.vaultItemId)) }
}
private fun handleArchiveClick() {
if (!state.hasPremium) {
mutableStateFlow.update {
it.copy(dialog = VaultAddEditState.DialogState.ArchiveRequiresPremium)
}
return
}
onEdit {
mutableStateFlow.update {
it.copy(
dialog = VaultAddEditState.DialogState.Loading(
label = BitwardenString.archiving.asText(),
),
)
}
}
onContent { content ->
content.common.originalCipher?.id?.let {
viewModelScope.launch {
trySendAction(
VaultAddEditAction.Internal.ArchiveCipherReceive(
result = vaultRepository.archiveCipher(
cipherId = it,
cipherView = content.common.originalCipher,
),
),
)
}
}
}
}
private fun handleUnarchiveClick() {
onEdit {
mutableStateFlow.update {
it.copy(
dialog = VaultAddEditState.DialogState.Loading(
label = BitwardenString.unarchiving.asText(),
),
)
}
}
onContent { content ->
content.common.originalCipher?.id?.let {
viewModelScope.launch {
trySendAction(
VaultAddEditAction.Internal.UnarchiveCipherReceive(
result = vaultRepository.unarchiveCipher(
cipherId = it,
cipherView = content.common.originalCipher,
),
),
)
}
}
}
}
private fun handleUpgradeToPremiumClick() {
val baseUrl = environmentRepository.environment.environmentUrlData.baseWebVaultUrlOrDefault
sendEvent(
VaultAddEditEvent.NavigateToPremium(
uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium",
),
)
}
private fun handleConfirmDeleteClick() {
mutableStateFlow.update {
it.copy(
@ -1543,6 +1629,18 @@ class VaultAddEditViewModel @Inject constructor(
handleUpdateCipherResultReceive(action)
}
is VaultAddEditAction.Internal.ArchiveCipherReceive -> {
handleArchiveCipherReceive(action)
}
is VaultAddEditAction.Internal.UnarchiveCipherReceive -> {
handleUnarchiveCipherReceive(action)
}
is VaultAddEditAction.Internal.ArchiveItemsFlagUpdateReceive -> {
handleArchiveItemsFlagUpdateReceive(action)
}
is VaultAddEditAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
is VaultAddEditAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action)
is VaultAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
@ -1704,6 +1802,60 @@ class VaultAddEditViewModel @Inject constructor(
}
}
private fun handleArchiveCipherReceive(
action: VaultAddEditAction.Internal.ArchiveCipherReceive,
) {
when (val result = action.result) {
is ArchiveCipherResult.Error -> {
showDialog(
dialogState = VaultAddEditState.DialogState.Generic(
message = BitwardenString.unable_to_archive_selected_item.asText(),
error = result.error,
),
)
}
ArchiveCipherResult.Success -> {
clearDialogState()
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_moved_to_archived.asText()),
relay = SnackbarRelay.CIPHER_ARCHIVED,
)
sendEvent(VaultAddEditEvent.NavigateBack)
}
}
}
private fun handleUnarchiveCipherReceive(
action: VaultAddEditAction.Internal.UnarchiveCipherReceive,
) {
when (val result = action.result) {
is UnarchiveCipherResult.Error -> {
showDialog(
dialogState = VaultAddEditState.DialogState.Generic(
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
error = result.error,
),
)
}
UnarchiveCipherResult.Success -> {
clearDialogState()
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_moved_to_vault.asText()),
relay = SnackbarRelay.CIPHER_UNARCHIVED,
)
sendEvent(VaultAddEditEvent.NavigateBack)
}
}
}
private fun handleArchiveItemsFlagUpdateReceive(
action: VaultAddEditAction.Internal.ArchiveItemsFlagUpdateReceive,
) {
mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) }
}
private fun handleDeleteCipherReceive(action: VaultAddEditAction.Internal.DeleteCipherReceive) {
when (val result = action.result) {
is DeleteCipherResult.Error -> {
@ -1860,6 +2012,7 @@ class VaultAddEditViewModel @Inject constructor(
(cipherView
?.toViewState(
isClone = isCloneMode,
isPremium = currentAccount.isPremium,
isIndividualVaultDisabled = isIndividualVaultDisabled,
totpData = totpData,
resourceManager = resourceManager,
@ -1980,6 +2133,11 @@ class VaultAddEditViewModel @Inject constructor(
}
is Fido2RegisterCredentialResult.Success -> {
// Reset verification state to ensure the next credential operation requires
// fresh user verification. Without this reset, the stale isUserVerified = true
// from this registration would cause subsequent login attempts to skip
// biometric verification.
bitwardenCredentialManager.isUserVerified = false
// Use toast here because we are closing the activity.
toastManager.show(BitwardenString.item_updated)
sendEvent(
@ -2242,12 +2400,14 @@ data class VaultAddEditState(
val bottomSheetState: BottomSheetState?,
val shouldShowCloseButton: Boolean = true,
// Internal
val hasPremium: Boolean,
val shouldExitOnSave: Boolean = false,
val shouldClearSpecialCircumstance: Boolean = true,
val totpData: TotpData? = null,
val createCredentialRequest: CreateCredentialRequest? = null,
val defaultUriMatchType: UriMatchType,
private val shouldShowCoachMarkTour: Boolean,
private val isArchiveEnabled: Boolean,
) : Parcelable {
/**
@ -2290,11 +2450,38 @@ data class VaultAddEditState(
*/
val isAddItemMode: Boolean get() = vaultAddEditType is VaultAddEditType.AddItem
/**
* Helper to determine if the UI should display the content in edit item mode.
*/
val isEditItemMode: Boolean get() = vaultAddEditType is VaultAddEditType.EditItem
/**
* Helper to determine if the UI should display the content in clone mode.
*/
val isCloneMode: Boolean get() = vaultAddEditType is VaultAddEditType.CloneItem
/**
* Helper to determine if the UI should display the archive button.
*/
val displayArchiveButton: Boolean
get() = isArchiveEnabled &&
isEditItemMode &&
(viewState as? ViewState.Content)
?.common
?.originalCipher
?.archivedDate == null
/**
* Helper to determine if the UI should display the unarchive button.
*/
val displayUnarchiveButton: Boolean
get() = isArchiveEnabled &&
isEditItemMode &&
(viewState as? ViewState.Content)
?.common
?.originalCipher
?.archivedDate != null
/**
* Helper to determine if the UI should allow deletion of this item.
*/
@ -2410,6 +2597,7 @@ data class VaultAddEditState(
val hasOrganizations: Boolean = false,
val canDelete: Boolean = true,
val canAssignToCollections: Boolean = true,
val archiveCalloutText: Text? = null,
) : Parcelable {
/**
@ -2734,6 +2922,11 @@ data class VaultAddEditState(
@Parcelize
sealed class DialogState : Parcelable {
/**
* Displays a dialog to the user indicating that archiving requires a premium account.
*/
data object ArchiveRequiresPremium : DialogState()
/**
* Displays a generic dialog to the user.
*/
@ -2861,6 +3054,13 @@ sealed class VaultAddEditEvent {
val cipherId: String,
) : VaultAddEditEvent()
/**
* Navigates to the upgrade-to-premium url.
*/
data class NavigateToPremium(
val uri: String,
) : VaultAddEditEvent()
/**
* Navigates to the collections screen.
*/
@ -2969,6 +3169,21 @@ sealed class VaultAddEditAction {
*/
data object CollectionsClick : Common()
/**
* The user has clicked the archive overflow option.
*/
data object ArchiveClick : Common()
/**
* The user has clicked the unarchive overflow option.
*/
data object UnarchiveClick : Common()
/**
* The user has clicked the upgrade to premium dialog.
*/
data object UpgradeToPremiumClick : Common()
/**
* The user has confirmed to deleted the cipher.
*/
@ -3535,6 +3750,20 @@ sealed class VaultAddEditAction {
val updateCipherResult: UpdateCipherResult,
) : Internal()
/**
* Indicates that the archive cipher result has been received.
*/
data class ArchiveCipherReceive(
val result: ArchiveCipherResult,
) : Internal()
/**
* Indicates that the unarchive cipher result has been received.
*/
data class UnarchiveCipherReceive(
val result: UnarchiveCipherResult,
) : Internal()
/**
* Indicates that the delete cipher result has been received.
*/
@ -3585,5 +3814,12 @@ sealed class VaultAddEditAction {
data class AvailableFoldersReceive(
val folderData: DataState<List<FolderView>>,
) : Internal()
/**
* Indicates that the Archive Items flag has been updated.
*/
data class ArchiveItemsFlagUpdateReceive(
val isEnabled: Boolean,
) : Internal()
}
}

View File

@ -37,6 +37,7 @@ import java.util.UUID
@Suppress("LongMethod", "LongParameterList")
fun CipherView.toViewState(
isClone: Boolean,
isPremium: Boolean,
isIndividualVaultDisabled: Boolean,
totpData: TotpData?,
resourceManager: ResourceManager,
@ -112,6 +113,15 @@ fun CipherView.toViewState(
customFieldData = this.fields.orEmpty().map { it.toCustomField() },
canDelete = canDelete,
canAssignToCollections = canAssignToCollections,
archiveCalloutText = if (this.archivedDate != null && isPremium) {
BitwardenString.this_item_is_archived.asText()
} else if (this.archivedDate != null) {
BitwardenString
.this_item_is_archived_saving_changes_will_restore_it_to_your_vault
.asText()
} else {
null
},
),
isIndividualVaultDisabled = isIndividualVaultDisabled,
)

View File

@ -57,6 +57,7 @@ fun VaultItemCardContent(
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
isArchived = commonState.archived,
iconData = cardState.paymentCardBrandIconData ?: commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "CardItemNameIcon",

View File

@ -57,6 +57,7 @@ fun VaultItemIdentityContent(
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
isArchived = commonState.archived,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "IdentityItemNameIcon",

View File

@ -65,6 +65,7 @@ fun VaultItemLoginContent(
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
isArchived = commonState.archived,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "LoginItemNameIcon",

View File

@ -134,6 +134,9 @@ fun VaultItemScreen(
onConfirmRestoreAction = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ConfirmRestoreClick) }
},
onUpgradeToPremiumClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.UpgradeToPremiumClick) }
},
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@ -212,6 +215,24 @@ fun VaultItemScreen(
state.isCipherInCollection &&
state.canAssignToCollections
},
OverflowMenuItemData(
text = stringResource(id = BitwardenString.archive_verb),
onClick = remember(viewModel) {
{ viewModel.trySendAction(VaultItemAction.Common.ArchiveClick) }
},
)
.takeIf { state.displayArchiveButton },
OverflowMenuItemData(
text = stringResource(id = BitwardenString.unarchive),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
VaultItemAction.Common.UnarchiveClick,
)
}
},
)
.takeIf { state.displayUnarchiveButton },
OverflowMenuItemData(
text = stringResource(id = BitwardenString.delete),
onClick = remember(viewModel) {
@ -280,8 +301,21 @@ private fun VaultItemDialogs(
onConfirmDeleteClick: () -> Unit,
onConfirmCloneWithoutFido2Credential: () -> Unit,
onConfirmRestoreAction: () -> Unit,
onUpgradeToPremiumClick: () -> Unit,
) {
when (dialog) {
is VaultItemState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onUpgradeToPremiumClick,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
)
}
is VaultItemState.DialogState.Generic -> BitwardenBasicDialog(
title = null,
message = dialog.message(),

View File

@ -51,6 +51,7 @@ fun VaultItemSecureNoteContent(
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
isArchived = commonState.archived,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "SecureNoteItemNameIcon",

View File

@ -55,6 +55,7 @@ fun VaultItemSshKeyContent(
itemHeader(
value = commonState.name,
isFavorite = commonState.favorite,
isArchived = commonState.archived,
iconData = commonState.iconData,
relatedLocations = commonState.relatedLocations,
iconTestTag = "SshKeyItemNameIcon",

View File

@ -4,12 +4,14 @@ import android.net.Uri
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.combineDataStates
import com.bitwarden.core.data.repository.util.mapNullable
import com.bitwarden.core.util.persistentListOfNotNull
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.data.repository.util.baseIconUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.icon.model.IconData
@ -25,15 +27,18 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.BreachCountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UnarchiveCipherResult
import com.x8bit.bitwarden.ui.platform.model.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation
@ -76,6 +81,7 @@ class VaultItemViewModel @Inject constructor(
private val environmentRepository: EnvironmentRepository,
private val settingsRepository: SettingsRepository,
private val snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
featureFlagManager: FeatureFlagManager,
) : BaseViewModel<VaultItemState, VaultItemEvent, VaultItemAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
@ -87,6 +93,8 @@ class VaultItemViewModel @Inject constructor(
dialog = null,
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
hasPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true,
isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems),
)
},
) {
@ -214,6 +222,8 @@ class VaultItemViewModel @Inject constructor(
snackbarRelayManager
.getSnackbarDataFlow(
SnackbarRelay.CIPHER_ARCHIVED,
SnackbarRelay.CIPHER_UNARCHIVED,
SnackbarRelay.CIPHER_DELETED_SOFT,
SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION,
SnackbarRelay.CIPHER_UPDATED,
@ -221,6 +231,12 @@ class VaultItemViewModel @Inject constructor(
.map { VaultItemAction.Internal.SnackbarDataReceived(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.ArchiveItems)
.map { VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultItemAction) {
@ -281,6 +297,9 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Common.RestoreVaultItemClick -> handleRestoreItemClicked()
is VaultItemAction.Common.CopyNotesClick -> handleCopyNotesClick()
is VaultItemAction.Common.PasswordHistoryClick -> handlePasswordHistoryClick()
VaultItemAction.Common.ArchiveClick -> handleArchiveClick()
VaultItemAction.Common.UnarchiveClick -> handleUnarchiveClick()
VaultItemAction.Common.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
}
}
@ -318,7 +337,7 @@ class VaultItemViewModel @Inject constructor(
private fun handleCopyCustomHiddenFieldClick(
action: VaultItemAction.Common.CopyCustomHiddenFieldClick,
) {
onContent { content ->
onContent { _ ->
clipboardManager.setText(text = action.field)
organizationEventManager.trackEvent(
event = OrganizationEvent.CipherClientCopiedHiddenField(
@ -598,7 +617,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopyPasswordClick() {
onLoginContent { content, login ->
onLoginContent { _, login ->
clipboardManager.setText(
text = requireNotNull(login.passwordData).password,
toastDescriptorOverride = BitwardenString.password.asText(),
@ -646,6 +665,67 @@ class VaultItemViewModel @Inject constructor(
sendEvent(VaultItemEvent.NavigateToPasswordHistory(itemId = state.vaultItemId))
}
private fun handleArchiveClick() {
if (!state.hasPremium) {
mutableStateFlow.update {
it.copy(dialog = VaultItemState.DialogState.ArchiveRequiresPremium)
}
return
}
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
message = BitwardenString.archiving.asText(),
),
)
}
onContent { content ->
content.common.currentCipher?.id?.let {
viewModelScope.launch {
trySendAction(
VaultItemAction.Internal.ArchiveCipherReceive(
result = vaultRepository.archiveCipher(
cipherId = it,
cipherView = content.common.currentCipher,
),
),
)
}
}
}
}
private fun handleUnarchiveClick() {
mutableStateFlow.update {
it.copy(
dialog = VaultItemState.DialogState.Loading(
message = BitwardenString.unarchiving.asText(),
),
)
}
onContent { content ->
content.common.currentCipher?.id?.let {
viewModelScope.launch {
trySendAction(
VaultItemAction.Internal.UnarchiveCipherReceive(
result = vaultRepository.unarchiveCipher(
cipherId = it,
cipherView = content.common.currentCipher,
),
),
)
}
}
}
}
private fun handleUpgradeToPremiumClick() {
updateDialogState(dialog = null)
val baseUrl = environmentRepository.environment.environmentUrlData.baseWebVaultUrlOrDefault
val uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium"
sendEvent(VaultItemEvent.NavigateToUri(uri = uri))
}
private fun handlePasswordVisibilityClicked(
action: VaultItemAction.ItemType.Login.PasswordVisibilityClicked,
) {
@ -715,7 +795,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopyNumberClick() {
onCardContent { content, card ->
onCardContent { _, card ->
clipboardManager.setText(
text = requireNotNull(card.number).number,
toastDescriptorOverride = BitwardenString.number.asText(),
@ -724,7 +804,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopySecurityCodeClick() {
onCardContent { content, card ->
onCardContent { _, card ->
clipboardManager.setText(
text = requireNotNull(card.securityCode).code,
toastDescriptorOverride = BitwardenString.security_code.asText(),
@ -799,7 +879,7 @@ class VaultItemViewModel @Inject constructor(
}
private fun handleCopyPrivateKeyClick() {
onSshKeyContent { content, sshKey ->
onSshKeyContent { _, sshKey ->
clipboardManager.setText(
text = sshKey.privateKey,
toastDescriptorOverride = BitwardenString.private_key.asText(),
@ -959,6 +1039,15 @@ class VaultItemViewModel @Inject constructor(
is VaultItemAction.Internal.IsIconLoadingDisabledUpdateReceive -> {
handleIsIconLoadingDisabledUpdateReceive(action)
}
is VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive -> {
handleArchiveItemsFlagUpdateReceive(action)
}
is VaultItemAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action)
is VaultItemAction.Internal.UnarchiveCipherReceive -> {
handleUnarchiveCipherReceive(action)
}
}
}
@ -1007,6 +1096,7 @@ class VaultItemViewModel @Inject constructor(
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.generic_error_message.asText(),
@ -1018,6 +1108,7 @@ class VaultItemViewModel @Inject constructor(
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.generic_error_message.asText(),
@ -1028,13 +1119,17 @@ class VaultItemViewModel @Inject constructor(
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = VaultItemState.ViewState.Loading)
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = VaultItemState.ViewState.Loading,
)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.internet_connection_required_title
@ -1051,6 +1146,7 @@ class VaultItemViewModel @Inject constructor(
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
hasPremium = userState.activeAccount.isPremium,
viewState = vaultDataState.toViewStateOrError(
account = userState.activeAccount,
errorText = BitwardenString.generic_error_message.asText(),
@ -1184,6 +1280,58 @@ class VaultItemViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isIconLoadingDisabled = action.isDisabled) }
}
private fun handleArchiveItemsFlagUpdateReceive(
action: VaultItemAction.Internal.ArchiveItemsFlagUpdateReceive,
) {
mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) }
}
private fun handleArchiveCipherReceive(action: VaultItemAction.Internal.ArchiveCipherReceive) {
when (val result = action.result) {
is ArchiveCipherResult.Error -> {
updateDialogState(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_archive_selected_item.asText(),
error = result.error,
),
)
}
ArchiveCipherResult.Success -> {
updateDialogState(dialog = null)
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_moved_to_archived.asText()),
relay = SnackbarRelay.CIPHER_ARCHIVED,
)
sendEvent(VaultItemEvent.NavigateBack)
}
}
}
private fun handleUnarchiveCipherReceive(
action: VaultItemAction.Internal.UnarchiveCipherReceive,
) {
when (val result = action.result) {
is UnarchiveCipherResult.Error -> {
updateDialogState(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.unable_to_unarchive_selected_item.asText(),
error = result.error,
),
)
}
UnarchiveCipherResult.Success -> {
updateDialogState(dialog = null)
snackbarRelayManager.sendSnackbarData(
data = BitwardenSnackbarData(BitwardenString.item_moved_to_vault.asText()),
relay = SnackbarRelay.CIPHER_UNARCHIVED,
)
sendEvent(VaultItemEvent.NavigateBack)
}
}
}
//endregion Internal Type Handlers
private fun updateDialogState(dialog: VaultItemState.DialogState?) {
@ -1274,6 +1422,8 @@ data class VaultItemState(
val dialog: DialogState?,
val baseIconUrl: String,
val isIconLoadingDisabled: Boolean,
val isArchiveEnabled: Boolean,
val hasPremium: Boolean,
) : Parcelable {
/**
@ -1335,6 +1485,26 @@ data class VaultItemState(
?.common
?.canRestore == true
/**
* Helper to determine if the UI should display the archive button.
*/
val displayArchiveButton: Boolean
get() = isArchiveEnabled &&
viewState.asContentOrNull()
?.common
?.currentCipher
?.let { it.archivedDate == null && it.deletedDate == null } == true
/**
* Helper to determine if the UI should display the unarchive button.
*/
val displayUnarchiveButton: Boolean
get() = isArchiveEnabled &&
viewState.asContentOrNull()
?.common
?.currentCipher
?.let { it.archivedDate != null && it.deletedDate == null } == true
val canAssignToCollections: Boolean
get() = viewState.asContentOrNull()
?.common
@ -1421,6 +1591,7 @@ data class VaultItemState(
val canAssignToCollections: Boolean,
val canEdit: Boolean,
val favorite: Boolean,
val archived: Boolean,
val passwordHistoryCount: Int?,
val iconData: IconData,
val relatedLocations: ImmutableList<VaultItemLocation>,
@ -1702,6 +1873,12 @@ data class VaultItemState(
*/
sealed class DialogState : Parcelable {
/**
* Displays a dialog to the user indicating that archiving requires a premium account.
*/
@Parcelize
data object ArchiveRequiresPremium : DialogState()
/**
* Displays a generic dialog to the user.
*/
@ -1836,6 +2013,21 @@ sealed class VaultItemAction {
*/
sealed class Common : VaultItemAction() {
/**
* The user has clicked the archive button.
*/
data object ArchiveClick : Common()
/**
* The user has clicked the unarchive button.
*/
data object UnarchiveClick : Common()
/**
* The user has clicked the upgrade to premium button.
*/
data object UpgradeToPremiumClick : Common()
/**
* The user has clicked the close button.
*/
@ -2138,6 +2330,27 @@ sealed class VaultItemAction {
val data: BitwardenSnackbarData,
) : Internal()
/**
* Indicates that the Archive Items flag has been updated.
*/
data class ArchiveItemsFlagUpdateReceive(
val isEnabled: Boolean,
) : Internal()
/**
* Indicates that the archive cipher result has been received.
*/
data class ArchiveCipherReceive(
val result: ArchiveCipherResult,
) : Internal()
/**
* Indicates that the unarchive cipher result has been received.
*/
data class UnarchiveCipherReceive(
val result: UnarchiveCipherResult,
) : Internal()
/**
* Indicates that the vault item data has been received.
*/

Some files were not shown because too many files have changed in this diff Show More