mirror of
https://github.com/bitwarden/android.git
synced 2026-06-11 09:06:13 -05:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3835b051ff | ||
|
|
de45264511 | ||
|
|
b358d3c070 | ||
|
|
4adb46170d | ||
|
|
3360999706 | ||
|
|
b10568a3ae | ||
|
|
d9f6fe97ff | ||
|
|
89f70a6b18 | ||
|
|
8b2aaf9c79 | ||
|
|
c9f3afa851 | ||
|
|
5ef7482fae | ||
|
|
c69f3554c6 | ||
|
|
c6b4c490ca | ||
|
|
92664b6752 | ||
|
|
06284a31df | ||
|
|
794781213e | ||
|
|
d1cf808e97 | ||
|
|
4356156aad | ||
|
|
268be4210e | ||
|
|
4ee55111f4 | ||
|
|
1a6936262c | ||
|
|
6f19ae534f | ||
|
|
46a8236ef7 | ||
|
|
f6f630ff8c | ||
|
|
bd0640e5b4 | ||
|
|
436ae9333c | ||
|
|
9b13cd4498 | ||
|
|
f6cd94485a | ||
|
|
222bc44c99 | ||
|
|
275d90bb61 | ||
|
|
a23183597c | ||
|
|
e3ab4f3d68 | ||
|
|
34a7c4455c | ||
|
|
4a68c2343d | ||
|
|
fb9d16730e | ||
|
|
5c348ac360 | ||
|
|
3985817c16 | ||
|
|
8664ce4614 | ||
|
|
f3c746fd49 | ||
|
|
ce3f0acf74 | ||
|
|
b20622e7d6 | ||
|
|
e939b20a82 | ||
|
|
a8e77a5abc | ||
|
|
afa9c28341 | ||
|
|
bb44586d76 | ||
|
|
4cdd0b8422 | ||
|
|
5a4973d678 | ||
|
|
a914d12e6f | ||
|
|
e5875cd8fe | ||
|
|
a3aefd369a | ||
|
|
60a1265c5d | ||
|
|
95272d9692 | ||
|
|
3be5bead89 | ||
|
|
31d480d6b4 | ||
|
|
43940102ff | ||
|
|
253f0d7ec4 | ||
|
|
d7428a15bc | ||
|
|
5d84df9d31 | ||
|
|
d8c69a3243 | ||
|
|
f0837f7668 | ||
|
|
f094430d6c | ||
|
|
cf3660a5aa | ||
|
|
5300386ce3 | ||
|
|
eb24a50baa | ||
|
|
4d31dccc74 | ||
|
|
8ee721c8ae | ||
|
|
c0907b867b | ||
|
|
6eba9ecd4b | ||
|
|
594cb507df | ||
|
|
e615bdbea5 | ||
|
|
071d3c8cd5 | ||
|
|
ad3a9a6c2e | ||
|
|
cbe13d2015 | ||
|
|
f728c15794 | ||
|
|
586f24ffec | ||
|
|
8e8367a82f | ||
|
|
47b9509062 | ||
|
|
29648e03c8 | ||
|
|
15e217bc49 | ||
|
|
7ec4faf424 | ||
|
|
e31fa46a73 | ||
|
|
aff8b0347b | ||
|
|
f4d34e4649 | ||
|
|
2e18b079f8 | ||
|
|
b0eea88af2 | ||
|
|
4cac4d6a6e | ||
|
|
a2ec99fb05 | ||
|
|
d49629de9e | ||
|
|
c85cbb70a1 | ||
|
|
e482820201 | ||
|
|
74d45c3906 | ||
|
|
12eb42097c | ||
|
|
0811d14606 | ||
|
|
365067e5be | ||
|
|
9652c7e049 | ||
|
|
6cc519bc3f | ||
|
|
9f82b42e36 | ||
|
|
5531b478d3 | ||
|
|
fe5b61bf25 | ||
|
|
92ba38c831 | ||
|
|
675b346666 | ||
|
|
0f087b7d15 | ||
|
|
99a6dd7647 | ||
|
|
ea4df7dde9 | ||
|
|
f541919d39 | ||
|
|
3d1f46983a | ||
|
|
b0084d2f1f | ||
|
|
0d0a5cb292 | ||
|
|
ebfe293c81 | ||
|
|
254b2cd25b | ||
|
|
3d974d710c | ||
|
|
7717a09c06 | ||
|
|
674cff1c3c | ||
|
|
ca9ec45548 | ||
|
|
009136ce1e | ||
|
|
19a3697605 | ||
|
|
954571ff4a | ||
|
|
66316e4bd2 | ||
|
|
9463cf646b | ||
|
|
e81710c24f | ||
|
|
71466405fa | ||
|
|
618bdc7424 | ||
|
|
0f05e30997 | ||
|
|
006a13d5ac | ||
|
|
1d35004999 | ||
|
|
85249987aa |
@@ -1,105 +1,284 @@
|
||||
# Claude Guidelines
|
||||
# Bitwarden Android - Claude Code Configuration
|
||||
|
||||
Core directives for maintaining code quality and consistency in the Bitwarden Android project.
|
||||
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
|
||||
## Overview
|
||||
|
||||
**You MUST follow these directives at all times.**
|
||||
### 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
|
||||
|
||||
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
|
||||
### 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
|
||||
|
||||
## Code Quality Standards
|
||||
---
|
||||
|
||||
### Module Organization
|
||||
## Architecture & Patterns
|
||||
|
||||
**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
|
||||
### System Architecture
|
||||
|
||||
**Application Modules:**
|
||||
- **`:app`** - Password Manager application, feature screens, ViewModels, DI setup
|
||||
- **`:authenticator`** - Authenticator application for 2FA/TOTP code generation
|
||||
```
|
||||
User Request (UI Action)
|
||||
|
|
||||
Screen (Compose)
|
||||
|
|
||||
ViewModel (State/Action/Event)
|
||||
|
|
||||
Repository (Business Logic)
|
||||
|
|
||||
+----+----+----+
|
||||
| | | |
|
||||
Disk Network SDK
|
||||
| | |
|
||||
Room Retrofit Bitwarden
|
||||
DB APIs Rust SDK
|
||||
```
|
||||
|
||||
**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
|
||||
### Code Organization
|
||||
|
||||
### Patterns Enforcement
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
- **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
|
||||
### Key Principles
|
||||
|
||||
## Security Requirements
|
||||
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
|
||||
|
||||
**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
|
||||
### Core Patterns
|
||||
|
||||
## Workflow Practices
|
||||
- **BaseViewModel**: Enforces UDF with State/Action/Event pattern. See `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt`.
|
||||
- **Repository Result Pattern**: Type-safe error handling using custom sealed classes for discrete operations and `DataState<T>` wrapper for streaming data.
|
||||
- **Common Patterns**: Flow collection via `Internal` actions, error handling via `when` branches, `DataState` streaming with `.map { }` and `.stateIn()`.
|
||||
|
||||
### Before Implementation
|
||||
> For complete architecture patterns and code templates, see `docs/ARCHITECTURE.md`.
|
||||
|
||||
1. Read relevant architecture documentation
|
||||
2. Search for existing patterns to follow
|
||||
3. Identify affected modules and dependencies
|
||||
4. Consider security implications
|
||||
---
|
||||
|
||||
### During Implementation
|
||||
## Development Guide
|
||||
|
||||
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
|
||||
### Adding New Feature Screen
|
||||
|
||||
### After Implementation
|
||||
Use the `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and copy-pasteable templates. Follow these steps:
|
||||
|
||||
1. Ensure all tests pass
|
||||
2. Verify compilation succeeds
|
||||
3. Review security considerations
|
||||
4. Update relevant documentation
|
||||
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.
|
||||
|
||||
### Codebase Discovery
|
||||
|
||||
```bash
|
||||
# Find existing Bitwarden UI components
|
||||
find ui/src/main/kotlin/com/bitwarden/ui/platform/components/ -name "Bitwarden*.kt" | sort
|
||||
|
||||
# Find all ViewModels
|
||||
grep -rl "BaseViewModel<" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find all Navigation files with @Serializable routes
|
||||
find app/src/main/kotlin/ -name "*Navigation.kt" | sort
|
||||
|
||||
# Find all Hilt modules
|
||||
find app/src/main/kotlin/ -name "*Module.kt" -path "*/di/*" | sort
|
||||
|
||||
# Find all repository interfaces
|
||||
find app/src/main/kotlin/ -name "*Repository.kt" -not -name "*Impl.kt" -path "*/repository/*" | sort
|
||||
|
||||
# Find encrypted disk source examples
|
||||
grep -rl "EncryptedPreferences" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find Clock injection usage
|
||||
grep -rl "private val clock: Clock" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Search existing strings before adding new ones
|
||||
grep -n "search_term" ui/src/main/res/values/strings.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
Key types used throughout the codebase:
|
||||
|
||||
- **`UserState`** (`data/auth/`) - Active user ID, accounts list, pending account state
|
||||
- **`VaultUnlockData`** (`data/vault/repository/model/`) - User ID and vault unlock status
|
||||
- **`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
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`)
|
||||
|
||||
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
**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
|
||||
In addition to the Key Principles above, follow these rules:
|
||||
|
||||
## Communication & Decision-Making
|
||||
### DO
|
||||
- Use `remember(viewModel)` for lambdas passed to composables
|
||||
- Map async results to internal actions before updating state
|
||||
- Inject `Clock` for time-dependent operations
|
||||
- Return early to reduce nesting
|
||||
|
||||
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]?"
|
||||
### DON'T
|
||||
- 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
|
||||
|
||||
Defer high-impact decisions to the user:
|
||||
- Architecture/module changes, public API modifications
|
||||
- Security mechanisms, database migrations
|
||||
- Third-party library additions
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
## Deployment
|
||||
|
||||
Critical resources:
|
||||
- `docs/ARCHITECTURE.md` - Architecture patterns and principles
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style guidelines
|
||||
### Building
|
||||
|
||||
**Do not duplicate information from these files - reference them instead.**
|
||||
```bash
|
||||
# Debug builds
|
||||
./gradlew app:assembleDebug
|
||||
./gradlew authenticator:assembleDebug
|
||||
|
||||
# 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
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- **Troubleshooting**: See `docs/TROUBLESHOOTING.md`
|
||||
- **Architecture**: `docs/ARCHITECTURE.md` | [Bitwarden SDK](https://github.com/bitwarden/sdk) | [Jetpack Compose](https://developer.android.com/jetpack/compose) | [Hilt DI](https://dagger.dev/hilt/)
|
||||
|
||||
10
.claude/settings.json
Normal file
10
.claude/settings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "bitwarden/ai-plugins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
.claude/skills/implementing-android-code/CHANGELOG.md
Normal file
34
.claude/skills/implementing-android-code/CHANGELOG.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the `implementing-android-code` skill will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.1] - 2026-02-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added missing `@EncryptedPreferences` and `@UnencryptedPreferences` annotations to `ExampleDiskSourceImpl` code example
|
||||
- Fixed typographic apostrophe example to use correct right single quotation mark (U+2019)
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed redundant "Summary" section that duplicated existing content
|
||||
|
||||
## [0.1.0] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- Bitwarden Android implementation patterns covering:
|
||||
- ViewModel State-Action-Event (SAE) pattern with `BaseViewModel`
|
||||
- Type-safe navigation with `@Serializable` routes and `composableWithSlideTransitions`
|
||||
- Screen/Compose implementation with `EventsEffect` and stateless composables
|
||||
- Data layer patterns: repositories, data sources, `DataState<T>`, error handling
|
||||
- UI component library usage and string resource conventions
|
||||
- Security patterns: zero-knowledge architecture, encrypted storage, SDK isolation
|
||||
- Testing quick reference for ViewModels, repositories, compose, and data sources
|
||||
- Clock/time injection patterns for deterministic operations
|
||||
- Anti-patterns and common gotchas
|
||||
- Copy-pasteable code templates (templates.md) for all layer types
|
||||
- README.md, CHANGELOG.md, CONTRIBUTING.md for marketplace preparation
|
||||
44
.claude/skills/implementing-android-code/CONTRIBUTING.md
Normal file
44
.claude/skills/implementing-android-code/CONTRIBUTING.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Contributing to implementing-android-code
|
||||
|
||||
## Development
|
||||
|
||||
This skill provides Bitwarden Android implementation patterns, gotchas, and code templates for Claude Code. It consists of two content files:
|
||||
|
||||
- **SKILL.md** - Quick reference for patterns, anti-patterns, and gotchas
|
||||
- **templates.md** - Copy-pasteable code templates for all layer types
|
||||
|
||||
## Making Changes
|
||||
|
||||
This skill follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **Patch** (0.1.x): Typo fixes, minor clarifications, template corrections
|
||||
- **Minor** (0.x.0): New patterns, new templates, expanded coverage areas
|
||||
- **Major** (x.0.0): Structural changes, pattern overhauls, breaking reorganizations
|
||||
|
||||
When making changes:
|
||||
|
||||
1. Update the relevant content in `SKILL.md` and/or `templates.md`
|
||||
2. Bump the `version` field in the SKILL.md YAML frontmatter
|
||||
3. Add an entry to `CHANGELOG.md` under the appropriate version heading
|
||||
|
||||
## Testing Locally
|
||||
|
||||
To test the skill locally with Claude Code:
|
||||
|
||||
```bash
|
||||
# From the repository root, invoke Claude Code and trigger the skill
|
||||
claude "How do I implement a ViewModel?"
|
||||
```
|
||||
|
||||
Verify that:
|
||||
- The skill triggers on expected phrases
|
||||
- Templates render correctly
|
||||
- Pattern references are accurate against the current codebase
|
||||
|
||||
## Pull Requests
|
||||
|
||||
All pull requests that modify skill content must include:
|
||||
|
||||
1. A version bump in the SKILL.md frontmatter
|
||||
2. A corresponding CHANGELOG.md entry
|
||||
3. Verification that templates compile against the current codebase patterns
|
||||
77
.claude/skills/implementing-android-code/README.md
Normal file
77
.claude/skills/implementing-android-code/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# implementing-android-code
|
||||
|
||||
Bitwarden Android implementation patterns skill for Claude Code. Provides critical patterns, gotchas, anti-patterns, and copy-pasteable templates unique to the Bitwarden Android codebase.
|
||||
|
||||
## Features
|
||||
|
||||
- **ViewModel SAE Pattern** - State-Action-Event with `BaseViewModel`, `SavedStateHandle` persistence, process death recovery
|
||||
- **Type-Safe Navigation** - `@Serializable` routes, `composableWithSlideTransitions`, `NavGraphBuilder`/`NavController` extensions
|
||||
- **Screen/Compose** - Stateless composables, `EventsEffect`, `remember(viewModel)` lambda patterns
|
||||
- **Data Layer** - Repository pattern, `DataState<T>` streaming, `Result` sealed classes, Flow collection via Internal actions
|
||||
- **UI Components** - Bitwarden component library usage, theming, string resources
|
||||
- **Security Patterns** - Zero-knowledge architecture, encrypted storage, SDK isolation
|
||||
- **Testing Patterns** - ViewModel, repository, compose, and data source test structure
|
||||
- **Clock/Time Handling** - `Clock` injection for deterministic time operations
|
||||
|
||||
## Skill Structure
|
||||
|
||||
```
|
||||
implementing-android-code/
|
||||
├── SKILL.md # Quick reference for patterns, gotchas, and anti-patterns
|
||||
├── templates.md # Copy-pasteable code templates for all layer types
|
||||
├── README.md # This file
|
||||
├── CHANGELOG.md # Version history
|
||||
└── CONTRIBUTING.md # Contribution guidelines
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Claude triggers this skill automatically when conversations involve implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
|
||||
**Example trigger phrases:**
|
||||
- "How do I implement a ViewModel?"
|
||||
- "Create a new screen"
|
||||
- "Add navigation"
|
||||
- "Write a repository"
|
||||
- "BaseViewModel pattern"
|
||||
- "State-Action-Event"
|
||||
- "type-safe navigation"
|
||||
- "Clock injection"
|
||||
|
||||
## Content Summary
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| A. ViewModel Implementation | SAE pattern, `handleAction`, `sendAction`, `SavedStateHandle` |
|
||||
| B. Type-Safe Navigation | `@Serializable` routes, transitions, `NavGraphBuilder` extensions |
|
||||
| C. Screen Implementation | Stateless composables, `EventsEffect`, action lambdas |
|
||||
| D. Data Layer | Repositories, data sources, `DataState`, error handling |
|
||||
| E. UI Components | Bitwarden component library, theming, string resources |
|
||||
| F. Security Patterns | Zero-knowledge, encrypted storage, SDK isolation |
|
||||
| G. Testing Quick Reference | ViewModel, repository, compose, data source tests |
|
||||
| H. Clock/Time Patterns | `Clock` injection, deterministic time testing |
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) - Comprehensive architecture patterns and examples
|
||||
- [`docs/STYLE_AND_BEST_PRACTICES.md`](../../../docs/STYLE_AND_BEST_PRACTICES.md) - Code style, formatting, Compose conventions
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines, versioning, and pull request requirements.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
|
||||
## License
|
||||
|
||||
This skill is part of the [Bitwarden Android](https://github.com/bitwarden/android) project and follows its licensing terms.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- Bitwarden Android team
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, open an issue in the [bitwarden/android](https://github.com/bitwarden/android) repository.
|
||||
480
.claude/skills/implementing-android-code/SKILL.md
Normal file
480
.claude/skills/implementing-android-code/SKILL.md
Normal file
@@ -0,0 +1,480 @@
|
||||
---
|
||||
name: implementing-android-code
|
||||
version: 0.1.1
|
||||
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
---
|
||||
|
||||
# Implementing Android Code - Bitwarden Quick Reference
|
||||
|
||||
**This skill provides tactical guidance for Bitwarden-specific patterns.** For comprehensive architecture decisions and complete code style rules, consult `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Critical Patterns Reference
|
||||
|
||||
### A. ViewModel Implementation (State-Action-Event Pattern)
|
||||
|
||||
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
|
||||
|
||||
**Key Requirements:**
|
||||
- Annotate with `@HiltViewModel`
|
||||
- State class MUST be `@Parcelize data class : Parcelable`
|
||||
- Implement `handleAction(action: A)` - MUST be synchronous
|
||||
- Post internal actions from coroutines using `sendAction()`
|
||||
- Save/restore state via `SavedStateHandle[KEY_STATE]`
|
||||
- Private action handlers: `private fun handle*` naming convention
|
||||
|
||||
**Template**: See [ViewModel template](templates.md#viewmodel-template-state-action-event-pattern)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: ExampleState()
|
||||
) {
|
||||
init {
|
||||
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
// Synchronous dispatch only
|
||||
when (action) {
|
||||
is Action.Click -> handleClick()
|
||||
is Action.Internal.DataReceived -> handleDataReceived(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClick() {
|
||||
viewModelScope.launch {
|
||||
val result = repository.fetchData()
|
||||
sendAction(Action.Internal.DataReceived(result)) // Post internal action
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDataReceived(action: Action.Internal.DataReceived) {
|
||||
mutableStateFlow.update { it.copy(data = action.result) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt` (see class declaration)
|
||||
|
||||
**Critical Gotchas:**
|
||||
- ❌ **NEVER** update `mutableStateFlow` directly inside coroutines
|
||||
- ✅ **ALWAYS** post internal actions from coroutines, update state in `handleAction()`
|
||||
- ❌ **NEVER** forget `@IgnoredOnParcel` for sensitive data (causes security leak)
|
||||
- ✅ **ALWAYS** use `@Parcelize` on state classes for process death recovery
|
||||
- ✅ State restoration happens automatically if properly saved to `SavedStateHandle`
|
||||
|
||||
---
|
||||
|
||||
### B. Navigation Implementation (Type-Safe)
|
||||
|
||||
All navigation uses **type-safe routes** with kotlinx.serialization.
|
||||
|
||||
**Pattern Structure:**
|
||||
1. `@Serializable` route data class with parameters
|
||||
2. `...Args` helper class for extracting from `SavedStateHandle`
|
||||
3. `NavGraphBuilder.{screen}Destination()` extension for adding screen to graph
|
||||
4. `NavController.navigateTo{Screen}()` extension for navigation calls
|
||||
|
||||
**Template**: See [Navigation template](templates.md#navigation-template-type-safe-routes)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)
|
||||
|
||||
data class ExampleArgs(val userId: String, val isEditMode: Boolean)
|
||||
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
|
||||
}
|
||||
|
||||
fun NavController.navigateToExample(userId: String, isEditMode: Boolean = false, navOptions: NavOptions? = null) {
|
||||
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(onNavigateBack = onNavigateBack)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt` (see `LoginRoute` and extensions)
|
||||
|
||||
**Key Benefits:**
|
||||
- ✅ Type safety: Compile-time errors for missing parameters
|
||||
- ✅ No string literals in navigation code
|
||||
- ✅ Automatic serialization/deserialization
|
||||
- ✅ Clear contract for screen dependencies
|
||||
|
||||
---
|
||||
|
||||
### C. Screen/Compose Implementation
|
||||
|
||||
All screens follow consistent Compose patterns.
|
||||
|
||||
**Template**: See [Screen/Compose template](templates.md#screencompose-template)
|
||||
|
||||
**Key Patterns:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.title),
|
||||
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// UI content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
|
||||
|
||||
**Essential Requirements:**
|
||||
- ✅ Use `hiltViewModel()` for dependency injection
|
||||
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
|
||||
- ✅ Use `EventsEffect(viewModel)` for one-shot events
|
||||
- ✅ Use `remember(viewModel) { }` for stable callbacks to prevent recomposition
|
||||
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
|
||||
|
||||
**State Hoisting Rules:**
|
||||
- **ViewModel state**: Data that needs to survive process death or affects business logic
|
||||
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
|
||||
|
||||
---
|
||||
|
||||
### D. Data Layer Implementation
|
||||
|
||||
The data layer follows strict patterns for repositories, managers, and data sources.
|
||||
|
||||
**Interface + Implementation Separation (ALWAYS)**
|
||||
|
||||
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// Interface (injected via Hilt)
|
||||
interface ExampleRepository {
|
||||
suspend fun fetchData(id: String): ExampleResult
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
|
||||
// Implementation (NOT directly injected)
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
) : ExampleRepository {
|
||||
override suspend fun fetchData(id: String): ExampleResult {
|
||||
// NO exceptions thrown - return Result or sealed class
|
||||
return exampleService.getData(id).fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealed result class (domain-specific)
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: ExampleData) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
|
||||
// Hilt Module
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
**Three-Layer Data Architecture:**
|
||||
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
|
||||
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
|
||||
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
|
||||
|
||||
**Critical Rules:**
|
||||
- ❌ **NEVER** throw exceptions in data layer
|
||||
- ✅ **ALWAYS** use interface + `...Impl` pattern
|
||||
- ✅ **ALWAYS** inject interfaces, never implementations
|
||||
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
|
||||
- ✅ Use `StateFlow` for continuously observed data
|
||||
|
||||
---
|
||||
|
||||
### E. UI Components
|
||||
|
||||
**Use Existing Components First:**
|
||||
|
||||
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
|
||||
|
||||
**Common Components:**
|
||||
- `BitwardenFilledButton` - Primary action buttons
|
||||
- `BitwardenOutlinedButton` - Secondary action buttons
|
||||
- `BitwardenTextField` - Text input fields
|
||||
- `BitwardenPasswordField` - Password input with show/hide
|
||||
- `BitwardenSwitch` - Toggle switches
|
||||
- `BitwardenTopAppBar` - Toolbar/app bar
|
||||
- `BitwardenScaffold` - Screen container with scaffold
|
||||
- `BitwardenBasicDialog` - Simple dialogs
|
||||
- `BitwardenLoadingDialog` - Loading indicators
|
||||
|
||||
**Component Discovery:**
|
||||
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. See **Codebase Discovery** in `CLAUDE.md` for search commands.
|
||||
|
||||
**When to Create New Reusable Components:**
|
||||
- Component used in 3+ places
|
||||
- Component needs consistent theming across app
|
||||
- Component has semantic meaning (accessibility)
|
||||
- Component has complex state management
|
||||
|
||||
**New Component Requirements:**
|
||||
- Prefix with `Bitwarden`
|
||||
- Accept themed colors/styles from `BitwardenTheme`
|
||||
- Include preview composables for testing
|
||||
- Support accessibility (content descriptions, semantics)
|
||||
|
||||
**String Resources:**
|
||||
|
||||
New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
|
||||
|
||||
- Use typographic apostrophes and quotes to avoid escape characters: `you’ll` not `you\'ll`, `“word”` not `\"word\"`
|
||||
- Reference strings via generated `BitwardenString` resource IDs
|
||||
- Do not add strings to other modules unless explicitly instructed
|
||||
|
||||
---
|
||||
|
||||
### F. Security Patterns
|
||||
|
||||
**Encrypted vs Unencrypted Storage:**
|
||||
|
||||
**Template**: See [Security templates](templates.md#security-templates)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
class ExampleDiskSourceImpl(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
fun storeAuthToken(token: String) {
|
||||
putEncryptedString(KEY_TOKEN, token) // Sensitive — uses base class method
|
||||
}
|
||||
|
||||
fun storeThemePreference(isDark: Boolean) {
|
||||
putBoolean(KEY_THEME, isDark) // Non-sensitive — uses base class method
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Android Keystore (Biometric Keys):**
|
||||
- User-scoped encryption keys: `BiometricsEncryptionManager`
|
||||
- Keys stored in Android Keystore (hardware-backed when available)
|
||||
- Integrity validation on biometric state changes
|
||||
|
||||
**Input Validation:**
|
||||
```kotlin
|
||||
// Validation returns boolean, NEVER throws
|
||||
interface RequestValidator {
|
||||
fun validate(request: Request): Boolean
|
||||
}
|
||||
|
||||
// Sanitization removes dangerous content
|
||||
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
|
||||
if (this.isNullOrBlank()) return null
|
||||
// Sanitize and return safe value
|
||||
}
|
||||
```
|
||||
|
||||
**Security Checklist:**
|
||||
- ✅ Use `@EncryptedPreferences` for credentials, keys, tokens
|
||||
- ✅ Use `@UnencryptedPreferences` for UI state, preferences
|
||||
- ✅ Use `@IgnoredOnParcel` for sensitive ViewModel state
|
||||
- ❌ **NEVER** log sensitive data (passwords, tokens, vault items)
|
||||
- ✅ Validate all user input before processing
|
||||
- ✅ Use Timber for non-sensitive logging only
|
||||
|
||||
---
|
||||
|
||||
### G. Testing Patterns
|
||||
|
||||
**ViewModel Testing:**
|
||||
|
||||
**Template**: See [Testing templates](templates.md#testing-templates)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
|
||||
@Test
|
||||
fun `ButtonClick should fetch data and update state`() = runTest {
|
||||
val expectedResult = ExampleResult.Success(data = "test")
|
||||
coEvery { mockRepository.fetchData(any()) } returns expectedResult
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(ExampleAction.ButtonClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(EXPECTED_STATE.copy(data = "test"), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to EXPECTED_STATE)),
|
||||
repository = mockRepository,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
**Key Testing Patterns:**
|
||||
- ✅ Extend `BaseViewModelTest` for proper dispatcher management
|
||||
- ✅ Use `runTest` from `kotlinx.coroutines.test`
|
||||
- ✅ Use Turbine's `.test { awaitItem() }` for Flow assertions
|
||||
- ✅ Use MockK: `coEvery` for suspend functions, `every` for sync
|
||||
- ✅ Test both state changes and event emissions
|
||||
- ✅ Test both success and failure Result paths
|
||||
|
||||
**Flow Testing with Turbine:**
|
||||
```kotlin
|
||||
// Test state and events simultaneously
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.Submit)
|
||||
assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
|
||||
assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
**MockK Quick Reference:**
|
||||
```kotlin
|
||||
coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
|
||||
every { diskSource.getData() } returns "cached" // Sync
|
||||
coVerify { repository.fetchData("123") } // Verify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H. Clock/Time Handling
|
||||
|
||||
All code needing current time must inject `Clock` for testability.
|
||||
|
||||
**Key Requirements:**
|
||||
- ✅ Inject `Clock` via Hilt in ViewModels
|
||||
- ✅ Pass `Clock` as parameter in extension functions
|
||||
- ✅ Use `clock.instant()` to get current time
|
||||
- ❌ Never call `Instant.now()` or `DateTime.now()` directly
|
||||
- ❌ Never use `mockkStatic` for datetime classes in tests
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// ViewModel with Clock
|
||||
class MyViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
) {
|
||||
val timestamp = clock.instant()
|
||||
}
|
||||
|
||||
// Extension function with Clock parameter
|
||||
fun State.getTimestamp(clock: Clock): Instant =
|
||||
existingTime ?: clock.instant()
|
||||
|
||||
// Test with fixed clock
|
||||
val FIXED_CLOCK = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC
|
||||
)
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` (see Time and Clock Handling section)
|
||||
- `core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt` (see `provideClock` function)
|
||||
|
||||
**Critical Gotchas:**
|
||||
- ❌ `Instant.now()` creates hidden dependency, non-testable
|
||||
- ❌ `mockkStatic(Instant::class)` is fragile, can leak between tests
|
||||
- ✅ `Clock.fixed(...)` provides deterministic test behavior
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden-Specific Anti-Patterns
|
||||
|
||||
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
|
||||
|
||||
❌ **NEVER update ViewModel state directly in coroutines**
|
||||
- Post internal actions, update state synchronously in `handleAction()`
|
||||
|
||||
❌ **NEVER inject `...Impl` classes**
|
||||
- Only inject interfaces via Hilt
|
||||
|
||||
❌ **NEVER create navigation without `@Serializable` routes**
|
||||
- No string-based navigation, always type-safe
|
||||
|
||||
❌ **NEVER use raw `Result<T>` in repositories**
|
||||
- Use domain-specific sealed classes for better error handling
|
||||
|
||||
❌ **NEVER make state classes without `@Parcelize`**
|
||||
- All ViewModel state must survive process death
|
||||
|
||||
❌ **NEVER skip `SavedStateHandle` persistence for ViewModels**
|
||||
- Users lose form progress on process death
|
||||
|
||||
❌ **NEVER forget `@IgnoredOnParcel` for passwords/tokens**
|
||||
- Causes security vulnerability (sensitive data in parcel)
|
||||
|
||||
❌ **NEVER use generic `Exception` catching**
|
||||
- Catch specific exceptions only (`RemoteException`, `IOException`)
|
||||
|
||||
❌ **NEVER call `Instant.now()` or `DateTime.now()` directly**
|
||||
- Inject `Clock` via Hilt, use `clock.instant()` for testability
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
For build, test, and codebase discovery commands, see the **Codebase Discovery**, **Testing**, and **Deployment** sections in `CLAUDE.md`.
|
||||
|
||||
**File Reference Format:**
|
||||
When pointing to specific code, use: `file_path:line_number`
|
||||
|
||||
Example: `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
|
||||
|
||||
644
.claude/skills/implementing-android-code/templates.md
Normal file
644
.claude/skills/implementing-android-code/templates.md
Normal file
@@ -0,0 +1,644 @@
|
||||
# Code Templates - Bitwarden Android
|
||||
|
||||
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
|
||||
|
||||
---
|
||||
|
||||
## ViewModel Template (State-Action-Event Pattern)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
|
||||
|
||||
### State Class
|
||||
|
||||
```kotlin
|
||||
@Parcelize
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
|
||||
val dialogState: DialogState? = null,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Dialog states for the Example screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
@Parcelize
|
||||
data class Loading(val message: Text) : DialogState()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Sealed Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* One-shot UI events for the Example screen.
|
||||
*/
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
|
||||
data class ShowToast(val message: Text) : ExampleEvent()
|
||||
}
|
||||
```
|
||||
|
||||
### Action Sealed Class (with Internal)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* User and system actions for the Example screen.
|
||||
*/
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
|
||||
data object SubmitClick : ExampleAction()
|
||||
|
||||
data class InputChanged(val input: String) : ExampleAction()
|
||||
|
||||
data object ErrorDialogDismiss : ExampleAction()
|
||||
|
||||
/**
|
||||
* Internal actions dispatched by the ViewModel from coroutines.
|
||||
*/
|
||||
sealed class Internal : ExampleAction() {
|
||||
data class ReceiveDataState(
|
||||
val dataState: DataState<ExampleData>,
|
||||
) : Internal()
|
||||
|
||||
data class ReceiveDataResult(
|
||||
val result: ExampleResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel
|
||||
|
||||
```kotlin
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Example screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val exampleRepository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val args = savedStateHandle.toExampleArgs()
|
||||
ExampleState(
|
||||
data = args.itemId,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
// Persist state for process death recovery
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// Collect repository flows as internal actions
|
||||
exampleRepository.dataFlow
|
||||
.map { ExampleAction.Internal.ReceiveDataState(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
when (action) {
|
||||
ExampleAction.BackClick -> handleBackClick()
|
||||
ExampleAction.SubmitClick -> handleSubmitClick()
|
||||
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
|
||||
is ExampleAction.InputChanged -> handleInputChanged(action)
|
||||
is ExampleAction.Internal.ReceiveDataState -> {
|
||||
handleReceiveDataState(action)
|
||||
}
|
||||
is ExampleAction.Internal.ReceiveDataResult -> {
|
||||
handleReceiveDataResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(ExampleEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleErrorDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleSubmitClick() {
|
||||
viewModelScope.launch {
|
||||
val result = exampleRepository.submitData(state.data.orEmpty())
|
||||
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInputChanged(action: ExampleAction.InputChanged) {
|
||||
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
|
||||
}
|
||||
|
||||
private fun handleReceiveDataState(
|
||||
action: ExampleAction.Internal.ReceiveDataState,
|
||||
) {
|
||||
when (action.dataState) {
|
||||
is DataState.Loaded -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = action.dataState.data.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loading -> {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
}
|
||||
|
||||
is DataState.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = action.dataState.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveDataResult(
|
||||
action: ExampleAction.Internal.ReceiveDataResult,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is ExampleResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = result.data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ExampleResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = result.message?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Template (Type-Safe Routes)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
|
||||
|
||||
```kotlin
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Route for the Example screen.
|
||||
*/
|
||||
@Serializable
|
||||
@OmitFromCoverage
|
||||
data class ExampleRoute(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Args extracted from [SavedStateHandle] for the Example screen.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
data class ExampleArgs(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts [ExampleArgs] from the [SavedStateHandle].
|
||||
*/
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(
|
||||
itemId = route.itemId,
|
||||
isEditMode = route.isEditMode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Example screen.
|
||||
*/
|
||||
fun NavController.navigateToExample(
|
||||
itemId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = ExampleRoute(
|
||||
itemId = itemId,
|
||||
isEditMode = isEditMode,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Example screen destination to the navigation graph.
|
||||
*/
|
||||
fun NavGraphBuilder.exampleDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen/Compose Template
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
|
||||
|
||||
```kotlin
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* The Example screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
is ExampleEvent.ShowToast -> {
|
||||
// Handle toast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
ExampleDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) }
|
||||
},
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.example),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
ExampleScreenContent(
|
||||
state = state,
|
||||
onInputChanged = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.InputChanged(it)) }
|
||||
},
|
||||
onSubmitClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Layer Template (Repository + Hilt Module)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
### Interface
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Provides data operations for the Example feature.
|
||||
*/
|
||||
interface ExampleRepository {
|
||||
/**
|
||||
* Submits data and returns a typed result.
|
||||
*/
|
||||
suspend fun submitData(input: String): ExampleResult
|
||||
|
||||
/**
|
||||
* Continuously observed data stream.
|
||||
*/
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
```
|
||||
|
||||
### Sealed Result Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Domain-specific result for Example operations.
|
||||
*/
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: String) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Default implementation of [ExampleRepository].
|
||||
*/
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : ExampleRepository {
|
||||
|
||||
override val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
get() = // ...
|
||||
|
||||
override suspend fun submitData(input: String): ExampleResult {
|
||||
return exampleService
|
||||
.postData(input)
|
||||
.fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hilt Module
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): ExampleRepository = ExampleRepositoryImpl(
|
||||
exampleDiskSource = exampleDiskSource,
|
||||
exampleService = exampleService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Templates
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
|
||||
|
||||
### Encrypted Disk Source (Module)
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleDiskSource(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): ExampleDiskSource = ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Encrypted Disk Source (Implementation)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Disk source for Example data using encrypted and unencrypted storage.
|
||||
*/
|
||||
class ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
|
||||
private companion object {
|
||||
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
|
||||
const val UNENCRYPTED_PREF_KEY = "examplePreference"
|
||||
}
|
||||
|
||||
override var authToken: String?
|
||||
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
|
||||
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
|
||||
|
||||
override var uiPreference: Boolean
|
||||
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
|
||||
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Templates
|
||||
|
||||
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
### ViewModel Test
|
||||
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository = mockk<ExampleRepository>()
|
||||
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockRepository.dataFlow } returns mutableDataFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is no saved state`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is a saved state`() {
|
||||
val savedState = DEFAULT_STATE.copy(data = "saved")
|
||||
val viewModel = createViewModel(state = savedState)
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should call repository and update state on success`() = runTest {
|
||||
val expected = ExampleResult.Success(data = "result")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
// Initial state
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Updated state after result
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "result", isLoading = false),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should show error dialog on failure`() = runTest {
|
||||
val expected = ExampleResult.Error(message = "Network error")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
val errorState = awaitItem()
|
||||
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExampleAction.BackClick)
|
||||
assertEquals(ExampleEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create ViewModel with optional saved state
|
||||
private fun createViewModel(
|
||||
state: ExampleState? = DEFAULT_STATE,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to state),
|
||||
),
|
||||
exampleRepository = mockRepository,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Testing with stateEventFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `SubmitClick should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Assert state change
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "data"),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
|
||||
// Assert event emission
|
||||
assertEquals(
|
||||
ExampleEvent.ShowToast("Success".asText()),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
44
.claude/skills/testing-android-code/README.md
Normal file
44
.claude/skills/testing-android-code/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Testing Android Code Skill
|
||||
|
||||
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
The skill automatically loads when you ask questions like:
|
||||
|
||||
- "How do I test this ViewModel?"
|
||||
- "Why is my Bitwarden test failing?"
|
||||
- "Write tests for this repository"
|
||||
|
||||
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
|
||||
|
||||
## What's Included
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Core testing patterns and base class locations |
|
||||
| `references/test-base-classes.md` | Detailed base class documentation |
|
||||
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
|
||||
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
|
||||
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
|
||||
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
|
||||
| `examples/repository-test-example.md` | Complete repository test with mocks |
|
||||
|
||||
## Patterns Covered
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
|
||||
|
||||
## Quick Start
|
||||
|
||||
For comprehensive architecture and testing philosophy, see:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
319
.claude/skills/testing-android-code/SKILL.md
Normal file
319
.claude/skills/testing-android-code/SKILL.md
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
name: testing-android-code
|
||||
description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Testing Android Code - Bitwarden Testing Patterns
|
||||
|
||||
**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Test Framework Configuration
|
||||
|
||||
**Required Dependencies:**
|
||||
- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine)
|
||||
- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test**
|
||||
|
||||
**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales.
|
||||
|
||||
---
|
||||
|
||||
## A. ViewModel Testing Patterns
|
||||
|
||||
### Base Class: BaseViewModelTest
|
||||
|
||||
**Always extend `BaseViewModelTest` for ViewModel tests.**
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
|
||||
|
||||
@Test
|
||||
fun `ButtonClick should fetch data and update state`() = runTest {
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
|
||||
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
viewModel.trySendAction(ExampleAction.ButtonClick)
|
||||
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete examples:** See `references/test-base-classes.md`
|
||||
|
||||
### StateFlow vs EventFlow (Critical Distinction)
|
||||
|
||||
| Flow Type | Replay | First Action | Pattern |
|
||||
|-----------|--------|--------------|---------|
|
||||
| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new |
|
||||
| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event |
|
||||
|
||||
**For detailed patterns:** See `references/flow-testing-patterns.md`
|
||||
|
||||
---
|
||||
|
||||
## B. Compose UI Testing Patterns
|
||||
|
||||
### Base Class: BitwardenComposeTest
|
||||
|
||||
**Always extend `BitwardenComposeTest` for Compose screen tests.**
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed Clock for deterministic time-based tests
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed.
|
||||
|
||||
**For complete base class details:** See `references/test-base-classes.md`
|
||||
|
||||
---
|
||||
|
||||
## C. Repository and Service Testing
|
||||
|
||||
### Service Testing with MockWebServer
|
||||
|
||||
**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`)
|
||||
|
||||
```kotlin
|
||||
class ExampleServiceTest : BaseServiceTest() {
|
||||
private val api: ExampleApi = retrofit.create()
|
||||
private val service = ExampleServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
val result = service.getConfig()
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Testing Pattern
|
||||
|
||||
```kotlin
|
||||
class ExampleRepositoryTest {
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
private val mockDiskSource: ExampleDiskSource = mockk()
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
exampleDiskSource = mockDiskSource,
|
||||
exampleService = mockService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fetchData should return success when service succeeds`() = runTest {
|
||||
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
|
||||
val result = repository.fetchData(userId)
|
||||
assertTrue(result.isSuccess)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers.
|
||||
|
||||
---
|
||||
|
||||
## D. Test Data Builders
|
||||
|
||||
### Builder Pattern with Number Parameter
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/`
|
||||
|
||||
```kotlin
|
||||
fun createMockCipher(
|
||||
number: Int,
|
||||
id: String = "mockId-$number",
|
||||
name: String? = "mockName-$number",
|
||||
// ... more parameters with defaults
|
||||
): SyncResponseJson.Cipher
|
||||
|
||||
// Usage:
|
||||
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
|
||||
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
|
||||
val custom = createMockCipher(number = 3, name = "Custom")
|
||||
```
|
||||
|
||||
**Available Builders (35+):**
|
||||
- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()`
|
||||
- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()`
|
||||
- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()`
|
||||
- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()`
|
||||
- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()`
|
||||
|
||||
See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list.
|
||||
|
||||
---
|
||||
|
||||
## E. Result Type Testing
|
||||
|
||||
**Locations:**
|
||||
- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt`
|
||||
- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt`
|
||||
|
||||
```kotlin
|
||||
// Create results
|
||||
"data".asSuccess() // Result.success("data")
|
||||
throwable.asFailure() // Result.failure<T>(throwable)
|
||||
|
||||
// Assertions
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(expectedValue, result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## F. Test Utilities and Helpers
|
||||
|
||||
### Fake Implementations
|
||||
|
||||
| Fake | Location | Purpose |
|
||||
|------|----------|---------|
|
||||
| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution |
|
||||
| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage |
|
||||
| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences |
|
||||
|
||||
### Exception Testing (CRITICAL)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - Call directly, NOT inside runTest
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<IllegalStateException> {
|
||||
repository.throwingFunction()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern.
|
||||
|
||||
---
|
||||
|
||||
## G. Critical Gotchas
|
||||
|
||||
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
|
||||
|
||||
**Core Patterns:**
|
||||
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
|
||||
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
|
||||
- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first
|
||||
- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl`
|
||||
- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests
|
||||
|
||||
**Assertion Patterns:**
|
||||
- **Complete state assertions** - Assert entire state objects, not individual fields
|
||||
- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()`
|
||||
- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions
|
||||
|
||||
**Test Design:**
|
||||
- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths
|
||||
- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic
|
||||
- **Null stream testing** - Test null returns from ContentResolver operations
|
||||
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
|
||||
- **Test factory methods** - Accept domain state types, not SavedStateHandle
|
||||
|
||||
---
|
||||
|
||||
## H. Test File Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
module/src/test/kotlin/com/bitwarden/.../
|
||||
├── ui/*ScreenTest.kt, *ViewModelTest.kt
|
||||
├── data/repository/*RepositoryTest.kt
|
||||
└── network/service/*ServiceTest.kt
|
||||
|
||||
module/src/testFixtures/kotlin/com/bitwarden/.../
|
||||
├── util/TestHelpers.kt
|
||||
├── base/Base*Test.kt
|
||||
└── model/*Util.kt
|
||||
```
|
||||
|
||||
### Test Naming
|
||||
|
||||
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`
|
||||
- Functions: `` `given state when action should result` ``
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Key Bitwarden-specific testing patterns:
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - Consistent `number: Int` parameter pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`
|
||||
|
||||
**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations.
|
||||
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
For detailed information, see:
|
||||
|
||||
- `references/test-base-classes.md` - Detailed base class documentation and usage patterns
|
||||
- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow
|
||||
- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips
|
||||
|
||||
**Complete Examples:**
|
||||
- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow
|
||||
- `examples/compose-screen-test-example.md` - Full Compose screen test
|
||||
- `examples/repository-test-example.md` - Full repository test with mocks and fakes
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Complete Compose Screen Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BitwardenComposeTest
|
||||
* - Mocking ViewModel with flows
|
||||
* - Testing UI interactions
|
||||
* - Testing navigation callbacks
|
||||
* - Using bufferedMutableSharedFlow for events
|
||||
* - Testing dialogs with isDialog() and hasAnyAncestor()
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.util.assertNoDialogExists
|
||||
import com.bitwarden.ui.util.isProgressBar
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
|
||||
// Track navigation callbacks
|
||||
private var haveCalledNavigateBack = false
|
||||
private var haveCalledNavigateToNext = false
|
||||
|
||||
// Use bufferedMutableSharedFlow for events (default replay = 0)
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
||||
// Mock ViewModel with relaxed = true
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
haveCalledNavigateBack = false
|
||||
haveCalledNavigateToNext = false
|
||||
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
onNavigateToNext = { haveCalledNavigateToNext = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Back button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Submit button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on submit click should send SubmitClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Submit")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading state shows progress indicator
|
||||
*/
|
||||
@Test
|
||||
fun `loading state should display progress indicator`() {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
|
||||
composeTestRule
|
||||
.onNode(isProgressBar)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Data state shows content
|
||||
*/
|
||||
@Test
|
||||
fun `data state should display content`() {
|
||||
mutableStateFlow.update { it.copy(data = "Test Data") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Data")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error state shows error message
|
||||
*/
|
||||
@Test
|
||||
fun `error state should display error message`() {
|
||||
mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Something went wrong")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateBack event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledNavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateToNext event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateToNext event should call onNavigateToNext`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
|
||||
|
||||
assertTrue(haveCalledNavigateToNext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Item in list can be clicked
|
||||
*/
|
||||
@Test
|
||||
fun `on item click should send ItemClick action`() {
|
||||
val itemId = "item-123"
|
||||
mutableStateFlow.update {
|
||||
it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item")))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Item")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) }
|
||||
}
|
||||
|
||||
// ==================== DIALOG TESTS ====================
|
||||
|
||||
/**
|
||||
* Test: No dialog exists when dialogState is null
|
||||
*/
|
||||
@Test
|
||||
fun `no dialog should exist when dialogState is null`() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading dialog displays when state updates
|
||||
* PATTERN: Use isDialog() to check dialog exists
|
||||
*/
|
||||
@Test
|
||||
fun `loading dialog should display when dialogState is Loading`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ExampleState.DialogState.Loading("Please wait..."))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify loading text within dialog using hasAnyAncestor(isDialog())
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Please wait...")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error dialog displays title and message
|
||||
* PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog should display title and message`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "An error has occurred",
|
||||
message = "Something went wrong. Please try again.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify dialog exists
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify title within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify message within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Something went wrong. Please try again.")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Dialog button click sends action
|
||||
* PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog dismiss button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "Error",
|
||||
message = "An error occurred",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click dismiss button within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Confirmation dialog with multiple buttons
|
||||
* PATTERN: Test both confirm and cancel actions
|
||||
*/
|
||||
@Test
|
||||
fun `confirmation dialog confirm button should send ConfirmAction`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure you want to proceed?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click confirm button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Confirm")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmation dialog cancel button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click cancel button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
items = emptyList(),
|
||||
dialogState = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val items: List<ExampleItem> = emptyList(),
|
||||
val dialogState: DialogState? = null,
|
||||
) {
|
||||
/**
|
||||
* PATTERN: Nested sealed class for dialog states.
|
||||
* Common dialog types: Loading, Error, Confirmation
|
||||
*/
|
||||
sealed class DialogState {
|
||||
data class Loading(val message: String) : DialogState()
|
||||
data class Error(val title: String, val message: String) : DialogState()
|
||||
data class Confirmation(val title: String, val message: String) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
data class ExampleItem(val id: String, val name: String)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data class ItemClick(val itemId: String) : ExampleAction()
|
||||
data object DismissDialog : ExampleAction()
|
||||
data object ConfirmAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Complete Repository Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Fake for disk sources, Mock for network services
|
||||
* - Using FakeDispatcherManager for deterministic coroutines
|
||||
* - Using fixed Clock for deterministic time
|
||||
* - Testing Result types with .asSuccess() / .asFailure()
|
||||
* - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
|
||||
* - Testing Flow emissions with Turbine
|
||||
*/
|
||||
package com.bitwarden.example.data.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class ExampleRepositoryTest {
|
||||
|
||||
// Fixed clock for deterministic time-based tests
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
// Use FakeDispatcherManager for deterministic coroutine execution
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
|
||||
// Mock service (network layer is always mocked)
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
/**
|
||||
* PATTERN: Use Fake for disk source in happy path tests.
|
||||
* This is the Bitwarden convention for repository testing.
|
||||
*/
|
||||
private val fakeDiskSource = FakeExampleDiskSource()
|
||||
|
||||
private lateinit var repository: ExampleRepositoryImpl
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
service = mockService,
|
||||
diskSource = fakeDiskSource,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== HAPPY PATH TESTS (use Fake) ====================
|
||||
|
||||
/**
|
||||
* Test: Successful fetch returns data and saves to disk
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return success and save to disk when service succeeds`() = runTest {
|
||||
val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns expectedData.asSuccess()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(expectedData, result.getOrThrow())
|
||||
// Fake automatically stores the data - verify it's there
|
||||
assertEquals(expectedData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Service failure returns failure without saving
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service fails`() = runTest {
|
||||
val exception = Exception("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake was not updated
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Repository flow emits when disk source updates
|
||||
*/
|
||||
@Test
|
||||
fun `dataFlow should emit when disk source updates`() = runTest {
|
||||
val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant())
|
||||
val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial null value from Fake
|
||||
assertNull(awaitItem())
|
||||
|
||||
// Update via Fake property setter (triggers emission)
|
||||
fakeDiskSource.storedData = data1
|
||||
assertEquals(data1, awaitItem())
|
||||
|
||||
// Another update
|
||||
fakeDiskSource.storedData = data2
|
||||
assertEquals(data2, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Refresh fetches and saves new data
|
||||
*/
|
||||
@Test
|
||||
fun `refresh should fetch new data and update disk source`() = runTest {
|
||||
val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns newData.asSuccess()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertEquals(Unit, result.getOrThrow())
|
||||
coVerify { mockService.getData() }
|
||||
assertEquals(newData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Delete clears data from disk
|
||||
*/
|
||||
@Test
|
||||
fun `deleteData should clear disk source`() = runTest {
|
||||
// Pre-populate the fake
|
||||
fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.deleteData()
|
||||
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Cached data returns from disk when available
|
||||
*/
|
||||
@Test
|
||||
fun `getCachedData should return disk data without network call`() = runTest {
|
||||
val cachedData = ExampleData(
|
||||
id = "cached",
|
||||
name = "Cached",
|
||||
updatedAt = fixedClock.instant(),
|
||||
)
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
val result = repository.getCachedData()
|
||||
|
||||
assertEquals(cachedData, result)
|
||||
coVerify(exactly = 0) { mockService.getData() }
|
||||
}
|
||||
|
||||
// ==================== ERROR PATH TESTS ====================
|
||||
|
||||
/**
|
||||
* PATTERN: For error paths, reconfigure the class-level mock per-test.
|
||||
* Use coEvery to change mock behavior for each specific test case.
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service returns error`() = runTest {
|
||||
val exception = Exception("Server unavailable")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake state unchanged on failure
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh should return failure and preserve cached data when service fails`() = runTest {
|
||||
// Pre-populate cache via Fake
|
||||
val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant())
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
// Reconfigure mock to return failure
|
||||
coEvery { mockService.getData() } returns Exception("Network error").asFailure()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
// Cached data preserved on failure
|
||||
assertEquals(cachedData, fakeDiskSource.storedData)
|
||||
}
|
||||
}
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleData(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
interface ExampleService {
|
||||
suspend fun getData(): Result<ExampleData>
|
||||
}
|
||||
|
||||
interface ExampleDiskSource {
|
||||
val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?>
|
||||
fun getData(): ExampleData?
|
||||
fun saveData(data: ExampleData)
|
||||
fun clearData()
|
||||
}
|
||||
|
||||
/**
|
||||
* PATTERN: Fake implementation for happy path testing.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
|
||||
* - Uses .onSubscription { emit(state) } for immediate state emission
|
||||
* - Private storage with override property setter that emits to flow
|
||||
* - Test assertions done via the override property getter
|
||||
*/
|
||||
class FakeExampleDiskSource : ExampleDiskSource {
|
||||
private var storedDataValue: ExampleData? = null
|
||||
private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
|
||||
|
||||
/**
|
||||
* Override property with getter/setter. Setter emits to flow automatically.
|
||||
* Tests can read this property for assertions and write to trigger emissions.
|
||||
*/
|
||||
var storedData: ExampleData?
|
||||
get() = storedDataValue
|
||||
set(value) {
|
||||
storedDataValue = value
|
||||
mutableDataFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val dataFlow: Flow<ExampleData?>
|
||||
get() = mutableDataFlow.onSubscription { emit(storedData) }
|
||||
|
||||
override fun getData(): ExampleData? = storedData
|
||||
|
||||
override fun saveData(data: ExampleData) {
|
||||
storedData = data
|
||||
}
|
||||
|
||||
override fun clearData() {
|
||||
storedData = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Complete ViewModel Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BaseViewModelTest
|
||||
* - Testing StateFlow with Turbine
|
||||
* - Testing EventFlow with Turbine
|
||||
* - Using stateEventFlow() for simultaneous testing
|
||||
* - MockK mocking patterns
|
||||
* - Test factory method design (accepts domain state, not SavedStateHandle)
|
||||
* - Complete state assertions (assert entire state objects)
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val mockAuthDiskSource: AuthDiskSource = mockk {
|
||||
every { userStateFlow } returns MutableStateFlow(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* StateFlow has replay=1, so first awaitItem() returns current state
|
||||
*/
|
||||
@Test
|
||||
fun `initial state should be default state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state transitions: initial -> loading -> success
|
||||
*/
|
||||
@Test
|
||||
fun `LoadData action should update state from idle to loading to success`() = runTest {
|
||||
val expectedData = "loaded data"
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.LoadData)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFlow has no replay - MUST call expectNoEvents() first
|
||||
*/
|
||||
@Test
|
||||
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // CRITICAL for EventFlow
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
assertEquals(ExampleEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use stateEventFlow() helper for simultaneous testing
|
||||
*/
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
|
||||
assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state restoration from saved state.
|
||||
* Note: Use initialState parameter, NOT SavedStateHandle directly.
|
||||
*/
|
||||
@Test
|
||||
fun `initial state from saved state should be preserved`() = runTest {
|
||||
// Build complete expected state - always assert full objects
|
||||
val savedState = ExampleState(
|
||||
isLoading = false,
|
||||
data = "restored data",
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(savedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method accepts domain state, NOT SavedStateHandle.
|
||||
* This hides Android framework details from test logic.
|
||||
*/
|
||||
private fun createViewModel(
|
||||
initialState: ExampleState? = null,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
authDiskSource = mockAuthDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object LoadData : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data object ComplexAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
data class ShowToast(val message: String) : ExampleEvent()
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
# Critical Gotchas and Anti-Patterns
|
||||
|
||||
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
|
||||
|
||||
## ❌ NEVER wrap assertCoroutineThrows in runTest
|
||||
|
||||
### The Problem
|
||||
|
||||
`runTest` catches exceptions and rethrows them, which breaks the `assertCoroutineThrows` assertion pattern.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() = runTest {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Won't work - exception is caught by runTest!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Works correctly
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Happens
|
||||
|
||||
`runTest` provides a coroutine scope and catches exceptions to provide better error messages. However, `assertCoroutineThrows` needs to catch the exception itself to verify it was thrown. When wrapped in `runTest`, the exception is caught twice, breaking the assertion.
|
||||
|
||||
## ❌ ALWAYS unmock static functions
|
||||
|
||||
### The Problem
|
||||
|
||||
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
// Forgot @After - subsequent tests will fail mysteriously!
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
|
||||
}
|
||||
```
|
||||
|
||||
### Common Static Functions to Watch
|
||||
|
||||
```kotlin
|
||||
// Platform version checks
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
unmockkStatic(::isBuildVersionAtLeast)
|
||||
|
||||
// URI parsing
|
||||
mockkStatic(Uri::class)
|
||||
unmockkStatic(Uri::class)
|
||||
|
||||
// Static utility functions
|
||||
mockkStatic(MyUtilClass::class)
|
||||
unmockkStatic(MyUtilClass::class)
|
||||
```
|
||||
|
||||
### Debugging Tip
|
||||
|
||||
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
|
||||
|
||||
## ❌ Don't confuse StateFlow and EventFlow testing
|
||||
|
||||
### StateFlow (replay = 1)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - StateFlow always has current value
|
||||
viewModel.stateFlow.test {
|
||||
val initial = awaitItem() // Gets current state immediately
|
||||
viewModel.trySendAction(action)
|
||||
val updated = awaitItem() // Gets new state
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (no replay)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - EventFlow has no initial value
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // MUST do this first
|
||||
viewModel.trySendAction(action)
|
||||
val event = awaitItem() // Gets emitted event
|
||||
}
|
||||
```
|
||||
|
||||
### Common Mistake
|
||||
|
||||
```kotlin
|
||||
// WRONG - Forgetting expectNoEvents() on EventFlow
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May cause flaky tests
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
## ❌ Don't mix real and test dispatchers
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test will have timing issues - real dispatcher != test dispatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test runs deterministically
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
|
||||
- Non-deterministic test failures
|
||||
- Real delays in tests (slow test suite)
|
||||
- Race conditions
|
||||
|
||||
### Always Use
|
||||
|
||||
- `FakeDispatcherManager()` for repositories
|
||||
- `UnconfinedTestDispatcher()` when manually creating dispatchers
|
||||
- `runTest` for coroutine tests (provides TestDispatcher automatically)
|
||||
|
||||
## ❌ Don't forget to use runTest for coroutine tests
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() {
|
||||
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
|
||||
}
|
||||
```
|
||||
|
||||
This causes:
|
||||
- Test completes before coroutines finish
|
||||
- False positives (test passes but assertions never run)
|
||||
- Mysterious failures
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() = runTest {
|
||||
viewModel.stateFlow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### When runTest is Required
|
||||
|
||||
- Testing ViewModels (they use `viewModelScope`)
|
||||
- Testing Flows with Turbine `.test {}`
|
||||
- Testing repositories with suspend functions
|
||||
- Any test calling suspend functions
|
||||
|
||||
### Exception: assertCoroutineThrows
|
||||
|
||||
As noted above, `assertCoroutineThrows` should NOT be wrapped in `runTest`.
|
||||
|
||||
## ❌ Don't forget relaxed = true for complex mocks
|
||||
|
||||
### Without relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
|
||||
|
||||
// Error: "no answer found for: stateFlow"
|
||||
```
|
||||
|
||||
### With relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
// Only mock what you care about
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use relaxed
|
||||
|
||||
- Mocking ViewModels in Compose tests
|
||||
- Mocking complex objects with many methods
|
||||
- When you only care about specific method calls
|
||||
|
||||
### When NOT to Use relaxed
|
||||
|
||||
- Mocking repository interfaces (be explicit about behavior)
|
||||
- When you want to verify NO unexpected calls
|
||||
- Testing error paths (want test to fail if unexpected method called)
|
||||
|
||||
## ❌ Don't assert individual fields when complete state is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Asserting individual state fields can miss unintended side effects on other fields.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals(null, state.dialog) // Only checks one field!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val expected = SomeState(
|
||||
isLoading = false,
|
||||
data = "result",
|
||||
dialog = null,
|
||||
)
|
||||
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Catches unintended mutations to other state fields
|
||||
- Makes expected state explicit and readable
|
||||
- Prevents silent regressions when state structure changes
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use Kotlin assert() for boolean checks
|
||||
|
||||
### The Problem
|
||||
|
||||
Kotlin's `assert()` doesn't follow JUnit conventions and provides poor failure messages.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assert(onNavigateCalled) // Kotlin assert - bad failure messages
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
|
||||
}
|
||||
```
|
||||
|
||||
### Always Use JUnit Assertions
|
||||
|
||||
- `assertTrue()` / `assertFalse()` for booleans
|
||||
- `assertEquals()` for value comparisons
|
||||
- `assertNotNull()` / `assertNull()` for nullability
|
||||
- `assertThrows<T>()` for exceptions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't pass SavedStateHandle to test factory methods
|
||||
|
||||
### The Problem
|
||||
|
||||
Exposing `SavedStateHandle` in test factory methods leaks Android framework details into test logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
|
||||
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
initialState: MyState? = null, // Domain type only
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Cleaner, more intuitive test code
|
||||
- Hides SavedStateHandle implementation details
|
||||
- Follows Bitwarden conventions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't test SavedStateHandle persistence in unit tests
|
||||
|
||||
### The Problem
|
||||
|
||||
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `state should persist to SavedStateHandle`() = runTest {
|
||||
val savedStateHandle = SavedStateHandle()
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
val savedState = savedStateHandle.get<MyState>("state")
|
||||
assertEquals(expectedState, savedState) // Testing framework, not logic!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Focus on testing business logic and state transformations:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state correctly`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use static mocking when DI pattern is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Static mocking (`mockkStatic`) is harder to maintain and less testable than dependency injection.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class ParserTest {
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(UUID::class)
|
||||
every { UUID.randomUUID() } returns mockk {
|
||||
every { toString() } returns "fixed-uuid"
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(UUID::class)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Extract an interface and inject it:
|
||||
|
||||
```kotlin
|
||||
// Production code
|
||||
interface UuidManager {
|
||||
fun generateUuid(): String
|
||||
}
|
||||
|
||||
class UuidManagerImpl : UuidManager {
|
||||
override fun generateUuid(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
class Parser(private val uuidManager: UuidManager) { ... }
|
||||
|
||||
// Test code
|
||||
class ParserTest {
|
||||
private val mockUuidManager = mockk<UuidManager>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
|
||||
}
|
||||
|
||||
// No tearDown needed - no static mocking!
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use This Pattern
|
||||
|
||||
- UUID generation
|
||||
- Timestamp/Clock operations
|
||||
- System property access
|
||||
- Any static function that needs deterministic testing
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't forget to test null stream returns from Android APIs
|
||||
|
||||
### The Problem
|
||||
|
||||
Android's `ContentResolver.openOutputStream()` and `openInputStream()` can return null, not just throw exceptions.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
// Missing: test for null return!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stringToUri with null stream should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } returns null
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result) // CRITICAL: must handle null!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Android APIs That Return Null
|
||||
|
||||
- `ContentResolver.openOutputStream()` / `openInputStream()`
|
||||
- `Context.getExternalFilesDir()`
|
||||
- `PackageManager.getApplicationInfo()` (can throw)
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden Mocking Guidelines
|
||||
|
||||
**Mock at architectural boundaries:**
|
||||
- Repository → ViewModel (mock repository)
|
||||
- Service → Repository (mock service)
|
||||
- API → Service (use MockWebServer, not mocks)
|
||||
- DiskSource → Repository (mock disk source)
|
||||
|
||||
**Fake vs Mock Strategy (IMPORTANT):**
|
||||
- **Happy paths**: Use Fake implementations (`FakeAuthenticatorDiskSource`, `FakeVaultDiskSource`)
|
||||
- **Error paths**: Use MockK with isolated repository instances
|
||||
|
||||
```kotlin
|
||||
// Happy path - use Fake
|
||||
private val fakeDiskSource = FakeAuthenticatorDiskSource()
|
||||
|
||||
@Test
|
||||
fun `createItem should return Success`() = runTest {
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Success, result)
|
||||
}
|
||||
|
||||
// Error path - use isolated Mock
|
||||
@Test
|
||||
fun `createItem with exception should return Error`() = runTest {
|
||||
val mockDiskSource = mockk<AuthenticatorDiskSource> {
|
||||
coEvery { saveItem(any()) } throws RuntimeException()
|
||||
}
|
||||
val repository = RepositoryImpl(diskSource = mockDiskSource)
|
||||
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Error, result)
|
||||
}
|
||||
```
|
||||
|
||||
**Use Fakes for:**
|
||||
- `FakeDispatcherManager` - deterministic coroutines
|
||||
- `FakeConfigDiskSource` - in-memory config storage
|
||||
- `FakeSharedPreferences` - memory-backed preferences
|
||||
- `FakeAuthenticatorDiskSource` - in-memory authenticator storage
|
||||
|
||||
**Create real instances for:**
|
||||
- Data classes, value objects (User, Config, CipherView)
|
||||
- Test data builders (`createMockCipher(number = 1)`)
|
||||
|
||||
## ❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
|
||||
|
||||
### The Problem
|
||||
|
||||
Fake data sources using `MutableSharedFlow` won't emit cached state to new subscribers without explicit handling.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = MutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection gets nothing!
|
||||
repository.dataFlow.test {
|
||||
// Hangs or fails - no initial emission
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
.onSubscription { emit(storedItems.toList()) }
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems.toList())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection receives current state
|
||||
repository.dataFlow.test {
|
||||
assertEquals(emptyList(), awaitItem()) // Works!
|
||||
}
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- Use `bufferedMutableSharedFlow()` from `core/data/repository/util/`
|
||||
- Add `.onSubscription { emit(currentState) }` for immediate state emission
|
||||
- This ensures new collectors receive the current cached state
|
||||
|
||||
---
|
||||
|
||||
## ✅ Use Result extension functions for assertions
|
||||
|
||||
### The Pattern
|
||||
|
||||
Use `asSuccess()` and `asFailure()` extensions from `com.bitwarden.core.data.util` for cleaner Result assertions.
|
||||
|
||||
### Success Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData should return success`() = runTest {
|
||||
val result = repository.getData()
|
||||
val expected = expectedData.asSuccess()
|
||||
|
||||
assertEquals(expected.getOrNull(), result.getOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Failure Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData with error should return failure`() = runTest {
|
||||
val exception = IOException("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.getData()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Redundant Assertions
|
||||
|
||||
```kotlin
|
||||
// WRONG - redundant success checks
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(expected.isSuccess)
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
|
||||
// CORRECT - final assertion is sufficient
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
Before submitting tests, verify:
|
||||
|
||||
**Core Patterns:**
|
||||
- [ ] No `assertCoroutineThrows` inside `runTest`
|
||||
- [ ] All static mocks have `unmockk` in `@After`
|
||||
- [ ] EventFlow tests start with `expectNoEvents()`
|
||||
- [ ] Using FakeDispatcherManager, not real dispatchers
|
||||
- [ ] All coroutine tests use `runTest`
|
||||
|
||||
**Assertion Patterns:**
|
||||
- [ ] Assert complete state objects, not individual fields
|
||||
- [ ] Use JUnit `assertTrue()`, not Kotlin `assert()`
|
||||
- [ ] Use `asSuccess()` for Result type assertions
|
||||
- [ ] Avoid redundant assertion patterns
|
||||
|
||||
**Test Design:**
|
||||
- [ ] Test factory methods accept domain types, not SavedStateHandle
|
||||
- [ ] Use Fakes for happy paths, Mocks for error paths
|
||||
- [ ] Prefer DI patterns over static mocking
|
||||
- [ ] Test null returns from Android APIs (streams, files)
|
||||
- [ ] Fakes use `bufferedMutableSharedFlow()` with `.onSubscription`
|
||||
|
||||
**General:**
|
||||
- [ ] Tests don't depend on execution order
|
||||
- [ ] Complex mocks use `relaxed = true`
|
||||
- [ ] Test data is created fresh for each test
|
||||
- [ ] Mocking behavior, not value objects
|
||||
- [ ] Testing observable behavior, not implementation
|
||||
|
||||
When tests fail mysteriously, check these gotchas first.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Flow Testing with Turbine
|
||||
|
||||
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
|
||||
|
||||
## StateFlow vs EventFlow
|
||||
|
||||
### StateFlow (Replayed)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 1` - Always emits current value to new collectors
|
||||
- First `awaitItem()` returns the current/initial state
|
||||
- Survives configuration changes
|
||||
- Used for UI state that needs to be immediately available
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
// First awaitItem() gets CURRENT state
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.LoadData)
|
||||
|
||||
// Next awaitItem() gets UPDATED state
|
||||
assertEquals(LOADING_STATE, awaitItem())
|
||||
assertEquals(SUCCESS_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (No Replay)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 0` - Only emits new events after subscription
|
||||
- No initial value emission
|
||||
- One-time events (navigation, toasts, dialogs)
|
||||
- Does not survive configuration changes
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
// MUST call expectNoEvents() first - nothing emitted yet
|
||||
expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
// Now expect the event
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Always call `expectNoEvents()` before triggering actions on EventFlow. Forgetting this causes flaky tests.
|
||||
|
||||
## Testing State and Events Simultaneously
|
||||
|
||||
Use the `stateEventFlow()` helper from `BaseViewModelTest`:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.ComplexAction)
|
||||
|
||||
// Verify state progression
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emission
|
||||
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Repository Flow Testing
|
||||
|
||||
### Testing Database Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `dataFlow should emit when database updates`() = runTest {
|
||||
val dataFlow = MutableStateFlow(initialData)
|
||||
every { mockDiskSource.dataFlow } returns dataFlow
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial value
|
||||
assertEquals(initialData, awaitItem())
|
||||
|
||||
// Update disk source
|
||||
dataFlow.value = updatedData
|
||||
|
||||
// Verify emission
|
||||
assertEquals(updatedData, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Transformed Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `flow transformation should map correctly`() = runTest {
|
||||
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
|
||||
every { mockDao.observeUser() } returns sourceFlow
|
||||
|
||||
// Repository transforms entity to domain model
|
||||
repository.userFlow.test {
|
||||
val expectedUser = User(id = "1", name = "John")
|
||||
assertEquals(expectedUser, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Testing Initial State + Action
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data should update from idle to loading to success`() = runTest {
|
||||
coEvery { repository.getData() } returns "data".asSuccess()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Testing Error States
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data with error should emit failure state`() = runTest {
|
||||
val error = Exception("Network error")
|
||||
coEvery { repository.getData() } returns error.asFailure()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Testing Event Sequences
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `submit should emit validation then navigation events`() = runTest {
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
assertEquals(MyEvent.ShowValidation, awaitItem())
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Testing Cancellation
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `cancelling collection should stop emissions`() = runTest {
|
||||
val flow = flow {
|
||||
repeat(100) {
|
||||
emit(it)
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
flow.test {
|
||||
assertEquals(0, awaitItem())
|
||||
assertEquals(1, awaitItem())
|
||||
|
||||
// Cancel after 2 items
|
||||
cancel()
|
||||
|
||||
// No more items received
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Forgetting expectNoEvents() on EventFlow
|
||||
|
||||
```kotlin
|
||||
// WRONG
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // ALWAYS do this first
|
||||
viewModel.trySendAction(action)
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Not Using runTest
|
||||
|
||||
```kotlin
|
||||
// WRONG - Missing runTest
|
||||
@Test
|
||||
fun `test flow`() {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
@Test
|
||||
fun `test flow`() = runTest {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mixing StateFlow and EventFlow Patterns
|
||||
|
||||
```kotlin
|
||||
// WRONG - Treating StateFlow like EventFlow
|
||||
stateFlow.test {
|
||||
expectNoEvents() // Unnecessary - StateFlow always has value
|
||||
/* ... */
|
||||
}
|
||||
|
||||
// WRONG - Treating EventFlow like StateFlow
|
||||
eventFlow.test {
|
||||
val item = awaitItem() // Will hang - no initial value!
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
**ViewModel with StateFlow and EventFlow:**
|
||||
`app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
**Repository Flow Testing:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt`
|
||||
|
||||
**Complex Flow Transformations:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt`
|
||||
@@ -0,0 +1,259 @@
|
||||
# Test Base Classes Reference
|
||||
|
||||
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
|
||||
|
||||
## BaseViewModelTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
|
||||
|
||||
### Automatic Configuration
|
||||
- Registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Ensures deterministic coroutine execution in tests
|
||||
- All coroutines complete immediately without real delays
|
||||
|
||||
### Key Feature: stateEventFlow() Helper
|
||||
|
||||
**Use Case:** When you need to test both StateFlow and EventFlow simultaneously.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Verify initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
// Verify state updated
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emitted
|
||||
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: MyRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to INITIAL_STATE)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test action`() = runTest {
|
||||
val viewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository
|
||||
)
|
||||
|
||||
// Test with automatic dispatcher setup
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BitwardenComposeTest
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
|
||||
|
||||
### Automatic Configuration
|
||||
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed `Clock` for deterministic time-based tests
|
||||
- Extends `BaseComposeTest` for Robolectric and dispatcher setup
|
||||
|
||||
### Key Features
|
||||
|
||||
**Pre-configured Managers:**
|
||||
- `FeatureFlagManager` - Controls feature flag behavior
|
||||
- `AuthTabManager` - Manages auth tab state
|
||||
- `BiometricsManager` - Handles biometric authentication
|
||||
- `ClipboardManager` - Clipboard operations
|
||||
- `NotificationManager` - Notification display
|
||||
|
||||
**Fixed Clock:**
|
||||
All tests use a fixed clock for deterministic time-based testing:
|
||||
```kotlin
|
||||
// Tests use consistent time: 2023-10-27T12:00:00Z
|
||||
val fixedClock: Clock
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<MyViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
MyScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(MyAction.BackClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading state should show progress`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
|
||||
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important: bufferedMutableSharedFlow for Events
|
||||
|
||||
In Compose tests, use `bufferedMutableSharedFlow` instead of regular `MutableSharedFlow` (default replay is 0):
|
||||
|
||||
```kotlin
|
||||
// Correct for Compose tests
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
|
||||
// This allows triggering events and having the UI react
|
||||
mutableEventFlow.tryEmit(MyEvent.NavigateBack)
|
||||
```
|
||||
|
||||
## BaseServiceTest
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides MockWebServer setup for testing API service implementations.
|
||||
|
||||
### Automatic Configuration
|
||||
- `server: MockWebServer` - Auto-started before each test, stopped after
|
||||
- `retrofit: Retrofit` - Pre-configured with:
|
||||
- JSON converter (kotlinx.serialization)
|
||||
- NetworkResultCallAdapter for Result<T> responses
|
||||
- Base URL pointing to MockWebServer
|
||||
- `json: Json` - kotlinx.serialization JSON instance
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyServiceTest : BaseServiceTest() {
|
||||
private val api: MyApi = retrofit.create()
|
||||
private val service = MyServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
// Enqueue mock response
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify result
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConfig should return failure when API fails`() = runTest {
|
||||
// Enqueue error response
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify failure
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MockWebServer Patterns
|
||||
|
||||
**Enqueue successful response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))
|
||||
```
|
||||
|
||||
**Enqueue error response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
```
|
||||
|
||||
**Enqueue delayed response:**
|
||||
```kotlin
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody("""{"key": "value"}""")
|
||||
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
|
||||
)
|
||||
```
|
||||
|
||||
**Verify request details:**
|
||||
```kotlin
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/config", request.path)
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("Bearer token", request.getHeader("Authorization"))
|
||||
```
|
||||
|
||||
## BaseComposeTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Base class for Compose tests that extends `BaseRobolectricTest` and provides `setTestContent()` helper.
|
||||
|
||||
### Features
|
||||
- Robolectric configuration for Compose
|
||||
- Proper dispatcher setup
|
||||
- `composeTestRule` for UI testing
|
||||
- `setTestContent()` helper wraps content in theme
|
||||
|
||||
### Usage
|
||||
Typically you'll extend `BitwardenComposeTest` which extends this class. Use `BaseComposeTest` directly only for tests that don't need Bitwarden-specific manager configuration.
|
||||
|
||||
## When to Use Each Base Class
|
||||
|
||||
| Test Type | Base Class | Use When |
|
||||
|-----------|------------|----------|
|
||||
| ViewModel tests | `BaseViewModelTest` | Testing ViewModel state and events |
|
||||
| Compose screen tests | `BitwardenComposeTest` | Testing Compose UI with Bitwarden components |
|
||||
| API service tests | `BaseServiceTest` | Testing network layer with MockWebServer |
|
||||
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
|
||||
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
|
||||
|
||||
## Complete Examples
|
||||
|
||||
**ViewModel Test:**
|
||||
`../examples/viewmodel-test-example.md`
|
||||
|
||||
**Compose Screen Test:**
|
||||
`../examples/compose-screen-test-example.md`
|
||||
|
||||
**Repository Test:**
|
||||
`../examples/repository-test-example.md`
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,27 +9,3 @@
|
||||
## 📸 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
21
.github/label-pr.json
vendored
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"title_patterns": {
|
||||
"t:feature-app": ["feat", "feature"],
|
||||
"t:feature-tool": ["tool"],
|
||||
"t:feature": ["feat", "feature", "tool"],
|
||||
"t:bug": ["fix", "bug", "bugfix"],
|
||||
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
|
||||
"t:docs": ["docs"],
|
||||
"t:ci": ["ci", "build", "chore(ci)"],
|
||||
"t:deps": ["deps"],
|
||||
"t:breaking-change": ["breaking", "breaking-change"],
|
||||
"t:misc": ["misc"]
|
||||
"t:misc": ["misc"],
|
||||
"t:llm": ["llm"]
|
||||
},
|
||||
"path_patterns": {
|
||||
"app:shared": [
|
||||
@@ -28,12 +28,14 @@
|
||||
"app:authenticator": [
|
||||
"authenticator/"
|
||||
],
|
||||
"t:feature-tool": [
|
||||
"t:feature": [
|
||||
"app/src/main/assets/fido2_privileged_community.json",
|
||||
"app/src/main/assets/fido2_privileged_google.json",
|
||||
"testharness/"
|
||||
],
|
||||
"t:feature-app": [
|
||||
"app/src/main/assets/fido2_privileged_community.json",
|
||||
"app/src/main/assets/fido2_privileged_google.json"
|
||||
"t:tech-debt": [
|
||||
"gradle.properties",
|
||||
"keystore/"
|
||||
],
|
||||
"t:ci": [
|
||||
".checkmarx/",
|
||||
@@ -41,7 +43,6 @@
|
||||
"scripts/",
|
||||
"fastlane/",
|
||||
".gradle/",
|
||||
".claude/",
|
||||
"detekt-config.yml"
|
||||
],
|
||||
"t:docs": [
|
||||
@@ -50,8 +51,8 @@
|
||||
"t:deps": [
|
||||
"gradle/"
|
||||
],
|
||||
"t:misc": [
|
||||
"keystore/"
|
||||
"t:llm": [
|
||||
".claude/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
11
.github/release.yml
vendored
11
.github/release.yml
vendored
@@ -6,14 +6,13 @@ changelog:
|
||||
- title: '✨ Community Highlight'
|
||||
labels:
|
||||
- community-pr
|
||||
- title: '🚀 New Features & Enhancements'
|
||||
- title: ':shipit: Feature Development'
|
||||
labels:
|
||||
- t:feature
|
||||
- t:feature-app
|
||||
- t:feature-tool
|
||||
- t:new-feature
|
||||
- t:enhancement
|
||||
- title: ':shipit: Tools'
|
||||
labels:
|
||||
- t:feature-tool
|
||||
- title: '❗ Breaking Changes'
|
||||
labels:
|
||||
- t:breaking-change
|
||||
@@ -26,8 +25,10 @@ changelog:
|
||||
- t:ci
|
||||
- t:docs
|
||||
- t:misc
|
||||
- '*'
|
||||
- title: '📦 Dependency Updates'
|
||||
labels:
|
||||
- dependencies
|
||||
- t:deps
|
||||
- title: '🎨 Other'
|
||||
labels:
|
||||
- '*'
|
||||
|
||||
15
.github/renovate.json
vendored
15
.github/renovate.json
vendored
@@ -3,6 +3,7 @@
|
||||
"extends": [
|
||||
"github>bitwarden/renovate-config"
|
||||
],
|
||||
"ignoreDeps": ["com.bitwarden:sdk-android"],
|
||||
"enabledManagers": [
|
||||
"github-actions",
|
||||
"gradle",
|
||||
@@ -19,20 +20,6 @@
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "gradle minor",
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"matchManagers": [
|
||||
"gradle"
|
||||
],
|
||||
"excludePackageNames": [
|
||||
"com.github.bumptech.glide:compose",
|
||||
"com.bitwarden:sdk-android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "kotlin",
|
||||
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",
|
||||
|
||||
@@ -18,6 +18,7 @@ jobs:
|
||||
workflow_name: "publish-github-release-bwa.yml"
|
||||
credentials_filename: "authenticator_play_store-creds.json"
|
||||
project_type: android
|
||||
make_latest: false
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
|
||||
secrets: inherit
|
||||
|
||||
@@ -19,6 +19,7 @@ jobs:
|
||||
workflow_name: "publish-github-release-bwpm.yml"
|
||||
credentials_filename: "play_creds.json"
|
||||
project_type: android
|
||||
make_latest: true
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
|
||||
secrets: inherit
|
||||
|
||||
64
.github/workflows/sdlc-enforce-labels.yml
vendored
Normal file
64
.github/workflows/sdlc-enforce-labels.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
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
|
||||
10
Gemfile.lock
10
Gemfile.lock
@@ -8,8 +8,8 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1206.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-partitions (1.1213.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
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.212.0)
|
||||
aws-sdk-s3 (1.213.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.4)
|
||||
faraday (1.10.5)
|
||||
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.0)
|
||||
json (2.18.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
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)
|
||||
}
|
||||
|
||||
android {
|
||||
configure<LibraryExtension> {
|
||||
namespace = "com.bitwarden.annotation"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdkBwa.get().toInt()
|
||||
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdkBwa.get().toInt())
|
||||
}
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
@@ -37,6 +40,6 @@ android {
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
}
|
||||
}
|
||||
|
||||
0
annotation/consumer-rules.pro
Normal file
0
annotation/consumer-rules.pro
Normal file
21
annotation/proguard-rules.pro
vendored
Normal file
21
annotation/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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
|
||||
@@ -1,9 +1,10 @@
|
||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
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 dagger.hilt.android.plugin.util.capitalize
|
||||
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
@@ -15,7 +16,6 @@ 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,27 +43,35 @@ val ciProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.x8bit.bitwarden"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
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")
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
configure<ApplicationExtension> {
|
||||
namespace = "com.x8bit.bitwarden"
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.x8bit.bitwarden"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdk.get().toInt())
|
||||
}
|
||||
targetSdk {
|
||||
version = release(libs.versions.targetSdk.get().toInt())
|
||||
}
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
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",
|
||||
@@ -141,39 +149,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
|
||||
outputs
|
||||
.mapNotNull { it as? BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val fileNameWithoutExtension = when (flavorName) {
|
||||
"fdroid" -> "$applicationId-$flavorName"
|
||||
"standard" -> "$applicationId"
|
||||
else -> output.outputFileName.removeExtensionIfPresent(".apk")
|
||||
}
|
||||
|
||||
// Set the APK output filename.
|
||||
output.outputFileName = "$fileNameWithoutExtension.apk"
|
||||
|
||||
val variantName = name
|
||||
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
|
||||
tasks.register(renameTaskName) {
|
||||
group = "build"
|
||||
description = "Renames the bundle files for $variantName variant"
|
||||
doLast {
|
||||
renameFile(
|
||||
"$bundlesDir/$variantName/$namespace-$flavorName-${buildType.name}.aab",
|
||||
"$fileNameWithoutExtension.aab",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Force renaming task to execute after the variant is built.
|
||||
tasks
|
||||
.getByName("bundle${variantName.capitalize()}")
|
||||
.finalizedBy(renameTaskName)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility(libs.versions.jvmTarget.get())
|
||||
targetCompatibility(libs.versions.jvmTarget.get())
|
||||
@@ -200,9 +175,50 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
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 = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +276,8 @@ dependencies {
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.bitwarden.sdk)
|
||||
implementation(libs.bumptech.glide)
|
||||
implementation(libs.bumptech.glide.okhttp)
|
||||
ksp(libs.bumptech.glide.compiler)
|
||||
implementation(libs.google.hilt.android)
|
||||
ksp(libs.google.hilt.compiler)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
@@ -299,18 +317,6 @@ 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>() +
|
||||
|
||||
@@ -84,15 +84,6 @@
|
||||
<data android:host="*.bitwarden.eu" />
|
||||
<data android:pathPattern="/redirect-connector.*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -120,6 +111,35 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Credential Provider Activity for handling passkey and password credential operations.
|
||||
This activity is NOT exported to protect against external apps attempting to extract
|
||||
vault credentials by sending malicious intents. Only our own PendingIntents can
|
||||
launch this activity.
|
||||
|
||||
This is a transparent trampoline activity that launches MainActivity for credential
|
||||
operations and forwards results back to the Credential Manager framework.
|
||||
Uses Theme.Translucent.NoTitleBar for invisibility while allowing normal lifecycle
|
||||
(Theme.NoDisplay requires finish() before onResume(), incompatible with ActivityResult).
|
||||
|
||||
Note: Unlike AuthCallbackActivity, this does NOT use noHistory="true" because it
|
||||
must remain in the back stack to receive the ActivityResult callback from
|
||||
MainActivity. -->
|
||||
<activity
|
||||
android:name=".CredentialProviderActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".AccessibilityActivity"
|
||||
android:exported="false"
|
||||
@@ -183,6 +203,16 @@
|
||||
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
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.iode.firefox",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "C9:96:DA:AB:86:A8:CD:32:53:77:49:A5:EE:1D:C2:F9:84:F2:9D:43:F3:06:7D:2C:0A:54:BF:8B:BF:AB:62:C0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
@@ -12,7 +24,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"
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -815,6 +815,38 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -28,6 +29,7 @@ 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)
|
||||
@@ -45,6 +47,12 @@ class AuthCallbackViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
cookieCallbackResult != null -> {
|
||||
authRepository.setCookieCallbackResult(
|
||||
result = cookieCallbackResult,
|
||||
)
|
||||
}
|
||||
|
||||
webAuthResult != null -> {
|
||||
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
@@ -24,9 +23,6 @@ class BitwardenApplication : Application() {
|
||||
@Inject
|
||||
lateinit var logsManager: LogsManager
|
||||
|
||||
@Inject
|
||||
lateinit var networkConnectionManager: NetworkConnectionManager
|
||||
|
||||
@Inject
|
||||
lateinit var networkConfigManager: NetworkConfigManager
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.app.ComponentCaller
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.util.validate
|
||||
import com.x8bit.bitwarden.data.credentials.BitwardenCredentialProviderService
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* Transparent trampoline activity for handling credential provider operations.
|
||||
*
|
||||
* This activity is declared as `exported="false"` in the manifest to ensure only
|
||||
* our own PendingIntents can launch it. This protects against external apps attempting
|
||||
* to extract vault credentials by sending malicious intents via CredentialManager.
|
||||
*
|
||||
* All credential flows (FIDO2 passkeys, password credentials) are routed through this
|
||||
* activity when triggered by the Android CredentialManager framework via our
|
||||
* [BitwardenCredentialProviderService].
|
||||
*
|
||||
* ## Architecture
|
||||
*
|
||||
* This activity does not host any UI itself. It acts as a trampoline that:
|
||||
* 1. Receives the credential intent from the CredentialManager framework
|
||||
* 2. Sets the pending credential request via [CredentialProviderViewModel], which stores
|
||||
* it in `CredentialProviderRequestManager` for secure relay to [MainViewModel]
|
||||
* 3. Launches [MainActivity] to handle the actual credential UI
|
||||
* 4. Forwards the result back to the CredentialManager framework
|
||||
*
|
||||
* This preserves the single-Activity architecture where all UI is hosted by MainActivity,
|
||||
* while still allowing the CredentialManager framework to receive results properly.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@AndroidEntryPoint
|
||||
class CredentialProviderActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: CredentialProviderViewModel by viewModels()
|
||||
|
||||
/**
|
||||
* Launcher for MainActivity that forwards the result back to Credential Manager.
|
||||
*/
|
||||
private val mainActivityLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
// Forward result back to Credential Manager framework
|
||||
setResult(result.resultCode, result.data)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent = intent.validate()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
// Process credential intent (sets pending request on CredentialProviderRequestManager)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveFirstIntent(intent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
// On restoration (process death), result comes via mainActivityLauncher callback
|
||||
}
|
||||
|
||||
private fun launchMainActivityForResult() {
|
||||
val mainIntent = Intent(this, MainActivity::class.java).apply {
|
||||
// Pending credential request is retrieved by MainViewModel from
|
||||
// CredentialProviderRequestManager, triggering appropriate navigation.
|
||||
// CredentialProviderCompletionManager handles setResult/finish.
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
mainActivityLauncher.launch(mainIntent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
val newIntent = intent.validate()
|
||||
super.onNewIntent(newIntent)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
|
||||
val newIntent = intent.validate()
|
||||
super.onNewIntent(newIntent, caller)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.content.Intent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
|
||||
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A view model that handles credential provider operations for [CredentialProviderActivity].
|
||||
*
|
||||
* This ViewModel processes credential-related intents and sets the pending credential request
|
||||
* on [CredentialProviderRequestManager] for relay to [MainViewModel]. This ensures credential
|
||||
* data is never passed through intent extras to exported activities, providing security
|
||||
* hardening against malicious intent attacks.
|
||||
*
|
||||
* Since [CredentialProviderActivity] is a transparent trampoline with no UI, this ViewModel only
|
||||
* handles intent processing. All UI state management (theme, feature flags, auth flows) is
|
||||
* handled by [MainActivity].
|
||||
*
|
||||
* @see RootNavViewModel for navigation based on SpecialCircumstance.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CredentialProviderViewModel @Inject constructor(
|
||||
private val credentialProviderRequestManager: CredentialProviderRequestManager,
|
||||
private val authRepository: AuthRepository,
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
) : BaseViewModel<Unit, Unit, CredentialProviderAction>(initialState = Unit) {
|
||||
|
||||
override fun handleAction(action: CredentialProviderAction) {
|
||||
when (action) {
|
||||
is CredentialProviderAction.ReceiveFirstIntent -> handleIntent(action.intent)
|
||||
is CredentialProviderAction.ReceiveNewIntent -> handleIntent(action.intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
intent.getCreateCredentialRequestOrNull()?.let { handleCreateCredential(it) }
|
||||
?: intent.getFido2AssertionRequestOrNull()?.let { handleFido2Assertion(it) }
|
||||
?: intent.getProviderGetPasswordRequestOrNull()?.let { handlePasswordGet(it) }
|
||||
?: intent.getGetCredentialsRequestOrNull()?.let { handleGetCredentials(it) }
|
||||
}
|
||||
|
||||
private fun handleCreateCredential(request: CreateCredentialRequest) {
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
// Switch accounts if the selected user is not the active user
|
||||
if (authRepository.activeUserId != null &&
|
||||
authRepository.activeUserId != request.userId
|
||||
) {
|
||||
authRepository.switchAccount(request.userId)
|
||||
}
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.CreateCredential(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleFido2Assertion(request: Fido2CredentialAssertionRequest) {
|
||||
// Set the user's verification status when a new FIDO 2 request is received
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.Fido2Assertion(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePasswordGet(request: ProviderGetPasswordCredentialRequest) {
|
||||
// Set the user's verification status when a new GetPassword request is received
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.GetPassword(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleGetCredentials(request: GetCredentialsRequest) {
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.GetCredentials(request),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the [CredentialProviderViewModel].
|
||||
*/
|
||||
sealed class CredentialProviderAction {
|
||||
|
||||
/**
|
||||
* Receive the first intent when the activity is created.
|
||||
*/
|
||||
data class ReceiveFirstIntent(val intent: Intent) : CredentialProviderAction()
|
||||
|
||||
/**
|
||||
* Receive a new intent when the activity receives onNewIntent.
|
||||
*/
|
||||
data class ReceiveNewIntent(val intent: Intent) : CredentialProviderAction()
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
@@ -33,6 +35,8 @@ 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
|
||||
@@ -80,6 +84,10 @@ class MainActivity : AppCompatActivity() {
|
||||
mainViewModel.trySendAction(MainAction.WebAuthnResult(it))
|
||||
}
|
||||
|
||||
private val cookieLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
|
||||
mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent = intent.validate()
|
||||
var shouldShowSplashScreen = true
|
||||
@@ -106,6 +114,7 @@ class MainActivity : AppCompatActivity() {
|
||||
duo = duoLauncher,
|
||||
sso = ssoLauncher,
|
||||
webAuthn = webAuthnLauncher,
|
||||
cookie = cookieLauncher,
|
||||
),
|
||||
) {
|
||||
ObserveScreenDataEffect(
|
||||
@@ -120,15 +129,22 @@ class MainActivity : AppCompatActivity() {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = RootNavigationRoute,
|
||||
modifier = Modifier
|
||||
.background(color = BitwardenTheme.colorScheme.background.primary),
|
||||
) {
|
||||
// Both root navigation and debug menu exist at this top level.
|
||||
// The debug menu can appear on top of the rest of the app without
|
||||
// interacting with the state-based navigation used by RootNavScreen.
|
||||
// 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.
|
||||
rootNavDestination { shouldShowSplashScreen = false }
|
||||
debugMenuDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
cookieAcquisitionDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +218,8 @@ 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),
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.bitwarden.vault.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResult
|
||||
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.getWebAuthResult
|
||||
@@ -26,12 +27,10 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.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
|
||||
@@ -54,6 +53,7 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -77,10 +77,11 @@ 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 bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
private val credentialProviderRequestManager: CredentialProviderRequestManager,
|
||||
private val shareManager: ShareManager,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
@@ -164,6 +165,13 @@ class MainViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
cookieAcquisitionRequestManager
|
||||
.cookieAcquisitionRequestFlow
|
||||
.filterNotNull()
|
||||
.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 {
|
||||
@@ -188,6 +196,7 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.DuoResult -> handleDuoResult(action)
|
||||
is MainAction.SsoResult -> handleSsoResult(action)
|
||||
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
|
||||
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
|
||||
is MainAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +218,7 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +240,12 @@ class MainViewModel @Inject constructor(
|
||||
authRepository.setWebAuthResult(webAuthResult = action.authResult.getWebAuthResult())
|
||||
}
|
||||
|
||||
private fun handleCookieAcquisitionResult(action: MainAction.CookieAcquisitionResult) {
|
||||
authRepository.setCookieCallbackResult(
|
||||
result = action.cookieCallbackResult.getCookieCallbackResult(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
|
||||
when (val data = action.screenResumeData) {
|
||||
null -> appResumeManager.clearResumeScreen()
|
||||
@@ -273,6 +289,10 @@ 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,
|
||||
@@ -314,11 +334,9 @@ class MainViewModel @Inject constructor(
|
||||
val hasVaultShortcut = intent.isMyVaultShortcut
|
||||
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
|
||||
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
|
||||
val createCredentialRequest = intent.getCreateCredentialRequestOrNull()
|
||||
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
|
||||
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
|
||||
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
|
||||
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
|
||||
val credentialProviderRequest =
|
||||
credentialProviderRequestManager.getPendingCredentialRequest()
|
||||
when {
|
||||
passwordlessRequestData != null -> {
|
||||
authRepository.activeUserId?.let {
|
||||
@@ -376,59 +394,6 @@ class MainViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
createCredentialRequest != null -> {
|
||||
// Set the user's verification status when a new FIDO 2 request is received to force
|
||||
// explicit verification if the user's vault is unlocked when the request is
|
||||
// received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
createCredentialRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderCreateCredential(
|
||||
createCredentialRequest = createCredentialRequest,
|
||||
)
|
||||
|
||||
// Switch accounts if the selected user is not the active user.
|
||||
if (authRepository.activeUserId != null &&
|
||||
authRepository.activeUserId != createCredentialRequest.userId
|
||||
) {
|
||||
authRepository.switchAccount(createCredentialRequest.userId)
|
||||
}
|
||||
}
|
||||
|
||||
fido2AssertCredentialRequest != null -> {
|
||||
// Set the user's verification status when a new FIDO 2 request is received to force
|
||||
// explicit verification if the user's vault is unlocked when the request is
|
||||
// received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
fido2AssertCredentialRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.Fido2Assertion(
|
||||
fido2AssertionRequest = fido2AssertCredentialRequest,
|
||||
)
|
||||
}
|
||||
|
||||
providerGetPasswordRequest != null -> {
|
||||
// Set the user's verification status when a new GetPassword request is
|
||||
// received to force explicit verification if the user's vault is
|
||||
// unlocked when the request is received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
providerGetPasswordRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderGetPasswordRequest(
|
||||
passwordGetRequest = providerGetPasswordRequest,
|
||||
)
|
||||
}
|
||||
|
||||
getCredentialsRequest != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderGetCredentials(
|
||||
getCredentialsRequest = getCredentialsRequest,
|
||||
)
|
||||
}
|
||||
|
||||
hasGeneratorShortcut -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.GeneratorShortcut
|
||||
@@ -448,10 +413,52 @@ class MainViewModel @Inject constructor(
|
||||
SpecialCircumstance.CredentialExchangeExport(
|
||||
data = ImportCredentialsRequestData(
|
||||
uri = importCredentialsRequest.uri,
|
||||
requestJson = importCredentialsRequest.request.requestJson,
|
||||
credentialTypes = importCredentialsRequest.request.credentialTypes,
|
||||
knownExtensions = importCredentialsRequest.request.knownExtensions,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +541,13 @@ sealed class MainAction {
|
||||
*/
|
||||
data class WebAuthnResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive the result from the cookie acquisition flow.
|
||||
*/
|
||||
data class CookieAcquisitionResult(
|
||||
val cookieCallbackResult: AuthTabIntent.AuthResult,
|
||||
) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive first Intent by the application.
|
||||
*/
|
||||
@@ -604,6 +618,12 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +653,11 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents the current account information for a given user.
|
||||
@@ -103,7 +103,7 @@ data class AccountJson(
|
||||
|
||||
@SerialName("creationDate")
|
||||
@Contextual
|
||||
val creationDate: ZonedDateTime?,
|
||||
val creationDate: Instant?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,7 +128,6 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
clearPendingAuthRequest()
|
||||
@@ -199,7 +198,6 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
isComplete = true
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents a Login Approval request.
|
||||
@@ -27,8 +27,8 @@ data class AuthRequest(
|
||||
val ipAddress: String,
|
||||
val key: String?,
|
||||
val masterPasswordHash: String?,
|
||||
val creationDate: ZonedDateTime,
|
||||
val responseDate: ZonedDateTime?,
|
||||
val creationDate: Instant,
|
||||
val responseDate: Instant?,
|
||||
val requestApproved: Boolean,
|
||||
val originUrl: String,
|
||||
val fingerprint: String,
|
||||
|
||||
@@ -12,7 +12,7 @@ sealed class CreateAuthRequestResult {
|
||||
) : CreateAuthRequestResult()
|
||||
|
||||
/**
|
||||
* Models the data returned when a auth request has been approved.
|
||||
* Models the data returned when an auth request has been approved.
|
||||
*/
|
||||
data class Success(
|
||||
val authRequest: AuthRequest,
|
||||
@@ -21,7 +21,7 @@ sealed class CreateAuthRequestResult {
|
||||
) : CreateAuthRequestResult()
|
||||
|
||||
/**
|
||||
* There was a generic error getting the user's auth requests.
|
||||
* There was a generic error creating the auth request.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
@@ -17,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
@@ -34,6 +34,7 @@ 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
|
||||
@@ -44,7 +45,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Provides an API for observing an modifying authentication state.
|
||||
* Provides an API for observing and modifying authentication state.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface AuthRepository :
|
||||
@@ -70,6 +71,12 @@ 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.
|
||||
@@ -126,7 +133,7 @@ interface AuthRepository :
|
||||
/**
|
||||
* The organization for the active user.
|
||||
*/
|
||||
val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
val organizations: List<Organization>
|
||||
|
||||
/**
|
||||
* Whether or not the welcome carousel should be displayed, based on the feature flag and
|
||||
@@ -283,7 +290,7 @@ interface AuthRepository :
|
||||
): PasswordHintResult
|
||||
|
||||
/**
|
||||
* Removes the users password from the account. This used used when migrating from master
|
||||
* Removes the users password from the account. This is used when migrating from master
|
||||
* password login to key connector login.
|
||||
*/
|
||||
suspend fun removePassword(masterPassword: String): RemovePasswordResult
|
||||
@@ -342,6 +349,11 @@ 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.
|
||||
*/
|
||||
@@ -385,7 +397,7 @@ interface AuthRepository :
|
||||
): SendVerificationEmailResult
|
||||
|
||||
/**
|
||||
* Validates the given [token] for the given [email]. Part of th new account registration flow.
|
||||
* Validates the given [token] for the given [email]. Part of the new account registration flow.
|
||||
*/
|
||||
suspend fun validateEmailToken(
|
||||
email: String,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
@@ -47,6 +49,7 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
|
||||
import com.bitwarden.network.service.IdentityService
|
||||
import com.bitwarden.network.service.OrganizationService
|
||||
import com.bitwarden.network.util.isSslHandShakeError
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
||||
@@ -72,6 +75,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
@@ -91,11 +95,13 @@ 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
|
||||
@@ -170,6 +176,7 @@ class AuthRepositoryImpl(
|
||||
private val policyManager: PolicyManager,
|
||||
private val userStateManager: UserStateManager,
|
||||
private val kdfManager: KdfManager,
|
||||
private val toastManager: ToastManager,
|
||||
logsManager: LogsManager,
|
||||
pushManager: PushManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
@@ -263,6 +270,10 @@ 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
|
||||
@@ -288,8 +299,11 @@ class AuthRepositoryImpl(
|
||||
?.profile
|
||||
?.forcePasswordResetReason
|
||||
|
||||
override val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
|
||||
override val organizations: List<Organization>
|
||||
get() = activeUserId
|
||||
?.let { authDiskSource.getOrganizations(it) }
|
||||
.orEmpty()
|
||||
.toOrganizations()
|
||||
|
||||
override val showWelcomeCarousel: Boolean
|
||||
get() = !settingsRepository.hasUserLoggedInOrCreatedAccount
|
||||
@@ -724,18 +738,27 @@ class AuthRepositoryImpl(
|
||||
when (refreshTokenResponse) {
|
||||
is RefreshTokenResponseJson.Error -> {
|
||||
if (refreshTokenResponse.isInvalidGrant) {
|
||||
logout(userId = userId, reason = LogoutReason.InvalidGrant)
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.InvalidGrant,
|
||||
)
|
||||
}
|
||||
IllegalStateException(refreshTokenResponse.error).asFailure()
|
||||
}
|
||||
|
||||
is RefreshTokenResponseJson.Forbidden -> {
|
||||
logout(userId = userId, reason = LogoutReason.RefreshForbidden)
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.RefreshForbidden,
|
||||
)
|
||||
refreshTokenResponse.error.asFailure()
|
||||
}
|
||||
|
||||
is RefreshTokenResponseJson.Unauthorized -> {
|
||||
logout(userId = userId, reason = LogoutReason.RefreshUnauthorized)
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.RefreshUnauthorized,
|
||||
)
|
||||
refreshTokenResponse.error.asFailure()
|
||||
}
|
||||
|
||||
@@ -975,8 +998,8 @@ class AuthRepositoryImpl(
|
||||
val keyConnectorUrl = organizations
|
||||
.find {
|
||||
it.shouldUseKeyConnector &&
|
||||
it.type != OrganizationType.OWNER &&
|
||||
it.type != OrganizationType.ADMIN
|
||||
it.role != OrganizationType.OWNER &&
|
||||
it.role != OrganizationType.ADMIN
|
||||
}
|
||||
?.keyConnectorUrl
|
||||
?: return RemovePasswordResult.Error(
|
||||
@@ -1038,9 +1061,10 @@ class AuthRepositoryImpl(
|
||||
onSuccess = { it },
|
||||
)
|
||||
}
|
||||
val userId = activeAccount.profile.userId
|
||||
return vaultSdkSource
|
||||
.updatePassword(
|
||||
userId = activeAccount.profile.userId,
|
||||
userId = userId,
|
||||
newPassword = newPassword,
|
||||
)
|
||||
.flatMap { updatePasswordResponse ->
|
||||
@@ -1066,14 +1090,15 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.onSuccess { passwordHash ->
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = activeAccount.profile.userId,
|
||||
userId = userId,
|
||||
passwordHash = passwordHash,
|
||||
)
|
||||
}
|
||||
|
||||
toastManager.show(BitwardenString.updated_master_password)
|
||||
// Log out the user after successful password reset.
|
||||
// This clears all user state including forcePasswordResetReason.
|
||||
logout(reason = LogoutReason.PasswordReset)
|
||||
logout(reason = LogoutReason.PasswordReset, userId = userId)
|
||||
|
||||
// Return the success.
|
||||
ResetPasswordResult.Success
|
||||
@@ -1238,6 +1263,10 @@ class AuthRepositoryImpl(
|
||||
mutableSsoCallbackResultFlow.tryEmit(result)
|
||||
}
|
||||
|
||||
override fun setCookieCallbackResult(result: CookieCallbackResult) {
|
||||
mutableCookieCallbackResultFlow.tryEmit(result)
|
||||
}
|
||||
|
||||
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
|
||||
devicesService
|
||||
.getIsKnownDevice(
|
||||
@@ -1563,6 +1592,7 @@ class AuthRepositoryImpl(
|
||||
): LoginResult = identityService
|
||||
.getToken(
|
||||
uniqueAppId = authDiskSource.uniqueAppId,
|
||||
deeplinkScheme = environmentRepository.environment.environmentUrlData.appLinksScheme,
|
||||
email = email,
|
||||
authModel = authModel,
|
||||
twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.di
|
||||
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.toast.ToastManager
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.network.service.AccountsService
|
||||
import com.bitwarden.network.service.DevicesService
|
||||
@@ -71,6 +72,7 @@ object AuthRepositoryModule {
|
||||
logsManager: LogsManager,
|
||||
userStateManager: UserStateManager,
|
||||
kdfManager: KdfManager,
|
||||
toastManager: ToastManager,
|
||||
): AuthRepository = AuthRepositoryImpl(
|
||||
clock = clock,
|
||||
accountsService = accountsService,
|
||||
@@ -97,6 +99,7 @@ object AuthRepositoryModule {
|
||||
logsManager = logsManager,
|
||||
userStateManager = userStateManager,
|
||||
kdfManager = kdfManager,
|
||||
toastManager = toastManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -14,14 +14,16 @@ import com.bitwarden.network.model.OrganizationType
|
||||
* @property keyConnectorUrl The key connector domain (if applicable).
|
||||
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
|
||||
* @property limitItemDeletion Indicates that the organization limits item deletion.
|
||||
* @property shouldUseEvents Indicates if the organization uses tracking events.
|
||||
*/
|
||||
data class Organization(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val name: String,
|
||||
val shouldManageResetPassword: Boolean,
|
||||
val shouldUseKeyConnector: Boolean,
|
||||
val role: OrganizationType,
|
||||
val keyConnectorUrl: String?,
|
||||
val userIsClaimedByOrganization: Boolean,
|
||||
val limitItemDeletion: Boolean = false,
|
||||
val limitItemDeletion: Boolean,
|
||||
val shouldUseEvents: Boolean,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
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
|
||||
return uri.getCookieCallbackResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a [CookieCallbackResult] from an [AuthTabIntent.AuthResult]. There are two possible
|
||||
* cases.
|
||||
*
|
||||
* - [CookieCallbackResult.Success]: The URI is the cookie callback with correct data.
|
||||
* - [CookieCallbackResult.MissingCookie]: The URI is the cookie callback with incorrect data or a
|
||||
* failure has occurred.
|
||||
*/
|
||||
fun AuthTabIntent.AuthResult.getCookieCallbackResult(): CookieCallbackResult =
|
||||
when (this.resultCode) {
|
||||
AuthTabIntent.RESULT_OK -> this.resultUri.getCookieCallbackResult()
|
||||
AuthTabIntent.RESULT_CANCELED -> CookieCallbackResult.MissingCookie
|
||||
AuthTabIntent.RESULT_UNKNOWN_CODE -> CookieCallbackResult.MissingCookie
|
||||
AuthTabIntent.RESULT_VERIFICATION_FAILED -> CookieCallbackResult.MissingCookie
|
||||
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> CookieCallbackResult.MissingCookie
|
||||
else -> CookieCallbackResult.MissingCookie
|
||||
}
|
||||
|
||||
private fun Uri?.getCookieCallbackResult(): CookieCallbackResult {
|
||||
if (this == null) return CookieCallbackResult.MissingCookie
|
||||
val cookies = queryParameterNames
|
||||
.asSequence()
|
||||
.filter { it != COMPLETENESS_MARKER_PARAM }
|
||||
.mapNotNull { name ->
|
||||
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()
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import android.net.Uri
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "duo-callback"
|
||||
@@ -34,9 +33,7 @@ fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -11,31 +11,31 @@ import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
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(SSO_URI, "UTF-8")
|
||||
val redirectUri = URLEncoder.encode(redirectUrl, "UTF-8")
|
||||
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
|
||||
val encodedToken = URLEncoder.encode(token, "UTF-8")
|
||||
|
||||
@@ -81,9 +81,7 @@ fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -13,26 +13,30 @@ private val JSON = Json {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization].
|
||||
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization] or `null` if the
|
||||
* [SyncResponseJson.Profile.Organization.name] is not present.
|
||||
*/
|
||||
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
|
||||
Organization(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
|
||||
limitItemDeletion = this.limitItemDeletion,
|
||||
)
|
||||
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
|
||||
this.name?.let {
|
||||
Organization(
|
||||
id = this.id,
|
||||
name = it,
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
|
||||
limitItemDeletion = this.limitItemDeletion,
|
||||
shouldUseEvents = this.shouldUseEvents,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
|
||||
* [Organization]s.
|
||||
*/
|
||||
fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organization> =
|
||||
this.map { it.toOrganization() }
|
||||
this.mapNotNull { it.toOrganization() }
|
||||
|
||||
/**
|
||||
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.
|
||||
|
||||
@@ -5,20 +5,18 @@ 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 const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
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.
|
||||
*
|
||||
@@ -39,9 +37,7 @@ fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
@@ -79,29 +75,33 @@ 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(CALLBACK_URI, "UTF-8")
|
||||
val parentParam = URLEncoder.encode(authTabData.callbackUrl, "UTF-8")
|
||||
val url = baseUrl +
|
||||
"/webauthn-mobile-connector.html" +
|
||||
"?data=$base64Data" +
|
||||
"&parent=$parentParam" +
|
||||
"&v=2"
|
||||
"&client=mobile" +
|
||||
"&v=2" +
|
||||
"&deeplinkScheme=${authTabData.callbackScheme}"
|
||||
return url.toUri()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ class AutofillActivityManagerImpl(
|
||||
braveStableStatusData = browserThirdPartyAutofillManager.stableBraveAutofillStatus,
|
||||
chromeStableStatusData = browserThirdPartyAutofillManager.stableChromeAutofillStatus,
|
||||
chromeBetaChannelStatusData = browserThirdPartyAutofillManager.betaChromeAutofillStatus,
|
||||
vivaldiStableChannelStatusData = browserThirdPartyAutofillManager
|
||||
.stableVivaldiAutofillStatus,
|
||||
defaultBrowserPackageName = browserThirdPartyAutofillManager
|
||||
.defaultBrowserPackageName,
|
||||
)
|
||||
|
||||
init {
|
||||
|
||||
@@ -29,7 +29,7 @@ internal class BrowserAutofillDialogManagerImpl(
|
||||
get() = autofillEnabledManager.isAutofillEnabled &&
|
||||
browserThirdPartyAutofillEnabledManager
|
||||
.browserThirdPartyAutofillStatus
|
||||
.isAnyIsAvailableAndDisabled &&
|
||||
.isDefaultBrowserAvailableAndDisabled &&
|
||||
!firstTimeActionManager
|
||||
.currentOrDefaultUserFirstTimeState
|
||||
.showSetupBrowserAutofillCard &&
|
||||
|
||||
@@ -39,4 +39,9 @@ private val DEFAULT_STATUS = BrowserThirdPartyAutofillStatus(
|
||||
isAvailable = false,
|
||||
isThirdPartyEnabled = false,
|
||||
),
|
||||
vivaldiStableChannelStatusData = BrowserThirdPartyAutoFillData(
|
||||
isAvailable = false,
|
||||
isThirdPartyEnabled = false,
|
||||
),
|
||||
defaultBrowserPackageName = null,
|
||||
)
|
||||
|
||||
@@ -22,4 +22,14 @@ 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?
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ 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
|
||||
@@ -27,6 +30,18 @@ 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,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.
|
||||
@@ -13,4 +14,5 @@ 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),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val braveStableStatusData: BrowserThirdPartyAutoFillData,
|
||||
val chromeStableStatusData: BrowserThirdPartyAutoFillData,
|
||||
val chromeBetaChannelStatusData: BrowserThirdPartyAutoFillData,
|
||||
val vivaldiStableChannelStatusData: BrowserThirdPartyAutoFillData,
|
||||
val defaultBrowserPackageName: String?,
|
||||
) {
|
||||
/**
|
||||
* The total number of available browsers.
|
||||
@@ -24,7 +26,8 @@ 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 (chromeBetaChannelStatusData.isAvailable) 1 else 0) +
|
||||
(if (vivaldiStableChannelStatusData.isAvailable) 1 else 0)
|
||||
|
||||
/**
|
||||
* Whether any of the available browsers have third party autofill disabled.
|
||||
@@ -32,5 +35,28 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val isAnyIsAvailableAndDisabled: Boolean
|
||||
get() = braveStableStatusData.isAvailableButDisabled ||
|
||||
chromeStableStatusData.isAvailableButDisabled ||
|
||||
chromeBetaChannelStatusData.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,21 @@ 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",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,16 +7,19 @@ import com.bitwarden.vault.CopyableCipherFields
|
||||
import com.bitwarden.vault.LoginListView
|
||||
|
||||
/**
|
||||
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
|
||||
* Returns true when the cipher is not archived, not deleted and contains at least one FIDO 2
|
||||
* credential.
|
||||
*/
|
||||
val CipherListView.isActiveWithFido2Credentials: Boolean
|
||||
get() = deletedDate == null && login?.hasFido2 ?: false
|
||||
get() = archivedDate == null && deletedDate == null && login?.hasFido2 ?: false
|
||||
|
||||
/**
|
||||
* Returns true when the cipher type is not deleted and contains a copyable password.
|
||||
* Returns true when the cipher type is not archived, not deleted and contains a copyable password.
|
||||
*/
|
||||
val CipherListView.isActiveWithCopyablePassword: Boolean
|
||||
get() = deletedDate == null && copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
|
||||
get() = archivedDate == null &&
|
||||
deletedDate == null &&
|
||||
copyableFields.contains(CopyableCipherFields.LOGIN_PASSWORD)
|
||||
|
||||
/**
|
||||
* Returns the [LoginListView] if the cipher is of type [CipherListViewType.Login], otherwise null.
|
||||
|
||||
@@ -48,13 +48,17 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
|
||||
* Returns true when the cipher is not archived, not deleted and contains at least one FIDO 2
|
||||
* credential.
|
||||
*/
|
||||
val CipherView.isActiveWithFido2Credentials: Boolean
|
||||
get() = deletedDate == null && !(login?.fido2Credentials.isNullOrEmpty())
|
||||
get() = archivedDate == null &&
|
||||
deletedDate == null &&
|
||||
!(login?.fido2Credentials.isNullOrEmpty())
|
||||
|
||||
/**
|
||||
* Returns true when the cipher is not deleted and contains at least one Pasword credential.
|
||||
* Returns true when the cipher is not archived, not deleted and contains at least one Password
|
||||
* credential.
|
||||
*/
|
||||
val CipherView.isActiveWithPasswordCredentials: Boolean
|
||||
get() = deletedDate == null && !(login?.password.isNullOrEmpty())
|
||||
get() = archivedDate == null && deletedDate == null && !(login?.password.isNullOrEmpty())
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialManagerPendingIntentManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.manager.OriginManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.OriginManagerImpl
|
||||
import com.x8bit.bitwarden.data.credentials.parser.RelyingPartyParser
|
||||
@@ -145,4 +147,9 @@ object CredentialProviderModule {
|
||||
@Singleton
|
||||
fun providePasskeyAttestationOptionsSanitizer(): PasskeyAttestationOptionsSanitizer =
|
||||
PasskeyAttestationOptionsSanitizerImpl
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialProviderRequestManager(): CredentialProviderRequestManager =
|
||||
CredentialProviderRequestManagerImpl()
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
|
||||
private const val DAL_ROUTE = ".well-known/assetlinks.json"
|
||||
|
||||
/**
|
||||
* Primary implementation of [BitwardenCredentialManager].
|
||||
*/
|
||||
@@ -123,7 +125,7 @@ class BitwardenCredentialManagerImpl(
|
||||
.getSignatureFingerprintAsHexString()
|
||||
.orEmpty(),
|
||||
host = hostUrl,
|
||||
assetLinkUrl = hostUrl,
|
||||
assetLinkUrl = hostUrl.toDigitalAssetLinkUrl(),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -316,7 +318,7 @@ class BitwardenCredentialManagerImpl(
|
||||
packageName = callingAppInfo.packageName,
|
||||
sha256CertFingerprint = signatureFingerprint,
|
||||
host = host,
|
||||
assetLinkUrl = host,
|
||||
assetLinkUrl = host.toDigitalAssetLinkUrl(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -428,6 +430,13 @@ class BitwardenCredentialManagerImpl(
|
||||
?.relyingParty
|
||||
?.id
|
||||
?.prefixHttpsIfNecessaryOrNull()
|
||||
|
||||
private fun String.toDigitalAssetLinkUrl(): String =
|
||||
when {
|
||||
this.endsWith(DAL_ROUTE) -> this
|
||||
this.endsWith("/") -> "$this$DAL_ROUTE"
|
||||
else -> "$this/$DAL_ROUTE"
|
||||
}
|
||||
}
|
||||
|
||||
private const val MAX_AUTHENTICATION_ATTEMPTS = 5
|
||||
|
||||
@@ -23,6 +23,7 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(CREATE_PASSKEY_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
@@ -44,6 +45,7 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(GET_PASSKEY_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
.putExtra(EXTRA_KEY_CREDENTIAL_ID, credentialId)
|
||||
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
|
||||
@@ -65,6 +67,7 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(UNLOCK_ACCOUNT_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
@@ -83,6 +86,7 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(CREATE_PASSWORD_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
|
||||
return PendingIntent.getActivity(
|
||||
@@ -103,6 +107,7 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
): PendingIntent {
|
||||
val intent = Intent(GET_PASSWORD_ACTION)
|
||||
.setPackage(context.packageName)
|
||||
.setClass(context, CREDENTIAL_ACTIVITY_CLASS)
|
||||
.putExtra(EXTRA_KEY_USER_ID, userId)
|
||||
.putExtra(EXTRA_KEY_CIPHER_ID, cipherId)
|
||||
.putExtra(EXTRA_KEY_UV_PERFORMED_DURING_UNLOCK, isUserVerified)
|
||||
@@ -116,6 +121,7 @@ class CredentialManagerPendingIntentManagerImpl(
|
||||
}
|
||||
}
|
||||
|
||||
private val CREDENTIAL_ACTIVITY_CLASS = com.x8bit.bitwarden.CredentialProviderActivity::class.java
|
||||
private const val CREATE_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY"
|
||||
private const val UNLOCK_ACCOUNT_ACTION = "com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT"
|
||||
private const val GET_PASSKEY_ACTION = "com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.x8bit.bitwarden.data.credentials.manager
|
||||
|
||||
import com.x8bit.bitwarden.CredentialProviderActivity
|
||||
import com.x8bit.bitwarden.MainActivity
|
||||
import com.x8bit.bitwarden.MainViewModel
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
|
||||
/**
|
||||
* Manages pending credential provider requests, relaying them from [CredentialProviderActivity]
|
||||
* to [MainActivity] via a pull-based pattern.
|
||||
*
|
||||
* This approach ensures credential data is never passed through intent extras to
|
||||
* exported activities. [CredentialProviderActivity] sets the request, then [MainViewModel]
|
||||
* retrieves it once when handling the incoming intent.
|
||||
*/
|
||||
interface CredentialProviderRequestManager {
|
||||
/**
|
||||
* Set a pending credential request.
|
||||
*/
|
||||
fun setPendingCredentialRequest(request: CredentialProviderRequest)
|
||||
|
||||
/**
|
||||
* Get and clear the pending credential request. Returns null if no request is pending.
|
||||
*/
|
||||
fun getPendingCredentialRequest(): CredentialProviderRequest?
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.x8bit.bitwarden.data.credentials.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Primary implementation of [CredentialProviderRequestManager].
|
||||
*
|
||||
* Uses an [AtomicReference] for thread-safe get-and-clear semantics, ensuring
|
||||
* the pending request is only processed once.
|
||||
*/
|
||||
@Singleton
|
||||
class CredentialProviderRequestManagerImpl : CredentialProviderRequestManager {
|
||||
|
||||
private val pendingRequest = AtomicReference<CredentialProviderRequest?>(null)
|
||||
|
||||
override fun setPendingCredentialRequest(request: CredentialProviderRequest) {
|
||||
pendingRequest.set(request)
|
||||
}
|
||||
|
||||
override fun getPendingCredentialRequest(): CredentialProviderRequest? {
|
||||
return pendingRequest.getAndSet(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.x8bit.bitwarden.data.credentials.manager.model
|
||||
|
||||
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
|
||||
|
||||
/**
|
||||
* Represents a pending credential provider request to be processed by MainActivity.
|
||||
*/
|
||||
sealed class CredentialProviderRequest {
|
||||
/**
|
||||
* Request to create a new FIDO2 passkey credential.
|
||||
*/
|
||||
data class CreateCredential(
|
||||
val request: CreateCredentialRequest,
|
||||
) : CredentialProviderRequest()
|
||||
|
||||
/**
|
||||
* Request to assert (authenticate with) an existing FIDO2 passkey.
|
||||
*/
|
||||
data class Fido2Assertion(
|
||||
val request: Fido2CredentialAssertionRequest,
|
||||
) : CredentialProviderRequest()
|
||||
|
||||
/**
|
||||
* Request to retrieve a password credential.
|
||||
*/
|
||||
data class GetPassword(
|
||||
val request: ProviderGetPasswordCredentialRequest,
|
||||
) : CredentialProviderRequest()
|
||||
|
||||
/**
|
||||
* Request to get available credentials (BeginGetCredential flow).
|
||||
*/
|
||||
data class GetCredentials(
|
||||
val request: GetCredentialsRequest,
|
||||
) : CredentialProviderRequest()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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?)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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) })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Primary access point for push notification information.
|
||||
@@ -25,7 +25,7 @@ interface PushDiskSource {
|
||||
/**
|
||||
* Retrieves the last time a push token was registered for a user.
|
||||
*/
|
||||
fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime?
|
||||
fun getLastPushTokenRegistrationDate(userId: String): Instant?
|
||||
|
||||
/**
|
||||
* Sets the current token for a user.
|
||||
@@ -35,5 +35,5 @@ interface PushDiskSource {
|
||||
/**
|
||||
* Sets the last push token registration date for a user.
|
||||
*/
|
||||
fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: ZonedDateTime?)
|
||||
fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: Instant?)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.core.util.getBinaryLongFromZoneDateTime
|
||||
import com.bitwarden.core.util.getZoneDateTimeFromBinaryLong
|
||||
import com.bitwarden.core.util.getBinaryLongFromInstant
|
||||
import com.bitwarden.core.util.getInstantFromBinaryLong
|
||||
import com.bitwarden.data.datasource.disk.BaseDiskSource
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
private const val CURRENT_PUSH_TOKEN_KEY = "pushCurrentToken"
|
||||
private const val LAST_REGISTRATION_DATE_KEY = "pushLastRegistrationDate"
|
||||
@@ -35,9 +35,9 @@ class PushDiskSourceImpl(
|
||||
return getString(CURRENT_PUSH_TOKEN_KEY.appendIdentifier(userId))
|
||||
}
|
||||
|
||||
override fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? {
|
||||
override fun getLastPushTokenRegistrationDate(userId: String): Instant? {
|
||||
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId))
|
||||
?.let { getZoneDateTimeFromBinaryLong(it) }
|
||||
?.let { getInstantFromBinaryLong(it) }
|
||||
}
|
||||
|
||||
override fun storeCurrentPushToken(userId: String, pushToken: String?) {
|
||||
@@ -49,11 +49,11 @@ class PushDiskSourceImpl(
|
||||
|
||||
override fun storeLastPushTokenRegistrationDate(
|
||||
userId: String,
|
||||
registrationDate: ZonedDateTime?,
|
||||
registrationDate: Instant?,
|
||||
) {
|
||||
putLong(
|
||||
key = LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId),
|
||||
value = registrationDate?.let { getBinaryLongFromZoneDateTime(registrationDate) },
|
||||
value = registrationDate?.let { getBinaryLongFromInstant(registrationDate) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
|
||||
|
||||
/**
|
||||
* Room database for storing any persisted data for platform data.
|
||||
@@ -21,7 +21,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTyp
|
||||
AutoMigration(from = 1, to = 2),
|
||||
],
|
||||
)
|
||||
@TypeConverters(ZonedDateTimeTypeConverter::class)
|
||||
@TypeConverters(InstantTypeConverter::class)
|
||||
abstract class PlatformDatabase : RoomDatabase() {
|
||||
/**
|
||||
* Provides the DAO for accessing organization event data.
|
||||
|
||||
@@ -8,6 +8,8 @@ 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
|
||||
@@ -29,7 +31,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStor
|
||||
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.callback.DatabaseSchemeCallback
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -69,7 +71,7 @@ object PlatformDiskModule {
|
||||
name = "platform_database",
|
||||
)
|
||||
.fallbackToDestructiveMigration(dropAllTables = false)
|
||||
.addTypeConverter(ZonedDateTimeTypeConverter())
|
||||
.addTypeConverter(InstantTypeConverter())
|
||||
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
|
||||
.build()
|
||||
|
||||
@@ -155,4 +157,16 @@ 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.entity
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Entity representing an organization event in the database.
|
||||
@@ -24,7 +24,7 @@ data class OrganizationEventEntity(
|
||||
val cipherId: String?,
|
||||
|
||||
@ColumnInfo(name = "date")
|
||||
val date: ZonedDateTime,
|
||||
val date: Instant,
|
||||
|
||||
@ColumnInfo(name = "organization_id")
|
||||
val organizationId: String?,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -55,6 +56,7 @@ object PlatformNetworkModule {
|
||||
authDiskSource: AuthDiskSource,
|
||||
certificateManager: CertificateManager,
|
||||
buildInfoManager: BuildInfoManager,
|
||||
networkCookieManager: NetworkCookieManager,
|
||||
clock: Clock,
|
||||
): BitwardenServiceClient = bitwardenServiceClient(
|
||||
BitwardenServiceClientConfig(
|
||||
@@ -69,6 +71,7 @@ object PlatformNetworkModule {
|
||||
baseUrlsProvider = baseUrlsProvider,
|
||||
certificateProvider = certificateManager,
|
||||
enableHttpBodyLogging = buildInfoManager.isDevBuild,
|
||||
cookieProvider = networkCookieManager,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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")
|
||||
@@ -0,0 +1,24 @@
|
||||
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?)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
import java.time.Clock
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import javax.inject.Inject
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
@@ -340,8 +338,7 @@ class PushManagerImpl @Inject constructor(
|
||||
private suspend fun registerPushTokenIfNecessaryInternal(userId: String, token: String) {
|
||||
val currentToken = pushDiskSource.getCurrentPushToken(userId)
|
||||
if (token == currentToken) {
|
||||
val lastRegistration =
|
||||
pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toInstant() ?: return
|
||||
val lastRegistration = pushDiskSource.getLastPushTokenRegistrationDate(userId) ?: return
|
||||
val updateTime = clock.instant().minus(PUSH_TOKEN_UPDATE_DELAY.toJavaDuration())
|
||||
if (updateTime.isBefore(lastRegistration)) return
|
||||
}
|
||||
@@ -354,7 +351,7 @@ class PushManagerImpl @Inject constructor(
|
||||
onSuccess = {
|
||||
pushDiskSource.storeLastPushTokenRegistrationDate(
|
||||
userId = userId,
|
||||
registrationDate = ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC),
|
||||
registrationDate = clock.instant(),
|
||||
)
|
||||
pushDiskSource.storeCurrentPushToken(
|
||||
userId = userId,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,7 @@ 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(
|
||||
@@ -20,6 +22,10 @@ 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))
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -22,6 +23,7 @@ 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
|
||||
@@ -36,6 +38,8 @@ 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
|
||||
@@ -69,8 +73,12 @@ 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
|
||||
@@ -219,10 +227,12 @@ object PlatformManagerModule {
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
nativeLibraryManager: NativeLibraryManager,
|
||||
sdkRepositoryFactory: SdkRepositoryFactory,
|
||||
sdkPlatformApiFactory: SdkPlatformApiFactory,
|
||||
): SdkClientManager = SdkClientManagerImpl(
|
||||
featureFlagManager = featureFlagManager,
|
||||
nativeLibraryManager = nativeLibraryManager,
|
||||
sdkRepoFactory = sdkRepositoryFactory,
|
||||
sdkPlatformApiFactory = sdkPlatformApiFactory,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -362,10 +372,22 @@ object PlatformManagerModule {
|
||||
@Singleton
|
||||
fun provideSdkRepositoryFactory(
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
bitwardenServiceClient: BitwardenServiceClient,
|
||||
cookieDiskSource: CookieDiskSource,
|
||||
configDiskSource: ConfigDiskSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
): SdkRepositoryFactory = SdkRepositoryFactoryImpl(
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
bitwardenServiceClient = bitwardenServiceClient,
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
configDiskSource = configDiskSource,
|
||||
authDiskSource = authDiskSource,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSdkPlatformApiFactory(
|
||||
serverCommConfigManager: CookieAcquisitionRequestManager,
|
||||
): SdkPlatformApiFactory = SdkPlatformApiFactoryImpl(
|
||||
serverCommConfigManager = serverCommConfigManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -413,4 +435,21 @@ 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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Clock
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* The amount of time to delay before attempting the first upload events after the app is
|
||||
@@ -78,7 +77,7 @@ class OrganizationEventManagerImpl(
|
||||
event = OrganizationEventJson(
|
||||
type = event.type,
|
||||
cipherId = event.cipherId,
|
||||
date = ZonedDateTime.now(clock),
|
||||
date = clock.instant(),
|
||||
organizationId = event.organizationId,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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,
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* The payload of a push notification.
|
||||
@@ -31,7 +31,7 @@ sealed class NotificationPayload {
|
||||
@JsonNames("OrganizationId", "organizationId") val organizationId: String?,
|
||||
@JsonNames("CollectionIds", "collectionIds") val collectionIds: List<String>?,
|
||||
@Contextual
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
|
||||
) : NotificationPayload()
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ sealed class NotificationPayload {
|
||||
@JsonNames("Id", "id") val folderId: String?,
|
||||
@JsonNames("UserId", "userId") override val userId: String?,
|
||||
@Contextual
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
|
||||
) : NotificationPayload()
|
||||
|
||||
/**
|
||||
@@ -55,7 +55,7 @@ sealed class NotificationPayload {
|
||||
|
||||
@Contextual
|
||||
@JsonNames("Date", "date")
|
||||
val date: ZonedDateTime?,
|
||||
val date: Instant?,
|
||||
|
||||
@JsonNames("Reason", "reason")
|
||||
val pushNotificationLogOutReason: PushNotificationLogOutReason?,
|
||||
@@ -69,7 +69,7 @@ sealed class NotificationPayload {
|
||||
@JsonNames("Id", "id") val sendId: String?,
|
||||
@JsonNames("UserId", "userId") override val userId: String?,
|
||||
@Contextual
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
|
||||
) : NotificationPayload()
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Required data for sync cipher upsert operations.
|
||||
@@ -14,7 +14,7 @@ import java.time.ZonedDateTime
|
||||
data class SyncCipherUpsertData(
|
||||
val userId: String,
|
||||
val cipherId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val revisionDate: Instant,
|
||||
val organizationId: String?,
|
||||
val collectionIds: List<String>?,
|
||||
val isUpdate: Boolean,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Required data for sync folder upsert operations.
|
||||
@@ -14,6 +14,6 @@ import java.time.ZonedDateTime
|
||||
data class SyncFolderUpsertData(
|
||||
val userId: String,
|
||||
val folderId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val revisionDate: Instant,
|
||||
val isUpdate: Boolean,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Required data for sync send upsert operations.
|
||||
@@ -14,6 +14,6 @@ import java.time.ZonedDateTime
|
||||
data class SyncSendUpsertData(
|
||||
val userId: String,
|
||||
val sendId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val revisionDate: Instant,
|
||||
val isUpdate: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.network
|
||||
|
||||
import com.bitwarden.network.provider.CookieProvider
|
||||
|
||||
/**
|
||||
* A manager class for handling cookies.
|
||||
*/
|
||||
interface NetworkCookieManager : CookieProvider {
|
||||
|
||||
/**
|
||||
* Stores acquired cookies for the given [hostname].
|
||||
*
|
||||
* @param hostname The hostname to associate with the cookies.
|
||||
* @param cookies A map of cookie name to cookie value.
|
||||
*/
|
||||
fun storeCookies(hostname: String, cookies: Map<String, String>)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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.datasource.disk.model.CookieConfigurationData
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun storeCookies(hostname: String, cookies: Map<String, String>) {
|
||||
cookieDiskSource.storeCookieConfig(
|
||||
hostname = hostname,
|
||||
config = CookieConfigurationData(
|
||||
hostname = hostname,
|
||||
cookies = cookies.map { (name, value) ->
|
||||
CookieConfigurationData.Cookie(name = name, value = value)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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.
|
||||
@@ -16,4 +17,9 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk
|
||||
|
||||
import com.bitwarden.core.ClientManagedTokens
|
||||
import com.bitwarden.network.BitwardenServiceClient
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -12,7 +16,9 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
*/
|
||||
class SdkRepositoryFactoryImpl(
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val bitwardenServiceClient: BitwardenServiceClient,
|
||||
private val cookieDiskSource: CookieDiskSource,
|
||||
private val configDiskSource: ConfigDiskSource,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
) : SdkRepositoryFactory {
|
||||
override fun getCipherRepository(
|
||||
userId: String,
|
||||
@@ -27,6 +33,12 @@ class SdkRepositoryFactoryImpl(
|
||||
): ClientManagedTokens =
|
||||
SdkTokenRepository(
|
||||
userId = userId,
|
||||
tokenProvider = bitwardenServiceClient.tokenProvider,
|
||||
authDiskSource = authDiskSource,
|
||||
)
|
||||
|
||||
override fun getServerCommunicationConfigRepository(): ServerCommunicationConfigRepository =
|
||||
ServerCommunicationConfigRepositoryImpl(
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
configDiskSource = configDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
|
||||
|
||||
import com.bitwarden.core.ClientManagedTokens
|
||||
import com.bitwarden.network.provider.TokenProvider
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
|
||||
/**
|
||||
* 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 tokenProvider: TokenProvider,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
) : ClientManagedTokens {
|
||||
override suspend fun getAccessToken(): String? =
|
||||
userId?.let { tokenProvider.getAccessToken(userId = it) }
|
||||
userId?.let {
|
||||
authDiskSource.getAccountTokens(userId = it)?.accessToken
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
)
|
||||
@@ -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 ciphers.
|
||||
.filter { it.deletedDate == null }
|
||||
// Filter out any deleted and archived ciphers.
|
||||
.filter { it.deletedDate == null && it.archivedDate == null }
|
||||
.mapNotNull {
|
||||
scopedVaultSdkSource
|
||||
.decryptCipher(userId = userId, cipher = it.toEncryptedSdkCipher())
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user