Compare commits

..

1 Commits

Author SHA1 Message Date
David Perez
bb8fd1753d Prep work for AGP v9.0 2026-01-23 16:14:57 -06:00
412 changed files with 3631 additions and 21308 deletions

View File

@@ -1,329 +1,105 @@
# Bitwarden Android - Claude Code Configuration
# Claude Guidelines
Official Android application for Bitwarden Password Manager and Bitwarden Authenticator, providing secure password management, two-factor authentication, and credential autofill services with zero-knowledge encryption.
Core directives for maintaining code quality and consistency in the Bitwarden Android project.
## Overview
## Core Directives
### What This Project Does
- Multi-module Android application providing secure password management and TOTP code generation
- Implements zero-knowledge architecture where encryption/decryption happens client-side
- Key entry points: `:app` (Password Manager), `:authenticator` (2FA TOTP generator)
- Target users: End-users via Google Play Store and F-Droid
**You MUST follow these directives at all times.**
### Key Concepts
- **Zero-Knowledge Architecture**: Server never has access to unencrypted vault data or encryption keys
- **Bitwarden SDK**: Rust-based cryptographic SDK handling all encryption/decryption operations
- **DataState**: Wrapper for streaming data states (Loading, Loaded, Pending, Error, NoNetwork)
- **Result Types**: Custom sealed classes for operation results (never throw exceptions from data layer)
- **UDF (Unidirectional Data Flow)**: State flows down, actions flow up through ViewModels
1. **Adhere to Architecture**: All code modifications MUST follow patterns in `docs/ARCHITECTURE.md`
2. **Follow Code Style**: ALWAYS follow `docs/STYLE_AND_BEST_PRACTICES.md`
3. **Error Handling**: Use Result types and sealed classes per architecture guidelines
4. **Best Practices**: Follow Kotlin idioms (immutability, appropriate data structures, coroutines)
5. **Document Everything**: All public APIs require KDoc documentation
6. **Dependency Management**: Use Hilt DI patterns as established in the project
7. **Use Established Patterns**: Leverage existing components before creating new ones
8. **File References**: Use file:line_number format when referencing code
---
## Code Quality Standards
## Architecture & Patterns
### Module Organization
### System Architecture
**Core Library Modules:**
- **`:core`** - Common utilities and managers shared across multiple modules
- **`:data`** - Data sources, database, data repositories
- **`:network`** - Networking interfaces, API clients, network utilities
- **`:ui`** - Reusable Bitwarden Composables, theming, UI utilities
```
User Request (UI Action)
|
Screen (Compose)
|
ViewModel (State/Action/Event)
|
Repository (Business Logic)
|
+----+----+----+
| | | |
Disk Network SDK
| | |
Room Retrofit Bitwarden
DB APIs Rust SDK
```
**Application Modules:**
- **`:app`** - Password Manager application, feature screens, ViewModels, DI setup
- **`:authenticator`** - Authenticator application for 2FA/TOTP code generation
### Code Organization
**Specialized Library Modules:**
- **`:authenticatorbridge`** - Communication bridge between :authenticator and :app
- **`:annotation`** - Custom annotations for code generation (Hilt, Room, etc.)
- **`:cxf`** - Android Credential Exchange (CXF/CXP) integration layer
```
android/
├── app/ # Password Manager application
│ └── src/main/kotlin/com/x8bit/bitwarden/
│ ├── data/ # Repositories, managers, data sources
│ │ ├── auth/ # Authentication domain
│ │ ├── vault/ # Vault/cipher domain
│ │ ├── platform/ # Platform services
│ │ └── tools/ # Generator, export tools
│ └── ui/ # ViewModels, Screens, Navigation
│ ├── auth/ # Login, registration screens
│ ├── vault/ # Vault screens
│ └── platform/ # Settings, debug menu
├── authenticator/ # Authenticator 2FA application
├── core/ # Shared utilities, dispatcher management
├── data/ # Shared data layer (disk sources, models)
├── network/ # Network layer (Retrofit services, models)
├── ui/ # Shared UI components, theming
├── authenticatorbridge/ # IPC bridge between apps
├── cxf/ # Credential Exchange integration
└── annotation/ # Custom annotations for code generation
```
### Patterns Enforcement
### Key Principles
- **MVVM + UDF**: ViewModels with StateFlow, Compose UI
- **Hilt DI**: Interface injection, @HiltViewModel, @Inject constructor
- **Testing**: JUnit 5, MockK, Turbine for Flow testing
- **Error Handling**: Sealed Result types, no throws in business logic
1. **No Exceptions from Data Layer**: All suspending functions return `Result<T>` or custom sealed classes
2. **State Hoisting to ViewModel**: All state that affects behavior must live in the ViewModel's state
3. **Interface-Based DI**: All implementations use interface/`...Impl` pairs with Hilt injection
4. **Encryption by Default**: All sensitive data encrypted via SDK before storage
## Security Requirements
### Core Patterns
**Every change must consider:**
- Zero-knowledge architecture preservation
- Proper encryption key handling (Android Keystore)
- Input validation and sanitization
- Secure data storage patterns
- Threat model implications
- **BaseViewModel**: Enforces UDF with State/Action/Event pattern. See `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` and `docs/ARCHITECTURE.md` for full templates and usage examples.
- **Repository Result Pattern**: Type-safe error handling using custom sealed classes for discrete operations and `DataState<T>` wrapper for streaming data. See `docs/ARCHITECTURE.md` for implementation details.
- **Common Patterns**: Flow collection via `Internal` actions, error handling via `when` branches, `DataState` streaming with `.map { }` and `.stateIn()`.
## Workflow Practices
> For complete architecture patterns, code templates, and examples, see `docs/ARCHITECTURE.md`.
### Before Implementation
---
1. Read relevant architecture documentation
2. Search for existing patterns to follow
3. Identify affected modules and dependencies
4. Consider security implications
## Development Guide
### During Implementation
### Adding New Feature Screen
1. Follow existing code style in surrounding files
2. Write tests alongside implementation
3. Add KDoc to all public APIs
4. Validate against architecture guidelines
Follow these steps (see `docs/ARCHITECTURE.md` for full templates and patterns):
### After Implementation
1. **Define State/Event/Action** - `@Parcelize` state, sealed event/action classes with `Internal` subclass
2. **Implement ViewModel** - Extend `BaseViewModel<S, E, A>`, persist state via `SavedStateHandle`, map Flow results to internal actions
3. **Implement Screen** - Stateless `@Composable`, use `EventsEffect` for navigation, `remember(viewModel)` for action lambdas
4. **Define Navigation** - `@Serializable` route, `NavGraphBuilder` extension with `composableWithSlideTransitions`, `NavController` extension
5. **Write Tests** - Use the `testing-android-code` skill for comprehensive test patterns and templates
### Code Reviews
Use the `reviewing-changes` skill for structured code review checklists covering MVVM/Compose patterns, security validation, and type-specific review guidance.
---
## Data Models
Key types used throughout the codebase (see source files and `docs/ARCHITECTURE.md` for full definitions):
- **`UserState`** (`data/auth/`) - Active user ID, accounts list, pending account state
- **`VaultUnlockData`** (`data/vault/repository/model/`) - User ID and vault unlock status
- **`DataState<T>`** (`data/`) - Async data wrapper: Loading, Loaded, Pending, Error, NoNetwork
- **`NetworkResult<T>`** (`network/`) - HTTP operation result: Success or Failure
- **`BitwardenError`** (`network/`) - Error classification: Http, Network, Other
---
## Security & Configuration
### Security Rules
**MANDATORY - These rules have no exceptions:**
1. **Zero-Knowledge Architecture**: Never transmit unencrypted vault data or master passwords to the server. All encryption happens client-side via the Bitwarden SDK.
2. **No Plaintext Key Storage**: Encryption keys must be stored using Android Keystore (biometric unlock) or encrypted with PIN/master password.
3. **Sensitive Data Cleanup**: On logout, all sensitive data must be cleared from memory and storage via `UserLogoutManager.logout()`.
4. **Input Validation**: Validate all user inputs before processing, especially URLs and credentials.
5. **SDK Isolation**: Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user crypto context leakage.
### Security Components
| Component | Location | Purpose |
|-----------|----------|---------|
| `BiometricsEncryptionManager` | `data/platform/manager/` | Android Keystore integration for biometric unlock |
| `VaultLockManager` | `data/vault/manager/` | Vault lock/unlock operations |
| `AuthDiskSource` | `data/auth/datasource/disk/` | Secure token and key storage |
| `BaseEncryptedDiskSource` | `data/datasource/disk/` | EncryptedSharedPreferences base class |
### Environment Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages authentication for SDK |
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
| Build types | - | `debug`, `beta`, `release` |
### Authentication & Authorization
- **Login Methods**: Email/password, SSO (OAuth 2.0 + PKCE), trusted device, passwordless auth request
- **Vault Unlock**: Master password, PIN, biometric, trusted device key
- **Token Management**: JWT access tokens with automatic refresh via `AuthTokenManager`
- **Key Derivation**: PBKDF2-SHA256 or Argon2id via `KdfManager`
---
## Testing
### Test Structure
```
app/src/test/ # App unit tests
app/src/testFixtures/ # App test utilities
core/src/testFixtures/ # Core test utilities (FakeDispatcherManager)
data/src/testFixtures/ # Data test utilities (FakeSharedPreferences)
network/src/testFixtures/ # Network test utilities (BaseServiceTest)
ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseComposeTest)
```
### Running Tests
```bash
./gradlew test # Run all unit tests
./gradlew app:testDebugUnitTest # Run app module tests
./gradlew :core:test # Run core module tests
./fastlane check # Run full validation (detekt, lint, tests, coverage)
```
### Test Quick Reference
- **Dispatcher Control**: `FakeDispatcherManager` from `:core:testFixtures`
- **MockK**: `mockk<T> { every { } returns }`, `coEvery { }` for suspend
- **Flow Testing**: Turbine with `stateEventFlow()` helper from `BaseViewModelTest`
- **Time Control**: Inject `Clock` for deterministic time testing
> For comprehensive test templates (ViewModel, Screen, Repository, DataSource, Network), use the `testing-android-code` skill.
---
## Code Style & Standards
- **Formatter**: Android Studio with `bitwarden-style.xml` | **Line Limit**: 100 chars | **Detekt**: Enabled
- **Naming**: `camelCase` (vars/fns), `PascalCase` (classes), `SCREAMING_SNAKE_CASE` (constants), `...Impl` (implementations)
- **KDoc**: Required for all public APIs
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
---
1. Ensure all tests pass
2. Verify compilation succeeds
3. Review security considerations
4. Update relevant documentation
## Anti-Patterns
### DO
- Use `Result<T>` or sealed classes for operations that can fail
- Hoist state to ViewModel when it affects business logic
- Use `remember(viewModel)` for lambdas passed to composables
- Map async results to internal actions before updating state
- Use interface-based DI with Hilt
- Inject `Clock` for time-dependent operations
- Return early to reduce nesting
**Avoid these:**
- Creating new patterns when established ones exist
- Exception-based error handling in business logic
- Direct dependency access (use DI)
- Mutable state in ViewModels (use StateFlow)
- Missing null safety handling
- Undocumented public APIs
- Tight coupling between modules
### DON'T
- Throw exceptions from data layer functions
- Update state directly inside coroutines (use internal actions)
- Use `any` types or suppress null safety
- Catch generic `Exception` (catch specific types)
- Use `e.printStackTrace()` (use Timber logging)
- Create new patterns when established ones exist
- Skip KDoc for public APIs
## Communication & Decision-Making
---
Always clarify ambiguous requirements before implementing. Use specific questions:
- "Should this use [Approach A] or [Approach B]?"
- "This affects [X]. Proceed or review first?"
- "Expected behavior for [specific requirement]?"
## Deployment
Defer high-impact decisions to the user:
- Architecture/module changes, public API modifications
- Security mechanisms, database migrations
- Third-party library additions
### Building
## Reference Documentation
```bash
# Debug builds
./gradlew app:assembleDebug
./gradlew authenticator:assembleDebug
Critical resources:
- `docs/ARCHITECTURE.md` - Architecture patterns and principles
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style guidelines
# Release builds (requires signing keys)
./gradlew app:assembleStandardRelease
./gradlew app:bundleStandardRelease
# F-Droid builds
./gradlew app:assembleFdroidRelease
```
### Versioning
**Location**: `gradle/libs.versions.toml`
```toml
appVersionCode = "1"
appVersionName = "2025.11.1"
```
Follow semantic versioning pattern: `YEAR.MONTH.PATCH`
### Publishing
- **Play Store**: Via GitHub Actions workflow with signed AAB
- **F-Droid**: Via dedicated workflow with F-Droid signing keys
- **Firebase App Distribution**: For beta testing
---
## Troubleshooting
### Common Issues
#### Build fails with SDK dependency error
**Problem**: Cannot resolve Bitwarden SDK from GitHub Packages
**Solution**:
1. Ensure `GITHUB_TOKEN` is set in `ci.properties` or environment
2. Verify token has `read:packages` scope
3. Check network connectivity to `maven.pkg.github.com`
#### Tests fail with dispatcher issues
**Problem**: Tests hang or fail with "Module with Main dispatcher had failed to initialize"
**Solution**:
1. Extend `BaseViewModelTest` for ViewModel tests
2. Use `@RegisterExtension val mainDispatcherExtension = MainDispatcherExtension()`
3. Ensure `runTest { }` wraps test body
#### Compose preview not rendering
**Problem**: @Preview functions show "Rendering problem"
**Solution**:
1. Check for missing theme wrapper: `BitwardenTheme { YourComposable() }`
2. Verify no ViewModel dependency in preview (use state-based preview)
3. Clean and rebuild project
#### ProGuard/R8 stripping required classes
**Problem**: Release build crashes with missing class errors
**Solution**:
1. Add keep rules to `proguard-rules.pro`
2. Check `consumer-rules.pro` in library modules
3. Verify kotlinx.serialization rules are present
### Debug Tips
- **Timber Logging**: Enabled in debug builds, check Logcat with tag filter
- **Debug Menu**: Available in debug builds via Settings > About > Debug Menu
- **Network Inspector**: Use Android Studio Network Profiler or Charles Proxy
- **SDK Debugging**: Check `BaseSdkSource` for wrapped exceptions
---
## References
### Internal Documentation
- `docs/ARCHITECTURE.md` - Complete architecture patterns, BaseViewModel, Repository Result, DataState
- `docs/STYLE_AND_BEST_PRACTICES.md` - Kotlin and Compose code style, formatting, imports, documentation
### Skills & Tools
- `testing-android-code` - Comprehensive test templates and patterns (ViewModel, Screen, Repository, DataSource, Network)
- `reviewing-changes` - Structured code review checklists with MVVM/Compose pattern validation
- `bitwarden-code-review:code-review` - Automated GitHub PR review with inline comments
- `bitwarden-code-review:code-review-local` - Local change review written to files
### External Documentation
- [Bitwarden SDK](https://github.com/bitwarden/sdk) - Cryptographic SDK
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI framework
- [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-guide.html) - Async programming
- [Hilt DI](https://dagger.dev/hilt/) - Dependency injection
- [Turbine](https://github.com/cashapp/turbine) - Flow testing
### Tools & Libraries
- [MockK](https://mockk.io/) - Kotlin mocking library
- [Retrofit](https://square.github.io/retrofit/) - HTTP client
- [Room](https://developer.android.com/training/data-storage/room) - Database
- [Detekt](https://detekt.dev/) - Static analysis
**Do not duplicate information from these files - reference them instead.**

View File

@@ -1,10 +0,0 @@
{
"extraKnownMarketplaces": {
"bitwarden-marketplace": {
"source": {
"source": "github",
"repo": "bitwarden/ai-plugins"
}
}
}
}

View File

@@ -1,44 +0,0 @@
# 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

@@ -1,319 +0,0 @@
---
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

@@ -1,337 +0,0 @@
/**
* 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

@@ -1,255 +0,0 @@
/**
* 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

@@ -1,161 +0,0 @@
/**
* 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

@@ -1,698 +0,0 @@
# 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

@@ -1,274 +0,0 @@
# 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

@@ -1,259 +0,0 @@
# 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`

View File

@@ -9,3 +9,27 @@
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## ⏰ Reminders before review
- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Protected functional changes with optionality (feature flags)
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
## 🦮 Reviewer guidelines
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes

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

@@ -1,14 +1,14 @@
{
"title_patterns": {
"t:feature": ["feat", "feature", "tool"],
"t:feature-app": ["feat", "feature"],
"t:feature-tool": ["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:llm": ["llm"]
"t:misc": ["misc"]
},
"path_patterns": {
"app:shared": [
@@ -28,14 +28,12 @@
"app:authenticator": [
"authenticator/"
],
"t:feature": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json",
"t:feature-tool": [
"testharness/"
],
"t:tech-debt": [
"gradle.properties",
"keystore/"
"t:feature-app": [
"app/src/main/assets/fido2_privileged_community.json",
"app/src/main/assets/fido2_privileged_google.json"
],
"t:ci": [
".checkmarx/",
@@ -43,6 +41,7 @@
"scripts/",
"fastlane/",
".gradle/",
".claude/",
"detekt-config.yml"
],
"t:docs": [
@@ -51,8 +50,8 @@
"t:deps": [
"gradle/"
],
"t:llm": [
".claude/"
"t:misc": [
"keystore/"
]
}
}

11
.github/release.yml vendored
View File

@@ -6,13 +6,14 @@ changelog:
- title: '✨ Community Highlight'
labels:
- community-pr
- title: ':shipit: Feature Development'
- title: '🚀 New Features & Enhancements'
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
@@ -25,10 +26,8 @@ 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,7 +3,6 @@
"extends": [
"github>bitwarden/renovate-config"
],
"ignoreDeps": ["com.bitwarden:sdk-android"],
"enabledManagers": [
"github-actions",
"gradle",
@@ -20,6 +19,20 @@
"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

@@ -18,7 +18,6 @@ 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,7 +19,6 @@ 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,64 +0,0 @@
name: SDLC / Enforce PR labels
run-name: Enforce labels for PR ${{ github.event.pull_request.number }}
on:
pull_request:
types: [labeled, unlabeled, opened, reopened, edited, synchronize]
permissions: {}
jobs:
enforce-label:
name: Enforce Label
runs-on: ubuntu-24.04
permissions:
pull-requests: read
steps:
- name: Enforce banned labels (e.g. hold, needs-qa)
env:
_HOLD_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'hold') }}
_NEEDS_QA_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'needs-qa') }}
run: |
if [ "$_HOLD_LABEL" = "true" ]; then
echo "::error::PR has banned label: hold"
exit 1
fi
if [ "$_NEEDS_QA_LABEL" = "true" ]; then
echo "::error::PR has banned label: needs-qa"
exit 1
fi
echo "✅ No banned labels found."
- name: Enforce exactly one Change Type (t:*) label
env:
_PR_ACTION: ${{ github.event.action }}
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
_REPO: ${{ github.repository }}
_PR_NUMBER: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
run: |
if [ "$_PR_ACTION" = "opened" ] || [ "$_PR_ACTION" = "reopened" ]; then
echo "⏳ Waiting 15s for labeler to run..."
sleep 15
_PR_LABELS=$(gh api "repos/$_REPO/pulls/$_PR_NUMBER" --jq '.labels')
echo "Labels fetched from PR: $_PR_LABELS"
fi
_IGNORE_FOR_RELEASE_LABEL=$(echo "$_PR_LABELS" | jq 'any(.[]; .name == "ignore-for-release")')
if [ "$_IGNORE_FOR_RELEASE_LABEL" = "true" ]; then
echo "⏭️ Skipping type label check - 'ignore-for-release' label present"
exit 0
fi
_T_LABEL_COUNT=$(echo "$_PR_LABELS" | jq '[.[] | select(.name | startswith("t:"))] | length')
case "$_T_LABEL_COUNT" in
1)
echo "✅ PR has exactly one Change Type (t:*) label"
;;
0)
echo "::error::PR is missing a Change Type (t:*) label. PRs must have exactly one Change Type (t:*) label"
exit 1
;;
*)
echo "::error::PR has $_T_LABEL_COUNT Change Type (t:*) labels. PRs must have exactly one Change Type (t:*) label"
exit 1
;;
esac

View File

@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1213.0)
aws-sdk-core (3.242.0)
aws-partitions (1.1206.0)
aws-sdk-core (3.241.4)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -20,7 +20,7 @@ GEM
aws-sdk-kms (1.121.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0)
aws-sdk-s3 (1.212.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -43,7 +43,7 @@ GEM
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.5)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -169,7 +169,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.18.1)
json (2.18.0)
jwt (2.10.2)
base64
logger (1.7.0)

View File

@@ -1,20 +1,19 @@
import com.android.build.api.dsl.LibraryExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
configure<LibraryExtension> {
android {
namespace = "com.bitwarden.annotation"
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk {
version = release(libs.versions.minSdkBwa.get().toInt())
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}
@@ -40,6 +39,6 @@ configure<LibraryExtension> {
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
}
}

View File

@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,10 +1,9 @@
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.impl.VariantOutputImpl
import com.android.utils.cxx.io.removeExtensionIfPresent
import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFileIdTask
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
import com.google.gms.googleservices.GoogleServicesTask
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import dagger.hilt.android.plugin.util.capitalize
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream
import java.util.Properties
@@ -16,6 +15,7 @@ plugins {
// standardDebug builds in the merged manifest.
alias(libs.plugins.crashlytics)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose.compiler)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
@@ -43,22 +43,16 @@ val ciProperties = Properties().apply {
}
}
base {
// Set the base archive name for publishing purposes. This is used to derive the
// APK and AAB artifact names when uploading to Firebase and Play Store.
archivesName.set("com.x8bit.bitwarden")
}
room {
schemaDirectory("$projectDir/schemas")
}
configure<ApplicationExtension> {
android {
namespace = "com.x8bit.bitwarden"
compileSdk {
version = release(libs.versions.compileSdk.get().toInt())
}
room {
schemaDirectory("$projectDir/schemas")
}
defaultConfig {
applicationId = "com.x8bit.bitwarden"
minSdk {
@@ -72,6 +66,10 @@ configure<ApplicationExtension> {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Set the base archive name for publishing purposes. This is used to derive the APK and AAB
// artifact names when uploading to Firebase and Play Store.
base.archivesName = "com.x8bit.bitwarden"
buildConfigField(
type = "String",
name = "CI_INFO",
@@ -149,6 +147,43 @@ configure<ApplicationExtension> {
}
}
androidComponents.onVariants { appVariant ->
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
val applicationId = appVariant.applicationId.get()
val flavorName = appVariant.flavorName
val variantName = appVariant.name
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
val buildType = appVariant.buildType
appVariant
.outputs
.mapNotNull { it as? VariantOutputImpl }
.forEach { output ->
val fileNameWithoutExtension = when (flavorName) {
"fdroid" -> "$applicationId-$flavorName"
"standard" -> applicationId
else -> output.outputFileName.get().removeExtensionIfPresent(".apk")
}
// Set the APK output filename.
output.outputFileName.set("$fileNameWithoutExtension.apk")
tasks.register(renameTaskName) {
group = "build"
description = "Renames the bundle files for $variantName variant"
doLast {
renameFile(
"$bundlesDir/$variantName/$namespace-$flavorName-$buildType.aab",
"$fileNameWithoutExtension.aab",
)
}
}
// Force renaming task to execute after the variant is built.
tasks
.matching { it.name == "bundle${variantName.capitalize()}" }
.forEach { it.finalizedBy(renameTaskName) }
}
}
compileOptions {
sourceCompatibility(libs.versions.jvmTarget.get())
targetCompatibility(libs.versions.jvmTarget.get())
@@ -175,50 +210,9 @@ configure<ApplicationExtension> {
}
}
androidComponents {
onVariants { appVariant ->
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
val applicationId = appVariant.applicationId.get()
val flavorName = appVariant.flavorName
val variantName = appVariant.name
val buildType = appVariant.buildType
appVariant
.outputs
.mapNotNull { it as? VariantOutputImpl }
.forEach { output ->
val fileNameWithoutExtension = when (flavorName) {
"fdroid" -> "$applicationId-$flavorName"
"standard" -> applicationId
else -> output.outputFileName.get().removeExtensionIfPresent(".apk")
}
// Set the APK output filename.
output.outputFileName.set("$fileNameWithoutExtension.apk")
val renameTaskName = "rename${variantName.uppercaseFirstChar()}AabFiles"
tasks.register(renameTaskName) {
group = "build"
description = "Renames the bundle files for $variantName variant"
doLast {
val namespace = appVariant.namespace.get()
renameFile(
"$bundlesDir/$variantName/$namespace-$flavorName-$buildType.aab",
"$fileNameWithoutExtension.aab",
)
}
}
// Force renaming task to execute after the variant is built.
val bundleTaskName = "bundle${variantName.uppercaseFirstChar()}"
tasks
.named { it == bundleTaskName }
.configureEach { finalizedBy(renameTaskName) }
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
}
}
@@ -276,8 +270,6 @@ 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)
@@ -317,6 +309,18 @@ 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"
}
}
afterEvaluate {
// Disable Fdroid-specific tasks that we want to exclude
val fdroidTasksToDisable = tasks.withType<GoogleServicesTask>() +

View File

@@ -84,6 +84,15 @@
<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" />
@@ -111,35 +120,6 @@
</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"
@@ -203,16 +183,6 @@
android:host="webauthn-callback"
android:scheme="bitwarden" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="sso_cookie_vendor"
android:scheme="bitwarden" />
</intent-filter>
</activity>
<provider

View File

@@ -1,17 +1,5 @@
{
"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": {
@@ -24,7 +12,7 @@
{
"build": "release",
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
}
},
]
}
},

View File

@@ -815,38 +815,6 @@
}
]
}
},
{
"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"
}
]
}
},
{
"type": "android",
"info": {
"package_name": "com.amazon.cloud9",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "2F:19:AD:EB:28:4E:B3:6F:7F:07:78:61:52:B9:A1:D1:4B:21:65:32:03:AD:0B:04:EB:BF:9C:73:AB:6D:76:25"
},
{
"build": "release",
"cert_fingerprint_sha256": "70:D5:68:EC:6A:E6:F3:38:BC:1A:63:99:A6:53:7E:E0:69:08:CA:1D:72:FB:8F:F0:48:74:AB:95:43:3B:25:0E"
},
{
"build": "userdebug",
"cert_fingerprint_sha256": "7C:AC:39:19:37:98:1B:61:34:BD:CE:1F:D9:83:4C:25:31:81:F5:AB:F9:1D:ED:60:78:21:0D:0F:91:AC:E3:60"
}
]
}
}
]
}

View File

@@ -3,7 +3,6 @@ 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.auth.repository.util.getCookieCallbackResultOrNull
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
@@ -29,7 +28,6 @@ class AuthCallbackViewModel @Inject constructor(
val webAuthResult = action.intent.getWebAuthResultOrNull()
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
val ssoCallbackResult = action.intent.getSsoCallbackResult()
val cookieCallbackResult = action.intent.getCookieCallbackResultOrNull()
when {
yubiKeyResult != null -> {
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
@@ -47,12 +45,6 @@ class AuthCallbackViewModel @Inject constructor(
)
}
cookieCallbackResult != null -> {
authRepository.setCookieCallbackResult(
result = cookieCallbackResult,
)
}
webAuthResult != null -> {
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
}

View File

@@ -6,6 +6,7 @@ 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
@@ -23,6 +24,9 @@ class BitwardenApplication : Application() {
@Inject
lateinit var logsManager: LogsManager
@Inject
lateinit var networkConnectionManager: NetworkConnectionManager
@Inject
lateinit var networkConfigManager: NetworkConfigManager

View File

@@ -1,89 +0,0 @@
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

@@ -1,110 +0,0 @@
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,11 +12,9 @@ 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
@@ -35,8 +33,6 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
@@ -124,22 +120,15 @@ class MainActivity : AppCompatActivity() {
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Root navigation, debug menu, and cookie acquisition exist at
// this top level. They can appear on top of the rest of the app
// without interacting with the state-based navigation used by
// RootNavScreen.
// 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
// interacting with the state-based navigation used by RootNavScreen.
rootNavDestination { shouldShowSplashScreen = false }
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
cookieAcquisitionDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
@@ -213,8 +202,6 @@ class MainActivity : AppCompatActivity() {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),

View File

@@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.util.getProviderImportCredentialsRequest
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.share.ShareManager
@@ -27,10 +26,12 @@ 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.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
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.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
@@ -49,7 +50,6 @@ import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -77,11 +77,10 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val credentialProviderRequestManager: CredentialProviderRequestManager,
private val bitwardenCredentialManager: BitwardenCredentialManager,
private val shareManager: ShareManager,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
@@ -165,23 +164,6 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
combine(
authRepository.userStateFlow,
cookieAcquisitionRequestManager.cookieAcquisitionRequestFlow,
) { userState, request ->
userState != null &&
userState.activeAccount.isVaultUnlocked &&
request != null &&
request.hostname ==
userState.activeAccount.environment.environmentUrlData
.baseWebVaultUrlOrDefault
}
.distinctUntilChanged()
.filter { it }
.map { MainAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
// This covers any users who are active prior to this value being recorded.
viewModelScope.launch {
@@ -227,7 +209,6 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
}
}
@@ -292,10 +273,6 @@ class MainViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
private fun handleCookieAcquisitionReady() {
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
handleIntent(
intent = action.intent,
@@ -337,9 +314,11 @@ 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 {
@@ -397,6 +376,59 @@ 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
@@ -416,52 +448,10 @@ class MainViewModel @Inject constructor(
SpecialCircumstance.CredentialExchangeExport(
data = ImportCredentialsRequestData(
uri = importCredentialsRequest.uri,
credentialTypes = importCredentialsRequest.request.credentialTypes,
knownExtensions = importCredentialsRequest.request.knownExtensions,
requestJson = importCredentialsRequest.request.requestJson,
),
)
}
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,
)
}
}
}
@@ -614,12 +604,6 @@ sealed class MainAction {
data class DynamicColorsUpdate(
val isDynamicColorsEnabled: Boolean,
) : Internal()
/**
* Indicates that the cookie acquisition conditions are met and navigation
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
}
}
@@ -649,11 +633,6 @@ sealed class MainEvent {
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Navigate to the cookie acquisition screen.
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

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

View File

@@ -1,6 +1,7 @@
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
@@ -16,7 +17,6 @@ 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
@@ -34,7 +34,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
@@ -45,7 +44,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing and modifying authentication state.
* Provides an API for observing an modifying authentication state.
*/
@Suppress("TooManyFunctions")
interface AuthRepository :
@@ -71,12 +70,6 @@ interface AuthRepository :
*/
val ssoCallbackResultFlow: Flow<SsoCallbackResult>
/**
* Flow of the current [CookieCallbackResult]. Subscribers should listen to the flow in order
* to receive updates whenever [setCookieCallbackResult] is called.
*/
val cookieCallbackResultFlow: Flow<CookieCallbackResult>
/**
* Flow of the current [YubiKeyResult]. Subscribers should listen to the flow in order to
* receive updates whenever [setYubiKeyResult] is called.
@@ -133,7 +126,7 @@ interface AuthRepository :
/**
* The organization for the active user.
*/
val organizations: List<Organization>
val organizations: List<SyncResponseJson.Profile.Organization>
/**
* Whether or not the welcome carousel should be displayed, based on the feature flag and
@@ -290,7 +283,7 @@ interface AuthRepository :
): PasswordHintResult
/**
* Removes the users password from the account. This is used when migrating from master
* Removes the users password from the account. This used used when migrating from master
* password login to key connector login.
*/
suspend fun removePassword(masterPassword: String): RemovePasswordResult
@@ -349,11 +342,6 @@ interface AuthRepository :
*/
fun setSsoCallbackResult(result: SsoCallbackResult)
/**
* Set the value of [cookieCallbackResultFlow].
*/
fun setCookieCallbackResult(result: CookieCallbackResult)
/**
* Get a [Boolean] indicating whether this is a known device.
*/
@@ -397,7 +385,7 @@ interface AuthRepository :
): SendVerificationEmailResult
/**
* Validates the given [token] for the given [email]. Part of the new account registration flow.
* Validates the given [token] for the given [email]. Part of th new account registration flow.
*/
suspend fun validateEmailToken(
email: String,

View File

@@ -5,7 +5,6 @@ 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,7 +13,6 @@ import com.bitwarden.core.data.util.flatMap
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.data.repository.util.appLinksScheme
import com.bitwarden.data.repository.util.toEnvironmentUrls
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.CreateAccountKeysResponseJson
@@ -49,7 +47,6 @@ 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
@@ -75,7 +72,6 @@ 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
@@ -95,13 +91,11 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
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
@@ -176,7 +170,6 @@ class AuthRepositoryImpl(
private val policyManager: PolicyManager,
private val userStateManager: UserStateManager,
private val kdfManager: KdfManager,
private val toastManager: ToastManager,
logsManager: LogsManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
@@ -270,10 +263,6 @@ class AuthRepositoryImpl(
override val ssoCallbackResultFlow: Flow<SsoCallbackResult> =
mutableSsoCallbackResultFlow.asSharedFlow()
private val mutableCookieCallbackResultFlow = bufferedMutableSharedFlow<CookieCallbackResult>()
override val cookieCallbackResultFlow: Flow<CookieCallbackResult> =
mutableCookieCallbackResultFlow.asSharedFlow()
override var rememberedEmailAddress: String? by authDiskSource::rememberedEmailAddress
override var rememberedOrgIdentifier: String? by authDiskSource::rememberedOrgIdentifier
@@ -299,11 +288,8 @@ class AuthRepositoryImpl(
?.profile
?.forcePasswordResetReason
override val organizations: List<Organization>
get() = activeUserId
?.let { authDiskSource.getOrganizations(it) }
.orEmpty()
.toOrganizations()
override val organizations: List<SyncResponseJson.Profile.Organization>
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
override val showWelcomeCarousel: Boolean
get() = !settingsRepository.hasUserLoggedInOrCreatedAccount
@@ -738,27 +724,18 @@ class AuthRepositoryImpl(
when (refreshTokenResponse) {
is RefreshTokenResponseJson.Error -> {
if (refreshTokenResponse.isInvalidGrant) {
userLogoutManager.softLogout(
userId = userId,
reason = LogoutReason.InvalidGrant,
)
logout(userId = userId, reason = LogoutReason.InvalidGrant)
}
IllegalStateException(refreshTokenResponse.error).asFailure()
}
is RefreshTokenResponseJson.Forbidden -> {
userLogoutManager.softLogout(
userId = userId,
reason = LogoutReason.RefreshForbidden,
)
logout(userId = userId, reason = LogoutReason.RefreshForbidden)
refreshTokenResponse.error.asFailure()
}
is RefreshTokenResponseJson.Unauthorized -> {
userLogoutManager.softLogout(
userId = userId,
reason = LogoutReason.RefreshUnauthorized,
)
logout(userId = userId, reason = LogoutReason.RefreshUnauthorized)
refreshTokenResponse.error.asFailure()
}
@@ -998,8 +975,8 @@ class AuthRepositoryImpl(
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
it.type != OrganizationType.OWNER &&
it.type != OrganizationType.ADMIN
}
?.keyConnectorUrl
?: return RemovePasswordResult.Error(
@@ -1061,10 +1038,9 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
}
val userId = activeAccount.profile.userId
return vaultSdkSource
.updatePassword(
userId = userId,
userId = activeAccount.profile.userId,
newPassword = newPassword,
)
.flatMap { updatePasswordResponse ->
@@ -1090,15 +1066,14 @@ class AuthRepositoryImpl(
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userId,
userId = activeAccount.profile.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, userId = userId)
logout(reason = LogoutReason.PasswordReset)
// Return the success.
ResetPasswordResult.Success
@@ -1263,10 +1238,6 @@ class AuthRepositoryImpl(
mutableSsoCallbackResultFlow.tryEmit(result)
}
override fun setCookieCallbackResult(result: CookieCallbackResult) {
mutableCookieCallbackResultFlow.tryEmit(result)
}
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
devicesService
.getIsKnownDevice(
@@ -1592,7 +1563,6 @@ class AuthRepositoryImpl(
): LoginResult = identityService
.getToken(
uniqueAppId = authDiskSource.uniqueAppId,
deeplinkScheme = environmentRepository.environment.environmentUrlData.appLinksScheme,
email = email,
authModel = authModel,
twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email),

View File

@@ -1,7 +1,6 @@
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
@@ -72,7 +71,6 @@ object AuthRepositoryModule {
logsManager: LogsManager,
userStateManager: UserStateManager,
kdfManager: KdfManager,
toastManager: ToastManager,
): AuthRepository = AuthRepositoryImpl(
clock = clock,
accountsService = accountsService,
@@ -99,7 +97,6 @@ object AuthRepositoryModule {
logsManager = logsManager,
userStateManager = userStateManager,
kdfManager = kdfManager,
toastManager = toastManager,
)
@Provides

View File

@@ -14,16 +14,14 @@ 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,
val shouldUseEvents: Boolean,
val limitItemDeletion: Boolean = false,
)

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

@@ -1,60 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/** URI scheme for cookie vendor callback. */
private const val COOKIE_CALLBACK_SCHEME: String = "bitwarden"
/** URI host for cookie vendor callback. */
private const val COOKIE_CALLBACK_HOST: String = "sso_cookie_vendor"
/** Completeness marker parameter name (filtered from cookie extraction). */
private const val COMPLETENESS_MARKER_PARAM = "d"
/**
* Extracts cookie callback result from Intent.
* Handles both single and sharded cookie formats.
* Filters out the 'd' completeness marker parameter.
*
* @return [CookieCallbackResult] if this is a cookie callback, null otherwise.
*/
fun Intent.getCookieCallbackResultOrNull(): CookieCallbackResult? {
if (action != Intent.ACTION_VIEW) return null
val uri = data ?: return null
if (uri.scheme != COOKIE_CALLBACK_SCHEME) return null
if (uri.host != COOKIE_CALLBACK_HOST) return null
val cookies = uri.queryParameterNames
.asSequence()
.filter { it != COMPLETENESS_MARKER_PARAM }
.mapNotNull { name ->
uri.getQueryParameter(name)?.takeIf { it.isNotEmpty() }?.let { name to it }
}
.toMap()
return if (cookies.isEmpty()) {
CookieCallbackResult.MissingCookie
} else {
CookieCallbackResult.Success(cookies)
}
}
/**
* Represents the result of a cookie callback from a deep link.
*/
sealed class CookieCallbackResult : Parcelable {
/**
* The callback did not contain any cookies.
*/
@Parcelize
data object MissingCookie : CookieCallbackResult()
/**
* Successfully extracted cookies from the callback.
* @param cookies Map of cookie name to cookie value. Supports sharded cookies.
*/
@Parcelize
data class Success(val cookies: Map<String, String>) : CookieCallbackResult()
}

View File

@@ -5,7 +5,8 @@ import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import com.bitwarden.annotation.OmitFromCoverage
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "duo-callback"
@@ -33,7 +34,9 @@ fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
}
APP_LINK_SCHEME -> {
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getDuoCallbackTokenResult()
} else {
null

View File

@@ -11,31 +11,31 @@ import java.net.URLEncoder
import java.security.MessageDigest
import java.util.Base64
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "sso-callback"
const val SSO_URI: String = "bitwarden://$CALLBACK"
/**
* Generates a URI for the SSO custom tab.
*
* @param identityBaseUrl The base URl for the identity service.
* @param redirectUrl The redirect URI used in the SSO request.
* @param organizationIdentifier The SSO organization identifier.
* @param token The prevalidated SSO token.
* @param state Random state used to verify the validity of the response.
* @param codeVerifier A random string used to generate the code challenge.
*/
@Suppress("LongParameterList")
fun generateUriForSso(
identityBaseUrl: String,
redirectUrl: String,
organizationIdentifier: String,
token: String,
state: String,
codeVerifier: String,
): Uri {
val redirectUri = URLEncoder.encode(redirectUrl, "UTF-8")
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
val encodedToken = URLEncoder.encode(token, "UTF-8")
@@ -81,7 +81,9 @@ fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
}
APP_LINK_SCHEME -> {
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getSsoCallbackResult()
} else {
null

View File

@@ -13,30 +13,26 @@ private val JSON = Json {
}
/**
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization] or `null` if the
* [SyncResponseJson.Profile.Organization.name] is not present.
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization].
*/
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,
)
}
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,
)
/**
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
* [Organization]s.
*/
fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organization> =
this.mapNotNull { it.toOrganization() }
this.map { it.toOrganization() }
/**
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.

View File

@@ -5,18 +5,20 @@ import android.net.Uri
import androidx.browser.auth.AuthTabIntent
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URLEncoder
import java.util.Base64
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
private const val APP_LINK_SCHEME: String = "https"
private const val DEEPLINK_SCHEME: String = "bitwarden"
private const val CALLBACK: String = "webauthn-callback"
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
/**
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
*
@@ -37,7 +39,9 @@ fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
}
APP_LINK_SCHEME -> {
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
localData.path == "/$CALLBACK"
) {
localData.getWebAuthResult()
} else {
null
@@ -75,33 +79,29 @@ private fun Uri?.getWebAuthResult(): WebAuthResult =
/**
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
*/
@Suppress("LongParameterList")
fun generateUriForWebAuth(
baseUrl: String,
authTabData: AuthTabData,
data: JsonObject,
headerText: String,
buttonText: String,
returnButtonText: String,
): Uri {
val json = buildJsonObject {
put(key = "callbackUri", value = CALLBACK_URI)
put(key = "data", value = data.toString())
put(key = "headerText", value = headerText)
put(key = "btnText", value = buttonText)
put(key = "btnReturnText", value = returnButtonText)
put(key = "mobile", value = true)
}
val base64Data = Base64
.getEncoder()
.encodeToString(json.toString().toByteArray(Charsets.UTF_8))
val parentParam = URLEncoder.encode(authTabData.callbackUrl, "UTF-8")
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
val url = baseUrl +
"/webauthn-mobile-connector.html" +
"?data=$base64Data" +
"&parent=$parentParam" +
"&client=mobile" +
"&v=2" +
"&deeplinkScheme=${authTabData.callbackScheme}"
"&v=2"
return url.toUri()
}

View File

@@ -30,10 +30,6 @@ class AutofillActivityManagerImpl(
braveStableStatusData = browserThirdPartyAutofillManager.stableBraveAutofillStatus,
chromeStableStatusData = browserThirdPartyAutofillManager.stableChromeAutofillStatus,
chromeBetaChannelStatusData = browserThirdPartyAutofillManager.betaChromeAutofillStatus,
vivaldiStableChannelStatusData = browserThirdPartyAutofillManager
.stableVivaldiAutofillStatus,
defaultBrowserPackageName = browserThirdPartyAutofillManager
.defaultBrowserPackageName,
)
init {

View File

@@ -29,7 +29,7 @@ internal class BrowserAutofillDialogManagerImpl(
get() = autofillEnabledManager.isAutofillEnabled &&
browserThirdPartyAutofillEnabledManager
.browserThirdPartyAutofillStatus
.isDefaultBrowserAvailableAndDisabled &&
.isAnyIsAvailableAndDisabled &&
!firstTimeActionManager
.currentOrDefaultUserFirstTimeState
.showSetupBrowserAutofillCard &&

View File

@@ -39,9 +39,4 @@ private val DEFAULT_STATUS = BrowserThirdPartyAutofillStatus(
isAvailable = false,
isThirdPartyEnabled = false,
),
vivaldiStableChannelStatusData = BrowserThirdPartyAutoFillData(
isAvailable = false,
isThirdPartyEnabled = false,
),
defaultBrowserPackageName = null,
)

View File

@@ -22,14 +22,4 @@ interface BrowserThirdPartyAutofillManager {
* The data representing the status of the beta Chrome version
*/
val betaChromeAutofillStatus: BrowserThirdPartyAutoFillData
/**
* The data representing the status of the Vivaldi version
*/
val stableVivaldiAutofillStatus: BrowserThirdPartyAutoFillData
/**
* The package name of the device's default browser, or null if it cannot be determined.
*/
val defaultBrowserPackageName: String?
}

View File

@@ -2,10 +2,7 @@ package com.x8bit.bitwarden.data.autofill.manager.browser
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutoFillData
@@ -30,18 +27,6 @@ class BrowserThirdPartyAutofillManagerImpl(
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.CHROME_STABLE)
override val betaChromeAutofillStatus: BrowserThirdPartyAutoFillData
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.CHROME_BETA)
override val stableVivaldiAutofillStatus: BrowserThirdPartyAutoFillData
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.VIVALDI_STABLE)
override val defaultBrowserPackageName: String?
get() {
val intent = Intent(Intent.ACTION_VIEW, "https://example.com".toUri())
return context
.packageManager
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo
?.packageName
}
private fun getThirdPartyAutoFillStatusForChannel(
releaseChannel: BrowserPackage,

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.autofill.model.browser
private const val BRAVE_CHANNEL_PACKAGE = "com.brave.browser"
private const val CHROME_BETA_CHANNEL_PACKAGE = "com.chrome.beta"
private const val CHROME_RELEASE_CHANNEL_PACKAGE = "com.android.chrome"
private const val VIVALDI_RELEASE_CHANNEL_PACKAGE = "com.vivaldi.browser"
/**
* Enumerated values of each browser that supports third party autofill checks.
@@ -14,5 +13,4 @@ enum class BrowserPackage(val packageName: String) {
BRAVE_RELEASE(BRAVE_CHANNEL_PACKAGE),
CHROME_STABLE(CHROME_RELEASE_CHANNEL_PACKAGE),
CHROME_BETA(CHROME_BETA_CHANNEL_PACKAGE),
VIVALDI_STABLE(VIVALDI_RELEASE_CHANNEL_PACKAGE),
}

View File

@@ -17,8 +17,6 @@ data class BrowserThirdPartyAutofillStatus(
val braveStableStatusData: BrowserThirdPartyAutoFillData,
val chromeStableStatusData: BrowserThirdPartyAutoFillData,
val chromeBetaChannelStatusData: BrowserThirdPartyAutoFillData,
val vivaldiStableChannelStatusData: BrowserThirdPartyAutoFillData,
val defaultBrowserPackageName: String?,
) {
/**
* The total number of available browsers.
@@ -26,8 +24,7 @@ data class BrowserThirdPartyAutofillStatus(
val availableCount: Int
get() = (if (braveStableStatusData.isAvailable) 1 else 0) +
(if (chromeStableStatusData.isAvailable) 1 else 0) +
(if (chromeBetaChannelStatusData.isAvailable) 1 else 0) +
(if (vivaldiStableChannelStatusData.isAvailable) 1 else 0)
(if (chromeBetaChannelStatusData.isAvailable) 1 else 0)
/**
* Whether any of the available browsers have third party autofill disabled.
@@ -35,28 +32,5 @@ data class BrowserThirdPartyAutofillStatus(
val isAnyIsAvailableAndDisabled: Boolean
get() = braveStableStatusData.isAvailableButDisabled ||
chromeStableStatusData.isAvailableButDisabled ||
chromeBetaChannelStatusData.isAvailableButDisabled ||
vivaldiStableChannelStatusData.isAvailableButDisabled
/**
* Whether the device's default browser is one of the supported browsers and has third party
* autofill disabled. Returns false if the default browser is not a supported browser or
* cannot be determined.
*/
val isDefaultBrowserAvailableAndDisabled: Boolean
get() {
val browserPackage = defaultBrowserPackageName
?.let { packageName ->
BrowserPackage.entries.firstOrNull { it.packageName == packageName }
}
?: return false
return when (browserPackage) {
BrowserPackage.BRAVE_RELEASE -> braveStableStatusData.isAvailableButDisabled
BrowserPackage.CHROME_STABLE -> chromeStableStatusData.isAvailableButDisabled
BrowserPackage.CHROME_BETA -> chromeBetaChannelStatusData.isAvailableButDisabled
BrowserPackage.VIVALDI_STABLE -> {
vivaldiStableChannelStatusData.isAvailableButDisabled
}
}
}
chromeBetaChannelStatusData.isAvailableButDisabled
}

View File

@@ -33,21 +33,14 @@ private val BLOCK_LISTED_URIS: List<String> = listOf(
* A map of package ids and the known associated id entry for their url bar.
*/
private val URL_BARS: Map<String, String> = mapOf(
// Edge Browser Variants
"com.microsoft.emmx" to "url_bar",
"com.microsoft.emmx.beta" to "url_bar",
"com.microsoft.emmx.canary" to "url_bar",
"com.microsoft.emmx.dev" to "url_bar",
// Samsung Internet Browser Variants
"com.sec.android.app.sbrowser" to "location_bar_edit_text",
"com.sec.android.app.sbrowser.beta" to "location_bar_edit_text",
// Opera Browser Variants
"com.opera.browser" to "url_bar",
"com.opera.browser.beta" to "url_bar",
// Brave Browser Variants
"com.brave.browser" to "url_bar",
"com.brave.browser_beta" to "url_bar",
"com.brave.browser_nightly" to "url_bar",
)
/**

View File

@@ -7,19 +7,16 @@ import com.bitwarden.vault.CopyableCipherFields
import com.bitwarden.vault.LoginListView
/**
* Returns true when the cipher is not archived, not deleted and contains at least one FIDO 2
* credential.
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
*/
val CipherListView.isActiveWithFido2Credentials: Boolean
get() = archivedDate == null && deletedDate == null && login?.hasFido2 ?: false
get() = deletedDate == null && login?.hasFido2 ?: false
/**
* Returns true when the cipher type is not archived, not deleted and contains a copyable password.
* Returns true when the cipher type is not deleted and contains a copyable password.
*/
val CipherListView.isActiveWithCopyablePassword: Boolean
get() = archivedDate == null &&
deletedDate == null &&
copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
get() = deletedDate == null && copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
/**
* Returns the [LoginListView] if the cipher is of type [CipherListViewType.Login], otherwise null.

View File

@@ -48,17 +48,13 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
}
/**
* Returns true when the cipher is not archived, not deleted and contains at least one FIDO 2
* credential.
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
*/
val CipherView.isActiveWithFido2Credentials: Boolean
get() = archivedDate == null &&
deletedDate == null &&
!(login?.fido2Credentials.isNullOrEmpty())
get() = deletedDate == null && !(login?.fido2Credentials.isNullOrEmpty())
/**
* Returns true when the cipher is not archived, not deleted and contains at least one Password
* credential.
* Returns true when the cipher is not deleted and contains at least one Pasword credential.
*/
val CipherView.isActiveWithPasswordCredentials: Boolean
get() = archivedDate == null && deletedDate == null && !(login?.password.isNullOrEmpty())
get() = deletedDate == null && !(login?.password.isNullOrEmpty())

View File

@@ -14,8 +14,6 @@ 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
@@ -147,9 +145,4 @@ object CredentialProviderModule {
@Singleton
fun providePasskeyAttestationOptionsSanitizer(): PasskeyAttestationOptionsSanitizer =
PasskeyAttestationOptionsSanitizerImpl
@Provides
@Singleton
fun provideCredentialProviderRequestManager(): CredentialProviderRequestManager =
CredentialProviderRequestManagerImpl()
}

View File

@@ -50,8 +50,6 @@ 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].
*/
@@ -125,7 +123,7 @@ class BitwardenCredentialManagerImpl(
.getSignatureFingerprintAsHexString()
.orEmpty(),
host = hostUrl,
assetLinkUrl = hostUrl.toDigitalAssetLinkUrl(),
assetLinkUrl = hostUrl,
),
)
}
@@ -318,7 +316,7 @@ class BitwardenCredentialManagerImpl(
packageName = callingAppInfo.packageName,
sha256CertFingerprint = signatureFingerprint,
host = host,
assetLinkUrl = host.toDigitalAssetLinkUrl(),
assetLinkUrl = host,
),
)
@@ -430,13 +428,6 @@ 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,7 +23,6 @@ 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(
@@ -45,7 +44,6 @@ 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)
@@ -67,7 +65,6 @@ 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(
@@ -86,7 +83,6 @@ 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(
@@ -107,7 +103,6 @@ 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)
@@ -121,7 +116,6 @@ 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

@@ -1,26 +0,0 @@
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

@@ -1,25 +0,0 @@
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

@@ -1,39 +0,0 @@
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

@@ -1,25 +0,0 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
/**
* Disk source for cookie persistence.
*/
interface CookieDiskSource {
/**
* Gets cookie configuration for a specific [hostname].
*
* @param hostname The server hostname to retrieve configuration for.
* @return The [CookieConfigurationData] if found, or null if no cookies stored.
*/
fun getCookieConfig(hostname: String): CookieConfigurationData?
/**
* Stores cookie [config] for the given [hostname]. Pass `null` to delete the configuration.
*
* @param hostname The server hostname to associate with this configuration.
* @param config The [CookieConfigurationData] to persist, or `null` to delete.
*/
fun storeCookieConfig(hostname: String, config: CookieConfigurationData?)
}

View File

@@ -1,36 +0,0 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.data.datasource.disk.BaseEncryptedDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
import kotlinx.serialization.json.Json
private const val CONFIG_PREFIX = "elb_cookie_config"
/**
* Implementation of [CookieDiskSource] using encrypted SharedPreferences.
*
* Simple storage layer for cookies.
*/
class CookieDiskSourceImpl(
sharedPreferences: SharedPreferences,
encryptedSharedPreferences: SharedPreferences,
private val json: Json,
) : CookieDiskSource,
BaseEncryptedDiskSource(
sharedPreferences = sharedPreferences,
encryptedSharedPreferences = encryptedSharedPreferences,
) {
override fun getCookieConfig(hostname: String): CookieConfigurationData? {
val key = CONFIG_PREFIX.appendIdentifier(hostname)
return getEncryptedString(key)
?.let { json.decodeFromStringOrNull<CookieConfigurationData>(it) }
}
override fun storeCookieConfig(hostname: String, config: CookieConfigurationData?) {
val key = CONFIG_PREFIX.appendIdentifier(hostname)
putEncryptedString(key, config?.let { json.encodeToString(it) })
}
}

View File

@@ -8,8 +8,6 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
@@ -157,16 +155,4 @@ object PlatformDiskModule {
): FeatureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl(
sharedPreferences = sharedPreferences,
)
@Provides
@Singleton
fun provideCookieDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
json: Json,
): CookieDiskSource = CookieDiskSourceImpl(
sharedPreferences = sharedPreferences,
encryptedSharedPreferences = encryptedSharedPreferences,
json = json,
)
}

View File

@@ -1,27 +0,0 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.model
import kotlinx.serialization.Serializable
/**
* Simple domain model for cookie storage.
*
* @property hostname The server hostname this configuration applies to.
* @property cookies The list of cookies for this server configuration.
*/
@Serializable
data class CookieConfigurationData(
val hostname: String,
val cookies: List<Cookie>,
) {
/**
* Simple domain model for a cookie.
*
* @property name The cookie name.
* @property value The cookie value.
*/
@Serializable
data class Cookie(
val name: String,
val value: String,
)
}

View File

@@ -14,7 +14,6 @@ import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CL
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -56,7 +55,6 @@ object PlatformNetworkModule {
authDiskSource: AuthDiskSource,
certificateManager: CertificateManager,
buildInfoManager: BuildInfoManager,
networkCookieManager: NetworkCookieManager,
clock: Clock,
): BitwardenServiceClient = bitwardenServiceClient(
BitwardenServiceClientConfig(
@@ -71,7 +69,6 @@ object PlatformNetworkModule {
baseUrlsProvider = baseUrlsProvider,
certificateProvider = certificateManager,
enableHttpBodyLogging = buildInfoManager.isDevBuild,
cookieProvider = networkCookieManager,
),
)
}

View File

@@ -1,12 +0,0 @@
package com.x8bit.bitwarden.data.platform.error
import java.io.IOException
/**
* Thrown when SDK requires cookie acquisition before API call can proceed.
*
* @property hostname The server hostname requiring cookie acquisition.
*/
class CookiesRequiredException(
val hostname: String,
) : IOException("Cookie acquisition required for $hostname")

View File

@@ -1,24 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import kotlinx.coroutines.flow.StateFlow
/**
* Manager for server communication configuration state.
*/
interface CookieAcquisitionRequestManager {
/**
* StateFlow of pending cookie acquisition.
*
* Emits non-null when cookie acquisition is needed, null otherwise.
*/
val cookieAcquisitionRequestFlow: StateFlow<CookieAcquisitionRequest?>
/**
* Sets the pending cookie acquisition state.
*
* @param data The pending cookie acquisition data, or null to clear.
*/
fun setPendingCookieAcquisition(data: CookieAcquisitionRequest?)
}

View File

@@ -1,24 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Singleton
/**
* Implementation of [CookieAcquisitionRequestManager].
*/
@Singleton
class CookieAcquisitionRequestManagerImpl : CookieAcquisitionRequestManager {
private val mutableCookieAcquisitionRequestFlow =
MutableStateFlow<CookieAcquisitionRequest?>(null)
override val cookieAcquisitionRequestFlow: StateFlow<CookieAcquisitionRequest?> =
mutableCookieAcquisitionRequestFlow.asStateFlow()
override fun setPendingCookieAcquisition(data: CookieAcquisitionRequest?) {
mutableCookieAcquisitionRequestFlow.value = data
}
}

View File

@@ -4,7 +4,6 @@ import android.os.Build
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
/**
@@ -13,7 +12,6 @@ import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
class SdkClientManagerImpl(
nativeLibraryManager: NativeLibraryManager,
sdkRepoFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
private val featureFlagManager: FeatureFlagManager,
private val clientProvider: suspend (userId: String?) -> Client = { userId ->
Client(
@@ -22,10 +20,6 @@ class SdkClientManagerImpl(
)
.apply {
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
platform().serverCommunicationConfig(
repository = sdkRepoFactory.getServerCommunicationConfigRepository(),
platformApi = sdkPlatformApiFactory.getServerCommunicationConfigPlatformApi(),
)
userId?.let {
platform().state().apply {
registerCipherRepository(sdkRepoFactory.getCipherRepository(userId = it))

View File

@@ -11,7 +11,6 @@ import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.manager.toast.ToastManagerImpl
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
import com.bitwarden.cxf.registry.dsl.credentialExchangeRegistry
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.BitwardenServiceClient
@@ -23,7 +22,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -38,8 +36,6 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
import com.x8bit.bitwarden.data.platform.manager.CertificateManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManagerImpl
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
@@ -73,12 +69,8 @@ import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManagerImpl
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactoryImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactoryImpl
import com.x8bit.bitwarden.data.platform.processor.AuthenticatorBridgeProcessor
@@ -227,12 +219,10 @@ object PlatformManagerModule {
featureFlagManager: FeatureFlagManager,
nativeLibraryManager: NativeLibraryManager,
sdkRepositoryFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
): SdkClientManager = SdkClientManagerImpl(
featureFlagManager = featureFlagManager,
nativeLibraryManager = nativeLibraryManager,
sdkRepoFactory = sdkRepositoryFactory,
sdkPlatformApiFactory = sdkPlatformApiFactory,
)
@Provides
@@ -372,22 +362,10 @@ object PlatformManagerModule {
@Singleton
fun provideSdkRepositoryFactory(
vaultDiskSource: VaultDiskSource,
cookieDiskSource: CookieDiskSource,
configDiskSource: ConfigDiskSource,
authDiskSource: AuthDiskSource,
bitwardenServiceClient: BitwardenServiceClient,
): SdkRepositoryFactory = SdkRepositoryFactoryImpl(
vaultDiskSource = vaultDiskSource,
cookieDiskSource = cookieDiskSource,
configDiskSource = configDiskSource,
authDiskSource = authDiskSource,
)
@Provides
@Singleton
fun provideSdkPlatformApiFactory(
serverCommConfigManager: CookieAcquisitionRequestManager,
): SdkPlatformApiFactory = SdkPlatformApiFactoryImpl(
serverCommConfigManager = serverCommConfigManager,
bitwardenServiceClient = bitwardenServiceClient,
)
@Provides
@@ -435,21 +413,4 @@ object PlatformManagerModule {
credentialExchangeRegistry = credentialExchangeRegistry,
settingsDiskSource = settingsDiskSource,
)
@Provides
@Singleton
fun provideServerCommunicationConfigManager(): CookieAcquisitionRequestManager =
CookieAcquisitionRequestManagerImpl()
@Provides
@Singleton
fun provideNetworkCookieManager(
configDiskSource: ConfigDiskSource,
cookieDiskSource: CookieDiskSource,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
): NetworkCookieManager = NetworkCookieManagerImpl(
configDiskSource = configDiskSource,
cookieDiskSource = cookieDiskSource,
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
)
}

View File

@@ -1,10 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents pending cookie acquisition request for a specific hostname.
*
* @property hostname The server hostname requiring cookies.
*/
data class CookieAcquisitionRequest(
val hostname: String,
)

View File

@@ -1,8 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.network
import com.bitwarden.network.provider.CookieProvider
/**
* A manager class for handling cookies.
*/
interface NetworkCookieManager : CookieProvider

View File

@@ -1,56 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.network
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.network.model.NetworkCookie
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
import com.x8bit.bitwarden.data.platform.manager.util.toNetworkCookieList
private const val BOOTSTRAP_TYPE_SSO_COOKIE_VENDOR = "ssoCookieVendor"
/**
* Default implementation of [NetworkCookieManager].
*/
class NetworkCookieManagerImpl(
private val configDiskSource: ConfigDiskSource,
private val cookieDiskSource: CookieDiskSource,
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
) : NetworkCookieManager {
override fun needsBootstrap(hostname: String): Boolean = configDiskSource
.serverConfig
?.serverData
?.communication
?.bootstrap
?.type
?.let { bootstrapType ->
when (bootstrapType) {
BOOTSTRAP_TYPE_SSO_COOKIE_VENDOR -> {
// When the bootstrap type is SSO cookie vendor, but we do not yet have any
// cookies, the cookie manager needs to be bootstrapped. This includes the
// case where no cookie config exists for the hostname at all.
cookieDiskSource
.getCookieConfig(hostname = hostname)
?.cookies
?.none() != false
}
else -> false
}
}
?: false
override fun getCookies(hostname: String): List<NetworkCookie> = cookieDiskSource
.getCookieConfig(hostname = hostname)
?.cookies
.toNetworkCookieList()
override fun acquireCookies(hostname: String) {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(
CookieAcquisitionRequest(
hostname = hostname,
),
)
}
}

View File

@@ -1,14 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfigPlatformApi
/**
* Creates and manages sdk platform api's.
*/
interface SdkPlatformApiFactory {
/**
* Retrieves or creates a [ServerCommunicationConfigPlatformApi] for use with the Bitwarden SDK.
*/
fun getServerCommunicationConfigPlatformApi(): ServerCommunicationConfigPlatformApi
}

View File

@@ -1,20 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfigPlatformApi
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.sdk.platformapi.ServerCommunicationConfigPlatformApiImpl
/**
* Factory for creating and managing sdk platform api's.
*/
class SdkPlatformApiFactoryImpl(
private val serverCommConfigManager: CookieAcquisitionRequestManager,
) : SdkPlatformApiFactory {
/**
* Retrieves or creates a [ServerCommunicationConfigPlatformApi] for use with the Bitwarden SDK.
*/
override fun getServerCommunicationConfigPlatformApi(): ServerCommunicationConfigPlatformApi =
ServerCommunicationConfigPlatformApiImpl(
serverCommConfigManager = serverCommConfigManager,
)
}

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.sdk.CipherRepository
import com.bitwarden.sdk.ServerCommunicationConfigRepository
/**
* Creates and manages sdk repositories.
@@ -17,9 +16,4 @@ interface SdkRepositoryFactory {
* Retrieves or creates a [ClientManagedTokens] for use with the Bitwarden SDK.
*/
fun getClientManagedTokens(userId: String?): ClientManagedTokens
/**
* Retrieves or creates a [ServerCommunicationConfigRepository] for use with the Bitwarden SDK.
*/
fun getServerCommunicationConfigRepository(): ServerCommunicationConfigRepository
}

View File

@@ -1,14 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager.sdk
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.sdk.CipherRepository
import com.bitwarden.sdk.ServerCommunicationConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkCipherRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkTokenRepository
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.ServerCommunicationConfigRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
/**
@@ -16,9 +12,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
*/
class SdkRepositoryFactoryImpl(
private val vaultDiskSource: VaultDiskSource,
private val cookieDiskSource: CookieDiskSource,
private val configDiskSource: ConfigDiskSource,
private val authDiskSource: AuthDiskSource,
private val bitwardenServiceClient: BitwardenServiceClient,
) : SdkRepositoryFactory {
override fun getCipherRepository(
userId: String,
@@ -33,12 +27,6 @@ class SdkRepositoryFactoryImpl(
): ClientManagedTokens =
SdkTokenRepository(
userId = userId,
authDiskSource = authDiskSource,
)
override fun getServerCommunicationConfigRepository(): ServerCommunicationConfigRepository =
ServerCommunicationConfigRepositoryImpl(
cookieDiskSource = cookieDiskSource,
configDiskSource = configDiskSource,
tokenProvider = bitwardenServiceClient.tokenProvider,
)
}

View File

@@ -1,53 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.platformapi
import com.bitwarden.servercommunicationconfig.AcquiredCookie
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfigPlatformApi
import com.x8bit.bitwarden.data.platform.error.CookiesRequiredException
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
/**
* Implementation of SDK's [ServerCommunicationConfigPlatformApi].
*
* This is an SDK callback interface required by the SDK contract. The SDK's intended design is for
* [acquireCookies] to block while fetching cookies from the server cookie vending endpoint, return
* them, and have the SDK automatically retry the failed request. However, cookie acquisition
* requires async user interaction (browser authentication + deep link callback), which cannot be
* performed within a blocking suspend call.
*
* Because of this constraint, cookie acquisition is currently handled entirely outside the SDK
* context: our interceptor detects 302 redirects, the UI prompts the user, cookies are obtained
* via browser and stored directly. This implementation exists as a defensive fallback — if the SDK
* ever invokes [acquireCookies] internally, it:
* 1. Emits a [CookieAcquisitionRequest] via [CookieAcquisitionRequestManager] StateFlow to
* signal the UI to navigate to the cookie acquisition screen.
* 2. Throws [CookiesRequiredException] to abort the SDK's current call chain.
*
* Note: Future SDK versions may expose atomic cookie setters, removing the need for this blocking
* acquisition pattern entirely.
*
* @property serverCommConfigManager Manager that exposes pending cookie acquisition state for
* navigation.
*/
class ServerCommunicationConfigPlatformApiImpl(
private val serverCommConfigManager: CookieAcquisitionRequestManager,
) : ServerCommunicationConfigPlatformApi {
/**
* SDK callback for cookie acquisition. Not invoked during normal app operation — cookie
* acquisition is handled outside the SDK context. This serves as a defensive implementation
* that signals the UI and aborts the SDK operation if called unexpectedly.
*
* This method never returns normally.
*
* @throws CookiesRequiredException Always thrown to abort the SDK call chain.
*/
override suspend fun acquireCookies(hostname: String): List<AcquiredCookie>? {
serverCommConfigManager.setPendingCookieAcquisition(
CookieAcquisitionRequest(
hostname = hostname,
),
)
throw CookiesRequiredException(hostname)
}
}

View File

@@ -1,20 +1,15 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
import com.bitwarden.core.ClientManagedTokens
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.network.provider.TokenProvider
/**
* A user-scoped implementation of a Bitwarden SDK [ClientManagedTokens].
*
* Note: This intentionally provides the raw stored token without proactive expiration checks
* or refresh logic. The SDK handles automatic token refresh internally.
*/
class SdkTokenRepository(
private val userId: String?,
private val authDiskSource: AuthDiskSource,
private val tokenProvider: TokenProvider,
) : ClientManagedTokens {
override suspend fun getAccessToken(): String? =
userId?.let {
authDiskSource.getAccountTokens(userId = it)?.accessToken
}
userId?.let { tokenProvider.getAccessToken(userId = it) }
}

View File

@@ -1,76 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
import com.bitwarden.data.datasource.disk.ConfigDiskSource
import com.bitwarden.sdk.ServerCommunicationConfigRepository
import com.bitwarden.servercommunicationconfig.BootstrapConfig
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfig
import com.bitwarden.servercommunicationconfig.SsoCookieVendorConfig
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
import com.x8bit.bitwarden.data.platform.repository.util.toAcquiredCookiesList
import com.x8bit.bitwarden.data.platform.repository.util.toConfigurationDataCookies
/**
* Implementation of SDK's [ServerCommunicationConfigRepository].
* Bridges the SDK's storage interface to the application's [CookieDiskSource].
*
* @property cookieDiskSource The disk source for persisting cookie configurations.
*/
class ServerCommunicationConfigRepositoryImpl(
private val cookieDiskSource: CookieDiskSource,
private val configDiskSource: ConfigDiskSource,
) : ServerCommunicationConfigRepository {
override suspend fun get(hostname: String): ServerCommunicationConfig? {
val serverCommunicationConfig = configDiskSource
.serverConfig
?.serverData
?.communication
?: return null
if (serverCommunicationConfig.bootstrap.type != "ssoCookieVendor") {
return ServerCommunicationConfig(
bootstrap = BootstrapConfig.Direct,
)
}
val acquiredCookies = cookieDiskSource
.getCookieConfig(hostname)
?.cookies
?.toAcquiredCookiesList()
return ServerCommunicationConfig(
bootstrap = BootstrapConfig.SsoCookieVendor(
v1 = SsoCookieVendorConfig(
idpLoginUrl = serverCommunicationConfig.bootstrap.idpLoginUrl,
cookieName = serverCommunicationConfig.bootstrap.cookieName,
cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain,
cookieValue = acquiredCookies,
),
),
)
}
override suspend fun save(hostname: String, config: ServerCommunicationConfig) =
when (val bootstrapConfig = config.bootstrap) {
is BootstrapConfig.SsoCookieVendor -> {
// Only store cookies from [config]. The communication config is synced with the
// server (api/config), which takes precedence over the local configuration.
cookieDiskSource.storeCookieConfig(
hostname = hostname,
config = CookieConfigurationData(
hostname = hostname,
cookies = bootstrapConfig.v1.cookieValue
?.toConfigurationDataCookies()
.orEmpty(),
),
)
}
BootstrapConfig.Direct -> {
// Clear any existing cookie configuration now that the communication config
// has been updated.
cookieDiskSource.storeCookieConfig(hostname = hostname, config = null)
}
}
}

View File

@@ -1,20 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.bitwarden.network.model.NetworkCookie
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
/**
* Converts a list of [CookieConfigurationData.Cookie] to a list of [NetworkCookie].
*/
fun List<CookieConfigurationData.Cookie>?.toNetworkCookieList(): List<NetworkCookie> = this
?.map { it.toNetworkCookie() }
.orEmpty()
/**
* Converts a [CookieConfigurationData.Cookie] to a [NetworkCookie].
*/
fun CookieConfigurationData.Cookie.toNetworkCookie(): NetworkCookie =
NetworkCookie(
name = name,
value = value,
)

View File

@@ -90,8 +90,8 @@ class AuthenticatorBridgeRepositoryImpl(
// Vault is unlocked, query vault disk source for totp logins:
val totpUris = vaultDiskSource
.getTotpCiphers(userId = userId)
// Filter out any deleted and archived ciphers.
.filter { it.deletedDate == null && it.archivedDate == null }
// Filter out any deleted ciphers.
.filter { it.deletedDate == null }
.mapNotNull {
scopedVaultSdkSource
.decryptCipher(userId = userId, cipher = it.toEncryptedSdkCipher())

View File

@@ -1,19 +0,0 @@
package com.x8bit.bitwarden.data.platform.repository.util
import com.bitwarden.servercommunicationconfig.AcquiredCookie
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
/**
* Converts a list of [AcquiredCookie] to a list of [CookieConfigurationData.Cookie].
*/
fun List<AcquiredCookie>.toConfigurationDataCookies(): List<CookieConfigurationData.Cookie> = this
.map { it.toConfigurationCookie() }
/**
* Converts an [AcquiredCookie] to a [CookieConfigurationData.Cookie].
*/
fun AcquiredCookie.toConfigurationCookie(): CookieConfigurationData.Cookie =
CookieConfigurationData.Cookie(
name = name,
value = value,
)

View File

@@ -1,18 +0,0 @@
package com.x8bit.bitwarden.data.platform.repository.util
import com.bitwarden.servercommunicationconfig.AcquiredCookie
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
/**
* Converts a list of [CookieConfigurationData.Cookie] to a list of [AcquiredCookie].
*/
fun List<CookieConfigurationData.Cookie>.toAcquiredCookiesList() = this
.map { it.toAcquiredCookie() }
/**
* Converts a [CookieConfigurationData.Cookie] to an [AcquiredCookie].
*/
fun CookieConfigurationData.Cookie.toAcquiredCookie() = AcquiredCookie(
name = this.name,
value = this.value,
)

View File

@@ -1,51 +0,0 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.model.EnvironmentRegion
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
/**
* Creates the appropriate Duo [AuthTabData] for the given [EnvironmentUrlDataJson].
*/
val EnvironmentUrlDataJson.duoAuthTabData: AuthTabData get() = authTabData(kind = "duo")
/**
* Creates the appropriate WebAuthn [AuthTabData] for the given [EnvironmentUrlDataJson].
*/
val EnvironmentUrlDataJson.webAuthnAuthTabData: AuthTabData get() = authTabData(kind = "webauthn")
/**
* Creates the appropriate SSO [AuthTabData] for the given [EnvironmentUrlDataJson].
*/
val EnvironmentUrlDataJson.ssoAuthTabData: AuthTabData get() = authTabData(kind = "sso")
private fun EnvironmentUrlDataJson.authTabData(
kind: String,
): AuthTabData = when (this.environmentRegion) {
EnvironmentRegion.UNITED_STATES -> {
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
)
}
EnvironmentRegion.EUROPEAN_UNION -> {
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
)
}
EnvironmentRegion.INTERNAL -> {
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
)
}
EnvironmentRegion.SELF_HOSTED -> {
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
)
}
}

View File

@@ -6,7 +6,6 @@ import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.data.manager.NativeLibraryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
/**
@@ -18,7 +17,6 @@ class ScopedVaultSdkSourceImpl(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
sdkRepositoryFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
sdkClientManager = SdkClientManagerImpl(
// We do not want to have the real NativeLibraryManager used here to avoid
@@ -28,7 +26,6 @@ class ScopedVaultSdkSourceImpl(
},
sdkRepoFactory = sdkRepositoryFactory,
featureFlagManager = featureFlagManager,
sdkPlatformApiFactory = sdkPlatformApiFactory,
),
dispatcherManager = dispatcherManager,
),

View File

@@ -5,7 +5,6 @@ import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSourceImpl
@@ -42,13 +41,11 @@ object VaultSdkModule {
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
sdkRepositoryFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
): ScopedVaultSdkSource =
ScopedVaultSdkSourceImpl(
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
sdkRepositoryFactory = sdkRepositoryFactory,
sdkPlatformApiFactory = sdkPlatformApiFactory,
)
@Provides

View File

@@ -8,12 +8,10 @@ import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.data.manager.model.DownloadResult
import com.bitwarden.network.model.ArchiveCipherResponseJson
import com.bitwarden.network.model.AttachmentJsonResponse
import com.bitwarden.network.model.CreateCipherInOrganizationJsonRequest
import com.bitwarden.network.model.CreateCipherResponseJson
import com.bitwarden.network.model.ShareCipherJsonRequest
import com.bitwarden.network.model.UnarchiveCipherResponseJson
import com.bitwarden.network.model.UpdateCipherCollectionsJsonRequest
import com.bitwarden.network.model.UpdateCipherResponseJson
import com.bitwarden.network.service.CiphersService
@@ -170,29 +168,29 @@ class CipherManagerImpl(
cipherView: CipherView,
): ArchiveCipherResult {
val userId = activeUserId ?: return ArchiveCipherResult.Error(NoActiveUserException())
return ciphersService
.archiveCipher(cipherId = cipherId)
.flatMap { response ->
when (response) {
is ArchiveCipherResponseJson.Invalid -> {
IllegalStateException(response.firstValidationErrorMessage)
.asFailure()
}
is ArchiveCipherResponseJson.Success -> {
vaultDiskSource.saveCipher(
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { encryptionContext ->
ciphersService
.archiveCipher(cipherId = cipherId)
.flatMap {
vaultSdkSource.decryptCipher(
userId = userId,
cipher = response.cipher.copy(
collectionIds = cipherView.collectionIds,
),
cipher = encryptionContext.cipher,
)
settingsDiskSource.storeIntroducingArchiveActionCardDismissed(
userId = userId,
isDismissed = true,
)
response.asSuccess()
}
}
}
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = it.copy(archivedDate = clock.instant()),
)
}
.onSuccess {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { ArchiveCipherResult.Success },
@@ -205,25 +203,29 @@ class CipherManagerImpl(
cipherView: CipherView,
): UnarchiveCipherResult {
val userId = activeUserId ?: return UnarchiveCipherResult.Error(NoActiveUserException())
return ciphersService
.unarchiveCipher(cipherId = cipherId)
.flatMap { response ->
when (response) {
is UnarchiveCipherResponseJson.Invalid -> {
IllegalStateException(response.firstValidationErrorMessage)
.asFailure()
}
is UnarchiveCipherResponseJson.Success -> {
vaultDiskSource.saveCipher(
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { encryptionContext ->
ciphersService
.unarchiveCipher(cipherId = cipherId)
.flatMap {
vaultSdkSource.decryptCipher(
userId = userId,
cipher = response.cipher.copy(
collectionIds = cipherView.collectionIds,
),
cipher = encryptionContext.cipher,
)
response.asSuccess()
}
}
}
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = it.copy(archivedDate = null),
)
}
.onSuccess {
vaultDiskSource.saveCipher(
userId = userId,
cipher = it.toEncryptedNetworkCipherResponse(),
)
}
.fold(
onSuccess = { UnarchiveCipherResult.Success },
@@ -249,9 +251,6 @@ class CipherManagerImpl(
): DeleteCipherResult {
val userId = activeUserId
?: return DeleteCipherResult.Error(error = NoActiveUserException())
// Unlike archive/unarchive, soft delete requires edit permissions, so the
// migration check is intentional here to ensure the cipher is up-to-date
// before deletion.
return cipherView
.encryptCipherAndCheckForMigration(userId = userId, cipherId = cipherId)
.flatMap { encryptionContext ->

View File

@@ -1,15 +1,17 @@
package com.x8bit.bitwarden.data.vault.manager
import androidx.credentials.providerevents.exception.ImportCredentialsInvalidJsonException
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.cxf.model.CredentialExchangePayload
import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.cxf.model.CredentialExchangeExportResponse
import com.bitwarden.cxf.model.CredentialExchangeProtocolMessage
import com.bitwarden.network.model.ImportCiphersJsonRequest
import com.bitwarden.network.model.ImportCiphersResponseJson
import com.bitwarden.network.service.CiphersService
import com.bitwarden.vault.Cipher
import com.bitwarden.network.util.base64UrlDecodeOrNull
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.util.hasRestrictItemTypes
@@ -17,7 +19,16 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.ImportCxfPayloadResult
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkCipher
import timber.log.Timber
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
private val SUPPORTED_CXP_FORMAT_VERSIONS = mapOf(
0 to setOf(0),
)
private val SUPPORTED_CXF_FORMAT_VERSIONS = mapOf(
0 to setOf(0),
1 to setOf(0),
)
/**
* Default implementation of [CredentialExchangeImportManager].
@@ -27,92 +38,118 @@ class CredentialExchangeImportManagerImpl(
private val ciphersService: CiphersService,
private val vaultSyncManager: VaultSyncManager,
private val policyManager: PolicyManager,
private val credentialExchangePayloadParser: CredentialExchangePayloadParser,
private val json: Json,
) : CredentialExchangeImportManager {
@Suppress("LongMethod")
override suspend fun importCxfPayload(
userId: String,
payload: String,
): ImportCxfPayloadResult =
when (val exportResponse = credentialExchangePayloadParser.parse(payload)) {
is CredentialExchangePayload.Importable -> {
import(
userId = userId,
accountsJsonList = exportResponse.accountsJsonList,
)
}
CredentialExchangePayload.NoItems -> {
ImportCxfPayloadResult.NoItems
}
is CredentialExchangePayload.Error -> {
ImportCxfPayloadResult.Error(exportResponse.throwable)
}
}
private suspend fun import(
userId: String,
accountsJsonList: List<String>,
): ImportCxfPayloadResult {
val allCiphers = accountsJsonList.flatMap { accountJson ->
vaultSdkSource
.importCxf(userId = userId, payload = accountJson)
.getOrElse { return ImportCxfPayloadResult.Error(error = it) }
val credentialExchangeExportResult = json
.decodeFromStringOrNull<CredentialExchangeProtocolMessage>(payload)
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Invalid CXP JSON."),
)
if (SUPPORTED_CXP_FORMAT_VERSIONS[credentialExchangeExportResult.version.major]
?.contains(credentialExchangeExportResult.version.minor) != true
) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException(
"Unsupported CXF version: ${credentialExchangeExportResult.version}.",
),
)
}
// Filter out card ciphers if RESTRICT_ITEM_TYPES policy is active
val filteredCipherList = if (policyManager.hasRestrictItemTypes()) {
allCiphers.filter { cipher -> cipher.type != CipherType.CARD }
} else {
allCiphers
val decodedPayload = credentialExchangeExportResult.payload
.base64UrlDecodeOrNull()
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to decode payload."),
)
val exportResponse = json
.decodeFromStringOrNull<CredentialExchangeExportResponse>(decodedPayload)
?: return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to decode header."),
)
if (SUPPORTED_CXF_FORMAT_VERSIONS[exportResponse.version.major]
?.contains(exportResponse.version.minor) != true
) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unsupported CXF version."),
)
}
if (filteredCipherList.isEmpty()) {
if (exportResponse.accounts.isEmpty()) {
return ImportCxfPayloadResult.NoItems
}
return uploadCiphers(userId = userId, ciphers = filteredCipherList)
.map { syncVault(it) }
val accountsJson = try {
json.encodeToString(
value = exportResponse.accounts.firstOrNull(),
)
} catch (_: SerializationException) {
return ImportCxfPayloadResult.Error(
ImportCredentialsInvalidJsonException("Unable to re-encode accounts."),
)
}
return vaultSdkSource
.importCxf(
userId = userId,
payload = accountsJson,
)
.flatMap { cipherList ->
// Filter out card ciphers if RESTRICT_ITEM_TYPES policy is active
val filteredCipherList = if (policyManager.hasRestrictItemTypes()) {
cipherList.filter { cipher -> cipher.type != CipherType.CARD }
} else {
cipherList
}
if (filteredCipherList.isEmpty()) {
// If no ciphers were returned, we can skip the remaining steps and return the
// appropriate result.
return ImportCxfPayloadResult.NoItems
}
ciphersService
.importCiphers(
request = ImportCiphersJsonRequest(
ciphers = filteredCipherList.map {
it.toEncryptedNetworkCipher(
encryptedFor = userId,
)
},
folders = emptyList(),
folderRelationships = emptyList(),
),
)
.flatMap { importCiphersResponseJson ->
when (importCiphersResponseJson) {
is ImportCiphersResponseJson.Invalid -> {
ImportCredentialsUnknownErrorException().asFailure()
}
ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult
.Success(itemCount = filteredCipherList.size)
.asSuccess()
}
}
}
}
.map {
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> it
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)
}
}
}
.fold(
onSuccess = { it },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
}
private suspend fun uploadCiphers(
userId: String,
ciphers: List<Cipher>,
): Result<ImportCxfPayloadResult.Success> {
val request = ImportCiphersJsonRequest(
ciphers = ciphers.map { it.toEncryptedNetworkCipher(encryptedFor = userId) },
folders = emptyList(),
folderRelationships = emptyList(),
)
return ciphersService
.importCiphers(request)
.flatMap { response ->
when (response) {
is ImportCiphersResponseJson.Invalid -> {
Timber.w(
"Import ciphers validation failed: %s",
response.validationErrors,
)
ImportCredentialsUnknownErrorException().asFailure()
}
is ImportCiphersResponseJson.Success -> {
ImportCxfPayloadResult.Success(itemCount = ciphers.size).asSuccess()
}
}
}
}
private suspend fun syncVault(result: ImportCxfPayloadResult): ImportCxfPayloadResult =
when (val syncResult = vaultSyncManager.syncForResult(forced = true)) {
is SyncVaultDataResult.Success -> result
is SyncVaultDataResult.Error -> {
ImportCxfPayloadResult.SyncFailed(error = syncResult.throwable)
}
}
}

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.vault.manager.di
import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
@@ -50,6 +49,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
@@ -252,12 +252,12 @@ object VaultManagerModule {
ciphersService: CiphersService,
vaultSyncManager: VaultSyncManager,
policyManager: PolicyManager,
credentialExchangePayloadParser: CredentialExchangePayloadParser,
json: Json,
): CredentialExchangeImportManager = CredentialExchangeImportManagerImpl(
vaultSdkSource = vaultSdkSource,
ciphersService = ciphersService,
vaultSyncManager = vaultSyncManager,
policyManager = policyManager,
credentialExchangePayloadParser = credentialExchangePayloadParser,
json = json,
)
}

View File

@@ -68,7 +68,6 @@ fun Cipher.toEncryptedNetworkCipher(
card = card?.toEncryptedNetworkCard(),
key = key,
sshKey = sshKey?.toEncryptedNetworkSshKey(),
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
encryptedFor = encryptedFor,
)

View File

@@ -1,13 +1,9 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.data.repository.util.SpecialCharWithPrecedenceComparator
import com.bitwarden.network.model.SendAuthTypeJson
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
@@ -34,8 +30,6 @@ fun Send.toEncryptedNetworkSend(fileLength: Long? = null): SendJsonRequest =
password = password,
isDisabled = disabled,
shouldHideEmail = hideEmail,
authType = authType.toNetworkSendAuthType(),
emails = emails,
)
/**
@@ -98,31 +92,8 @@ fun SyncResponseJson.Send.toEncryptedSdkSend(): Send =
revisionDate = revisionDate.toInstant(),
deletionDate = deletionDate.toInstant(),
expirationDate = expirationDate?.toInstant(),
emails = emails,
authType = authType?.toSdkAuthType() ?: AuthType.NONE,
)
/**
* Converts a Bitwarden SDK [AuthType] object to a corresponding [SendAuthTypeJson] object.
*/
private fun AuthType.toNetworkSendAuthType(): SendAuthTypeJson =
when (this) {
AuthType.EMAIL -> SendAuthTypeJson.EMAIL
AuthType.PASSWORD -> SendAuthTypeJson.PASSWORD
AuthType.NONE -> SendAuthTypeJson.NONE
}
/**
* Converts a [SendAuthTypeJson] objects to a corresponding
* Bitwarden SDK [AuthType].
*/
private fun SendAuthTypeJson.toSdkAuthType(): AuthType =
when (this) {
SendAuthTypeJson.PASSWORD -> AuthType.PASSWORD
SendAuthTypeJson.EMAIL -> AuthType.EMAIL
SendAuthTypeJson.NONE -> AuthType.NONE
}
/**
* Converts a [SyncResponseJson.Send.Text] object to a corresponding
* Bitwarden SDK [SendText] object.

View File

@@ -81,5 +81,5 @@ fun NavGraphBuilder.completeRegistrationDestination(
* Pop up to the complete registration screen.
*/
fun NavController.popUpToCompleteRegistration() {
this.popBackStack(route = CompleteRegistrationRoute::class, inclusive = false)
this.popBackStack(route = CompleteRegistrationRoute, inclusive = false)
}

View File

@@ -66,7 +66,7 @@ fun EnterpriseSignOnScreen(
is EnterpriseSignOnEvent.NavigateToSsoLogin -> {
intentManager.startAuthTab(
uri = event.uri,
authTabData = event.authTabData,
redirectScheme = event.scheme,
launcher = authTabLaunchers.sso,
)
}

View File

@@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.data.repository.util.baseIdentityUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
@@ -15,11 +14,11 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
import com.x8bit.bitwarden.data.auth.repository.util.SSO_URI
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForSso
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.util.ssoAuthTabData
import com.x8bit.bitwarden.data.platform.util.toUriOrNull
import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.utils.generateRandomString
@@ -209,7 +208,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
sendEvent(
EnterpriseSignOnEvent.NavigateToSsoLogin(
uri = action.uri,
authTabData = action.authTabData,
scheme = action.scheme,
),
)
}
@@ -343,13 +342,14 @@ class EnterpriseSignOnViewModel @Inject constructor(
if (ssoCallbackResult.state == ssoData.state) {
showLoading()
viewModelScope.launch {
val result = authRepository.login(
email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress,
ssoCode = ssoCallbackResult.code,
ssoCodeVerifier = ssoData.codeVerifier,
ssoRedirectUri = ssoData.redirectUri,
organizationIdentifier = state.orgIdentifierInput,
)
val result = authRepository
.login(
email = savedStateHandle.toEnterpriseSignOnArgs().emailAddress,
ssoCode = ssoCallbackResult.code,
ssoCodeVerifier = ssoData.codeVerifier,
ssoRedirectUri = SSO_URI,
organizationIdentifier = state.orgIdentifierInput,
)
sendAction(EnterpriseSignOnAction.Internal.OnLoginResult(result))
}
} else {
@@ -385,22 +385,18 @@ class EnterpriseSignOnViewModel @Inject constructor(
) {
val codeVerifier = generatorRepository.generateRandomString(RANDOM_STRING_LENGTH)
val environmentData = environmentRepository.environment.environmentUrlData
val authTabData = environmentData.ssoAuthTabData
// Save this for later so that we can validate the SSO callback response
val generatedSsoState = generatorRepository
.generateRandomString(RANDOM_STRING_LENGTH)
.also {
ssoResponseData = SsoResponseData(
redirectUri = authTabData.callbackUrl,
codeVerifier = codeVerifier,
state = it,
)
}
val uri = generateUriForSso(
identityBaseUrl = environmentData.baseIdentityUrl,
redirectUrl = authTabData.callbackUrl,
identityBaseUrl = environmentRepository.environment.environmentUrlData.baseIdentityUrl,
organizationIdentifier = organizationIdentifier,
token = prevalidateSsoResult.token,
state = generatedSsoState,
@@ -412,7 +408,7 @@ class EnterpriseSignOnViewModel @Inject constructor(
sendAction(
EnterpriseSignOnAction.Internal.OnGenerateUriForSsoResult(
uri = uri,
authTabData = authTabData,
scheme = "bitwarden",
),
)
}
@@ -522,7 +518,7 @@ sealed class EnterpriseSignOnEvent {
*/
data class NavigateToSsoLogin(
val uri: Uri,
val authTabData: AuthTabData,
val scheme: String,
) : EnterpriseSignOnEvent()
/**
@@ -584,10 +580,7 @@ sealed class EnterpriseSignOnAction {
/**
* A [uri] has been generated to request an SSO result.
*/
data class OnGenerateUriForSsoResult(
val uri: Uri,
val authTabData: AuthTabData,
) : Internal()
data class OnGenerateUriForSsoResult(val uri: Uri, val scheme: String) : Internal()
/**
* A login result has been received.
@@ -619,7 +612,6 @@ sealed class EnterpriseSignOnAction {
/**
* Data needed by the SSO flow to verify and continue the process after receiving a response.
*
* @property redirectUri The redirect URI used in the SSO request.
* @property state A "state" maintained throughout the SSO process to verify that the response from
* the server is valid and matches what was originally sent in the request.
* @property codeVerifier A random string used to generate the code challenge for the initial SSO
@@ -627,7 +619,6 @@ sealed class EnterpriseSignOnAction {
*/
@Parcelize
data class SsoResponseData(
val redirectUri: String,
val state: String,
val codeVerifier: String,
) : Parcelable

View File

@@ -104,7 +104,7 @@ fun TwoFactorLoginScreen(
is TwoFactorLoginEvent.NavigateToDuo -> {
intentManager.startAuthTab(
uri = event.uri,
authTabData = event.authTabData,
redirectScheme = event.scheme,
launcher = authTabLaunchers.duo,
)
}
@@ -112,7 +112,7 @@ fun TwoFactorLoginScreen(
is TwoFactorLoginEvent.NavigateToWebAuth -> {
intentManager.startAuthTab(
uri = event.uri,
authTabData = event.authTabData,
redirectScheme = event.scheme,
launcher = authTabLaunchers.webAuthn,
)
}

View File

@@ -15,7 +15,6 @@ import com.bitwarden.network.util.twoFactorDisplayEmail
import com.bitwarden.network.util.twoFactorDuoAuthUrl
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
@@ -27,8 +26,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForWebAuth
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.util.duoAuthTabData
import com.x8bit.bitwarden.data.platform.util.webAuthnAuthTabData
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.button
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.imageRes
import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.util.isContinueButtonEnabled
@@ -176,15 +173,71 @@ class TwoFactorLoginViewModel @Inject constructor(
}
/**
* Navigates to the two-factor auth webpage if appropriate, else processes the login.
* Navigates to the Duo webpage if appropriate, else processes the login.
*/
@Suppress("LongMethod")
private fun handleContinueButtonClick() {
when (state.authMethod) {
TwoFactorAuthMethod.DUO,
TwoFactorAuthMethod.DUO_ORGANIZATION,
-> handleDuoContinueButtonClick()
-> {
val authUrl = authRepository.twoFactorResponse.twoFactorDuoAuthUrl
// The url should not be empty unless the environment is somehow not supported.
authUrl
?.let {
sendEvent(
event = TwoFactorLoginEvent.NavigateToDuo(
uri = it.toUri(),
scheme = "bitwarden",
),
)
}
?: mutableStateFlow.update {
@Suppress("MaxLineLength")
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString
.error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance
.asText(),
),
)
}
}
TwoFactorAuthMethod.WEB_AUTH -> {
sendEvent(
event = authRepository
.twoFactorResponse
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.let {
val uri = generateUriForWebAuth(
baseUrl = environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault,
data = it,
headerText = resourceManager.getString(
resId = BitwardenString.fido2_title,
),
buttonText = resourceManager.getString(
resId = BitwardenString.fido2_authenticate_web_authn,
),
returnButtonText = resourceManager.getString(
resId = BitwardenString.fido2_return_to_app,
),
)
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, scheme = "bitwarden")
}
?: TwoFactorLoginEvent.ShowSnackbar(
message = BitwardenString
.there_was_an_error_starting_web_authn_two_factor_authentication
.asText(),
),
)
}
TwoFactorAuthMethod.WEB_AUTH -> handleWebAuthnContinueButtonClick()
TwoFactorAuthMethod.AUTHENTICATOR_APP,
TwoFactorAuthMethod.EMAIL,
TwoFactorAuthMethod.YUBI_KEY,
@@ -195,73 +248,6 @@ class TwoFactorLoginViewModel @Inject constructor(
}
}
/**
* Navigates to the Duo webpage if appropriate, or displays the error dialog.
*/
private fun handleDuoContinueButtonClick() {
// The url should not be empty unless the environment is somehow not supported.
authRepository
.twoFactorResponse
.twoFactorDuoAuthUrl
?.toUri()
?.let {
val environmentData = environmentRepository.environment.environmentUrlData
sendEvent(
event = TwoFactorLoginEvent.NavigateToDuo(
uri = it,
authTabData = environmentData.duoAuthTabData,
),
)
}
?: mutableStateFlow.update {
@Suppress("MaxLineLength")
it.copy(
dialogState = TwoFactorLoginState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString
.error_connecting_with_the_duo_service_use_a_different_two_step_login_method_or_contact_duo_for_assistance
.asText(),
),
)
}
}
/**
* Navigates to the Web Authn webpage if appropriate, or displays the error snackbar.
*/
private fun handleWebAuthnContinueButtonClick() {
sendEvent(
event = authRepository
.twoFactorResponse
?.authMethodsData
?.get(TwoFactorAuthMethod.WEB_AUTH)
?.let {
val environmentData = environmentRepository.environment.environmentUrlData
val authTabData = environmentData.webAuthnAuthTabData
val uri = generateUriForWebAuth(
baseUrl = environmentData.baseWebVaultUrlOrDefault,
authTabData = authTabData,
data = it,
headerText = resourceManager.getString(
resId = BitwardenString.fido2_title,
),
buttonText = resourceManager.getString(
resId = BitwardenString.fido2_authenticate_web_authn,
),
returnButtonText = resourceManager.getString(
resId = BitwardenString.fido2_return_to_app,
),
)
TwoFactorLoginEvent.NavigateToWebAuth(uri = uri, authTabData = authTabData)
}
?: TwoFactorLoginEvent.ShowSnackbar(
message = BitwardenString
.there_was_an_error_starting_web_authn_two_factor_authentication
.asText(),
),
)
}
/**
* Dismiss the view.
*/
@@ -691,18 +677,12 @@ sealed class TwoFactorLoginEvent {
/**
* Navigates to the Duo 2-factor authentication screen.
*/
data class NavigateToDuo(
val uri: Uri,
val authTabData: AuthTabData,
) : TwoFactorLoginEvent()
data class NavigateToDuo(val uri: Uri, val scheme: String) : TwoFactorLoginEvent()
/**
* Navigates to the WebAuth authentication screen.
*/
data class NavigateToWebAuth(
val uri: Uri,
val authTabData: AuthTabData,
) : TwoFactorLoginEvent()
data class NavigateToWebAuth(val uri: Uri, val scheme: String) : TwoFactorLoginEvent()
/**
* Navigates to the recovery code help page.

View File

@@ -1,40 +0,0 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the cookie acquisition screen.
*/
@OmitFromCoverage
@Serializable
data object CookieAcquisitionRoute
/**
* Add the cookie acquisition screen to the nav graph.
*/
fun NavGraphBuilder.cookieAcquisitionDestination(
onDismiss: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithSlideTransitions<CookieAcquisitionRoute> {
CookieAcquisitionScreen(onDismiss = onDismiss)
// If we are displaying the cookie acquisition screen, then we can just hide
// the splash screen.
onSplashScreenRemoved()
}
}
/**
* Navigate to the cookie acquisition screen.
*/
fun NavController.navigateToCookieAcquisition() {
this.navigate(route = CookieAcquisitionRoute) {
launchSingleTop = true
}
}

View File

@@ -1,249 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.handlers.CookieAcquisitionHandler
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.handlers.rememberCookieAcquisitionHandler
/**
* Top-level composable for the Cookie Acquisition screen.
*/
@Composable
fun CookieAcquisitionScreen(
onDismiss: () -> Unit,
viewModel: CookieAcquisitionViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is CookieAcquisitionEvent.LaunchBrowser -> {
intentManager.startCustomTabsActivity(event.uri.toUri())
}
is CookieAcquisitionEvent.NavigateToHelp -> {
intentManager.launchUri(event.uri.toUri())
}
CookieAcquisitionEvent.NavigateBack -> onDismiss()
}
}
val handler = rememberCookieAcquisitionHandler(viewModel = viewModel)
// Route back through the ViewModel so the pending cookie request is cleared
// before dismissing. A normal back-pop would leave the request active and
// MainViewModel would immediately re-navigate to this screen.
BackHandler {
viewModel.trySendAction(CookieAcquisitionAction.ContinueWithoutSyncingClick)
}
CookieAcquisitionDialogs(
dialogState = state.dialogState,
onDismissRequest = handler.onDismissDialogClick,
)
BitwardenScaffold(
contentWindowInsets = ScaffoldDefaults
.contentWindowInsets
.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
) {
CookieAcquisitionContent(
state = state,
handler = handler,
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun CookieAcquisitionDialogs(
dialogState: CookieAcquisitionDialogState?,
onDismissRequest: () -> Unit,
) {
when (dialogState) {
is CookieAcquisitionDialogState.Error -> {
BitwardenBasicDialog(
title = dialogState.title(),
message = dialogState.message(),
onDismissRequest = onDismissRequest,
)
}
null -> Unit
}
}
@Suppress("LongMethod")
@Composable
private fun CookieAcquisitionContent(
state: CookieAcquisitionState,
handler: CookieAcquisitionHandler,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_sso_cookie_sync),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(100.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = BitwardenString.sync_with_browser),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString.sync_with_browser_description,
state.environmentUrl,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.launch_browser),
onClick = handler.onLaunchBrowserClick,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.continue_without_syncing),
onClick = handler.onContinueWithoutSyncingClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(id = BitwardenString.why_am_i_seeing_this),
onClick = handler.onWhyAmISeeingThisClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Preview(showBackground = true)
@Composable
private fun CookieAcquisitionScreen_preview() {
BitwardenTheme {
BitwardenScaffold {
CookieAcquisitionContent(
state = CookieAcquisitionState(
environmentUrl = "vault.bitwarden.com",
dialogState = null,
),
handler = CookieAcquisitionHandler(
onLaunchBrowserClick = {},
onContinueWithoutSyncingClick = {},
onWhyAmISeeingThisClick = {},
onDismissDialogClick = {},
),
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun CookieAcquisitionScreen_darkPreview() {
BitwardenTheme(theme = AppTheme.DARK) {
BitwardenScaffold {
CookieAcquisitionContent(
state = CookieAcquisitionState(
environmentUrl = "vault.bitwarden.com",
dialogState = null,
),
handler = CookieAcquisitionHandler(
onLaunchBrowserClick = {},
onContinueWithoutSyncingClick = {},
onWhyAmISeeingThisClick = {},
onDismissDialogClick = {},
),
modifier = Modifier.fillMaxSize(),
)
}
}
}

View File

@@ -1,203 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
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.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val HELP_URL = "https://bitwarden.com/help"
/**
* ViewModel for the Cookie Acquisition screen.
*/
@HiltViewModel
class CookieAcquisitionViewModel @Inject constructor(
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
authRepository: AuthRepository,
environmentRepository: EnvironmentRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<CookieAcquisitionState, CookieAcquisitionEvent, CookieAcquisitionAction>(
initialState = savedStateHandle[KEY_STATE] ?: CookieAcquisitionState(
environmentUrl = environmentRepository
.environment
.environmentUrlData
.baseWebVaultUrlOrDefault,
dialogState = null,
),
) {
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
authRepository
.cookieCallbackResultFlow
.onEach {
sendAction(
CookieAcquisitionAction.Internal.CookieAcquisitionResultReceived(
result = it,
),
)
}
.launchIn(viewModelScope)
}
override fun handleAction(action: CookieAcquisitionAction) {
when (action) {
CookieAcquisitionAction.LaunchBrowserClick -> handleLaunchBrowserClick()
CookieAcquisitionAction.ContinueWithoutSyncingClick -> {
handleContinueWithoutSyncingClick()
}
CookieAcquisitionAction.WhyAmISeeingThisClick -> handleWhyAmISeeingThisClick()
CookieAcquisitionAction.DismissDialogClick -> handleDismissDialogClick()
is CookieAcquisitionAction.Internal.CookieAcquisitionResultReceived -> {
handleCookieAcquisitionResultReceived(action)
}
}
}
private fun handleLaunchBrowserClick() {
val hostname = cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.value
?.hostname
?: return
sendEvent(CookieAcquisitionEvent.LaunchBrowser(uri = hostname))
}
private fun handleContinueWithoutSyncingClick() {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)
sendEvent(CookieAcquisitionEvent.NavigateBack)
}
private fun handleWhyAmISeeingThisClick() {
sendEvent(CookieAcquisitionEvent.NavigateToHelp(uri = HELP_URL))
}
private fun handleDismissDialogClick() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleCookieAcquisitionResultReceived(
action: CookieAcquisitionAction.Internal.CookieAcquisitionResultReceived,
) {
when (action.result) {
is CookieCallbackResult.Success -> {
cookieAcquisitionRequestManager.setPendingCookieAcquisition(data = null)
sendEvent(CookieAcquisitionEvent.NavigateBack)
}
is CookieCallbackResult.MissingCookie -> {
mutableStateFlow.update {
it.copy(
dialogState = CookieAcquisitionDialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
}
/**
* State for the Cookie Acquisition screen.
*/
@Parcelize
data class CookieAcquisitionState(
val environmentUrl: String,
val dialogState: CookieAcquisitionDialogState?,
) : Parcelable
/**
* Dialog states for the Cookie Acquisition screen.
*/
sealed class CookieAcquisitionDialogState : Parcelable {
/**
* Error dialog when cookie acquisition fails.
*/
@Parcelize
data class Error(
val title: Text,
val message: Text,
) : CookieAcquisitionDialogState()
}
/**
* Events for the Cookie Acquisition screen.
*/
sealed class CookieAcquisitionEvent {
/**
* Launch a browser to acquire cookies.
*/
data class LaunchBrowser(val uri: String) : CookieAcquisitionEvent()
/**
* Navigate to the help page.
*/
data class NavigateToHelp(val uri: String) : CookieAcquisitionEvent()
/**
* Navigate back, dismissing the cookie acquisition screen.
*
* Implements [BackgroundEvent] because the cookie callback result may arrive while
* the screen is not resumed (e.g. returning from a Custom Tab browser session).
*/
data object NavigateBack : CookieAcquisitionEvent(), BackgroundEvent
}
/**
* Actions for the Cookie Acquisition screen.
*/
sealed class CookieAcquisitionAction {
/**
* User clicked the "Launch browser" button.
*/
data object LaunchBrowserClick : CookieAcquisitionAction()
/**
* User clicked the "Continue without syncing" button.
*/
data object ContinueWithoutSyncingClick : CookieAcquisitionAction()
/**
* User clicked the "Why am I seeing this?" link.
*/
data object WhyAmISeeingThisClick : CookieAcquisitionAction()
/**
* User dismissed the error dialog.
*/
data object DismissDialogClick : CookieAcquisitionAction()
/**
* Internal actions for ViewModel processing.
*/
sealed class Internal : CookieAcquisitionAction() {
/**
* Cookie acquisition result received from the auth repository.
*/
data class CookieAcquisitionResultReceived(
val result: CookieCallbackResult,
) : Internal()
}
}

View File

@@ -1,59 +0,0 @@
package com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.handlers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.CookieAcquisitionAction
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.CookieAcquisitionViewModel
/**
* A class to handle user interactions for the Cookie Acquisition screen.
*/
data class CookieAcquisitionHandler(
val onLaunchBrowserClick: () -> Unit,
val onContinueWithoutSyncingClick: () -> Unit,
val onWhyAmISeeingThisClick: () -> Unit,
val onDismissDialogClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates an instance of [CookieAcquisitionHandler] using the provided
* [CookieAcquisitionViewModel].
*/
fun create(
viewModel: CookieAcquisitionViewModel,
): CookieAcquisitionHandler =
CookieAcquisitionHandler(
onLaunchBrowserClick = {
viewModel.trySendAction(
CookieAcquisitionAction.LaunchBrowserClick,
)
},
onContinueWithoutSyncingClick = {
viewModel.trySendAction(
CookieAcquisitionAction.ContinueWithoutSyncingClick,
)
},
onWhyAmISeeingThisClick = {
viewModel.trySendAction(
CookieAcquisitionAction.WhyAmISeeingThisClick,
)
},
onDismissDialogClick = {
viewModel.trySendAction(
CookieAcquisitionAction.DismissDialogClick,
)
},
)
}
}
/**
* Helper function to create and remember a [CookieAcquisitionHandler] instance.
*/
@Composable
fun rememberCookieAcquisitionHandler(
viewModel: CookieAcquisitionViewModel,
): CookieAcquisitionHandler = remember(viewModel) {
CookieAcquisitionHandler.create(viewModel)
}

View File

@@ -125,21 +125,6 @@ fun DebugMenuScreen(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 8.dp))
BitwardenFilledButton(
label = stringResource(BitwardenString.trigger_cookie_acquisition),
onClick = remember(viewModel) {
{
viewModel.trySendAction(
DebugMenuAction.TriggerCookieAcquisition,
)
}
},
isEnabled = true,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(Modifier.height(height = 16.dp))
BitwardenHorizontalDivider()
Spacer(Modifier.height(height = 16.dp))

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