Compare commits

..

2 Commits

Author SHA1 Message Date
Patrick Honkonen
6902c19c00 🍒 [PM-32802] fix: 400 error when archiving/unarchiving org-owned ciphers (#6596) 2026-02-27 21:08:02 +00:00
David Perez
07d06004a1 🍒 PM-32607: Label headers for accesibility (#6578) 2026-02-25 07:47:30 -05:00
527 changed files with 5294 additions and 10124 deletions

View File

@@ -4,12 +4,13 @@ Official Android application for Bitwarden Password Manager and Bitwarden Authen
## Overview
- Multi-module Android application: `:app` (Password Manager), `:authenticator` (2FA TOTP generator)
- Zero-knowledge architecture: encryption/decryption happens client-side via Bitwarden SDK
### 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
### 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)
@@ -18,7 +19,9 @@ Official Android application for Bitwarden Password Manager and Bitwarden Authen
---
## Architecture
## Architecture & Patterns
### System Architecture
```
User Request (UI Action)
@@ -37,6 +40,31 @@ User Request (UI Action)
DB APIs Rust SDK
```
### Code Organization
```
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
```
### Key Principles
1. **No Exceptions from Data Layer**: All suspending functions return `Result<T>` or custom sealed classes
@@ -46,48 +74,117 @@ User Request (UI Action)
### Core Patterns
- **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.
- **BaseViewModel**: Enforces UDF with State/Action/Event pattern. See `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` and `docs/ARCHITECTURE.md` for full templates and usage examples.
- **Repository Result Pattern**: Type-safe error handling using custom sealed classes for discrete operations and `DataState<T>` wrapper for streaming data. See `docs/ARCHITECTURE.md` for implementation details.
- **Common Patterns**: Flow collection via `Internal` actions, error handling via `when` branches, `DataState` streaming with `.map { }` and `.stateIn()`.
> For complete architecture patterns, code templates, and module organization, see `docs/ARCHITECTURE.md`.
> For complete architecture patterns, code templates, and examples, see `docs/ARCHITECTURE.md`.
---
## Development Guide
### Workflow Skills
### Adding New Feature Screen
> **Quick start**: Use `/plan-android-work <task>` to refine requirements and plan,
> then `/work-on-android <task>` for implementation.
Follow these steps (see `docs/ARCHITECTURE.md` for full templates and patterns):
**Planning Phase:**
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
1. `refining-android-requirements` - Gap analysis and structured spec from any input source
2. `planning-android-implementation` - Architecture design and phased task breakdown
### Code Reviews
**Implementation Phase:**
3. `implementing-android-code` - Patterns, gotchas, and templates for writing code
4. `testing-android-code` - Test patterns and templates for verifying code
5. `build-test-verify` - Build, test, lint, and deploy commands
6. `perform-android-preflight-checklist` - Quality gate before committing
7. `committing-android-changes` - Commit message format and pre-commit workflow
8. `reviewing-changes` - Code review checklists for MVVM/Compose patterns
9. `creating-android-pull-request` - PR creation workflow and templates
Use the `reviewing-changes` skill for structured code review checklists covering MVVM/Compose patterns, security validation, and type-specific review guidance.
---
## Security Rules
## Data Models
Key types used throughout the codebase (see source files and `docs/ARCHITECTURE.md` for full definitions):
- **`UserState`** (`data/auth/`) - Active user ID, accounts list, pending account state
- **`VaultUnlockData`** (`data/vault/repository/model/`) - User ID and vault unlock status
- **`DataState<T>`** (`data/`) - Async data wrapper: Loading, Loaded, Pending, Error, NoNetwork
- **`NetworkResult<T>`** (`network/`) - HTTP operation result: Success or Failure
- **`BitwardenError`** (`network/`) - Error classification: Http, Network, Other
---
## Security & Configuration
### Security Rules
**MANDATORY - These rules have no exceptions:**
1. **Zero-Knowledge Architecture**: Never transmit unencrypted vault data or master passwords to the server. All encryption happens client-side via the Bitwarden SDK.
2. **No Plaintext Key Storage**: Encryption keys must be stored using Android Keystore (biometric unlock) or encrypted with PIN/master password.
3. **Sensitive Data Cleanup**: On logout, all sensitive data must be cleared from memory and storage via `UserLogoutManager.logout()`.
4. **Input Validation**: Validate all user inputs before processing, especially URLs and credentials.
5. **SDK Isolation**: Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user crypto context leakage.
### Security Components
| Component | Location | Purpose |
|-----------|----------|---------|
| `BiometricsEncryptionManager` | `data/platform/manager/` | Android Keystore integration for biometric unlock |
| `VaultLockManager` | `data/vault/manager/` | Vault lock/unlock operations |
| `AuthDiskSource` | `data/auth/datasource/disk/` | Secure token and key storage |
| `BaseEncryptedDiskSource` | `data/datasource/disk/` | EncryptedSharedPreferences base class |
### Environment Configuration
| Variable | Required | Description |
|----------|----------|-------------|
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages authentication for SDK |
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
| Build types | - | `debug`, `beta`, `release` |
### Authentication & Authorization
- **Login Methods**: Email/password, SSO (OAuth 2.0 + PKCE), trusted device, passwordless auth request
- **Vault Unlock**: Master password, PIN, biometric, trusted device key
- **Token Management**: JWT access tokens with automatic refresh via `AuthTokenManager`
- **Key Derivation**: PBKDF2-SHA256 or Argon2id via `KdfManager`
---
## Testing
### Test Structure
```
app/src/test/ # App unit tests
app/src/testFixtures/ # App test utilities
core/src/testFixtures/ # Core test utilities (FakeDispatcherManager)
data/src/testFixtures/ # Data test utilities (FakeSharedPreferences)
network/src/testFixtures/ # Network test utilities (BaseServiceTest)
ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseComposeTest)
```
### Running Tests
```bash
./gradlew test # Run all unit tests
./gradlew app:testDebugUnitTest # Run app module tests
./gradlew :core:test # Run core module tests
./fastlane check # Run full validation (detekt, lint, tests, coverage)
```
### Test Quick Reference
- **Dispatcher Control**: `FakeDispatcherManager` from `:core:testFixtures`
- **MockK**: `mockk<T> { every { } returns }`, `coEvery { }` for suspend
- **Flow Testing**: Turbine with `stateEventFlow()` helper from `BaseViewModelTest`
- **Time Control**: Inject `Clock` for deterministic time testing
> For comprehensive test templates (ViewModel, Screen, Repository, DataSource, Network), use the `testing-android-code` skill.
---
## Code Style & Standards
@@ -95,7 +192,6 @@ User Request (UI Action)
- **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`.
@@ -103,14 +199,17 @@ User Request (UI Action)
## Anti-Patterns
In addition to the Key Principles above, follow these rules:
### DO
- Use `Result<T>` or sealed classes for operations that can fail
- Hoist state to ViewModel when it affects business logic
- Use `remember(viewModel)` for lambdas passed to composables
- Map async results to internal actions before updating state
- Use interface-based DI with Hilt
- Inject `Clock` for time-dependent operations
- Return early to reduce nesting
### DON'T
- Throw exceptions from data layer functions
- Update state directly inside coroutines (use internal actions)
- Use `any` types or suppress null safety
- Catch generic `Exception` (catch specific types)
@@ -120,14 +219,111 @@ In addition to the Key Principles above, follow these rules:
---
## Quick Reference
## Deployment
- **Code style**: Full rules: `docs/STYLE_AND_BEST_PRACTICES.md`
- **Before writing code**: Use `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and templates
- **Before writing tests**: Use `testing-android-code` skill for test patterns and templates
- **Building/testing**: Use `build-test-verify` skill | App tests: `./gradlew app:testStandardDebugUnitTest`
- **Before committing**: Use `perform-android-preflight-checklist` skill, then `committing-android-changes` skill for message format
- **Code review**: Use `reviewing-changes` skill for MVVM/Compose review checklists
- **Creating PRs**: Use `creating-android-pull-request` skill for PR workflow and templates
- **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/)
### Building
```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
---
## Troubleshooting
### Common Issues
#### Build fails with SDK dependency error
**Problem**: Cannot resolve Bitwarden SDK from GitHub Packages
**Solution**:
1. Ensure `GITHUB_TOKEN` is set in `ci.properties` or environment
2. Verify token has `read:packages` scope
3. Check network connectivity to `maven.pkg.github.com`
#### Tests fail with dispatcher issues
**Problem**: Tests hang or fail with "Module with Main dispatcher had failed to initialize"
**Solution**:
1. Extend `BaseViewModelTest` for ViewModel tests
2. Use `@RegisterExtension val mainDispatcherExtension = MainDispatcherExtension()`
3. Ensure `runTest { }` wraps test body
#### Compose preview not rendering
**Problem**: @Preview functions show "Rendering problem"
**Solution**:
1. Check for missing theme wrapper: `BitwardenTheme { YourComposable() }`
2. Verify no ViewModel dependency in preview (use state-based preview)
3. Clean and rebuild project
#### ProGuard/R8 stripping required classes
**Problem**: Release build crashes with missing class errors
**Solution**:
1. Add keep rules to `proguard-rules.pro`
2. Check `consumer-rules.pro` in library modules
3. Verify kotlinx.serialization rules are present
### Debug Tips
- **Timber Logging**: Enabled in debug builds, check Logcat with tag filter
- **Debug Menu**: Available in debug builds via Settings > About > Debug Menu
- **Network Inspector**: Use Android Studio Network Profiler or Charles Proxy
- **SDK Debugging**: Check `BaseSdkSource` for wrapped exceptions
---
## References
### Internal Documentation
- `docs/ARCHITECTURE.md` - Complete architecture patterns, BaseViewModel, Repository Result, DataState
- `docs/STYLE_AND_BEST_PRACTICES.md` - Kotlin and Compose code style, formatting, imports, documentation
### Skills & Tools
- `testing-android-code` - Comprehensive test templates and patterns (ViewModel, Screen, Repository, DataSource, Network)
- `reviewing-changes` - Structured code review checklists with MVVM/Compose pattern validation
- `bitwarden-code-review:code-review` - Automated GitHub PR review with inline comments
- `bitwarden-code-review:code-review-local` - Local change review written to files
### External Documentation
- [Bitwarden SDK](https://github.com/bitwarden/sdk) - Cryptographic SDK
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI framework
- [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-guide.html) - Async programming
- [Hilt DI](https://dagger.dev/hilt/) - Dependency injection
- [Turbine](https://github.com/cashapp/turbine) - Flow testing
### Tools & Libraries
- [MockK](https://mockk.io/) - Kotlin mocking library
- [Retrofit](https://square.github.io/retrofit/) - HTTP client
- [Room](https://developer.android.com/training/data-storage/room) - Database
- [Detekt](https://detekt.dev/) - Static analysis

View File

@@ -1,119 +0,0 @@
---
description: Guided requirements refinement and implementation planning for Bitwarden Android
argument-hint: <Jira ticket (PM-12345), Confluence URL, or free-text description>
---
# Android Planning Workflow
You are guiding the developer through requirements refinement and implementation planning for the Bitwarden Android project. The input to plan from is:
**Input**: $ARGUMENTS
## Prerequisites
- **Jira/Confluence access**: Fetching tickets and wiki pages requires the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin. If the plugin is not installed, Jira ticket IDs and Confluence URLs cannot be fetched automatically.
## Workflow Phases
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable. If starting from a partially completed plan, skip to the appropriate phase.
### Phase 1: Ingest Requirements
Parse the input to detect and fetch all available sources:
**Source Detection Rules:**
- **Jira tickets** (patterns like `PM-\d+`, `BWA-\d+`, `EC-\d+`): Fetch via `get_issue` and `get_issue_comments`. Also fetch linked issue summaries (parent epic, sub-tasks, blockers) for context.
- **Confluence URLs** (containing `atlassian.net/wiki` or confluence page IDs): Extract page ID and fetch via `get_confluence_page`. If the page is a parent page, fetch child pages via `get_child_pages` and ask the user which are relevant.
- **Free text**: Treat as direct requirements — no fetching needed.
- **Multiple inputs**: All are first-class sources. Fetch each independently and consolidate.
- **Tool unavailable**: If `get_issue`, `get_confluence_page`, or other Atlassian tools are not available, inform the user that the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required and prompt them to install and configure it. In the meantime, ask the user to paste the relevant content directly. Treat pasted content as free-text input.
**Present a structured summary:**
1. Sources identified and fetched (with links)
2. Raw requirements extracted from each source
3. Initial scope assessment (small / medium / large)
**Edge cases:**
- Jira ticket with no description → flag as critical gap that Phase 2 must address
- Multiple tickets → fetch all, consolidate, flag any contradictions
- Ticket + free text → both treated as first-class; free text supplements ticket
**Gate**: User confirms the summary is complete and may add additional sources or context before proceeding.
### Phase 2: Refine Requirements
Invoke the `refining-android-requirements` skill and use it to perform gap analysis on the raw requirements from Phase 1.
The skill will:
1. Consolidate all sources into a working document
2. Evaluate requirements against a structured rubric (functional, technical, security, UX, cross-cutting)
3. Present categorized gaps as blocking or non-blocking questions
4. After user answers, produce a structured specification with numbered IDs
**Gate**: User approves the refined specification. They may request changes or provide additional answers.
### Phase 3: Plan Implementation
Invoke the `planning-android-implementation` skill and use it to design the implementation approach based on the refined spec from Phase 2.
The skill will:
1. Classify the change type
2. Explore the codebase for reference implementations and integration points
3. Design the architecture with component relationships
4. Produce a file inventory and phased implementation plan
5. Assess risks and define verification criteria
**Gate**: User reviews the implementation plan and may request changes to architecture, phasing, or scope.
### Phase 4: Finalize & Save
Merge the outputs from Phase 2 (specification) and Phase 3 (implementation plan) into a single design document using this template:
```markdown
# [Feature Name] — Design Document
**Feature**: [concise description]
**Date**: [current date]
**Status**: Ready for Implementation
**Jira**: [ticket ID if available]
**Sources**: [list of all input sources with links]
---
## Requirements Specification
[Full output from Phase 2 — the refined specification with numbered IDs]
---
## Implementation Plan
[Full output from Phase 3 — architecture, file inventory, phases, risks]
---
## Executing This Plan
To implement this plan, run:
/work-on-android [ticket or feature reference]
Reference this design document during implementation for architecture decisions,
file locations, and phase ordering.
```
**Save the document:**
- With ticket: `.claude/outputs/plans/PM-XXXXX-FEATURE-NAME-PLAN.md`
- Without ticket: `.claude/outputs/plans/FEATURE-NAME-PLAN.md`
- Feature name should be uppercase with hyphens (e.g., `BIOMETRIC-TIMEOUT-CONFIG-PLAN.md`)
- Create the output directory if it does not exist
**On completion**: Present the saved file path and remind the user they can execute the plan with `/work-on-android`.
## Guidelines
- Be explicit about which phase you are in at all times.
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
- When fetching from Jira/Confluence, summarize what was found rather than dumping raw content.
- Questions in Phase 2 should be specific and actionable, not generic.
- The implementation plan in Phase 3 should reference concrete files in the codebase, not abstract descriptions.

View File

@@ -1,66 +0,0 @@
---
description: Guided Android development workflow through all lifecycle phases
argument-hint: <task description, plan, or Jira ticket reference>
---
# Android Development Workflow
You are guiding the developer through a complete Android development lifecycle for the Bitwarden Android project. The task to work on is:
**Task**: $ARGUMENTS
## Workflow Phases
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** If a phase fails (tests fail, lint errors, etc.), loop on that phase until resolved before advancing. The user may skip phases that are not applicable.
### Phase 1: Implement
Invoke the `implementing-android-code` skill and use it to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
**Before advancing**: Summarize what was implemented and confirm the user is ready to move to testing.
### Phase 2: Test
Invoke the `testing-android-code` skill and use it to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
**Before advancing**: Summarize what tests were written and confirm readiness for build verification.
### Phase 3: Build & Verify
Invoke the `build-test-verify` skill to run tests, lint, and detekt. Ensure everything passes.
**If failures occur**: Fix the issues and re-run verification. Do not advance until all checks pass.
**Before advancing**: Report build/test/lint results and confirm readiness for self-review.
### Phase 4: Self-Review
Invoke the `perform-android-preflight-checklist` skill to perform a quality gate check on all changes. Address any issues found.
**Before advancing**: Share the self-review results and confirm readiness to commit.
### Phase 5: Commit
Invoke the `committing-android-changes` skill to stage and commit the changes with a properly formatted commit message.
**Before advancing**: Confirm the commit was successful and ask if the user wants to proceed to review and PR creation, or stop here.
### Phase 6: Review
**Pre-requisites:**
- `bitwarden-code-review` from the [Bitwarden Plugin Marketplace](https://github.com/bitwarden/ai-plugins) must be installed in order to perform this phase. If it is not installed prompt the user to install it, or skip the review phase.
Launch a subagent with the `/bitwarden-code-review:code-review-local` command to perform a **local** code review of the committed diff. Validate and address any issues found before proceeding.
**Before advancing**: Share review findings and confirm readiness for PR creation.
### Phase 7: Pull Request
Prompt the user to invoke the `creating-android-pull-request` skill to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
## Guidelines
- Be explicit about which phase you are in at all times.
- Never proceed to another phase without user confirmation.
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
- If starting from a partially completed task (e.g., code already written), skip to the appropriate phase.

View File

@@ -1,8 +1,4 @@
{
"attribution": {
"commit": "",
"pr": ""
},
"extraKnownMarketplaces": {
"bitwarden-marketplace": {
"source": {

View File

@@ -1,136 +0,0 @@
---
name: build-test-verify
version: 0.1.0
description: Build, test, lint, and deploy commands for the Bitwarden Android project. Use when running tests, building APKs/AABs, running lint/detekt, deploying, using fastlane, or discovering codebase structure. Triggered by "run tests", "build", "gradle", "lint", "detekt", "deploy", "fastlane", "assemble", "verify", "coverage".
---
# Build, Test & Verify
## Environment Setup
| Variable | Required | Description |
|----------|----------|-------------|
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages auth for SDK (`read:packages` scope) |
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
| Build types | - | `debug`, `beta`, `release` |
If builds fail resolving the Bitwarden SDK, verify `GITHUB_TOKEN` in `user.properties` or environment and check connectivity to `maven.pkg.github.com`.
---
## Building
```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
```
---
## Running Tests
**IMPORTANT**: The app module uses the `standard` flavor. Always use `testStandardDebugUnitTest`, NOT `testDebugUnitTest`.
```bash
# App module tests (correct flavor!)
./gradlew app:testStandardDebugUnitTest
# Run all unit tests across all modules
./gradlew test
# Individual shared modules (no flavor needed)
./gradlew :core:test
./gradlew :data:test
./gradlew :network:test
./gradlew :ui:test
# Authenticator module
./gradlew authenticator:testStandardDebugUnitTest
```
### 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)
```
### 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
---
## Lint & Static Analysis
```bash
# Detekt (static analysis)
./gradlew detekt
# Android Lint
./gradlew lint
# Full validation suite (detekt + lint + tests + coverage)
./fastlane check
```
---
## 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
```
---
## Deployment & Versioning
**Version location**: `gradle/libs.versions.toml`
```toml
appVersionCode = "1"
appVersionName = "2025.11.1"
```
Pattern: `YEAR.MONTH.PATCH`
**Publishing channels**:
- **Play Store**: GitHub Actions workflow with signed AAB
- **F-Droid**: Dedicated workflow with F-Droid signing keys
- **Firebase App Distribution**: Beta testing

View File

@@ -1,81 +0,0 @@
---
name: committing-android-changes
version: 0.1.0
description: Git commit conventions and workflow for Bitwarden Android. Use when committing code, writing commit messages, or preparing changes for commit. Triggered by "commit", "git commit", "commit message", "prepare commit", "stage changes".
---
# Git Commit Conventions
## Commit Message Format
```
[PM-XXXXX] <type>: <imperative summary>
<optional body explaining why, not what>
```
### Rules
1. **Ticket prefix**: Always include `[PM-XXXXX]` matching the Jira ticket
2. **Type keyword**: Include a conventional commit type after the ticket prefix (see table below)
3. **Imperative mood**: "Add feature" not "Added feature" or "Adds feature"
4. **Short summary**: Under 72 characters for the first line
5. **Body**: Explain the "why" not the "what" — the diff shows the what
### Type Keywords
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
### Example
```
[PM-12345] feat: Add biometric unlock timeout configuration
Users reported confusion about when biometric prompts appear.
This adds a configurable timeout setting to the security preferences.
```
### Followup Commits
Only the first commit on a branch needs the full format (ticket prefix, type keyword, body). Subsequent commits — whether addressing review feedback, making intermediate changes, or iterating locally — can use a short, descriptive summary with no prefix or body required.
```
Update error handling in login flow
```
---
## Pre-Commit Checklist
Run the `perform-android-preflight-checklist` skill for the full quality gate. At minimum, before staging and committing:
1. **Run affected module tests** (use `build-test-verify` skill for correct commands)
2. **Check lint**: `./gradlew detekt` on changed modules
3. **Review staged changes**: `git diff --staged` — verify no unintended modifications
4. **Verify no secrets**: No API keys, tokens, passwords, or `.env` files staged
5. **Verify no generated files**: No build outputs, `.idea/` changes, or generated code
---
## What NOT to Commit
- `.env` files or `user.properties` with real tokens
- Credential files or signing keystores
- Build outputs (`build/`, `*.apk`, `*.aab`)
- IDE-specific files (`.idea/` changes, `*.iml`)
- Large binary files
---
## Staging Best Practices
- **Stage specific files** by name rather than `git add -A` or `git add .`
- Put each file path on its own line for readability:
```bash
git add \
path/to/first/File.kt \
path/to/second/File.kt \
path/to/third/File.kt
```
- Review each file being staged to avoid accidentally including sensitive data
- Use `git status` (without `-uall` flag) to see the working tree state

View File

@@ -1,64 +0,0 @@
---
name: creating-android-pull-request
version: 0.1.0
description: Pull request creation workflow for Bitwarden Android. Use when creating PRs, writing PR descriptions, or preparing branches for review. Triggered by "create PR", "pull request", "open PR", "gh pr create", "PR description".
---
# Create Pull Request
## PR Title Format
```
[PM-XXXXX] <type>: <short imperative summary>
```
**Examples:**
- `[PM-12345] feat: Add autofill support for passkeys`
- `[PM-12345] fix: Resolve crash during vault sync`
- `[PM-12345] refactor: Simplify authentication flow`
**Rules:**
- Include Jira ticket prefix
- Keep under 70 characters total
- Use imperative mood in the summary
**Type keywords** (triggers automatic `t:` label via CI):
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
---
## PR Body Template
**IMPORTANT:** Always follow the repo's PR template at `.github/PULL_REQUEST_TEMPLATE.md`. Delete the Screenshots section entirely if there are no UI changes.
---
## Pre-PR Checklist
1. **All tests pass**: Run `./gradlew app:testStandardDebugUnitTest` (and other affected modules)
2. **Lint clean**: Run `./gradlew detekt`
3. **Self-review done**: Use `perform-android-preflight-checklist` skill
4. **No unintended changes**: Check `git diff origin/main...HEAD` for unexpected files
5. **Branch up to date**: Rebase on `main` if needed
---
## Creating the PR
```bash
# Ensure branch is pushed
git push -u origin <branch-name>
# Create PR as draft by default (body follows .github/PULL_REQUEST_TEMPLATE.md)
gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "<fill in from PR template>"
```
**Default to draft PRs.** Only create a non-draft (ready for review) PR if the user explicitly requests it.
---
## Base Branch
- Default target: `main`
- Check with team if targeting a feature branch instead

View File

@@ -1,481 +0,0 @@
---
name: implementing-android-code
version: 0.1.2
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 = { 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 `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. For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
**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: `youll` 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, use the **`build-test-verify`** skill.
**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)

View File

@@ -1,636 +0,0 @@
# 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 = { 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 = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
ExampleScreenContent(
state = state,
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
onSubmitClick = { 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(),
)
}
}
```

View File

@@ -1,40 +0,0 @@
---
name: labeling-android-changes
version: 0.1.0
description: Conventional commit type keywords for PR titles and commit messages. Use when determining the change type for commits or PRs. Triggered by "what type", "label", "change type", "conventional commit", "t: label".
---
# Labeling Changes
PR titles and commit messages must include a conventional commit type keyword. This keyword drives automatic `t:` label assignment via CI (`.github/workflows/sdlc-label-pr.yml`).
## Format
The type keyword appears after the Jira ticket prefix:
```
[PM-XXXXX] <type>: <imperative summary>
```
## Type Keywords
| Type | Label | Use for |
|------|-------|---------|
| `feat` | `t:feature` | New features or functionality |
| `fix` | `t:bug` | Bug fixes |
| `refactor` | `t:tech-debt` | Code restructuring without behavior change |
| `chore` | `t:tech-debt` | Maintenance, cleanup, minor tweaks |
| `test` | `t:tech-debt` | Adding or updating tests |
| `perf` | `t:tech-debt` | Performance improvements |
| `docs` | `t:docs` | Documentation changes |
| `ci` / `build` | `t:ci` | CI/CD and build system changes |
| `deps` | `t:deps` | Dependency updates |
| `llm` | `t:llm` | LLM/Claude configuration changes |
| `breaking` | `t:breaking-change` | Breaking changes requiring migration |
| `misc` | `t:misc` | Changes that do not fit other categories |
## Selecting a Type
Infer the type from the task description and changes made. **If the type cannot be confidently determined, ask the user.**
The CI labeling script matches `<type>:` or `<type>(` in the lowercased PR title, so the keyword must be followed by a colon or parenthesis. CI also accepts additional aliases (e.g., `revert`, `bugfix`, `cleanup`). See `.github/label-pr.json` for the full mapping.

View File

@@ -1,37 +0,0 @@
---
name: perform-android-preflight-checklist
version: 0.1.0
description: Quality gate checklist to run before committing or creating a PR. Use when finishing implementation, checking work quality, or preparing to commit. Triggered by "self review", "check my work", "ready to commit", "done implementing", "review checklist", "quality check".
---
# Self-Review Checklist
Run through this checklist before committing or opening a PR.
## Tests
- [ ] Tests pass with correct flavor: `./gradlew app:testStandardDebugUnitTest`
- [ ] New code has corresponding test coverage
- [ ] Tests for affected modules also pass (`:core:test`, `:data:test`, etc.)
## Code Quality
- [ ] Lint/detekt clean: `./gradlew detekt`
- [ ] No unintended file changes (`git diff` review)
- [ ] KDoc on all new public APIs
- [ ] No TODO comments left behind (or they reference a ticket)
## Security
- [ ] No plaintext keys, tokens, or secrets in code
- [ ] User input validated before processing
- [ ] Sensitive data uses encrypted storage patterns
- [ ] No logging of sensitive data (passwords, keys, tokens)
## Bitwarden Patterns
- [ ] String resources in `:ui` module with typographic quotes
- [ ] Navigation route is `@Serializable` and registered in graph
- [ ] New implementations have Hilt `@Binds` or `@Provides` in a module
- [ ] ViewModel extends `BaseViewModel<S, E, A>` with proper state persistence
- [ ] Async results mapped through internal actions (not direct state updates)
## Files
- [ ] No accidental `.idea/`, build output, or generated files staged
- [ ] No credential files or `.env` files included

View File

@@ -1,191 +0,0 @@
---
name: planning-android-implementation
version: 0.1.0
description: Architecture design and phased implementation planning for Bitwarden Android. Use when planning implementation, designing architecture, creating file inventories, or breaking features into phases. Triggered by "plan implementation", "architecture design", "implementation plan", "break this into phases", "what files do I need", "design the architecture".
---
# Implementation Planning
This skill takes a refined specification (ideally from the `refining-android-requirements` skill) and produces a phased implementation plan with architecture design, file inventory, and risk assessment.
**Prerequisite**: A clear set of requirements. If requirements are vague or incomplete, invoke the `refining-android-requirements` skill first.
---
## Step 1: Classify Change
Determine the change type to guide scope and planning depth:
| Type | Description | Typical Scope |
|------|-------------|---------------|
| **New Feature** | Entirely new functionality, screens, or flows | New files + modifications, multi-phase |
| **Enhancement** | Extending existing feature with new capabilities | Mostly modifications, 1-2 phases |
| **Bug Fix** | Correcting incorrect behavior | Targeted modifications, single phase |
| **Refactoring** | Restructuring without behavior change | Modifications only, migration-aware |
| **Infrastructure** | Build, CI, tooling, or dependency changes | Config files, minimal code changes |
State the classification and rationale before proceeding.
---
## Step 2: Codebase Exploration
Search the codebase to find reference implementations and integration points. Use the discovery commands from the `build-test-verify` skill as needed.
### Find Pattern Anchors
Identify 2-3 existing files that serve as templates for the planned work:
```
**Pattern Anchors:**
1. [file path] — [why this is a good reference]
2. [file path] — [why this is a good reference]
3. [file path] — [why this is a good reference]
```
### Map Integration Points
Identify files that must be modified to integrate the new work:
- **Navigation**: Nav graph registrations, route definitions
- **Dependency Injection**: Hilt modules, `@Provides` / `@Binds` functions
- **Data Layer**: Repository interfaces, data source interfaces, Room DAOs
- **API Layer**: Retrofit service interfaces, request/response models
- **Feature Flags**: Feature flag definitions and checks
- **Managers**: Single-responsibility data layer classes (see `docs/ARCHITECTURE.md` Managers section)
- **Test Fixtures**: Shared test utilities in `src/testFixtures/` directories
- **Product Flavor Source Sets**: Code in `src/standard/` vs `src/main/` for Play Services dependencies
### Document Existing Patterns
Note the specific patterns used by the pattern anchors:
- State class structure (sealed class, data class fields)
- Action/Event naming conventions
- Repository method signatures and return types
- Test structure and assertion patterns
---
## Step 3: Architecture Design
Produce an ASCII diagram showing component relationships for the planned work:
```
┌─────────────────┐
│ Screen │ ← Compose UI
│ (Composable) │
└────────┬────────┘
│ State / Action / Event
┌────────▼────────┐
│ ViewModel │ ← Business logic orchestration
└────────┬────────┘
│ Repository calls
┌────────▼────────┐
│ Repository │ ← Data coordination (sealed class results)
└───┬────┬────┬───┘
│ │ │
┌───▼───┐ │ ┌─▼──────┐
│Manager│ │ │Manager │ ← Single-responsibility (optional)
└───┬───┘ │ └─┬──────┘
│ │ │
┌───▼─────▼───▼────┐
│ Data Sources │ ← Raw data (Result<T>, never throw)
└─┬────┬────┬──────┘
│ │ │
Room Retrofit SDK
```
Adapt the diagram to show the actual components planned. _Consult `docs/ARCHITECTURE.md` for full data layer patterns and conventions._
### Design Decisions
Document key architectural decisions in a table:
| Decision | Resolution | Rationale |
|----------|-----------|-----------|
| [What needed deciding] | [What was chosen] | [Why] |
---
## Step 4: File Inventory
### Files to Create
| File Path | Type | Pattern Reference |
|-----------|------|-------------------|
| [full path] | [ViewModel / Screen / Repository / etc.] | [pattern anchor file] |
**Include in file inventory:**
- `...Navigation.kt` files for new screens
- `...Module.kt` Hilt module files for new DI bindings
- Paired test files (`...Test.kt`) for each new class
### Files to Modify
| File Path | Change Description | Risk Level |
|-----------|-------------------|------------|
| [full path] | [what changes] | Low / Medium / High |
**Risk levels:**
- **Low**: Additive changes (new entries in nav graph, new bindings in Hilt module)
- **Medium**: Modifying existing logic (adding parameters, new branches)
- **High**: Changing interfaces, data models, or shared utilities
---
## Step 5: Implementation Phases
Break the work into sequential phases. Each phase should be independently testable and committable.
**Phase ordering principle**: Foundation → SDK/Data → Network → UI (tests accompany each phase)
For each phase:
```markdown
### Phase N: [Name]
**Goal**: [What this phase accomplishes]
**Files**:
- Create: [list]
- Modify: [list]
**Tasks**:
1. [Specific implementation task]
2. [Specific implementation task]
3. ...
**Verification**:
- [Test command or manual verification step]
**Skills**: [Which workflow skills apply — e.g., `implementing-android-code`, `testing-android-code`]
```
### Phase Guidelines
- Each phase should be small enough to be independently testable and committable
- Tests are written within the same phase as the code they verify (not deferred to a "testing phase")
- UI phases come after their data dependencies are in place
- If a phase has more than 5 tasks, consider splitting it
---
## Step 6: Risk & Verification
### Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| [What could go wrong] | Low/Med/High | Low/Med/High | [How to prevent or handle] |
### Verification Plan
**Automated Verification:**
- Unit test commands (from `build-test-verify` skill)
- Lint/detekt commands
- Build verification
**Manual Verification:**
- [Specific manual test scenarios]
- [Edge cases to manually verify]
- Verify ViewModel state survives process death (test via `SavedStateHandle` persistence and `Don't keep activities` developer option)

View File

@@ -1,181 +0,0 @@
---
name: refining-android-requirements
version: 0.1.0
description: Requirements gap analysis and structured specification for Bitwarden Android. Use when refining requirements, analyzing specs, identifying gaps, or producing structured specifications from tickets or descriptions. Triggered by "refine requirements", "gap analysis", "spec review", "requirements analysis", "what's missing from this spec", "analyze this ticket".
---
# Requirements Refinement
This skill takes raw requirements (from Jira tickets, Confluence pages, or free-text descriptions) and produces a structured, implementation-ready specification through systematic gap analysis.
**Key principle**: This skill identifies gaps and produces specifications. It does NOT propose solutions or architecture — that is the responsibility of the `planning-android-implementation` skill.
---
## Step 1: Source Consolidation
Combine all input sources into a single working document. For each requirement, note its source:
```
- [Source: PM-12345] User must be able to configure timeout
- [Source: Confluence] Timeout range is 1-60 minutes
- [Source: User] Default timeout should be 15 minutes
```
Flag any contradictions between sources for immediate resolution.
---
## Step 2: Gap Analysis
Evaluate the consolidated requirements against the following 5-category rubric. For each category, check every item and note whether it is **covered**, **partially covered**, or **missing**.
### A. Functional Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| User actions defined? | What specific user actions trigger this feature? |
| All states covered? (empty, loading, error, success) | What should the user see in [empty/loading/error] state? |
| Edge cases identified? | What happens when [boundary condition]? |
| Cancellation/back navigation flows? | Can the user cancel mid-flow? What happens to partial data? |
| Input validation rules? | What are the valid ranges/formats for [input]? |
| Success/failure criteria? | How does the user know the operation succeeded or failed? |
| Offline behavior? | What happens if this is attempted offline? |
### B. Technical Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| Module scope identified? (`:app`, `:authenticator`, shared) | Which module(s) does this feature belong to? |
| SDK dependencies? | Does this require Bitwarden SDK operations? Which ones? |
| Data storage approach? (Room, DataStore, in-memory) | Where is the data for this feature persisted? |
| Network API endpoints? | Which API endpoints are involved? Are they existing or new? |
| Process death handling? | What state needs to survive process death? |
| Migration requirements? | Does existing data need migration? |
| Feature flag needed? | Should this be behind a feature flag for staged rollout? |
| Product flavors (standard vs fdroid)? | Does this feature depend on Google Play Services? Available on F-Droid? |
| Data layer tier? | Does this need a new Manager (single-responsibility) or only Repository/DataSource? Consult `docs/ARCHITECTURE.md` Data Layer section. |
| Streaming vs discrete data? | Is data continuously observed (`DataState<T>` + `StateFlow`) or a one-shot operation (custom sealed class)? See `docs/ARCHITECTURE.md` Repositories section. |
### C. Security Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| Data sensitivity classified? | What sensitivity level does this data have? (vault-level, account-level, non-sensitive) |
| Storage encryption required? | Must this data be encrypted at rest? Via SDK or Android Keystore? |
| Logout cleanup behavior? | What must be cleared when the user logs out? |
| Auth-gating? | Does accessing this feature require active authentication? |
| Input sanitization? | Are there URL or credential inputs that need validation? |
| Sensitive data in ViewModel state? | Will passwords, tokens, or keys appear in state? Must use `@IgnoredOnParcel`. See `implementing-android-code` skill Section F. |
| SDK crypto context isolation? | Does this use vault encryption? Must use `ScopedVaultSdkSource` for multi-account safety. See CLAUDE.md Security Rules. |
### D. UX/UI Requirements
| Check | Question to Ask If Missing |
|-------|---------------------------|
| UI copy/strings defined? | What text should appear for [label/button/message]? |
| Error messages specified? | What should the error message say when [failure case]? |
| Loading states designed? | Should loading show a spinner, skeleton, or shimmer? |
| Navigation flow clear? | Where does the user go after [action]? Back stack behavior? |
| Accessibility considerations? | Are there content descriptions or focus order requirements? |
| Toast/snackbar/dialog for feedback? | What feedback mechanism for [action result]? |
### E. Cross-Cutting Concerns
| Check | Question to Ask If Missing |
|-------|---------------------------|
| Multi-account behavior? | How does this behave with multiple accounts? Per-account or global? |
| Backwards compatibility? | Does this affect existing users? Migration path? |
| Feature flag strategy? | Is this behind a server-side or local feature flag? |
| Analytics/logging? | Are there analytics events to track? |
| Bitwarden Authenticator impact? | Does this affect the `:authenticator` module? |
| F-Droid compatibility? | Does this degrade gracefully without Google Play Services (no push notifications, no Play Integrity)? |
---
## Step 3: Present Gaps
Organize all identified gaps into two categories:
### Blocking Questions
Questions that **must** be answered before implementation can begin because they change the architecture, data model, or core flow.
Format each question as:
```
**G[N]** ([Category]) — [Question text]
Context: [Why this matters / what depends on the answer]
```
### Non-Blocking Questions
Questions that have **reasonable defaults** and can be resolved during implementation. Note the assumed default.
Format each question as:
```
**G[N]** ([Category]) — [Question text]
Default assumption: [What we'll assume if not answered]
Context: [Why this matters]
```
---
## Step 4: Produce Specification
After the user answers blocking questions (and optionally non-blocking ones), produce a structured specification:
```markdown
## Overview
[1-2 paragraph summary of the feature, its purpose, and scope]
## Functional Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| FR1 | [requirement] | [source] | [any notes] |
| FR2 | ... | ... | ... |
## Technical Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| TR1 | [requirement] | [source] | [any notes] |
| TR2 | ... | ... | ... |
## Security Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| SR1 | [requirement] | [source] | [any notes] |
## UX Requirements
| ID | Requirement | Source | Notes |
|----|------------|--------|-------|
| UX1 | [requirement] | [source] | [any notes] |
## Open Items
Non-blocking items with assumed defaults that may be revisited:
| ID | Question | Assumed Default | Category |
|----|----------|----------------|----------|
| G[N] | [question] | [default] | [category] |
## Source Documentation
| Source | Type | Link |
|--------|------|------|
| [name] | Jira / Confluence / User-provided | [link if available] |
```
### Output Guidelines
- Requirements use numbered IDs (FR1, TR1, SR1, UX1) for traceability through implementation
- Each requirement cites its source (ticket, page, or user-provided)
- Technical requirements use table format for structured key/value data
- Interface signatures are included as fenced code blocks when applicable
- Open items preserve the gap ID (G[N]) for cross-referencing

View File

@@ -1,212 +0,0 @@
---
name: resolving-sdk-updates
description: >
Diagnose and resolve build failures from Bitwarden SDK updates (com.bitwarden:sdk-android).
Investigates sdk-internal PRs, fixes exhaustive when expressions, and assesses behavioral
impact. Use when reviewing SDK update PRs, fixing SDK build errors, encountering "when
expression must be exhaustive" after SDK bump, updating sdk-android in libs.versions.toml,
or when any PR from the sdlc/sdk-update branch has failing CI. Triggered by "fix the SDK
update", "resolve SDK breaking changes", "check the SDK PR", "SDK version bump",
"sdk-android", "sdk-internal". Do NOT use for general dependency updates unrelated to the
Bitwarden SDK.
allowed-tools: Bash(git branch --show-current), Bash(git diff *), Bash(gh run list *), Bash(gh run view *), Bash(gh pr view *), Bash(gh pr diff *)
---
# Resolving SDK Updates
Sequential five-phase workflow for diagnosing and resolving build failures caused by Bitwarden SDK (`com.bitwarden:sdk-android`) version updates.
## Important
- **SDK repo**: `bitwarden/sdk-internal`
- **Artifact**: `com.bitwarden:sdk-android` published to GitHub Packages
- **Version catalog**: `gradle/libs.versions.toml` (key: `sdk-android`)
- **Update branch convention**: `sdlc/sdk-update`
CRITICAL: Before applying any fix, always read the `sdk-internal` PR diff to understand the author's intent. A compile fix alone may be insufficient if the SDK change introduces new behavior that requires a dedicated code path.
## Current State (preprocessed)
- **Current branch**: !`git branch --show-current`
- **SDK version diff vs main**: !`git diff main -- gradle/libs.versions.toml | grep sdk-android || echo "No SDK version change detected"`
- **Latest CI failure (sdlc/sdk-update)**: !`gh run list --branch sdlc/sdk-update --status failure --limit 1 --json databaseId,event,conclusion -q '.[0]' 2>/dev/null`
## Proactive Behavior
- If the preprocessed state above shows CI failures, skip Phase 1 and jump directly to Phase 2.
- If no CI failures exist, focus on Phase 1 (changelog review) and Phase 5 (impact assessment).
- If the SDK version diff above shows no change, confirm the branch and version catalog before proceeding.
---
## Phase 1: Identify the Update
Determine what changed in the SDK version bump. The preprocessed SDK version diff above may already provide this; verify and continue.
1. **Extract version diff** (if not already shown above) from `gradle/libs.versions.toml`:
```bash
git diff main -- gradle/libs.versions.toml | grep sdk-android
```
2. **Parse PR body** for linked `sdk-internal` PRs and Jira tickets:
- SDK PR pattern: `bitwarden/sdk-internal#NNN` or `#NNN` in context of SDK references
- Jira ticket pattern: `PM-NNNNN`
- If working from a GitHub PR: `gh pr view {PR_NUMBER} --json body -q .body`
3. **Record findings**: List each `sdk-internal` PR number and Jira ticket for subsequent phases.
---
## Phase 2: Diagnose Build Failures
Identify and categorize all compiler errors introduced by the SDK change.
1. **Extract CI errors** (if CI is failing):
```bash
gh run list --branch {BRANCH} --status failure --limit 1 --json databaseId -q '.[0].databaseId'
gh run view {RUN_ID} --log-failed | grep -E "e: |error:" | head -50
```
2. **Or build locally**:
```bash
./gradlew app:compileStandardDebugKotlin 2>&1 | grep -E "e: " | head -30
```
3. **Categorize each error** — see `references/common-fix-patterns.md` for the full error category table and fix strategies.
---
## Phase 3: Investigate SDK Changes
Understand the SDK author's intent behind each change. This determines whether a compile fix alone is sufficient or a new code path is needed.
1. **For each referenced `sdk-internal` PR**, retrieve details:
```bash
gh pr view {N} --repo bitwarden/sdk-internal --json title,body,files
```
2. **Read the diff** for breaking changes:
```bash
gh pr diff {N} --repo bitwarden/sdk-internal
```
3. **Focus on**:
- New sealed class variants (will cause exhaustive `when` breaks)
- Changed function signatures (new/removed/renamed parameters)
- New error types in Result sealed classes
- Behavioral changes described in PR body or commit messages
- Deprecation notices or migration guidance
4. **Document** each change with: what changed, why it changed (from PR description), and expected Android impact.
---
## Phase 4: Apply Fixes
Resolve each compiler error using patterns from `references/common-fix-patterns.md`.
1. **Fix each error** according to its category. For exhaustive `when` expressions, the most common fix:
```kotlin
is NewSealedVariant -> {
// Handle new case appropriately based on SDK PR context
}
```
2. **Search for non-exhaustive usages** the compiler won't catch (`when` used as statement not expression, or `when` without `else` on non-sealed types):
```bash
grep -r "AffectedTypeName\." --include="*.kt" app/src/main/kotlin/
grep -r "is AffectedTypeName" --include="*.kt" app/src/main/kotlin/
```
3. **Verify the fix compiles**:
```bash
./gradlew app:compileStandardDebugKotlin
```
4. **Run affected tests** to ensure no regressions:
```bash
./gradlew app:testStandardDebugUnitTest --tests "*.AffectedClassTest"
```
---
## Phase 5: Assess Behavioral Impact
Classify each SDK change and determine if follow-up work is needed beyond compile fixes.
1. **For each change, classify**:
- **Compile-only fix**: New variant added but no feature work needed (e.g., new error type that maps to existing generic error handling)
- **Feature-requiring**: New capability exposed by SDK that needs a new code path, UI, or business logic
2. **For feature-requiring changes**, trace Jira tickets.
First, determine if the `bitwarden-atlassian-tools` MCP plugin is available by checking whether any tools starting with `mcp__plugin_bitwarden-atlassian-tools_` are listed in your available tools.
**If the plugin IS available:**
- Search for existing Android tickets: `project = PM AND text ~ "{keyword}" AND text ~ "Android"`
- Check linked issues from SDK tickets: `issue in linkedIssues(PM-XXXXX)`
- Document: ticket ID, status, assignee, whether Android work is already planned
**If the plugin is NOT available:**
- Inform the user: "Jira ticket tracing requires the `bitwarden-atlassian-tools@bitwarden-marketplace` plugin (github.com/bitwarden/ai-plugins). I can skip ticket tracing or pause for plugin installation."
- Document the Jira ticket IDs (from Phase 1) that should be investigated manually
- Note: behavioral impact assessment will be incomplete without ticket tracing
3. **Produce summary**:
- What was fixed (files changed, error types resolved)
- What needs separate feature work (with Jira ticket references)
- Any risks or behavioral changes to call out in PR review
---
## Examples
### Example 1: SDK update PR with failing CI
User says: "Fix the build errors on PR #6615"
Actions:
1. Extract CI errors from failing run — identify exhaustive `when` breaks
2. Parse PR body for `sdk-internal` PR references
3. Read `sdk-internal` PR diff to understand new sealed variants
4. Add new branches to affected `when` expressions
5. Grep for other usages of affected types across codebase
6. Verify fix compiles and tests pass
Result: Two files fixed with new `when` branches, behavioral impact documented showing compile-only changes.
### Example 2: SDK update PR review (no failures)
User says: "Review the SDK changes in this PR"
Actions:
1. Parse PR body for changelog and `sdk-internal` PR numbers
2. Read each `sdk-internal` PR diff to understand changes
3. Classify each change as compile-only vs feature-requiring
4. Search Jira for tracking tickets on feature-requiring changes
Result: Summary of SDK changes with impact assessment and Jira ticket status.
---
## Performance Notes
- Take your time to thoroughly investigate each `sdk-internal` PR diff before applying fixes.
- Quality is more important than speed — a hasty compile fix that misses behavioral intent creates harder bugs later.
- Do not skip the codebase-wide grep in Phase 4 step 2. The compiler only catches exhaustive `when` expressions, not `when` statements.
---
## Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| 401 Unauthorized fetching SDK | Missing or expired GitHub token | Check `GITHUB_TOKEN` in `user.properties` or env; needs `read:packages` scope |
| Cannot find `sdk-internal` repo | GitHub CLI lacks access | Run `gh auth status` and verify org access; may need `gh auth refresh` |
| `sdk-internal` PR not found | PR number parsed incorrectly | Verify PR number from the update PR body; check `bitwarden/sdk-internal` directly |
| Build succeeds but tests fail | Behavioral change in SDK | Review SDK PR description for behavioral changes; may need test updates |
**Cross-references**:
- `build-test-verify` skill — build and test commands
- `reviewing-changes` skill — general dependency update review checklists
- `implementing-android-code` skill — patterns for new code paths

View File

@@ -1,195 +0,0 @@
# Common Fix Patterns for SDK Updates
Quick-reference for resolving build failures after `com.bitwarden:sdk-android` version bumps.
---
## Error Categories and Fix Strategies
### 1. Exhaustive `when` Expression
**Compiler message**: `'when' expression must be exhaustive, add necessary 'X' branch or 'else' branch instead`
**Fix strategy**:
1. Identify the sealed class/enum that gained a new variant from the SDK diff
2. Search for ALL `when` usages of that type across the codebase:
```bash
grep -rn "is ${TypeName}\." --include="*.kt" app/src/main/kotlin/
grep -rn "when.*${TypeName}" --include="*.kt" app/src/main/kotlin/
```
3. Add the new branch to each `when` expression
4. Determine handling from SDK PR context — does the new variant map to existing behavior or need new logic?
**Template**:
```kotlin
// Before (compiler error)
when (value) {
is SealedType.VariantA -> handleA()
is SealedType.VariantB -> handleB()
}
// After (new variant added)
when (value) {
is SealedType.VariantA -> handleA()
is SealedType.VariantB -> handleB()
is SealedType.VariantC -> handleC() // Added in SDK vX.Y.Z
}
```
---
### 2. Removed API
**Compiler message**: `Unresolved reference: '<functionOrPropertyName>'`
**Fix strategy**:
1. Check SDK diff for what replaced the removed API:
```bash
gh pr diff <N> --repo bitwarden/sdk-internal | grep -A5 -B5 "<removedName>"
```
2. Look for deprecation annotations in previous SDK version
3. Update call sites to use the replacement API
**Template**:
```kotlin
// Before (removed API)
sdkClient.oldMethodName(params)
// After (replacement from SDK changelog)
sdkClient.newMethodName(updatedParams)
```
---
### 3. Renamed Type
**Compiler message**: `Unresolved reference: '<TypeName>'`
**Fix strategy**:
1. Search SDK diff for the old type name to find the rename:
```bash
gh pr diff <N> --repo bitwarden/sdk-internal | grep "<OldTypeName>"
```
2. Update all imports and usages:
```bash
grep -rn "import.*<OldTypeName>" --include="*.kt" app/src/main/kotlin/
grep -rn "<OldTypeName>" --include="*.kt" app/src/main/kotlin/
```
3. Use find-and-replace across affected files
---
### 4. New Required Parameter
**Compiler message**: `No value passed for parameter '<paramName>'`
**Fix strategy**:
1. Check SDK diff for parameter semantics and default behavior:
```bash
gh pr diff <N> --repo bitwarden/sdk-internal | grep -A10 "fun.*<functionName>"
```
2. Determine appropriate value from SDK PR description
3. If parameter has a logical default for Android, use it; otherwise surface to user
**Template**:
```kotlin
// Before (missing parameter)
sdkClient.initializeCrypto(
method = method,
)
// After (new required parameter added)
sdkClient.initializeCrypto(
method = method,
newParam = appropriateValue, // Added in SDK vX.Y.Z — see PM-XXXXX
)
```
---
### 5. New Error Type
**Compiler message**: `'when' expression must be exhaustive` on Result/Error sealed class
**Fix strategy**:
1. Identify the new error variant from SDK diff
2. Determine if it maps to an existing error handling path or needs new UX
3. Search for all catch/when sites handling the parent error type:
```bash
grep -rn "is.*Error\." --include="*.kt" app/src/main/kotlin/
```
4. Add handling — often maps to existing generic error display
**Template**:
```kotlin
// Error handling with new variant
when (error) {
is SdkError.Network -> showNetworkError()
is SdkError.Auth -> showAuthError()
is SdkError.NewErrorType -> {
// Map to appropriate existing handler or add new handling
showGenericError(error.message)
}
}
```
---
## Useful Commands Reference
### CI Error Extraction
```bash
# Get failing run ID for a branch
gh run list --branch <branch> --status failure --limit 1 --json databaseId -q '.[0].databaseId'
# Extract compiler errors
gh run view <run-id> --log-failed | grep -E "e: |error:" | head -50
```
### SDK Diff Investigation
```bash
# View sdk-internal PR details
gh pr view <N> --repo bitwarden/sdk-internal --json title,body,files
# Read full diff
gh pr diff <N> --repo bitwarden/sdk-internal
# Search for specific changes in diff
gh pr diff <N> --repo bitwarden/sdk-internal | grep -B5 -A10 "<searchTerm>"
```
### Codebase Impact Search
```bash
# Find all usages of a type
grep -rn "<TypeName>" --include="*.kt" app/src/main/kotlin/
# Find all when expressions over a sealed type
grep -rn "is <TypeName>\." --include="*.kt" app/src/main/kotlin/
# Find imports of SDK types
grep -rn "import com.bitwarden.sdk.*<TypeName>" --include="*.kt" app/src/main/kotlin/
```
### Jira Ticket Search (via bitwarden-atlassian-tools MCP)
```
# Search for Android tickets related to an SDK change
project = PM AND text ~ "<keyword>" AND text ~ "Android"
# Find linked issues from an SDK ticket
issue in linkedIssues(PM-XXXXX)
# Check ticket status
project = PM AND key = PM-XXXXX
```
### Build Verification
```bash
# Compile check (fastest feedback)
./gradlew app:compileStandardDebugKotlin
# Run specific test class
./gradlew app:testStandardDebugUnitTest --tests "*.AffectedClassTest"
# Full test suite
./gradlew app:testStandardDebugUnitTest
```

View File

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

View File

@@ -8,8 +8,27 @@ inputs:
runs:
using: 'composite'
steps:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0

View File

@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Runs fastlane setBuildVersionInfo and appends Version Name/Number to GITHUB_STEP_SUMMARY.
# Usage: set-build-version.sh <version_code> [version_name] [toml_path]
VERSION_CODE="${1:?Usage: $0 <version_code> [version_name] [toml_path]}"
VERSION_NAME="${2:-}"
TOML_FILE="${3:-gradle/libs.versions.toml}"
bundle exec fastlane setBuildVersionInfo \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
VERSION_NAME=""
regex='appVersionName = "([^"]+)"'
if [[ "$(cat "$TOML_FILE")" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -31,6 +31,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
@@ -64,8 +65,43 @@ jobs:
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Check Authenticator
run: bundle exec fastlane check
@@ -92,6 +128,16 @@ jobs:
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -151,15 +197,40 @@ jobs:
- name: AZ Logout
uses: bitwarden/gh-actions/azure-logout@main
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Verify Play Store credentials
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
run: |
bundle exec fastlane run validate_play_store_json_key \
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Update app CI Build info
run: |
./scripts/update_app_ci_build_info.sh \
@@ -171,9 +242,22 @@ jobs:
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
DEFAULT_VERSION_CODE: ${{ github.run_number }}
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
bundle exec fastlane setBuildVersionInfo \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME_INPUT"
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'aab' }}

View File

@@ -20,6 +20,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
permissions:
contents: read
@@ -52,14 +53,63 @@ jobs:
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
gem install bundler:2.2.27
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
DEFAULT_VERSION_CODE: ${{ github.run_number }}
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: |
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
bundle exec fastlane setBuildVersionInfo \
versionCode:"$VERSION_CODE" \
versionName:"$VERSION_NAME_INPUT"
regex='appVersionName = "(.+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
- name: Build Test Harness Debug APK
run: ./gradlew :testharness:assembleDebug

View File

@@ -31,6 +31,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
@@ -66,8 +67,43 @@ jobs:
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Check
run: bundle exec fastlane check
@@ -101,6 +137,16 @@ jobs:
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -153,8 +199,33 @@ jobs:
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Update app CI Build info
run: |
@@ -167,9 +238,13 @@ jobs:
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_CODE: ${{ needs.version.outputs.version_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
run: |
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:$VERSION_NAME
- name: Generate release Play Store bundle
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
@@ -380,6 +455,16 @@ jobs:
with:
persist-credentials: false
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Install Fastlane
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
@@ -418,8 +503,33 @@ jobs:
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Update app CI Build info
run: |
@@ -432,9 +542,20 @@ jobs:
- name: Increment version
env:
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
VERSION_CODE: ${{ needs.version.outputs.version_number }}
VERSION_NAME: ${{ needs.version.outputs.version_name }}
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
run: |
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
bundle exec fastlane setBuildVersionInfo \
versionCode:$VERSION_CODE \
versionName:$VERSION_NAME
regex='appVersionName = "([^"]+)"'
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
VERSION_NAME="${BASH_REMATCH[1]}"
fi
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
- name: Generate F-Droid artifacts
env:
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}

View File

@@ -3,8 +3,9 @@ name: Test
on:
push:
branches:
- main
- release/**/*
- "main"
- "rc"
- "hotfix-rc"
pull_request:
types: [opened, synchronize]
merge_group:
@@ -12,45 +13,16 @@ on:
workflow_dispatch:
env:
_JAVA_VERSION: 21
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
jobs:
test-sharded:
name: "Test ${{ matrix.group }}"
test:
name: Test
runs-on: ubuntu-24.04
permissions:
packages: read
strategy:
fail-fast: false
matrix:
include:
- group: static-analysis
fastlane_method: checkLint
fastlane_options: ""
# App shards
- group: app-data
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.data.*"
- group: app-ui-auth-tools
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.auth.* --tests com.x8bit.bitwarden.ui.tools.* --tests com.x8bit.bitwarden.ui.autofill.* --tests com.x8bit.bitwarden.ui.credentials.*"
- group: app-ui-platform
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.platform.*"
- group: app-ui-vault
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.vault.*"
# Authenticator
- group: authenticator
fastlane_method: testLibraries
fastlane_options: ":authenticator"
# Library shards
- group: lib-core-network-bridge
fastlane_method: testLibraries
fastlane_options: ":core :network :cxf :authenticatorbridge :testharness"
- group: lib-data-ui
fastlane_method: testLibraries
fastlane_options: ":data :ui"
pull-requests: write
steps:
- name: Check out repo
@@ -58,101 +30,87 @@ jobs:
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Run tests
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
_GROUP: ${{ matrix.group }}
_FASTLANE_METHOD: ${{ matrix.fastlane_method }}
_FASTLANE_OPTIONS: ${{ matrix.fastlane_options }}
run: |
if [ "$_GROUP" = "app-ui-auth-tools" ]; then
_TOP_LEVEL_TESTS=$(basename -a -s .kt app/src/test/kotlin/com/x8bit/bitwarden/*Test.kt \
| xargs -I{} printf ' --tests com.x8bit.bitwarden.{}')
_FASTLANE_OPTIONS="${_FASTLANE_OPTIONS} ${_TOP_LEVEL_TESTS}"
fi
if [ "$_GROUP" = "static-analysis" ]; then
bundle exec fastlane "$_FASTLANE_METHOD"
else
bundle exec fastlane "$_FASTLANE_METHOD" target:"$_FASTLANE_OPTIONS"
fi
- name: Generate coverage report
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
bundle exec fastlane generateCoverageReport
- name: Upload to codecov.io
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
continue-on-error: true
- name: Cache Gradle files
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
os: linux
files: build/reports/kover/reportMergedCoverage.xml
flags: ${{ matrix.group }}
fail_ci_if_error: true
disable_search: true
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-v2-
- name: Cache build output
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
${{ github.workspace }}/build-cache
key: ${{ runner.os }}-build-cache-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
distribution: "temurin"
java-version: ${{ env._JAVA_VERSION }}
- name: Install Fastlane
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Build and test
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
run: |
bundle exec fastlane check
- name: Upload test reports
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: always()
with:
name: test-reports-${{ matrix.group }}
name: test-reports
path: |
**/build/reports/tests/
app/build/reports/lint-results-*.html
app/build/reports/detekt/
if-no-files-found: warn
build/reports/kover/reportMergedCoverage.xml
app/build/reports/tests/
authenticator/build/reports/tests/
authenticatorbridge/build/reports/tests/
core/build/reports/tests/
data/build/reports/tests/
network/build/reports/tests/
ui/build/reports/tests/
coverage-notify:
name: Coverage Notification
runs-on: ubuntu-24.04
needs: test-sharded
if: always() && !cancelled() && (github.event_name == 'push' || github.event_name == 'pull_request')
permissions:
pull-requests: write
steps:
- name: Notify Codecov that all uploads are complete
id: codecov-notify
- name: Upload to codecov.io
id: upload-to-codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
if: github.event_name == 'push' || github.event_name == 'pull_request'
continue-on-error: true
with:
run_command: send-notifications
os: linux
files: build/reports/kover/reportMergedCoverage.xml
fail_ci_if_error: true
disable_search: true
- name: Comment PR if coverage notification failed
if: steps.codecov-notify.outcome == 'failure'
- name: Comment PR if tests failed
if: steps.upload-to-codecov.outcome == 'failure' && (github.event_name == 'push' || github.event_name == 'pull_request')
env:
PR_NUMBER: ${{ github.event.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUN_ACTOR: ${{ github.triggering_actor }}
run: |
echo "> [!WARNING]" >> "$GITHUB_STEP_SUMMARY"
echo "> Uploading code coverage report failed. Please check the \"Notify Codecov\" step for more details." >> "$GITHUB_STEP_SUMMARY"
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> "$GITHUB_STEP_SUMMARY"
if [ -n "$PR_NUMBER" ]; then
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Coverage Notification" step of [Test]('$_GITHUB_ACTION_RUN_URL') for more details.'
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Upload to codecov.io" step of [Process Test Reports job]('$_GITHUB_ACTION_RUN_URL') for more details.'
gh pr comment --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --body "$message"
fi
test:
name: Test
runs-on: ubuntu-24.04
permissions: {}
needs: test-sharded
if: always()
steps:
- name: Ensure sharded tests passed
env:
TESTS_RESULT: ${{ needs.test-sharded.result }}
run: |
if [ "$TESTS_RESULT" != "success" ]; then
echo "❌ Tests failed"
exit 1
fi
echo "✅ All tests passed!"

View File

@@ -210,7 +210,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="sso-cookie-vendor"
android:host="sso_cookie_vendor"
android:scheme="bitwarden" />
</intent-filter>
</activity>

View File

@@ -84,10 +84,6 @@ 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
@@ -114,7 +110,6 @@ class MainActivity : AppCompatActivity() {
duo = duoLauncher,
sso = ssoLauncher,
webAuthn = webAuthnLauncher,
cookie = cookieLauncher,
),
) {
ObserveScreenDataEffect(

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.util.getProviderImportCredentialsRequest
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.share.ShareManager
@@ -17,7 +18,6 @@ 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
@@ -49,11 +49,11 @@ import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
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
@@ -165,9 +165,19 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
combine(
authRepository.userStateFlow,
cookieAcquisitionRequestManager.cookieAcquisitionRequestFlow,
) { userState, request ->
userState != null &&
userState.activeAccount.isVaultUnlocked &&
request != null &&
request.hostname ==
userState.activeAccount.environment.environmentUrlData
.baseWebVaultUrlOrDefault
}
.distinctUntilChanged()
.filter { it }
.map { MainAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
@@ -196,7 +206,6 @@ 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)
}
}
@@ -240,12 +249,6 @@ 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()
@@ -541,13 +544,6 @@ 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.
*/

View File

@@ -8,7 +8,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.Instant
import java.time.ZonedDateTime
/**
* Represents the current account information for a given user.
@@ -37,8 +37,8 @@ data class AccountJson(
*
* @property userId The ID of the user.
* @property email The user's email address.
* @property isEmailVerified Whether the user's email is verified.
* @property isTwoFactorEnabled If the profile has two-factor authentication enabled.
* @property isEmailVerified Whether or not the user's email is verified.
* @property isTwoFactorEnabled If the profile has two factor authentication enabled.
* @property name The user's name (if applicable).
* @property stamp The account's security stamp (if applicable).
* @property organizationId The ID of the associated organization (if applicable).
@@ -103,7 +103,7 @@ data class AccountJson(
@SerialName("creationDate")
@Contextual
val creationDate: Instant?,
val creationDate: ZonedDateTime?,
)
/**

View File

@@ -128,6 +128,7 @@ class AuthRequestManagerImpl(
updateAuthRequest
.creationDate
.toInstant()
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
.isBefore(clock.instant()) -> {
clearPendingAuthRequest()
@@ -198,6 +199,7 @@ class AuthRequestManagerImpl(
updateAuthRequest
.creationDate
.toInstant()
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
.isBefore(clock.instant()) -> {
isComplete = true

View File

@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.Instant
import java.time.ZonedDateTime
/**
* Represents a Login Approval request.
@@ -27,8 +27,8 @@ data class AuthRequest(
val ipAddress: String,
val key: String?,
val masterPasswordHash: String?,
val creationDate: Instant,
val responseDate: Instant?,
val creationDate: ZonedDateTime,
val responseDate: ZonedDateTime?,
val requestApproved: Boolean,
val originUrl: String,
val fingerprint: String,

View File

@@ -136,7 +136,7 @@ interface AuthRepository :
val organizations: List<Organization>
/**
* Whether the welcome carousel should be displayed, based on the feature flag and
* Whether or not the welcome carousel should be displayed, based on the feature flag and
* whether the user has ever logged in or created an account before.
*/
val showWelcomeCarousel: Boolean

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
@@ -44,7 +43,7 @@ data class UserState(
* @property isPremium `true` if the account has a premium membership.
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
* authentication to view their vault.
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
* @property needsPasswordReset If the user needs to reset their password.
* @property needsMasterPassword Indicates whether the user needs to create a password (e.g.
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
@@ -97,32 +96,4 @@ data class UserState(
val hasLoginApprovingDevice: Boolean,
val hasResetPasswordPermission: Boolean,
)
@Suppress("UndocumentedPublicClass")
companion object {
/**
* A basic empty account model.
*/
val EMPTY_ACCOUNT: Account = Account(
userId = "",
name = null,
email = "",
avatarColorHex = "".toHexColorRepresentation(),
environment = Environment.Us,
isPremium = false,
isLoggedIn = false,
isVaultUnlocked = false,
needsPasswordReset = false,
organizations = emptyList(),
isBiometricsEnabled = false,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
needsMasterPassword = false,
hasMasterPassword = true,
trustedDevice = null,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(),
isExportable = false,
)
}
}

View File

@@ -1,16 +1,14 @@
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"
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"
@@ -27,36 +25,15 @@ fun Intent.getCookieCallbackResultOrNull(): CookieCallbackResult? {
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
val cookies = uri.queryParameterNames
.asSequence()
.filter { it != COMPLETENESS_MARKER_PARAM }
.mapNotNull { name ->
getQueryParameter(name)?.takeIf { it.isNotEmpty() }?.let { name to it }
uri.getQueryParameter(name)?.takeIf { it.isNotEmpty() }?.let { name to it }
}
.toMap()
return if (cookies.isEmpty()) {
CookieCallbackResult.MissingCookie
} else {

View File

@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.manager
import kotlinx.coroutines.flow.StateFlow
/**
* A container for values specifying whether the accessibility service is enabled.
* A container for values specifying whether or not the accessibility service is enabled.
*/
interface AccessibilityEnabledManager {
/**

View File

@@ -5,13 +5,13 @@ import android.view.autofill.AutofillManager
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bitwarden.data.manager.appstate.AppStateManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManagerImpl
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn

View File

@@ -2,10 +2,10 @@ package com.x8bit.bitwarden.data.autofill.manager
import android.view.autofill.AutofillManager
import androidx.lifecycle.LifecycleCoroutineScope
import com.bitwarden.data.manager.appstate.AppStateManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManager
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

View File

@@ -3,12 +3,12 @@ package com.x8bit.bitwarden.data.autofill.manager
import kotlinx.coroutines.flow.StateFlow
/**
* A container for values specifying whether autofill is enabled. These values should be
* A container for values specifying whether or not autofill is enabled. These values should be
* filled by an [AutofillActivityManager].
*/
interface AutofillEnabledManager {
/**
* Whether autofill should be considered enabled.
* Whether or not autofill should be considered enabled.
*
* Note that changing this does not enable or disable autofill; it is only an indicator that
* this has occurred elsewhere.

View File

@@ -14,7 +14,7 @@ sealed class AutofillCipher {
abstract val iconRes: Int
/**
* Whether TOTP is enabled for this cipher.
* Whether or not TOTP is enabled for this cipher.
*/
abstract val isTotpEnabled: Boolean

View File

@@ -2,6 +2,7 @@
package com.x8bit.bitwarden.data.autofill.util
import android.app.Activity
import android.app.PendingIntent
import android.app.assist.AssistStructure
import android.content.Context
@@ -12,10 +13,6 @@ import android.view.autofill.AutofillManager
import androidx.core.os.bundleOf
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.util.toPendingIntentMutabilityFlag
import com.bitwarden.data.autofill.util.AUTOFILL_BUNDLE_KEY
import com.bitwarden.data.autofill.util.AUTOFILL_CALLBACK_DATA_KEY
import com.bitwarden.data.autofill.util.AUTOFILL_SAVE_ITEM_DATA_KEY
import com.bitwarden.data.autofill.util.AUTOFILL_SELECTION_DATA_KEY
import com.bitwarden.ui.platform.util.getSafeParcelableExtra
import com.x8bit.bitwarden.AutofillCallbackActivity
import com.x8bit.bitwarden.MainActivity
@@ -25,6 +22,11 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import kotlin.random.Random
private const val AUTOFILL_SAVE_ITEM_DATA_KEY = "autofill-save-item-data"
private const val AUTOFILL_SELECTION_DATA_KEY = "autofill-selection-data"
private const val AUTOFILL_CALLBACK_DATA_KEY = "autofill-callback-data"
private const val AUTOFILL_BUNDLE_KEY = "autofill-bundle-key"
/**
* Creates an [Intent] in order to send the user to a manual selection process for autofill.
*/
@@ -147,3 +149,12 @@ fun Intent.getAutofillSelectionDataOrNull(): AutofillSelectionData? =
fun Intent.getAutofillCallbackIntentOrNull(): AutofillCallbackData? =
getBundleExtra(AUTOFILL_BUNDLE_KEY)
?.getSafeParcelableExtra(AUTOFILL_CALLBACK_DATA_KEY)
/**
* Checks if the given [Activity] was created for Autofill. This is useful to avoid locking the
* vault if one of the Autofill services starts the only instance of the [MainActivity].
*/
val Activity.createdForAutofill: Boolean
get() = intent.getAutofillSelectionDataOrNull() != null ||
intent.getAutofillSaveItemOrNull() != null ||
intent.getAutofillAssistStructureOrNull() != null

View File

@@ -57,7 +57,7 @@ interface BitwardenCredentialManager {
): Fido2CredentialAssertionResult
/**
* Whether the user has authentication attempts remaining.
* Whether or not the user has authentication attempts remaining.
*/
fun hasAuthenticationAttemptsRemaining(): Boolean

View File

@@ -28,7 +28,7 @@ class OriginManagerImpl(
callingAppInfo: CallingAppInfo,
): ValidateOriginResult {
return if (callingAppInfo.isOriginPopulated()) {
validatePrivilegedAppOrigin(relyingPartyId, callingAppInfo)
validatePrivilegedAppOrigin(callingAppInfo)
} else {
validateCallingApplicationAssetLinks(relyingPartyId, callingAppInfo)
}
@@ -64,58 +64,44 @@ class OriginManagerImpl(
}
private suspend fun validatePrivilegedAppOrigin(
relyingPartyId: String,
callingAppInfo: CallingAppInfo,
): ValidateOriginResult =
validatePrivilegedAppSignatureWithGoogleList(relyingPartyId, callingAppInfo)
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
?: validatePrivilegedAppSignatureWithCommunityList(relyingPartyId, callingAppInfo)
?: validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
?: validatePrivilegedAppSignatureWithUserTrustList(relyingPartyId, callingAppInfo)
?: validatePrivilegedAppSignatureWithUserTrustList(callingAppInfo)
private suspend fun validatePrivilegedAppSignatureWithGoogleList(
relyingPartyId: String,
callingAppInfo: CallingAppInfo,
): ValidateOriginResult =
validatePrivilegedAppSignatureWithAllowList(
relyingPartyId = relyingPartyId,
callingAppInfo = callingAppInfo,
fileName = GOOGLE_ALLOW_LIST_FILE_NAME,
isVerifiedSource = true,
)
private suspend fun validatePrivilegedAppSignatureWithCommunityList(
relyingPartyId: String,
callingAppInfo: CallingAppInfo,
): ValidateOriginResult = validatePrivilegedAppSignatureWithAllowList(
relyingPartyId = relyingPartyId,
callingAppInfo = callingAppInfo,
fileName = COMMUNITY_ALLOW_LIST_FILE_NAME,
isVerifiedSource = false,
)
private suspend fun validatePrivilegedAppSignatureWithUserTrustList(
relyingPartyId: String,
callingAppInfo: CallingAppInfo,
): ValidateOriginResult = callingAppInfo.validatePrivilegedApp(
relyingPartyId = relyingPartyId,
allowList = privilegedAppRepository.getUserTrustedAllowListJson(),
isVerifiedSource = true,
)
private suspend fun validatePrivilegedAppSignatureWithAllowList(
relyingPartyId: String,
callingAppInfo: CallingAppInfo,
fileName: String,
isVerifiedSource: Boolean,
): ValidateOriginResult =
assetManager
.readAsset(fileName)
.mapCatching { allowList ->
callingAppInfo.validatePrivilegedApp(
relyingPartyId = relyingPartyId,
allowList = allowList,
isVerifiedSource = isVerifiedSource,
)
}
.fold(

View File

@@ -22,9 +22,4 @@ interface CookieDiskSource {
* @param config The [CookieConfigurationData] to persist, or `null` to delete.
*/
fun storeCookieConfig(hostname: String, config: CookieConfigurationData?)
/**
* Clears all stored cookie configurations across all hostnames.
*/
fun clearCookies()
}

View File

@@ -1,14 +1,12 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import androidx.core.content.edit
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"
private const val ENCRYPTED_PREFIX = "bwSecureStorage:$CONFIG_PREFIX"
/**
* Implementation of [CookieDiskSource] using encrypted SharedPreferences.
@@ -17,7 +15,7 @@ private const val ENCRYPTED_PREFIX = "bwSecureStorage:$CONFIG_PREFIX"
*/
class CookieDiskSourceImpl(
sharedPreferences: SharedPreferences,
private val encryptedSharedPreferences: SharedPreferences,
encryptedSharedPreferences: SharedPreferences,
private val json: Json,
) : CookieDiskSource,
BaseEncryptedDiskSource(
@@ -35,14 +33,4 @@ class CookieDiskSourceImpl(
val key = CONFIG_PREFIX.appendIdentifier(hostname)
putEncryptedString(key, config?.let { json.encodeToString(it) })
}
override fun clearCookies() {
val keysToRemove = encryptedSharedPreferences
.all
.keys
.filter { it.startsWith(ENCRYPTED_PREFIX) }
encryptedSharedPreferences.edit {
keysToRemove.forEach { key -> remove(key) }
}
}
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import java.time.Instant
import java.time.ZonedDateTime
/**
* 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): Instant?
fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime?
/**
* 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: Instant?)
fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: ZonedDateTime?)
}

View File

@@ -1,10 +1,10 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.util.getBinaryLongFromInstant
import com.bitwarden.core.util.getInstantFromBinaryLong
import com.bitwarden.core.util.getBinaryLongFromZoneDateTime
import com.bitwarden.core.util.getZoneDateTimeFromBinaryLong
import com.bitwarden.data.datasource.disk.BaseDiskSource
import java.time.Instant
import java.time.ZonedDateTime
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): Instant? {
override fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? {
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId))
?.let { getInstantFromBinaryLong(it) }
?.let { getZoneDateTimeFromBinaryLong(it) }
}
override fun storeCurrentPushToken(userId: String, pushToken: String?) {
@@ -49,11 +49,11 @@ class PushDiskSourceImpl(
override fun storeLastPushTokenRegistrationDate(
userId: String,
registrationDate: Instant?,
registrationDate: ZonedDateTime?,
) {
putLong(
key = LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId),
value = registrationDate?.let { getBinaryLongFromInstant(registrationDate) },
value = registrationDate?.let { getBinaryLongFromZoneDateTime(registrationDate) },
)
}
}

View File

@@ -229,7 +229,7 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun storeDefaultUriMatchType(userId: String, uriMatchType: UriMatchType?)
/**
* Gets the value for whether the autofill save prompt should be disabled for the
* Gets the value for whether or not the autofill save prompt should be disabled for the
* given [userId].
*/
fun getAutofillSavePromptDisabled(userId: String): Boolean?
@@ -295,13 +295,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getUserHasSignedInPreviously(userId: String): Boolean
/**
* Gets whether the given [userId] has signaled they want to enable autofill in
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowBrowserAutofillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable the browser autofill integration in onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(userId: String, showBadge: Boolean?)
@@ -312,13 +312,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowBrowserAutofillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to enable autofill in
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowAutoFillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable autofill in onboarding.
*/
fun storeShowAutoFillSettingBadge(userId: String, showBadge: Boolean?)
@@ -329,13 +329,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowAutoFillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to enable unlock options
* Gets whether or not the given [userId] has signalled they want to enable unlock options
* later, during onboarding.
*/
fun getShowUnlockSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* set up unlock options later, during onboarding.
*/
fun storeShowUnlockSettingBadge(userId: String, showBadge: Boolean?)
@@ -346,12 +346,12 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowUnlockSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to import logins later.
* Gets whether or not the given [userId] has signalled they want to import logins later.
*/
fun getShowImportLoginsSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* set import logins later, during first time usage.
*/
fun storeShowImportLoginsSettingBadge(userId: String, showBadge: Boolean?)
@@ -362,13 +362,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowImportLoginsSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the application has registered for export via the credential exchange
* Gets whether or not the application has registered for export via the credential exchange
* protocol.
*/
fun getAppRegisteredForExport(): Boolean?
/**
* Stores the given value for whether the application has registered for export via
* Stores the given value for whether or not the application has registered for export via
* the credential exchange protocol.
*/
fun storeAppRegisteredForExport(isRegistered: Boolean?)

View File

@@ -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.InstantTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
/**
* Room database for storing any persisted data for platform data.
@@ -21,7 +21,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConve
AutoMigration(from = 1, to = 2),
],
)
@TypeConverters(InstantTypeConverter::class)
@TypeConverters(ZonedDateTimeTypeConverter::class)
abstract class PlatformDatabase : RoomDatabase() {
/**
* Provides the DAO for accessing organization event data.

View File

@@ -31,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.InstantTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -71,7 +71,7 @@ object PlatformDiskModule {
name = "platform_database",
)
.fallbackToDestructiveMigration(dropAllTables = false)
.addTypeConverter(InstantTypeConverter())
.addTypeConverter(ZonedDateTimeTypeConverter())
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
.build()

View File

@@ -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.Instant
import java.time.ZonedDateTime
/**
* Entity representing an organization event in the database.
@@ -24,7 +24,7 @@ data class OrganizationEventEntity(
val cipherId: String?,
@ColumnInfo(name = "date")
val date: Instant,
val date: ZonedDateTime,
@ColumnInfo(name = "organization_id")
val organizationId: String?,

View File

@@ -1,7 +1,8 @@
package com.bitwarden.data.manager.appstate
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.data.manager.appstate.model.AppCreationState
import com.bitwarden.data.manager.appstate.model.AppForegroundState
import com.x8bit.bitwarden.data.autofill.accessibility.BitwardenAccessibilityService
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import kotlinx.coroutines.flow.StateFlow
/**
@@ -11,8 +12,8 @@ interface AppStateManager {
/**
* Emits whenever there are changes to the app creation state.
*
* This is required because the Accessibility Service will keep the app process alive when the
* app would otherwise be destroyed.
* This is required because the [BitwardenAccessibilityService] will keep the app process alive
* when the app would otherwise be destroyed.
*/
val appCreatedStateFlow: StateFlow<AppCreationState>

View File

@@ -1,4 +1,4 @@
package com.bitwarden.data.manager.appstate
package com.x8bit.bitwarden.data.platform.manager
import android.app.Activity
import android.app.Application
@@ -6,9 +6,9 @@ import android.os.Bundle
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.bitwarden.data.autofill.util.createdForAutofill
import com.bitwarden.data.manager.appstate.model.AppCreationState
import com.bitwarden.data.manager.appstate.model.AppForegroundState
import com.x8bit.bitwarden.data.autofill.util.createdForAutofill
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -17,7 +17,7 @@ import timber.log.Timber
/**
* Primary implementation of [AppStateManager].
*/
internal class AppStateManagerImpl(
class AppStateManagerImpl(
application: Application,
processLifecycleOwner: LifecycleOwner = ProcessLifecycleOwner.get(),
) : AppStateManager {

View File

@@ -19,7 +19,8 @@ class FeatureFlagManagerImpl(
override val sdkFeatureFlags: Map<String, Boolean>
get() = mapOf(
CIPHER_KEY_ENCRYPTION_KEY to serverConfigRepository.isCipherKeyEncryptionEnabled,
CIPHER_KEY_ENCRYPTION_KEY to
getCipherKeyEncryptionFlagState(),
)
override fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T> =
@@ -42,13 +43,17 @@ class FeatureFlagManagerImpl(
.serverConfigStateFlow
.value
.getFlagValueOrDefault(key = key)
}
/**
* Get the computed value of the cipher key encryption flag based on server version.
*/
private val ServerConfigRepository.isCipherKeyEncryptionEnabled: Boolean
get() = isServerVersionAtLeast(serverConfigStateFlow.value, CIPHER_KEY_ENC_MIN_SERVER_VERSION)
/**
* Get the computed value of the cipher key encryption flag based on server version and
* remote flag.
*/
private fun getCipherKeyEncryptionFlagState() =
isServerVersionAtLeast(
serverConfigRepository.serverConfigStateFlow.value,
CIPHER_KEY_ENC_MIN_SERVER_VERSION,
) && getFeatureFlag(FlagKey.CipherKeyEncryption)
}
/**
* Extract the value of a [FlagKey] from the [ServerConfig]. If there is an issue with retrieving

View File

@@ -58,19 +58,19 @@ interface FirstTimeActionManager {
val currentOrDefaultUserFirstTimeState: FirstTimeState
/**
* Stores the given value for whether the active user has signaled they want to
* Stores the given value for whether or not the active user has signalled they want to
* set up unlock options later, during onboarding.
*/
fun storeShowUnlockSettingBadge(showBadge: Boolean)
/**
* Stores the given value for whether the active user has signaled they want to
* Stores the given value for whether or not the active user has signalled they want to
* enable the browser autofill integration later, during onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(showBadge: Boolean)
/**
* Stores the given value for whether the active user has signaled they want to
* Stores the given value for whether or not the active user has signalled they want to
* enable autofill later, during onboarding.
*/
fun storeShowAutoFillSettingBadge(showBadge: Boolean)

View File

@@ -31,6 +31,8 @@ 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
@@ -338,7 +340,8 @@ 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) ?: return
val lastRegistration =
pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toInstant() ?: return
val updateTime = clock.instant().minus(PUSH_TOKEN_UPDATE_DELAY.toJavaDuration())
if (updateTime.isBefore(lastRegistration)) return
}
@@ -351,7 +354,7 @@ class PushManagerImpl @Inject constructor(
onSuccess = {
pushDiskSource.storeLastPushTokenRegistrationDate(
userId = userId,
registrationDate = clock.instant(),
registrationDate = ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC),
)
pushDiskSource.storeCurrentPushToken(
userId = userId,

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager
/**
* Responsible for managing whether the app review prompt should be shown.
* Responsible for managing whether or not the app review prompt should be shown.
*/
interface ReviewPromptManager {
/**
@@ -20,7 +20,8 @@ interface ReviewPromptManager {
fun registerCreateSendAction()
/**
* Returns a boolean value indicating whether the user should be prompted to review the app.
* Returns a boolean value indicating whether or not the user should be prompted to
* review the app.
*/
fun shouldPromptForAppReview(): Boolean
}

View File

@@ -142,7 +142,7 @@ private fun getMatchingDomains(
* @param cipherListView The cipher to be judged for a match.
* @param resourceCacheManager The [ResourceCacheManager] for fetching cached resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether the [matchUri] belongs to an Android app.
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
* @param matchUri The uri that this cipher is being matched to.
*/
@@ -180,7 +180,7 @@ private fun checkForCipherMatch(
*
* @param resourceCacheManager The [ResourceCacheManager] for fetching cached resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether the [matchUri] belongs to an Android app.
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
* @param matchUri The uri that this [LoginUriView] is being matched to.
*/

View File

@@ -30,6 +30,8 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.AppResumeManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.AppStateManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.AssetManagerImpl
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
@@ -104,6 +106,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformManagerModule {
@Provides
@Singleton
fun provideAppStateManager(
application: Application,
): AppStateManager = AppStateManagerImpl(application = application)
@Provides
@Singleton
fun provideAuthenticatorBridgeProcessor(

View File

@@ -18,6 +18,7 @@ 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
@@ -77,7 +78,7 @@ class OrganizationEventManagerImpl(
event = OrganizationEventJson(
type = event.type,
cipherId = event.cipherId,
date = clock.instant(),
date = ZonedDateTime.now(clock),
organizationId = event.organizationId,
),
)

View File

@@ -1,4 +1,4 @@
package com.bitwarden.data.manager.appstate.model
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents the creation state of the app.

View File

@@ -1,4 +1,4 @@
package com.bitwarden.data.manager.appstate.model
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Represents the foreground state of the app.

View File

@@ -5,7 +5,7 @@ import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.Instant
import java.time.ZonedDateTime
/**
* 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: Instant?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : 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: Instant?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -55,7 +55,7 @@ sealed class NotificationPayload {
@Contextual
@JsonNames("Date", "date")
val date: Instant?,
val date: ZonedDateTime?,
@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: Instant?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.model
import java.time.Instant
import java.time.ZonedDateTime
/**
* Required data for sync cipher upsert operations.
@@ -9,12 +9,12 @@ import java.time.Instant
* @property cipherId The cipher ID.
* @property revisionDate The cipher's revision date. This is used to determine if the local copy of
* the cipher is out-of-date.
* @property isUpdate Whether this is an update of an existing cipher.
* @property isUpdate Whether or not this is an update of an existing cipher.
*/
data class SyncCipherUpsertData(
val userId: String,
val cipherId: String,
val revisionDate: Instant,
val revisionDate: ZonedDateTime,
val organizationId: String?,
val collectionIds: List<String>?,
val isUpdate: Boolean,

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.model
import java.time.Instant
import java.time.ZonedDateTime
/**
* Required data for sync folder upsert operations.
@@ -9,11 +9,11 @@ import java.time.Instant
* @property folderId The folder ID.
* @property revisionDate The folder's revision date. This is used to determine if the local copy of
* the folder is out-of-date.
* @property isUpdate Whether this is an update of an existing folder.
* @property isUpdate Whether or not this is an update of an existing folder.
*/
data class SyncFolderUpsertData(
val userId: String,
val folderId: String,
val revisionDate: Instant,
val revisionDate: ZonedDateTime,
val isUpdate: Boolean,
)

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.model
import java.time.Instant
import java.time.ZonedDateTime
/**
* Required data for sync send upsert operations.
@@ -8,12 +8,12 @@ import java.time.Instant
* @property userId The user ID associated with this update.
* @property sendId The send ID.
* @property revisionDate The send's revision date. This is used to determine if the local copy of
* the Send is out-of-date.
* @property isUpdate Whether this is an update of an existing send.
* the send is out-of-date.
* @property isUpdate Whether or not this is an update of an existing send.
*/
data class SyncSendUpsertData(
val userId: String,
val sendId: String,
val revisionDate: Instant,
val revisionDate: ZonedDateTime,
val isUpdate: Boolean,
)

View File

@@ -5,13 +5,4 @@ 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>)
}
interface NetworkCookieManager : CookieProvider

View File

@@ -3,11 +3,9 @@ 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
import timber.log.Timber
private const val BOOTSTRAP_TYPE_SSO_COOKIE_VENDOR = "ssoCookieVendor"
@@ -20,101 +18,39 @@ class NetworkCookieManagerImpl(
private val cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
) : NetworkCookieManager {
/**
* Returns the configured cookie domain from the server config, or null if not set.
*/
private val cookieDomain: String?
get() = configDiskSource
.serverConfig
?.serverData
?.communication
?.bootstrap
?.takeIf { it.type == BOOTSTRAP_TYPE_SSO_COOKIE_VENDOR }
?.cookieDomain
override fun needsBootstrap(hostname: String): Boolean {
val result = configDiskSource
.serverConfig
?.serverData
?.communication
?.bootstrap
?.type
?.let { bootstrapType ->
when (bootstrapType) {
BOOTSTRAP_TYPE_SSO_COOKIE_VENDOR -> {
val resolved = resolveHostname(hostname)
cookieDiskSource
.getCookieConfig(hostname = resolved)
?.cookies
?.none() != false
}
else -> false
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
}
}
?: false
Timber.d("needsBootstrap($hostname): $result (cookieDomain=$cookieDomain)")
return result
}
override fun getCookies(hostname: String): List<NetworkCookie> {
val resolved = resolveHostname(hostname)
val cookies = cookieDiskSource
.getCookieConfig(hostname = resolved)
?.cookies
.toNetworkCookieList()
Timber.d("getCookies($hostname): resolved=$resolved, count=${cookies.size}")
return cookies
}
else -> false
}
}
?: false
override fun getCookies(hostname: String): List<NetworkCookie> = cookieDiskSource
.getCookieConfig(hostname = hostname)
?.cookies
.toNetworkCookieList()
override fun acquireCookies(hostname: String) {
Timber.d("acquireCookies($hostname): requesting cookie acquisition")
cookieAcquisitionRequestManager.setPendingCookieAcquisition(
CookieAcquisitionRequest(
hostname = hostname,
),
)
}
override fun storeCookies(hostname: String, cookies: Map<String, String>) {
val resolvedHostname = cookieDomain ?: hostname
Timber.d(
"storeCookies($hostname): storing ${cookies.size} cookies under $resolvedHostname",
)
cookieDiskSource.storeCookieConfig(
hostname = resolvedHostname,
config = CookieConfigurationData(
hostname = resolvedHostname,
cookies = cookies.map { (name, value) ->
CookieConfigurationData.Cookie(name = name, value = value)
},
),
)
}
/**
* Resolves the storage key for a given [hostname] by performing domain-suffix fallback.
*
* Tries the exact hostname first, then progressively strips the leftmost DNS label
* until a stored cookie configuration is found or no labels remain. This supports
* the case where cookies are stored under a parent domain (e.g., "bitwarden.com")
* but looked up by a subdomain (e.g., "api.bitwarden.com").
*/
private fun resolveHostname(hostname: String): String {
var domain = hostname
while (true) {
if (cookieDiskSource.getCookieConfig(hostname = domain) != null) {
if (domain != hostname) {
Timber.d("resolveHostname($hostname): resolved to $domain")
}
return domain
}
val dotIndex = domain.indexOf('.')
if (dotIndex < 0) {
Timber.d("resolveHostname($hostname): no stored config found, using original")
return hostname
}
domain = domain.substring(dotIndex + 1)
}
}
}

View File

@@ -40,33 +40,4 @@ class SdkCipherRepository(
cipher = value.toEncryptedNetworkCipherResponse(encryptedFor = userId),
)
}
override suspend fun setBulk(values: Map<String, Cipher>) {
val validEntries = values.filter { (id, cipher) ->
if (id != cipher.id) {
Timber.e(
"SDK Cipher 'setBulk' operation: ID's do not match for '$id'",
)
false
} else {
true
}
}
if (validEntries.isEmpty()) return
vaultDiskSource.saveCiphers(
userId = userId,
ciphers = validEntries.values.map {
it.toEncryptedNetworkCipherResponse(encryptedFor = userId)
},
)
}
override suspend fun removeBulk(keys: List<String>) {
if (keys.isEmpty()) return
vaultDiskSource.deleteSelectedCiphers(userId = userId, cipherIds = keys)
}
override suspend fun removeAll() {
vaultDiskSource.deleteAllCiphers(userId = userId)
}
}

View File

@@ -156,7 +156,6 @@ class AuthenticatorBridgeRepositoryImpl(
method = InitUserCryptoMethod.DecryptedKey(
decryptedUserKey = decryptedUserKey,
),
upgradeToken = null,
),
)
.flatMap { result ->

View File

@@ -49,9 +49,4 @@ interface DebugMenuRepository {
* @param userStateUpdateTrigger A passable lambda to trigger a user state update.
*/
fun modifyStateToShowOnboardingCarousel(userStateUpdateTrigger: () -> Unit)
/**
* Clears all stored SSO cookie configurations.
*/
fun clearSsoCookies()
}

View File

@@ -6,7 +6,6 @@ import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault
@@ -21,7 +20,6 @@ class DebugMenuRepositoryImpl(
private val serverConfigRepository: ServerConfigRepository,
private val settingsDiskSource: SettingsDiskSource,
private val authDiskSource: AuthDiskSource,
private val cookieDiskSource: CookieDiskSource,
) : DebugMenuRepository {
private val mutableOverridesUpdatedFlow = bufferedMutableSharedFlow<Unit>(replay = 1)
@@ -70,8 +68,4 @@ class DebugMenuRepositoryImpl(
settingsDiskSource.hasUserLoggedInOrCreatedAccount = false
userStateUpdateTrigger.invoke()
}
override fun clearSsoCookies() {
cookieDiskSource.clearCookies()
}
}

View File

@@ -121,7 +121,7 @@ interface SettingsRepository : FlightRecorderManager {
var defaultUriMatchType: UriMatchType
/**
* Whether biometric unlocking is enabled for the current user.
* Whether or not biometric unlocking is enabled for the current user.
*/
val isUnlockWithBiometricsEnabled: Boolean
@@ -131,7 +131,7 @@ interface SettingsRepository : FlightRecorderManager {
val isUnlockWithBiometricsEnabledFlow: Flow<Boolean>
/**
* Whether PIN unlocking is enabled for the current user.
* Whether or not PIN unlocking is enabled for the current user.
*/
val isUnlockWithPinEnabled: Boolean
@@ -141,17 +141,17 @@ interface SettingsRepository : FlightRecorderManager {
val isUnlockWithPinEnabledFlow: Flow<Boolean>
/**
* Whether inline autofill is enabled for the current user.
* Whether or not inline autofill is enabled for the current user.
*/
var isInlineAutofillEnabled: Boolean
/**
* Whether the auto copying totp when autofilling is disabled for the current user.
* Whether or not the auto copying totp when autofilling is disabled for the current user.
*/
var isAutoCopyTotpDisabled: Boolean
/**
* Whether the autofill save prompt is disabled for the current user.
* Whether or not the autofill save prompt is disabled for the current user.
*/
var isAutofillSavePromptDisabled: Boolean
@@ -178,12 +178,12 @@ interface SettingsRepository : FlightRecorderManager {
val isAutofillEnabledStateFlow: StateFlow<Boolean>
/**
* Sets whether screen capture is allowed for the current user.
* Sets whether or not screen capture is allowed for the current user.
*/
var isScreenCaptureAllowed: Boolean
/**
* Whether screen capture is allowed for the current user.
* Whether or not screen capture is allowed for the current user.
*/
val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>

View File

@@ -7,7 +7,6 @@ import com.bitwarden.data.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -93,12 +92,10 @@ object PlatformRepositoryModule {
serverConfigRepository: ServerConfigRepository,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
cookieDiskSource: CookieDiskSource,
): DebugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
serverConfigRepository = serverConfigRepository,
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
cookieDiskSource = cookieDiskSource,
)
}

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.platform.util
import android.util.Base64
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.ui.platform.base.util.prefixHttpsIfNecessary
import com.x8bit.bitwarden.data.credentials.model.ValidateOriginResult
import java.security.MessageDigest
@@ -10,6 +9,7 @@ import java.security.MessageDigest
* Returns the application's signing certificate hash formatted as a hex string if it has a single
* signing certificate. Otherwise `null` is returned.
*/
@OptIn(ExperimentalStdlibApi::class)
fun CallingAppInfo.getSignatureFingerprintAsHexString(): String? {
return getAppSigningSignatureFingerprint()
?.joinToString(":") { b ->
@@ -21,11 +21,8 @@ fun CallingAppInfo.getSignatureFingerprintAsHexString(): String? {
* Returns true if this [CallingAppInfo] is present in the privileged app [allowList]. Otherwise,
* returns false.
*/
fun CallingAppInfo.validatePrivilegedApp(
relyingPartyId: String,
allowList: String,
isVerifiedSource: Boolean,
): ValidateOriginResult {
fun CallingAppInfo.validatePrivilegedApp(allowList: String): ValidateOriginResult {
if (!allowList.contains("\"$packageName\"")) {
return ValidateOriginResult.Error.PrivilegedAppNotAllowed
}
@@ -35,15 +32,7 @@ fun CallingAppInfo.validatePrivilegedApp(
if (origin.isNullOrEmpty()) {
ValidateOriginResult.Error.PasskeyNotSupportedForApp
} else {
ValidateOriginResult.Success(
origin = if (isVerifiedSource) {
// For verified sources we simplify the `origin` before passing it to the SDK
// in order to trivially match the Relying Party ID.
relyingPartyId.prefixHttpsIfNecessary()
} else {
origin
},
)
ValidateOriginResult.Success(origin)
}
} catch (_: IllegalStateException) {
// We know the package name is in the allow list so we can infer that this exception is

View File

@@ -1,13 +0,0 @@
package com.x8bit.bitwarden.data.platform.util
import com.bitwarden.network.exception.CookieRedirectException
/**
* Returns a user-friendly error message if this [Throwable] is an allow-listed
* exception type that carries one, or `null` otherwise.
*/
val Throwable.userFriendlyMessage: String?
get() = when (this) {
is CookieRedirectException -> message
else -> null
}

View File

@@ -57,21 +57,6 @@ interface VaultDiskSource {
*/
suspend fun deleteCipher(userId: String, cipherId: String)
/**
* Saves multiple ciphers to the data source for the given [userId].
*/
suspend fun saveCiphers(userId: String, ciphers: List<SyncResponseJson.Cipher>)
/**
* Deletes ciphers with the given [cipherIds] from the data source for the given [userId].
*/
suspend fun deleteSelectedCiphers(userId: String, cipherIds: List<String>)
/**
* Deletes all ciphers from the data source for the given [userId].
*/
suspend fun deleteAllCiphers(userId: String)
/**
* Saves a collection to the data source for the given [userId].
*/

View File

@@ -157,32 +157,6 @@ class VaultDiskSourceImpl(
ciphersDao.deleteCipher(userId, cipherId)
}
override suspend fun saveCiphers(
userId: String,
ciphers: List<SyncResponseJson.Cipher>,
) {
ciphersDao.insertCiphers(
ciphers = ciphers.map { cipher ->
CipherEntity(
id = cipher.id,
userId = userId,
hasTotp = cipher.login?.totp != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,
)
},
)
}
override suspend fun deleteSelectedCiphers(userId: String, cipherIds: List<String>) {
ciphersDao.deleteSelectedCiphers(userId = userId, cipherIds = cipherIds)
}
override suspend fun deleteAllCiphers(userId: String) {
ciphersDao.deleteAllCiphers(userId = userId)
}
override suspend fun saveCollection(userId: String, collection: SyncResponseJson.Collection) {
collectionsDao.insertCollection(
collection = CollectionEntity(

View File

@@ -1,27 +0,0 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.convertor
import androidx.room.ProvidedTypeConverter
import androidx.room.TypeConverter
import java.time.Instant
/**
* A [TypeConverter] to convert an [Instant] to and from a [Long].
*/
@ProvidedTypeConverter
class InstantTypeConverter {
/**
* A [TypeConverter] to convert a [Long] to an [Instant].
*/
@TypeConverter
fun fromTimestamp(
value: Long?,
): Instant? = value?.let { Instant.ofEpochSecond(it) }
/**
* A [TypeConverter] to convert an [Instant] to a [Long].
*/
@TypeConverter
fun toTimestamp(
instant: Instant?,
): Long? = instant?.epochSecond
}

View File

@@ -0,0 +1,31 @@
package com.x8bit.bitwarden.data.vault.datasource.disk.convertor
import androidx.room.ProvidedTypeConverter
import androidx.room.TypeConverter
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* A [TypeConverter] to convert a [ZonedDateTime] to and from a [Long].
*/
@ProvidedTypeConverter
class ZonedDateTimeTypeConverter {
/**
* A [TypeConverter] to convert a [Long] to a [ZonedDateTime].
*/
@TypeConverter
fun fromTimestamp(
value: Long?,
): ZonedDateTime? = value?.let {
ZonedDateTime.ofInstant(Instant.ofEpochSecond(it), ZoneOffset.UTC)
}
/**
* A [TypeConverter] to convert a [ZonedDateTime] to a [Long].
*/
@TypeConverter
fun toTimestamp(
localDateTime: ZonedDateTime?,
): Long? = localDateTime?.toEpochSecond()
}

View File

@@ -12,7 +12,6 @@ import kotlinx.coroutines.flow.Flow
* Provides methods for inserting, retrieving, and deleting ciphers from the database using the
* [CipherEntity].
*/
@Suppress("TooManyFunctions")
@Dao
interface CiphersDao {
@@ -78,13 +77,6 @@ interface CiphersDao {
@Query("DELETE FROM ciphers WHERE user_id = :userId AND id = :cipherId")
suspend fun deleteCipher(userId: String, cipherId: String): Int
/**
* Deletes the stored ciphers associated with the given [userId] whose IDs are in [cipherIds].
* This will return the number of rows deleted by this query.
*/
@Query("DELETE FROM ciphers WHERE user_id = :userId AND id IN (:cipherIds)")
suspend fun deleteSelectedCiphers(userId: String, cipherIds: List<String>): Int
/**
* Deletes all the stored ciphers associated with the given [userId] and then add all new
* [ciphers] to the database. This will return `true` if any changes were made to the database

View File

@@ -4,7 +4,7 @@ import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
@@ -34,7 +34,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.SendEntity
AutoMigration(from = 7, to = 8),
],
)
@TypeConverters(InstantTypeConverter::class)
@TypeConverters(ZonedDateTimeTypeConverter::class)
abstract class VaultDatabase : RoomDatabase() {
/**

View File

@@ -7,7 +7,7 @@ import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSourceImpl
import com.x8bit.bitwarden.data.vault.datasource.disk.callback.DatabaseSchemeCallback
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
@@ -42,7 +42,7 @@ class VaultDiskModule {
)
.fallbackToDestructiveMigration(dropAllTables = false)
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
.addTypeConverter(InstantTypeConverter())
.addTypeConverter(ZonedDateTimeTypeConverter())
.build()
@Provides

View File

@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.Instant
import java.time.ZonedDateTime
/**
* Entity representing a folder in the database.
@@ -21,5 +21,5 @@ data class FolderEntity(
val name: String?,
@ColumnInfo(name = "revision_date")
val revisionDate: Instant,
val revisionDate: ZonedDateTime,
)

View File

@@ -12,8 +12,6 @@ import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import timber.log.Timber
/**
@@ -78,45 +76,14 @@ class Fido2CredentialStoreImpl(
userId = authRepository.activeUserId ?: throw NoActiveUserException(),
cipher = cred.cipher,
)
.onSuccess { decryptedCipherView ->
val result = decryptedCipherView.id
?.let {
vaultRepository
.updateCipher(it, decryptedCipherView)
.toCreateCipherResult()
}
?: decryptedCipherView.createCipher()
when (result) {
CreateCipherResult.Success -> Unit
is CreateCipherResult.Error -> {
throw result.error ?: IllegalStateException(
result.errorMessage ?: "Failed to save credential",
)
}
}
.map { decryptedCipherView ->
decryptedCipherView.id
?.let { vaultRepository.updateCipher(it, decryptedCipherView) }
?: vaultRepository.createCipher(decryptedCipherView)
}
.onFailure { throw it }
}
private suspend fun CipherView.createCipher(): CreateCipherResult {
val collectionIds = this.collectionIds
return if (this.organizationId != null && collectionIds.isNotEmpty()) {
vaultRepository.createCipherInOrganization(
cipherView = this,
collectionIds = collectionIds,
)
} else {
vaultRepository.createCipher(cipherView = this)
}
}
private fun UpdateCipherResult.toCreateCipherResult(): CreateCipherResult =
when (this) {
UpdateCipherResult.Success -> CreateCipherResult.Success
is UpdateCipherResult.Error -> CreateCipherResult.Error(errorMessage, error)
}
/**
* Return a filtered list containing elements that match the given [relyingPartyId] and a
* credential ID contained in [credentialIds].

View File

@@ -775,7 +775,7 @@ class CipherManagerImpl(
// Return if local cipher is more recent
val localCipher = vaultDiskSource.getCipher(userId = userId, cipherId = cipherId)
if (localCipher != null &&
localCipher.revisionDate.epochSecond > revisionDate.epochSecond
localCipher.revisionDate.toEpochSecond() > revisionDate.toEpochSecond()
) {
return
}

View File

@@ -162,7 +162,7 @@ class FolderManagerImpl(
val isValidCreate = !isUpdate && localFolder == null
val isValidUpdate = isUpdate &&
localFolder != null &&
localFolder.revisionDate.epochSecond < revisionDate.epochSecond
localFolder.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
if (activeUserId != userId) {

View File

@@ -279,7 +279,7 @@ class SendManagerImpl(
val isValidCreate = !isUpdate && localSend == null
val isValidUpdate = isUpdate &&
localSend != null &&
localSend.revisionDate.epochSecond < revisionDate.epochSecond
localSend.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
if (activeUserId != userId) {
// We cannot update right now since the accounts do not match, so we will

View File

@@ -35,12 +35,12 @@ interface VaultLockManager {
var isFromLockFlow: Boolean
/**
* Whether the vault is currently locked for the given [userId].
* Whether or not the vault is currently locked for the given [userId].
*/
fun isVaultUnlocked(userId: String): Boolean
/**
* Whether the vault is currently unlocking for the given [userId].
* Whether or not the vault is currently unlocking for the given [userId].
*/
fun isVaultUnlocking(userId: String): Boolean

View File

@@ -17,9 +17,6 @@ import com.bitwarden.core.data.util.concurrentMapOf
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.data.manager.appstate.AppStateManager
import com.bitwarden.data.manager.appstate.model.AppCreationState
import com.bitwarden.data.manager.appstate.model.AppForegroundState
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.KdfManager
@@ -32,6 +29,9 @@ import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.model.AppCreationState
import com.x8bit.bitwarden.data.platform.manager.model.AppForegroundState
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
@@ -192,7 +192,6 @@ class VaultLockManagerImpl(
email = email,
method = initUserCryptoMethod,
userId = userId,
upgradeToken = null,
),
)
.flatMap { result ->

View File

@@ -4,7 +4,6 @@ import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.cxf.parser.CredentialExchangePayloadParser
import com.bitwarden.data.manager.appstate.AppStateManager
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.FolderService
@@ -17,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager

View File

@@ -11,11 +11,7 @@ sealed class ArchiveCipherResult {
data object Success : ArchiveCipherResult()
/**
* Generic error while archiving a cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
* Generic error while archiving a cipher.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : ArchiveCipherResult()
data class Error(val error: Throwable) : ArchiveCipherResult()
}

View File

@@ -13,11 +13,7 @@ sealed class CreateFolderResult {
data class Success(val folderView: FolderView) : CreateFolderResult()
/**
* Generic error while creating a folder. The optional [errorMessage] may be displayed
* directly in the UI when present.
* Generic error while creating a folder.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : CreateFolderResult()
data class Error(val error: Throwable) : CreateFolderResult()
}

View File

@@ -11,11 +11,7 @@ sealed class DeleteAttachmentResult {
data object Success : DeleteAttachmentResult()
/**
* Generic error while deleting an attachment. The optional [errorMessage] may be
* displayed directly in the UI when present.
* Generic error while deleting an attachment.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteAttachmentResult()
data class Error(val error: Throwable) : DeleteAttachmentResult()
}

View File

@@ -11,11 +11,7 @@ sealed class DeleteCipherResult {
data object Success : DeleteCipherResult()
/**
* Generic error while deleting a cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
* Generic error while deleting a cipher.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteCipherResult()
data class Error(val error: Throwable) : DeleteCipherResult()
}

View File

@@ -11,11 +11,7 @@ sealed class DeleteFolderResult {
data object Success : DeleteFolderResult()
/**
* Generic error while deleting a folder. The optional [errorMessage] may be displayed
* directly in the UI when present.
* Generic error while deleting a folder.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteFolderResult()
data class Error(val error: Throwable) : DeleteFolderResult()
}

View File

@@ -11,11 +11,7 @@ sealed class DeleteSendResult {
data object Success : DeleteSendResult()
/**
* Generic error while deleting a send. The optional [errorMessage] may be displayed
* directly in the UI when present.
* Generic error while deleting a send.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : DeleteSendResult()
data class Error(val error: Throwable) : DeleteSendResult()
}

View File

@@ -22,13 +22,9 @@ sealed class ImportCredentialsResult {
data class SyncFailed(val error: Throwable) : ImportCredentialsResult()
/**
* Indicates there was an error importing the vault data. The optional [errorMessage] may be
* displayed directly in the UI when present.
* Indicates there was an error importing the vault data.
*
* @param error The error that occurred during import.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : ImportCredentialsResult()
data class Error(val error: Throwable) : ImportCredentialsResult()
}

View File

@@ -11,11 +11,7 @@ sealed class RestoreCipherResult {
data object Success : RestoreCipherResult()
/**
* Generic error while restoring a cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
* Generic error while restoring a cipher.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : RestoreCipherResult()
data class Error(val error: Throwable) : RestoreCipherResult()
}

View File

@@ -10,11 +10,7 @@ sealed class ShareCipherResult {
data object Success : ShareCipherResult()
/**
* Generic error while sharing cipher. The optional [errorMessage] may be displayed
* directly in the UI when present.
* Generic error while sharing cipher.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : ShareCipherResult()
data class Error(val error: Throwable) : ShareCipherResult()
}

View File

@@ -11,11 +11,7 @@ sealed class UnarchiveCipherResult {
data object Success : UnarchiveCipherResult()
/**
* Generic error while unarchiving a cipher. The optional [errorMessage] may be
* displayed directly in the UI when present.
* Generic error while unarchiving a cipher.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = null,
) : UnarchiveCipherResult()
data class Error(val error: Throwable) : UnarchiveCipherResult()
}

View File

@@ -36,6 +36,8 @@ import com.bitwarden.vault.SecureNote
import com.bitwarden.vault.SecureNoteType
import com.bitwarden.vault.SshKey
import com.bitwarden.vault.UriMatchType
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* Converts a Bitwarden SDK [Cipher] object to a corresponding
@@ -53,7 +55,7 @@ fun Cipher.toEncryptedNetworkCipher(
?.associate { requireNotNull(it.id) to it.toNetworkAttachmentRequest() },
reprompt = reprompt.toNetworkRepromptType(),
passwordHistory = passwordHistory?.toEncryptedNetworkPasswordHistoryList(),
lastKnownRevisionDate = revisionDate,
lastKnownRevisionDate = ZonedDateTime.ofInstant(revisionDate, ZoneOffset.UTC),
type = type.toNetworkCipherType(),
login = login?.toEncryptedNetworkLogin(),
secureNote = secureNote?.toEncryptedNetworkSecureNote(),
@@ -66,7 +68,7 @@ fun Cipher.toEncryptedNetworkCipher(
card = card?.toEncryptedNetworkCard(),
key = key,
sshKey = sshKey?.toEncryptedNetworkSshKey(),
archivedDate = archivedDate,
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
encryptedFor = encryptedFor,
)
@@ -98,15 +100,15 @@ fun Cipher.toEncryptedNetworkCipherResponse(
sshKey = sshKey?.toEncryptedNetworkSshKey(),
shouldOrganizationUseTotp = organizationUseTotp,
shouldEdit = edit,
revisionDate = revisionDate,
creationDate = creationDate,
deletedDate = deletedDate,
revisionDate = ZonedDateTime.ofInstant(revisionDate, ZoneOffset.UTC),
creationDate = ZonedDateTime.ofInstant(creationDate, ZoneOffset.UTC),
deletedDate = deletedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
collectionIds = collectionIds,
id = id.orEmpty(),
shouldViewPassword = viewPassword,
key = key,
encryptedFor = encryptedFor,
archivedDate = archivedDate,
archivedDate = archivedDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
)
/**
@@ -295,7 +297,9 @@ private fun Login.toEncryptedNetworkLogin(): SyncResponseJson.Cipher.Login =
uris = uris?.toEncryptedNetworkUriList(),
totp = totp,
password = password,
passwordRevisionDate = passwordRevisionDate,
passwordRevisionDate = passwordRevisionDate?.let {
ZonedDateTime.ofInstant(it, ZoneOffset.UTC)
},
shouldAutofillOnPageLoad = autofillOnPageLoad,
// uri needs to be null to avoid duplicating the first url entry for a login item.
uri = null,
@@ -319,7 +323,7 @@ private fun Fido2Credential.toNetworkFido2Credential() = SyncResponseJson.Cipher
userDisplayName = userDisplayName,
counter = counter,
discoverable = discoverable,
creationDate = creationDate,
creationDate = ZonedDateTime.ofInstant(creationDate, ZoneOffset.UTC),
)
/**
@@ -338,7 +342,7 @@ private fun List<PasswordHistory>.toEncryptedNetworkPasswordHistoryList(): List<
private fun PasswordHistory.toEncryptedNetworkPasswordHistory(): SyncResponseJson.Cipher.PasswordHistory =
SyncResponseJson.Cipher.PasswordHistory(
password = password,
lastUsedDate = lastUsedDate,
lastUsedDate = ZonedDateTime.ofInstant(lastUsedDate, ZoneOffset.UTC),
)
/**
@@ -411,10 +415,10 @@ fun SyncResponseJson.Cipher.toEncryptedSdkCipher(): Cipher =
fields = fields?.toSdkFieldList(),
passwordHistory = passwordHistory?.toSdkPasswordHistoryList(),
permissions = permissions?.toSdkPermissions(),
creationDate = creationDate,
deletedDate = deletedDate,
revisionDate = revisionDate,
archivedDate = archivedDate,
creationDate = creationDate.toInstant(),
deletedDate = deletedDate?.toInstant(),
revisionDate = revisionDate.toInstant(),
archivedDate = archivedDate?.toInstant(),
data = null,
)
@@ -425,7 +429,7 @@ fun SyncResponseJson.Cipher.Login.toSdkLogin(): Login =
Login(
username = username,
password = password,
passwordRevisionDate = passwordRevisionDate,
passwordRevisionDate = passwordRevisionDate?.toInstant(),
uris = uris?.toSdkLoginUriList(),
totp = totp,
autofillOnPageLoad = shouldAutofillOnPageLoad,
@@ -448,7 +452,7 @@ private fun SyncResponseJson.Cipher.Fido2Credential.toSdkFido2Credential() = Fid
userDisplayName = userDisplayName,
counter = counter,
discoverable = discoverable,
creationDate = creationDate,
creationDate = creationDate.toInstant(),
)
/**
@@ -584,7 +588,7 @@ fun List<SyncResponseJson.Cipher.PasswordHistory>.toSdkPasswordHistoryList(): Li
fun SyncResponseJson.Cipher.PasswordHistory.toSdkPasswordHistory(): PasswordHistory =
PasswordHistory(
password = password,
lastUsedDate = lastUsedDate,
lastUsedDate = lastUsedDate.toInstant(),
)
/**

View File

@@ -21,7 +21,7 @@ fun SyncResponseJson.Folder.toEncryptedSdkFolder(): Folder =
Folder(
id = id,
name = name.orEmpty(),
revisionDate = revisionDate,
revisionDate = revisionDate.toInstant(),
)
/**

View File

@@ -13,6 +13,8 @@ import com.bitwarden.send.SendFile
import com.bitwarden.send.SendText
import com.bitwarden.send.SendType
import com.bitwarden.send.SendView
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* Converts a Bitwarden SDK [Send] object to a corresponding [SyncResponseJson.Send] object.
@@ -24,8 +26,8 @@ fun Send.toEncryptedNetworkSend(fileLength: Long? = null): SendJsonRequest =
notes = notes,
key = key,
maxAccessCount = maxAccessCount?.toInt(),
expirationDate = expirationDate,
deletionDate = deletionDate,
expirationDate = expirationDate?.let { ZonedDateTime.ofInstant(it, ZoneOffset.UTC) },
deletionDate = ZonedDateTime.ofInstant(deletionDate, ZoneOffset.UTC),
fileLength = fileLength,
file = file?.toNetworkSendFile(),
text = text?.toNetworkSendText(),
@@ -93,9 +95,9 @@ fun SyncResponseJson.Send.toEncryptedSdkSend(): Send =
accessCount = accessCount.toUInt(),
disabled = isDisabled,
hideEmail = shouldHideEmail,
revisionDate = revisionDate,
deletionDate = deletionDate,
expirationDate = expirationDate,
revisionDate = revisionDate.toInstant(),
deletionDate = deletionDate.toInstant(),
expirationDate = expirationDate?.toInstant(),
emails = emails,
authType = authType?.toSdkAuthType() ?: AuthType.NONE,
)

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