mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 02:15:43 -05:00
Compare commits
106 Commits
release/20
...
v2026.3.0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a9c596b2 | ||
|
|
e33c4df59c | ||
|
|
7f426f1037 | ||
|
|
9bde261007 | ||
|
|
37b336ee35 | ||
|
|
c9d28941c6 | ||
|
|
053ac28e38 | ||
|
|
3400d5f875 | ||
|
|
9f274bbffa | ||
|
|
cf1455a45a | ||
|
|
d0dc4200f8 | ||
|
|
8a2b46e81a | ||
|
|
3538ca54ca | ||
|
|
5a61ba96f6 | ||
|
|
836233f4d5 | ||
|
|
3b081faf65 | ||
|
|
61517014a7 | ||
|
|
4a1582b1e4 | ||
|
|
227224b6cb | ||
|
|
60bc6ee0ca | ||
|
|
e509d60af6 | ||
|
|
1f9390a668 | ||
|
|
ed1abcac5b | ||
|
|
209e216213 | ||
|
|
7bde0ce716 | ||
|
|
a517b3f970 | ||
|
|
c7d173cf9a | ||
|
|
38f3d3d720 | ||
|
|
487b163d38 | ||
|
|
52da80e0fc | ||
|
|
1abb640512 | ||
|
|
64a79ff108 | ||
|
|
fd6d32ec09 | ||
|
|
4ca79bb8c7 | ||
|
|
642456f2fe | ||
|
|
7b1b519b0d | ||
|
|
d51d6c7c54 | ||
|
|
4adb46170d | ||
|
|
3360999706 | ||
|
|
b10568a3ae | ||
|
|
d9f6fe97ff | ||
|
|
89f70a6b18 | ||
|
|
8b2aaf9c79 | ||
|
|
c9f3afa851 | ||
|
|
5ef7482fae | ||
|
|
c69f3554c6 | ||
|
|
c6b4c490ca | ||
|
|
92664b6752 | ||
|
|
06284a31df | ||
|
|
794781213e | ||
|
|
d1cf808e97 | ||
|
|
4356156aad | ||
|
|
268be4210e | ||
|
|
4ee55111f4 | ||
|
|
1a6936262c | ||
|
|
6f19ae534f | ||
|
|
46a8236ef7 | ||
|
|
f6f630ff8c | ||
|
|
bd0640e5b4 | ||
|
|
436ae9333c | ||
|
|
9b13cd4498 | ||
|
|
f6cd94485a | ||
|
|
222bc44c99 | ||
|
|
275d90bb61 | ||
|
|
a23183597c | ||
|
|
e3ab4f3d68 | ||
|
|
34a7c4455c | ||
|
|
4a68c2343d | ||
|
|
fb9d16730e | ||
|
|
5c348ac360 | ||
|
|
3985817c16 | ||
|
|
8664ce4614 | ||
|
|
f3c746fd49 | ||
|
|
ce3f0acf74 | ||
|
|
b20622e7d6 | ||
|
|
e939b20a82 | ||
|
|
a8e77a5abc | ||
|
|
afa9c28341 | ||
|
|
bb44586d76 | ||
|
|
4cdd0b8422 | ||
|
|
5a4973d678 | ||
|
|
a914d12e6f | ||
|
|
e5875cd8fe | ||
|
|
a3aefd369a | ||
|
|
60a1265c5d | ||
|
|
95272d9692 | ||
|
|
3be5bead89 | ||
|
|
31d480d6b4 | ||
|
|
43940102ff | ||
|
|
253f0d7ec4 | ||
|
|
d7428a15bc | ||
|
|
5d84df9d31 | ||
|
|
d8c69a3243 | ||
|
|
f0837f7668 | ||
|
|
f094430d6c | ||
|
|
cf3660a5aa | ||
|
|
5300386ce3 | ||
|
|
eb24a50baa | ||
|
|
4d31dccc74 | ||
|
|
8ee721c8ae | ||
|
|
c0907b867b | ||
|
|
6eba9ecd4b | ||
|
|
594cb507df | ||
|
|
e615bdbea5 | ||
|
|
071d3c8cd5 | ||
|
|
ad3a9a6c2e |
@@ -1,105 +1,134 @@
|
||||
# Claude Guidelines
|
||||
# Bitwarden Android - Claude Code Configuration
|
||||
|
||||
Core directives for maintaining code quality and consistency in the Bitwarden Android project.
|
||||
Official Android application for Bitwarden Password Manager and Bitwarden Authenticator, providing secure password management, two-factor authentication, and credential autofill services with zero-knowledge encryption.
|
||||
|
||||
## Core Directives
|
||||
## Overview
|
||||
|
||||
**You MUST follow these directives at all times.**
|
||||
- Multi-module Android application: `:app` (Password Manager), `:authenticator` (2FA TOTP generator)
|
||||
- Zero-knowledge architecture: encryption/decryption happens client-side via Bitwarden SDK
|
||||
- Target users: End-users via Google Play Store and F-Droid
|
||||
|
||||
1. **Adhere to Architecture**: All code modifications MUST follow patterns in `docs/ARCHITECTURE.md`
|
||||
2. **Follow Code Style**: ALWAYS follow `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
3. **Error Handling**: Use Result types and sealed classes per architecture guidelines
|
||||
4. **Best Practices**: Follow Kotlin idioms (immutability, appropriate data structures, coroutines)
|
||||
5. **Document Everything**: All public APIs require KDoc documentation
|
||||
6. **Dependency Management**: Use Hilt DI patterns as established in the project
|
||||
7. **Use Established Patterns**: Leverage existing components before creating new ones
|
||||
8. **File References**: Use file:line_number format when referencing code
|
||||
### Key Concepts
|
||||
|
||||
## Code Quality Standards
|
||||
- **Zero-Knowledge Architecture**: Server never has access to unencrypted vault data or encryption keys
|
||||
- **Bitwarden SDK**: Rust-based cryptographic SDK handling all encryption/decryption operations
|
||||
- **DataState**: Wrapper for streaming data states (Loading, Loaded, Pending, Error, NoNetwork)
|
||||
- **Result Types**: Custom sealed classes for operation results (never throw exceptions from data layer)
|
||||
- **UDF (Unidirectional Data Flow)**: State flows down, actions flow up through ViewModels
|
||||
|
||||
### Module Organization
|
||||
---
|
||||
|
||||
**Core Library Modules:**
|
||||
- **`:core`** - Common utilities and managers shared across multiple modules
|
||||
- **`:data`** - Data sources, database, data repositories
|
||||
- **`:network`** - Networking interfaces, API clients, network utilities
|
||||
- **`:ui`** - Reusable Bitwarden Composables, theming, UI utilities
|
||||
## Architecture
|
||||
|
||||
**Application Modules:**
|
||||
- **`:app`** - Password Manager application, feature screens, ViewModels, DI setup
|
||||
- **`:authenticator`** - Authenticator application for 2FA/TOTP code generation
|
||||
```
|
||||
User Request (UI Action)
|
||||
|
|
||||
Screen (Compose)
|
||||
|
|
||||
ViewModel (State/Action/Event)
|
||||
|
|
||||
Repository (Business Logic)
|
||||
|
|
||||
+----+----+----+
|
||||
| | | |
|
||||
Disk Network SDK
|
||||
| | |
|
||||
Room Retrofit Bitwarden
|
||||
DB APIs Rust SDK
|
||||
```
|
||||
|
||||
**Specialized Library Modules:**
|
||||
- **`:authenticatorbridge`** - Communication bridge between :authenticator and :app
|
||||
- **`:annotation`** - Custom annotations for code generation (Hilt, Room, etc.)
|
||||
- **`:cxf`** - Android Credential Exchange (CXF/CXP) integration layer
|
||||
### Key Principles
|
||||
|
||||
### Patterns Enforcement
|
||||
1. **No Exceptions from Data Layer**: All suspending functions return `Result<T>` or custom sealed classes
|
||||
2. **State Hoisting to ViewModel**: All state that affects behavior must live in the ViewModel's state
|
||||
3. **Interface-Based DI**: All implementations use interface/`...Impl` pairs with Hilt injection
|
||||
4. **Encryption by Default**: All sensitive data encrypted via SDK before storage
|
||||
|
||||
- **MVVM + UDF**: ViewModels with StateFlow, Compose UI
|
||||
- **Hilt DI**: Interface injection, @HiltViewModel, @Inject constructor
|
||||
- **Testing**: JUnit 5, MockK, Turbine for Flow testing
|
||||
- **Error Handling**: Sealed Result types, no throws in business logic
|
||||
### Core Patterns
|
||||
|
||||
## Security Requirements
|
||||
- **BaseViewModel**: Enforces UDF with State/Action/Event pattern. See `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt`.
|
||||
- **Repository Result Pattern**: Type-safe error handling using custom sealed classes for discrete operations and `DataState<T>` wrapper for streaming data.
|
||||
- **Common Patterns**: Flow collection via `Internal` actions, error handling via `when` branches, `DataState` streaming with `.map { }` and `.stateIn()`.
|
||||
|
||||
**Every change must consider:**
|
||||
- Zero-knowledge architecture preservation
|
||||
- Proper encryption key handling (Android Keystore)
|
||||
- Input validation and sanitization
|
||||
- Secure data storage patterns
|
||||
- Threat model implications
|
||||
> For complete architecture patterns, code templates, and module organization, see `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Workflow Practices
|
||||
---
|
||||
|
||||
### Before Implementation
|
||||
## Development Guide
|
||||
|
||||
1. Read relevant architecture documentation
|
||||
2. Search for existing patterns to follow
|
||||
3. Identify affected modules and dependencies
|
||||
4. Consider security implications
|
||||
### Workflow Skills
|
||||
|
||||
### During Implementation
|
||||
> **Quick start**: Use `/plan-android-work <task>` to refine requirements and plan,
|
||||
> then `/work-on-android <task>` for implementation.
|
||||
|
||||
1. Follow existing code style in surrounding files
|
||||
2. Write tests alongside implementation
|
||||
3. Add KDoc to all public APIs
|
||||
4. Validate against architecture guidelines
|
||||
**Planning Phase:**
|
||||
|
||||
### After Implementation
|
||||
1. `refining-android-requirements` - Gap analysis and structured spec from any input source
|
||||
2. `planning-android-implementation` - Architecture design and phased task breakdown
|
||||
|
||||
1. Ensure all tests pass
|
||||
2. Verify compilation succeeds
|
||||
3. Review security considerations
|
||||
4. Update relevant documentation
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## Code Style & Standards
|
||||
|
||||
- **Formatter**: Android Studio with `bitwarden-style.xml` | **Line Limit**: 100 chars | **Detekt**: Enabled
|
||||
- **Naming**: `camelCase` (vars/fns), `PascalCase` (classes), `SCREAMING_SNAKE_CASE` (constants), `...Impl` (implementations)
|
||||
- **KDoc**: Required for all public APIs
|
||||
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`)
|
||||
|
||||
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
**Avoid these:**
|
||||
- Creating new patterns when established ones exist
|
||||
- Exception-based error handling in business logic
|
||||
- Direct dependency access (use DI)
|
||||
- Mutable state in ViewModels (use StateFlow)
|
||||
- Missing null safety handling
|
||||
- Undocumented public APIs
|
||||
- Tight coupling between modules
|
||||
In addition to the Key Principles above, follow these rules:
|
||||
|
||||
## Communication & Decision-Making
|
||||
### DO
|
||||
- Use `remember(viewModel)` for lambdas passed to composables
|
||||
- Map async results to internal actions before updating state
|
||||
- Inject `Clock` for time-dependent operations
|
||||
- Return early to reduce nesting
|
||||
|
||||
Always clarify ambiguous requirements before implementing. Use specific questions:
|
||||
- "Should this use [Approach A] or [Approach B]?"
|
||||
- "This affects [X]. Proceed or review first?"
|
||||
- "Expected behavior for [specific requirement]?"
|
||||
### DON'T
|
||||
- Update state directly inside coroutines (use internal actions)
|
||||
- Use `any` types or suppress null safety
|
||||
- Catch generic `Exception` (catch specific types)
|
||||
- Use `e.printStackTrace()` (use Timber logging)
|
||||
- Create new patterns when established ones exist
|
||||
- Skip KDoc for public APIs
|
||||
|
||||
Defer high-impact decisions to the user:
|
||||
- Architecture/module changes, public API modifications
|
||||
- Security mechanisms, database migrations
|
||||
- Third-party library additions
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
## Quick Reference
|
||||
|
||||
Critical resources:
|
||||
- `docs/ARCHITECTURE.md` - Architecture patterns and principles
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style guidelines
|
||||
|
||||
**Do not duplicate information from these files - reference them instead.**
|
||||
- **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/)
|
||||
|
||||
119
.claude/commands/plan-android-work.md
Normal file
119
.claude/commands/plan-android-work.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
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.
|
||||
66
.claude/commands/work-on-android.md
Normal file
66
.claude/commands/work-on-android.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"attribution": {
|
||||
"commit": "",
|
||||
"pr": ""
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
|
||||
136
.claude/skills/build-test-verify/SKILL.md
Normal file
136
.claude/skills/build-test-verify/SKILL.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
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
|
||||
81
.claude/skills/committing-android-changes/SKILL.md
Normal file
81
.claude/skills/committing-android-changes/SKILL.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
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
|
||||
64
.claude/skills/creating-android-pull-request/SKILL.md
Normal file
64
.claude/skills/creating-android-pull-request/SKILL.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
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
|
||||
484
.claude/skills/implementing-android-code/SKILL.md
Normal file
484
.claude/skills/implementing-android-code/SKILL.md
Normal file
@@ -0,0 +1,484 @@
|
||||
---
|
||||
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 = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// UI content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
|
||||
|
||||
**Essential Requirements:**
|
||||
- ✅ Use `hiltViewModel()` for dependency injection
|
||||
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
|
||||
- ✅ Use `EventsEffect(viewModel)` for one-shot events
|
||||
- ✅ Use `remember(viewModel) { }` for stable callbacks to prevent recomposition
|
||||
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
|
||||
|
||||
**State Hoisting Rules:**
|
||||
- **ViewModel state**: Data that needs to survive process death or affects business logic
|
||||
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
|
||||
|
||||
---
|
||||
|
||||
### D. Data Layer Implementation
|
||||
|
||||
The data layer follows strict patterns for repositories, managers, and data sources.
|
||||
|
||||
**Interface + Implementation Separation (ALWAYS)**
|
||||
|
||||
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// Interface (injected via Hilt)
|
||||
interface ExampleRepository {
|
||||
suspend fun fetchData(id: String): ExampleResult
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
|
||||
// Implementation (NOT directly injected)
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
) : ExampleRepository {
|
||||
override suspend fun fetchData(id: String): ExampleResult {
|
||||
// NO exceptions thrown - return Result or sealed class
|
||||
return exampleService.getData(id).fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealed result class (domain-specific)
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: ExampleData) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
|
||||
// Hilt Module
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
**Three-Layer Data Architecture:**
|
||||
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
|
||||
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
|
||||
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
|
||||
|
||||
**Critical Rules:**
|
||||
- ❌ **NEVER** throw exceptions in data layer
|
||||
- ✅ **ALWAYS** use interface + `...Impl` pattern
|
||||
- ✅ **ALWAYS** inject interfaces, never implementations
|
||||
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
|
||||
- ✅ Use `StateFlow` for continuously observed data
|
||||
|
||||
---
|
||||
|
||||
### E. UI Components
|
||||
|
||||
**Use Existing Components First:**
|
||||
|
||||
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
|
||||
|
||||
**Common Components:**
|
||||
- `BitwardenFilledButton` - Primary action buttons
|
||||
- `BitwardenOutlinedButton` - Secondary action buttons
|
||||
- `BitwardenTextField` - Text input fields
|
||||
- `BitwardenPasswordField` - Password input with show/hide
|
||||
- `BitwardenSwitch` - Toggle switches
|
||||
- `BitwardenTopAppBar` - Toolbar/app bar
|
||||
- `BitwardenScaffold` - Screen container with scaffold
|
||||
- `BitwardenBasicDialog` - Simple dialogs
|
||||
- `BitwardenLoadingDialog` - Loading indicators
|
||||
|
||||
**Component Discovery:**
|
||||
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. 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: `you’ll` not `you\'ll`, `“word”` not `\"word\"`
|
||||
- Reference strings via generated `BitwardenString` resource IDs
|
||||
- Do not add strings to other modules unless explicitly instructed
|
||||
|
||||
---
|
||||
|
||||
### F. Security Patterns
|
||||
|
||||
**Encrypted vs Unencrypted Storage:**
|
||||
|
||||
**Template**: See [Security templates](templates.md#security-templates)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
class ExampleDiskSourceImpl(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
fun storeAuthToken(token: String) {
|
||||
putEncryptedString(KEY_TOKEN, token) // Sensitive — uses base class method
|
||||
}
|
||||
|
||||
fun storeThemePreference(isDark: Boolean) {
|
||||
putBoolean(KEY_THEME, isDark) // Non-sensitive — uses base class method
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Android Keystore (Biometric Keys):**
|
||||
- User-scoped encryption keys: `BiometricsEncryptionManager`
|
||||
- Keys stored in Android Keystore (hardware-backed when available)
|
||||
- Integrity validation on biometric state changes
|
||||
|
||||
**Input Validation:**
|
||||
```kotlin
|
||||
// Validation returns boolean, NEVER throws
|
||||
interface RequestValidator {
|
||||
fun validate(request: Request): Boolean
|
||||
}
|
||||
|
||||
// Sanitization removes dangerous content
|
||||
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
|
||||
if (this.isNullOrBlank()) return null
|
||||
// Sanitize and return safe value
|
||||
}
|
||||
```
|
||||
|
||||
**Security Checklist:**
|
||||
- ✅ Use `@EncryptedPreferences` for credentials, keys, tokens
|
||||
- ✅ Use `@UnencryptedPreferences` for UI state, preferences
|
||||
- ✅ Use `@IgnoredOnParcel` for sensitive ViewModel state
|
||||
- ❌ **NEVER** log sensitive data (passwords, tokens, vault items)
|
||||
- ✅ Validate all user input before processing
|
||||
- ✅ Use Timber for non-sensitive logging only
|
||||
|
||||
---
|
||||
|
||||
### G. Testing Patterns
|
||||
|
||||
**ViewModel Testing:**
|
||||
|
||||
**Template**: See [Testing templates](templates.md#testing-templates)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
|
||||
@Test
|
||||
fun `ButtonClick should fetch data and update state`() = runTest {
|
||||
val expectedResult = ExampleResult.Success(data = "test")
|
||||
coEvery { mockRepository.fetchData(any()) } returns expectedResult
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(ExampleAction.ButtonClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(EXPECTED_STATE.copy(data = "test"), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to EXPECTED_STATE)),
|
||||
repository = mockRepository,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
**Key Testing Patterns:**
|
||||
- ✅ Extend `BaseViewModelTest` for proper dispatcher management
|
||||
- ✅ Use `runTest` from `kotlinx.coroutines.test`
|
||||
- ✅ Use Turbine's `.test { awaitItem() }` for Flow assertions
|
||||
- ✅ Use MockK: `coEvery` for suspend functions, `every` for sync
|
||||
- ✅ Test both state changes and event emissions
|
||||
- ✅ Test both success and failure Result paths
|
||||
|
||||
**Flow Testing with Turbine:**
|
||||
```kotlin
|
||||
// Test state and events simultaneously
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.Submit)
|
||||
assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
|
||||
assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
**MockK Quick Reference:**
|
||||
```kotlin
|
||||
coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
|
||||
every { diskSource.getData() } returns "cached" // Sync
|
||||
coVerify { repository.fetchData("123") } // Verify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H. Clock/Time Handling
|
||||
|
||||
All code needing current time must inject `Clock` for testability.
|
||||
|
||||
**Key Requirements:**
|
||||
- ✅ Inject `Clock` via Hilt in ViewModels
|
||||
- ✅ Pass `Clock` as parameter in extension functions
|
||||
- ✅ Use `clock.instant()` to get current time
|
||||
- ❌ Never call `Instant.now()` or `DateTime.now()` directly
|
||||
- ❌ Never use `mockkStatic` for datetime classes in tests
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// ViewModel with Clock
|
||||
class MyViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
) {
|
||||
val timestamp = clock.instant()
|
||||
}
|
||||
|
||||
// Extension function with Clock parameter
|
||||
fun State.getTimestamp(clock: Clock): Instant =
|
||||
existingTime ?: clock.instant()
|
||||
|
||||
// Test with fixed clock
|
||||
val FIXED_CLOCK = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` (see Time and Clock Handling section)
|
||||
- `core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt` (see `provideClock` function)
|
||||
|
||||
**Critical Gotchas:**
|
||||
- ❌ `Instant.now()` creates hidden dependency, non-testable
|
||||
- ❌ `mockkStatic(Instant::class)` is fragile, can leak between tests
|
||||
- ✅ `Clock.fixed(...)` provides deterministic test behavior
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden-Specific Anti-Patterns
|
||||
|
||||
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
|
||||
|
||||
❌ **NEVER update ViewModel state directly in coroutines**
|
||||
- Post internal actions, update state synchronously in `handleAction()`
|
||||
|
||||
❌ **NEVER inject `...Impl` classes**
|
||||
- Only inject interfaces via Hilt
|
||||
|
||||
❌ **NEVER create navigation without `@Serializable` routes**
|
||||
- No string-based navigation, always type-safe
|
||||
|
||||
❌ **NEVER use raw `Result<T>` in repositories**
|
||||
- Use domain-specific sealed classes for better error handling
|
||||
|
||||
❌ **NEVER make state classes without `@Parcelize`**
|
||||
- All ViewModel state must survive process death
|
||||
|
||||
❌ **NEVER skip `SavedStateHandle` persistence for ViewModels**
|
||||
- Users lose form progress on process death
|
||||
|
||||
❌ **NEVER forget `@IgnoredOnParcel` for passwords/tokens**
|
||||
- Causes security vulnerability (sensitive data in parcel)
|
||||
|
||||
❌ **NEVER use generic `Exception` catching**
|
||||
- Catch specific exceptions only (`RemoteException`, `IOException`)
|
||||
|
||||
❌ **NEVER call `Instant.now()` or `DateTime.now()` directly**
|
||||
- Inject `Clock` via Hilt, use `clock.instant()` for testability
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
For build, test, and codebase discovery commands, 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)
|
||||
|
||||
644
.claude/skills/implementing-android-code/templates.md
Normal file
644
.claude/skills/implementing-android-code/templates.md
Normal file
@@ -0,0 +1,644 @@
|
||||
# Code Templates - Bitwarden Android
|
||||
|
||||
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
|
||||
|
||||
---
|
||||
|
||||
## ViewModel Template (State-Action-Event Pattern)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
|
||||
|
||||
### State Class
|
||||
|
||||
```kotlin
|
||||
@Parcelize
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
|
||||
val dialogState: DialogState? = null,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Dialog states for the Example screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
@Parcelize
|
||||
data class Loading(val message: Text) : DialogState()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Sealed Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* One-shot UI events for the Example screen.
|
||||
*/
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
|
||||
data class ShowToast(val message: Text) : ExampleEvent()
|
||||
}
|
||||
```
|
||||
|
||||
### Action Sealed Class (with Internal)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* User and system actions for the Example screen.
|
||||
*/
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
|
||||
data object SubmitClick : ExampleAction()
|
||||
|
||||
data class InputChanged(val input: String) : ExampleAction()
|
||||
|
||||
data object ErrorDialogDismiss : ExampleAction()
|
||||
|
||||
/**
|
||||
* Internal actions dispatched by the ViewModel from coroutines.
|
||||
*/
|
||||
sealed class Internal : ExampleAction() {
|
||||
data class ReceiveDataState(
|
||||
val dataState: DataState<ExampleData>,
|
||||
) : Internal()
|
||||
|
||||
data class ReceiveDataResult(
|
||||
val result: ExampleResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel
|
||||
|
||||
```kotlin
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Example screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val exampleRepository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val args = savedStateHandle.toExampleArgs()
|
||||
ExampleState(
|
||||
data = args.itemId,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
// Persist state for process death recovery
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// Collect repository flows as internal actions
|
||||
exampleRepository.dataFlow
|
||||
.map { ExampleAction.Internal.ReceiveDataState(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
when (action) {
|
||||
ExampleAction.BackClick -> handleBackClick()
|
||||
ExampleAction.SubmitClick -> handleSubmitClick()
|
||||
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
|
||||
is ExampleAction.InputChanged -> handleInputChanged(action)
|
||||
is ExampleAction.Internal.ReceiveDataState -> {
|
||||
handleReceiveDataState(action)
|
||||
}
|
||||
is ExampleAction.Internal.ReceiveDataResult -> {
|
||||
handleReceiveDataResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(ExampleEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleErrorDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleSubmitClick() {
|
||||
viewModelScope.launch {
|
||||
val result = exampleRepository.submitData(state.data.orEmpty())
|
||||
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInputChanged(action: ExampleAction.InputChanged) {
|
||||
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
|
||||
}
|
||||
|
||||
private fun handleReceiveDataState(
|
||||
action: ExampleAction.Internal.ReceiveDataState,
|
||||
) {
|
||||
when (action.dataState) {
|
||||
is DataState.Loaded -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = action.dataState.data.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loading -> {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
}
|
||||
|
||||
is DataState.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = action.dataState.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveDataResult(
|
||||
action: ExampleAction.Internal.ReceiveDataResult,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is ExampleResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = result.data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ExampleResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = result.message?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Template (Type-Safe Routes)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
|
||||
|
||||
```kotlin
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Route for the Example screen.
|
||||
*/
|
||||
@Serializable
|
||||
@OmitFromCoverage
|
||||
data class ExampleRoute(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Args extracted from [SavedStateHandle] for the Example screen.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
data class ExampleArgs(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts [ExampleArgs] from the [SavedStateHandle].
|
||||
*/
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(
|
||||
itemId = route.itemId,
|
||||
isEditMode = route.isEditMode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Example screen.
|
||||
*/
|
||||
fun NavController.navigateToExample(
|
||||
itemId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = ExampleRoute(
|
||||
itemId = itemId,
|
||||
isEditMode = isEditMode,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Example screen destination to the navigation graph.
|
||||
*/
|
||||
fun NavGraphBuilder.exampleDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen/Compose Template
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
|
||||
|
||||
```kotlin
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* The Example screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
is ExampleEvent.ShowToast -> {
|
||||
// Handle toast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
ExampleDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) }
|
||||
},
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.example),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
ExampleScreenContent(
|
||||
state = state,
|
||||
onInputChanged = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.InputChanged(it)) }
|
||||
},
|
||||
onSubmitClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Layer Template (Repository + Hilt Module)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
### Interface
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Provides data operations for the Example feature.
|
||||
*/
|
||||
interface ExampleRepository {
|
||||
/**
|
||||
* Submits data and returns a typed result.
|
||||
*/
|
||||
suspend fun submitData(input: String): ExampleResult
|
||||
|
||||
/**
|
||||
* Continuously observed data stream.
|
||||
*/
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
```
|
||||
|
||||
### Sealed Result Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Domain-specific result for Example operations.
|
||||
*/
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: String) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Default implementation of [ExampleRepository].
|
||||
*/
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : ExampleRepository {
|
||||
|
||||
override val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
get() = // ...
|
||||
|
||||
override suspend fun submitData(input: String): ExampleResult {
|
||||
return exampleService
|
||||
.postData(input)
|
||||
.fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hilt Module
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): ExampleRepository = ExampleRepositoryImpl(
|
||||
exampleDiskSource = exampleDiskSource,
|
||||
exampleService = exampleService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Templates
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
|
||||
|
||||
### Encrypted Disk Source (Module)
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleDiskSource(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): ExampleDiskSource = ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Encrypted Disk Source (Implementation)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Disk source for Example data using encrypted and unencrypted storage.
|
||||
*/
|
||||
class ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
|
||||
private companion object {
|
||||
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
|
||||
const val UNENCRYPTED_PREF_KEY = "examplePreference"
|
||||
}
|
||||
|
||||
override var authToken: String?
|
||||
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
|
||||
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
|
||||
|
||||
override var uiPreference: Boolean
|
||||
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
|
||||
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Templates
|
||||
|
||||
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
### ViewModel Test
|
||||
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository = mockk<ExampleRepository>()
|
||||
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockRepository.dataFlow } returns mutableDataFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is no saved state`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is a saved state`() {
|
||||
val savedState = DEFAULT_STATE.copy(data = "saved")
|
||||
val viewModel = createViewModel(state = savedState)
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should call repository and update state on success`() = runTest {
|
||||
val expected = ExampleResult.Success(data = "result")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
// Initial state
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Updated state after result
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "result", isLoading = false),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should show error dialog on failure`() = runTest {
|
||||
val expected = ExampleResult.Error(message = "Network error")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
val errorState = awaitItem()
|
||||
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExampleAction.BackClick)
|
||||
assertEquals(ExampleEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create ViewModel with optional saved state
|
||||
private fun createViewModel(
|
||||
state: ExampleState? = DEFAULT_STATE,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to state),
|
||||
),
|
||||
exampleRepository = mockRepository,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Testing with stateEventFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `SubmitClick should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Assert state change
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "data"),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
|
||||
// Assert event emission
|
||||
assertEquals(
|
||||
ExampleEvent.ShowToast("Success".asText()),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
40
.claude/skills/labeling-android-changes/SKILL.md
Normal file
40
.claude/skills/labeling-android-changes/SKILL.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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.
|
||||
37
.claude/skills/perform-android-preflight-checklist/SKILL.md
Normal file
37
.claude/skills/perform-android-preflight-checklist/SKILL.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
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
|
||||
191
.claude/skills/planning-android-implementation/SKILL.md
Normal file
191
.claude/skills/planning-android-implementation/SKILL.md
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
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)
|
||||
181
.claude/skills/refining-android-requirements/SKILL.md
Normal file
181
.claude/skills/refining-android-requirements/SKILL.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
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
|
||||
@@ -1,44 +0,0 @@
|
||||
# Testing Android Code Skill
|
||||
|
||||
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
The skill automatically loads when you ask questions like:
|
||||
|
||||
- "How do I test this ViewModel?"
|
||||
- "Why is my Bitwarden test failing?"
|
||||
- "Write tests for this repository"
|
||||
|
||||
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
|
||||
|
||||
## What's Included
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Core testing patterns and base class locations |
|
||||
| `references/test-base-classes.md` | Detailed base class documentation |
|
||||
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
|
||||
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
|
||||
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
|
||||
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
|
||||
| `examples/repository-test-example.md` | Complete repository test with mocks |
|
||||
|
||||
## Patterns Covered
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
|
||||
|
||||
## Quick Start
|
||||
|
||||
For comprehensive architecture and testing philosophy, see:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,27 +9,3 @@
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## ⏰ Reminders before review
|
||||
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Protected functional changes with optionality (feature flags)
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
||||
23
.github/actions/setup-android-build/action.yml
vendored
23
.github/actions/setup-android-build/action.yml
vendored
@@ -8,27 +8,8 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- 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: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
|
||||
23
.github/scripts/set-build-version.sh
vendored
Executable file
23
.github/scripts/set-build-version.sh
vendored
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/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
|
||||
100
.github/workflows/build-authenticator.yml
vendored
100
.github/workflows/build-authenticator.yml
vendored
@@ -31,7 +31,6 @@ 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' }}
|
||||
|
||||
@@ -65,43 +64,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Check Authenticator
|
||||
run: bundle exec fastlane check
|
||||
@@ -128,16 +92,6 @@ 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:
|
||||
@@ -197,40 +151,15 @@ 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 \
|
||||
@@ -242,22 +171,9 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
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"
|
||||
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"
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
|
||||
60
.github/workflows/build-testharness.yml
vendored
60
.github/workflows/build-testharness.yml
vendored
@@ -20,7 +20,6 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -53,63 +52,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
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"
|
||||
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"
|
||||
|
||||
- name: Build Test Harness Debug APK
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
141
.github/workflows/build.yml
vendored
141
.github/workflows/build.yml
vendored
@@ -31,7 +31,6 @@ 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' }}
|
||||
@@ -67,43 +66,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Check
|
||||
run: bundle exec fastlane check
|
||||
@@ -137,16 +101,6 @@ 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:
|
||||
@@ -199,33 +153,8 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- 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: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -238,13 +167,9 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
|
||||
@@ -455,16 +380,6 @@ 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:
|
||||
@@ -503,33 +418,8 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- 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: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -542,20 +432,9 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.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"
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
- name: Generate F-Droid artifacts
|
||||
env:
|
||||
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
|
||||
|
||||
64
.github/workflows/sdlc-enforce-labels.yml
vendored
Normal file
64
.github/workflows/sdlc-enforce-labels.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: SDLC / Enforce PR labels
|
||||
run-name: Enforce labels for PR ${{ github.event.pull_request.number }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, reopened, edited, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
enforce-label:
|
||||
name: Enforce Label
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Enforce banned labels (e.g. hold, needs-qa)
|
||||
env:
|
||||
_HOLD_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'hold') }}
|
||||
_NEEDS_QA_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'needs-qa') }}
|
||||
run: |
|
||||
if [ "$_HOLD_LABEL" = "true" ]; then
|
||||
echo "::error::PR has banned label: hold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$_NEEDS_QA_LABEL" = "true" ]; then
|
||||
echo "::error::PR has banned label: needs-qa"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No banned labels found."
|
||||
|
||||
- name: Enforce exactly one Change Type (t:*) label
|
||||
env:
|
||||
_PR_ACTION: ${{ github.event.action }}
|
||||
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
|
||||
_REPO: ${{ github.repository }}
|
||||
_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "$_PR_ACTION" = "opened" ] || [ "$_PR_ACTION" = "reopened" ]; then
|
||||
echo "⏳ Waiting 15s for labeler to run..."
|
||||
sleep 15
|
||||
_PR_LABELS=$(gh api "repos/$_REPO/pulls/$_PR_NUMBER" --jq '.labels')
|
||||
echo "Labels fetched from PR: $_PR_LABELS"
|
||||
fi
|
||||
_IGNORE_FOR_RELEASE_LABEL=$(echo "$_PR_LABELS" | jq 'any(.[]; .name == "ignore-for-release")')
|
||||
if [ "$_IGNORE_FOR_RELEASE_LABEL" = "true" ]; then
|
||||
echo "⏭️ Skipping type label check - 'ignore-for-release' label present"
|
||||
exit 0
|
||||
fi
|
||||
_T_LABEL_COUNT=$(echo "$_PR_LABELS" | jq '[.[] | select(.name | startswith("t:"))] | length')
|
||||
case "$_T_LABEL_COUNT" in
|
||||
1)
|
||||
echo "✅ PR has exactly one Change Type (t:*) label"
|
||||
;;
|
||||
0)
|
||||
echo "::error::PR is missing a Change Type (t:*) label. PRs must have exactly one Change Type (t:*) label"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::PR has $_T_LABEL_COUNT Change Type (t:*) labels. PRs must have exactly one Change Type (t:*) label"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
176
.github/workflows/test.yml
vendored
176
.github/workflows/test.yml
vendored
@@ -3,9 +3,8 @@ name: Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
- main
|
||||
- release/**/*
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
merge_group:
|
||||
@@ -13,16 +12,45 @@ 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:
|
||||
name: Test
|
||||
test-sharded:
|
||||
name: "Test ${{ matrix.group }}"
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: read
|
||||
pull-requests: write
|
||||
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"
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
@@ -30,87 +58,101 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- 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
|
||||
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
|
||||
- name: Run tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_GROUP: ${{ matrix.group }}
|
||||
_FASTLANE_METHOD: ${{ matrix.fastlane_method }}
|
||||
_FASTLANE_OPTIONS: ${{ matrix.fastlane_options }}
|
||||
run: |
|
||||
bundle exec fastlane check
|
||||
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
|
||||
with:
|
||||
os: linux
|
||||
files: build/reports/kover/reportMergedCoverage.xml
|
||||
flags: ${{ matrix.group }}
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
name: test-reports-${{ matrix.group }}
|
||||
path: |
|
||||
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/
|
||||
**/build/reports/tests/
|
||||
app/build/reports/lint-results-*.html
|
||||
app/build/reports/detekt/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload to codecov.io
|
||||
id: upload-to-codecov
|
||||
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
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
os: linux
|
||||
files: build/reports/kover/reportMergedCoverage.xml
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
run_command: send-notifications
|
||||
|
||||
- name: Comment PR if tests failed
|
||||
if: steps.upload-to-codecov.outcome == 'failure' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
- name: Comment PR if coverage notification failed
|
||||
if: steps.codecov-notify.outcome == 'failure'
|
||||
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 \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "> Uploading code coverage report failed. Please check the \"Notify Codecov\" step for more details." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
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.'
|
||||
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.'
|
||||
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!"
|
||||
|
||||
@@ -8,8 +8,8 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1211.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-partitions (1.1213.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -43,7 +43,7 @@ GEM
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
faraday (1.10.5)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@@ -169,7 +169,7 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.18.0)
|
||||
json (2.18.1)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
configure<LibraryExtension> {
|
||||
namespace = "com.bitwarden.annotation"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdkBwa.get().toInt()
|
||||
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdkBwa.get().toInt())
|
||||
}
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
@@ -37,6 +40,6 @@ android {
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
}
|
||||
}
|
||||
|
||||
0
annotation/consumer-rules.pro
Normal file
0
annotation/consumer-rules.pro
Normal file
21
annotation/proguard-rules.pro
vendored
Normal file
21
annotation/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -1,9 +1,10 @@
|
||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
import com.android.utils.cxx.io.removeExtensionIfPresent
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFileIdTask
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
|
||||
import com.google.gms.googleservices.GoogleServicesTask
|
||||
import dagger.hilt.android.plugin.util.capitalize
|
||||
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
@@ -15,7 +16,6 @@ plugins {
|
||||
// standardDebug builds in the merged manifest.
|
||||
alias(libs.plugins.crashlytics)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose.compiler)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
@@ -43,27 +43,35 @@ val ciProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.x8bit.bitwarden"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
base {
|
||||
// Set the base archive name for publishing purposes. This is used to derive the
|
||||
// APK and AAB artifact names when uploading to Firebase and Play Store.
|
||||
archivesName.set("com.x8bit.bitwarden")
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
configure<ApplicationExtension> {
|
||||
namespace = "com.x8bit.bitwarden"
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.x8bit.bitwarden"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdk.get().toInt())
|
||||
}
|
||||
targetSdk {
|
||||
version = release(libs.versions.targetSdk.get().toInt())
|
||||
}
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Set the base archive name for publishing purposes. This is used to derive the APK and AAB
|
||||
// artifact names when uploading to Firebase and Play Store.
|
||||
base.archivesName = "com.x8bit.bitwarden"
|
||||
|
||||
buildConfigField(
|
||||
type = "String",
|
||||
name = "CI_INFO",
|
||||
@@ -141,39 +149,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
|
||||
outputs
|
||||
.mapNotNull { it as? BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val fileNameWithoutExtension = when (flavorName) {
|
||||
"fdroid" -> "$applicationId-$flavorName"
|
||||
"standard" -> "$applicationId"
|
||||
else -> output.outputFileName.removeExtensionIfPresent(".apk")
|
||||
}
|
||||
|
||||
// Set the APK output filename.
|
||||
output.outputFileName = "$fileNameWithoutExtension.apk"
|
||||
|
||||
val variantName = name
|
||||
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
|
||||
tasks.register(renameTaskName) {
|
||||
group = "build"
|
||||
description = "Renames the bundle files for $variantName variant"
|
||||
doLast {
|
||||
renameFile(
|
||||
"$bundlesDir/$variantName/$namespace-$flavorName-${buildType.name}.aab",
|
||||
"$fileNameWithoutExtension.aab",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Force renaming task to execute after the variant is built.
|
||||
tasks
|
||||
.getByName("bundle${variantName.capitalize()}")
|
||||
.finalizedBy(renameTaskName)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility(libs.versions.jvmTarget.get())
|
||||
targetCompatibility(libs.versions.jvmTarget.get())
|
||||
@@ -200,9 +175,50 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { appVariant ->
|
||||
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
|
||||
val applicationId = appVariant.applicationId.get()
|
||||
val flavorName = appVariant.flavorName
|
||||
val variantName = appVariant.name
|
||||
val buildType = appVariant.buildType
|
||||
appVariant
|
||||
.outputs
|
||||
.mapNotNull { it as? VariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val fileNameWithoutExtension = when (flavorName) {
|
||||
"fdroid" -> "$applicationId-$flavorName"
|
||||
"standard" -> applicationId
|
||||
else -> output.outputFileName.get().removeExtensionIfPresent(".apk")
|
||||
}
|
||||
|
||||
// Set the APK output filename.
|
||||
output.outputFileName.set("$fileNameWithoutExtension.apk")
|
||||
|
||||
val renameTaskName = "rename${variantName.uppercaseFirstChar()}AabFiles"
|
||||
tasks.register(renameTaskName) {
|
||||
group = "build"
|
||||
description = "Renames the bundle files for $variantName variant"
|
||||
doLast {
|
||||
val namespace = appVariant.namespace.get()
|
||||
renameFile(
|
||||
"$bundlesDir/$variantName/$namespace-$flavorName-$buildType.aab",
|
||||
"$fileNameWithoutExtension.aab",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Force renaming task to execute after the variant is built.
|
||||
val bundleTaskName = "bundle${variantName.uppercaseFirstChar()}"
|
||||
tasks
|
||||
.named { it == bundleTaskName }
|
||||
.configureEach { finalizedBy(renameTaskName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,16 @@
|
||||
android:host="webauthn-callback"
|
||||
android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="sso-cookie-vendor"
|
||||
android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -827,6 +827,26 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.amazon.cloud9",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "2F:19:AD:EB:28:4E:B3:6F:7F:07:78:61:52:B9:A1:D1:4B:21:65:32:03:AD:0B:04:EB:BF:9C:73:AB:6D:76:25"
|
||||
},
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "70:D5:68:EC:6A:E6:F3:38:BC:1A:63:99:A6:53:7E:E0:69:08:CA:1D:72:FB:8F:F0:48:74:AB:95:43:3B:25:0E"
|
||||
},
|
||||
{
|
||||
"build": "userdebug",
|
||||
"cert_fingerprint_sha256": "7C:AC:39:19:37:98:1B:61:34:BD:CE:1F:D9:83:4C:25:31:81:F5:AB:F9:1D:ED:60:78:21:0D:0F:91:AC:E3:60"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden
|
||||
import android.content.Intent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResultOrNull
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
|
||||
@@ -28,6 +29,7 @@ class AuthCallbackViewModel @Inject constructor(
|
||||
val webAuthResult = action.intent.getWebAuthResultOrNull()
|
||||
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
|
||||
val ssoCallbackResult = action.intent.getSsoCallbackResult()
|
||||
val cookieCallbackResult = action.intent.getCookieCallbackResultOrNull()
|
||||
when {
|
||||
yubiKeyResult != null -> {
|
||||
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
|
||||
@@ -45,6 +47,12 @@ class AuthCallbackViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
cookieCallbackResult != null -> {
|
||||
authRepository.setCookieCallbackResult(
|
||||
result = cookieCallbackResult,
|
||||
)
|
||||
}
|
||||
|
||||
webAuthResult != null -> {
|
||||
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
|
||||
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
|
||||
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
|
||||
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
|
||||
@@ -82,6 +84,10 @@ class MainActivity : AppCompatActivity() {
|
||||
mainViewModel.trySendAction(MainAction.WebAuthnResult(it))
|
||||
}
|
||||
|
||||
private val cookieLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
|
||||
mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent = intent.validate()
|
||||
var shouldShowSplashScreen = true
|
||||
@@ -108,6 +114,7 @@ class MainActivity : AppCompatActivity() {
|
||||
duo = duoLauncher,
|
||||
sso = ssoLauncher,
|
||||
webAuthn = webAuthnLauncher,
|
||||
cookie = cookieLauncher,
|
||||
),
|
||||
) {
|
||||
ObserveScreenDataEffect(
|
||||
@@ -125,14 +132,19 @@ class MainActivity : AppCompatActivity() {
|
||||
modifier = Modifier
|
||||
.background(color = BitwardenTheme.colorScheme.background.primary),
|
||||
) {
|
||||
// Both root navigation and debug menu exist at this top level.
|
||||
// The debug menu can appear on top of the rest of the app without
|
||||
// interacting with the state-based navigation used by RootNavScreen.
|
||||
// Root navigation, debug menu, and cookie acquisition exist at
|
||||
// this top level. They can appear on top of the rest of the app
|
||||
// without interacting with the state-based navigation used by
|
||||
// RootNavScreen.
|
||||
rootNavDestination { shouldShowSplashScreen = false }
|
||||
debugMenuDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
cookieAcquisitionDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +218,8 @@ class MainActivity : AppCompatActivity() {
|
||||
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
|
||||
MainEvent.Recreate -> handleRecreate()
|
||||
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
|
||||
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
|
||||
|
||||
is MainEvent.UpdateAppLocale -> {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(event.localeName),
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.bitwarden.vault.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResult
|
||||
@@ -29,6 +30,7 @@ import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
|
||||
@@ -39,7 +41,6 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
|
||||
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
|
||||
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
|
||||
@@ -52,6 +53,7 @@ import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -75,6 +77,7 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
|
||||
class MainViewModel @Inject constructor(
|
||||
accessibilitySelectionManager: AccessibilitySelectionManager,
|
||||
autofillSelectionManager: AutofillSelectionManager,
|
||||
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
|
||||
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val garbageCollectionManager: GarbageCollectionManager,
|
||||
@@ -162,6 +165,13 @@ class MainViewModel @Inject constructor(
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
cookieAcquisitionRequestManager
|
||||
.cookieAcquisitionRequestFlow
|
||||
.filterNotNull()
|
||||
.map { MainAction.Internal.CookieAcquisitionReady }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// On app launch, mark all active users as having previously logged in.
|
||||
// This covers any users who are active prior to this value being recorded.
|
||||
viewModelScope.launch {
|
||||
@@ -186,6 +196,7 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.DuoResult -> handleDuoResult(action)
|
||||
is MainAction.SsoResult -> handleSsoResult(action)
|
||||
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
|
||||
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
|
||||
is MainAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
@@ -207,6 +218,7 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
|
||||
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
|
||||
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
|
||||
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +240,12 @@ class MainViewModel @Inject constructor(
|
||||
authRepository.setWebAuthResult(webAuthResult = action.authResult.getWebAuthResult())
|
||||
}
|
||||
|
||||
private fun handleCookieAcquisitionResult(action: MainAction.CookieAcquisitionResult) {
|
||||
authRepository.setCookieCallbackResult(
|
||||
result = action.cookieCallbackResult.getCookieCallbackResult(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
|
||||
when (val data = action.screenResumeData) {
|
||||
null -> appResumeManager.clearResumeScreen()
|
||||
@@ -271,6 +289,10 @@ class MainViewModel @Inject constructor(
|
||||
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
|
||||
}
|
||||
|
||||
private fun handleCookieAcquisitionReady() {
|
||||
sendEvent(MainEvent.NavigateToCookieAcquisition)
|
||||
}
|
||||
|
||||
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
|
||||
handleIntent(
|
||||
intent = action.intent,
|
||||
@@ -391,7 +413,8 @@ class MainViewModel @Inject constructor(
|
||||
SpecialCircumstance.CredentialExchangeExport(
|
||||
data = ImportCredentialsRequestData(
|
||||
uri = importCredentialsRequest.uri,
|
||||
requestJson = importCredentialsRequest.request.requestJson,
|
||||
credentialTypes = importCredentialsRequest.request.credentialTypes,
|
||||
knownExtensions = importCredentialsRequest.request.knownExtensions,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -518,6 +541,13 @@ sealed class MainAction {
|
||||
*/
|
||||
data class WebAuthnResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive the result from the cookie acquisition flow.
|
||||
*/
|
||||
data class CookieAcquisitionResult(
|
||||
val cookieCallbackResult: AuthTabIntent.AuthResult,
|
||||
) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive first Intent by the application.
|
||||
*/
|
||||
@@ -588,6 +618,12 @@ sealed class MainAction {
|
||||
data class DynamicColorsUpdate(
|
||||
val isDynamicColorsEnabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the cookie acquisition conditions are met and navigation
|
||||
* should proceed.
|
||||
*/
|
||||
data object CookieAcquisitionReady : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,6 +653,11 @@ sealed class MainEvent {
|
||||
*/
|
||||
data object NavigateToDebugMenu : MainEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the cookie acquisition screen.
|
||||
*/
|
||||
data object NavigateToCookieAcquisition : MainEvent()
|
||||
|
||||
/**
|
||||
* Indicates that the app language has been updated.
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents the current account information for a given user.
|
||||
@@ -103,7 +103,7 @@ data class AccountJson(
|
||||
|
||||
@SerialName("creationDate")
|
||||
@Contextual
|
||||
val creationDate: ZonedDateTime?,
|
||||
val creationDate: Instant?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,7 +128,6 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
clearPendingAuthRequest()
|
||||
@@ -199,7 +198,6 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
isComplete = true
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents a Login Approval request.
|
||||
@@ -27,8 +27,8 @@ data class AuthRequest(
|
||||
val ipAddress: String,
|
||||
val key: String?,
|
||||
val masterPasswordHash: String?,
|
||||
val creationDate: ZonedDateTime,
|
||||
val responseDate: ZonedDateTime?,
|
||||
val creationDate: Instant,
|
||||
val responseDate: Instant?,
|
||||
val requestApproved: Boolean,
|
||||
val originUrl: String,
|
||||
val fingerprint: String,
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
@@ -70,6 +71,12 @@ interface AuthRepository :
|
||||
*/
|
||||
val ssoCallbackResultFlow: Flow<SsoCallbackResult>
|
||||
|
||||
/**
|
||||
* Flow of the current [CookieCallbackResult]. Subscribers should listen to the flow in order
|
||||
* to receive updates whenever [setCookieCallbackResult] is called.
|
||||
*/
|
||||
val cookieCallbackResultFlow: Flow<CookieCallbackResult>
|
||||
|
||||
/**
|
||||
* Flow of the current [YubiKeyResult]. Subscribers should listen to the flow in order to
|
||||
* receive updates whenever [setYubiKeyResult] is called.
|
||||
@@ -342,6 +349,11 @@ interface AuthRepository :
|
||||
*/
|
||||
fun setSsoCallbackResult(result: SsoCallbackResult)
|
||||
|
||||
/**
|
||||
* Set the value of [cookieCallbackResultFlow].
|
||||
*/
|
||||
fun setCookieCallbackResult(result: CookieCallbackResult)
|
||||
|
||||
/**
|
||||
* Get a [Boolean] indicating whether this is a known device.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.bitwarden.core.data.util.flatMap
|
||||
import com.bitwarden.crypto.HashPurpose
|
||||
import com.bitwarden.crypto.Kdf
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.data.repository.util.appLinksScheme
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrls
|
||||
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
|
||||
import com.bitwarden.network.model.CreateAccountKeysResponseJson
|
||||
@@ -94,6 +95,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
@@ -268,6 +270,10 @@ class AuthRepositoryImpl(
|
||||
override val ssoCallbackResultFlow: Flow<SsoCallbackResult> =
|
||||
mutableSsoCallbackResultFlow.asSharedFlow()
|
||||
|
||||
private val mutableCookieCallbackResultFlow = bufferedMutableSharedFlow<CookieCallbackResult>()
|
||||
override val cookieCallbackResultFlow: Flow<CookieCallbackResult> =
|
||||
mutableCookieCallbackResultFlow.asSharedFlow()
|
||||
|
||||
override var rememberedEmailAddress: String? by authDiskSource::rememberedEmailAddress
|
||||
|
||||
override var rememberedOrgIdentifier: String? by authDiskSource::rememberedOrgIdentifier
|
||||
@@ -732,18 +738,27 @@ class AuthRepositoryImpl(
|
||||
when (refreshTokenResponse) {
|
||||
is RefreshTokenResponseJson.Error -> {
|
||||
if (refreshTokenResponse.isInvalidGrant) {
|
||||
logout(userId = userId, reason = LogoutReason.InvalidGrant)
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.InvalidGrant,
|
||||
)
|
||||
}
|
||||
IllegalStateException(refreshTokenResponse.error).asFailure()
|
||||
}
|
||||
|
||||
is RefreshTokenResponseJson.Forbidden -> {
|
||||
logout(userId = userId, reason = LogoutReason.RefreshForbidden)
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.RefreshForbidden,
|
||||
)
|
||||
refreshTokenResponse.error.asFailure()
|
||||
}
|
||||
|
||||
is RefreshTokenResponseJson.Unauthorized -> {
|
||||
logout(userId = userId, reason = LogoutReason.RefreshUnauthorized)
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.RefreshUnauthorized,
|
||||
)
|
||||
refreshTokenResponse.error.asFailure()
|
||||
}
|
||||
|
||||
@@ -1248,6 +1263,10 @@ class AuthRepositoryImpl(
|
||||
mutableSsoCallbackResultFlow.tryEmit(result)
|
||||
}
|
||||
|
||||
override fun setCookieCallbackResult(result: CookieCallbackResult) {
|
||||
mutableCookieCallbackResultFlow.tryEmit(result)
|
||||
}
|
||||
|
||||
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
|
||||
devicesService
|
||||
.getIsKnownDevice(
|
||||
@@ -1573,6 +1592,7 @@ class AuthRepositoryImpl(
|
||||
): LoginResult = identityService
|
||||
.getToken(
|
||||
uniqueAppId = authDiskSource.uniqueAppId,
|
||||
deeplinkScheme = environmentRepository.environment.environmentUrlData.appLinksScheme,
|
||||
email = email,
|
||||
authModel = authModel,
|
||||
twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/** URI scheme for cookie vendor callback. */
|
||||
private const val COOKIE_CALLBACK_SCHEME: String = "bitwarden"
|
||||
|
||||
/** URI host for cookie vendor callback. */
|
||||
private const val COOKIE_CALLBACK_HOST: String = "sso-cookie-vendor"
|
||||
|
||||
/** Completeness marker parameter name (filtered from cookie extraction). */
|
||||
private const val COMPLETENESS_MARKER_PARAM = "d"
|
||||
|
||||
/**
|
||||
* Extracts cookie callback result from Intent.
|
||||
* Handles both single and sharded cookie formats.
|
||||
* Filters out the 'd' completeness marker parameter.
|
||||
*
|
||||
* @return [CookieCallbackResult] if this is a cookie callback, null otherwise.
|
||||
*/
|
||||
fun Intent.getCookieCallbackResultOrNull(): CookieCallbackResult? {
|
||||
if (action != Intent.ACTION_VIEW) return null
|
||||
val uri = data ?: return null
|
||||
if (uri.scheme != COOKIE_CALLBACK_SCHEME) return null
|
||||
if (uri.host != COOKIE_CALLBACK_HOST) return null
|
||||
return uri.getCookieCallbackResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a [CookieCallbackResult] from an [AuthTabIntent.AuthResult]. There are two possible
|
||||
* cases.
|
||||
*
|
||||
* - [CookieCallbackResult.Success]: The URI is the cookie callback with correct data.
|
||||
* - [CookieCallbackResult.MissingCookie]: The URI is the cookie callback with incorrect data or a
|
||||
* failure has occurred.
|
||||
*/
|
||||
fun AuthTabIntent.AuthResult.getCookieCallbackResult(): CookieCallbackResult =
|
||||
when (this.resultCode) {
|
||||
AuthTabIntent.RESULT_OK -> this.resultUri.getCookieCallbackResult()
|
||||
AuthTabIntent.RESULT_CANCELED -> CookieCallbackResult.MissingCookie
|
||||
AuthTabIntent.RESULT_UNKNOWN_CODE -> CookieCallbackResult.MissingCookie
|
||||
AuthTabIntent.RESULT_VERIFICATION_FAILED -> CookieCallbackResult.MissingCookie
|
||||
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> CookieCallbackResult.MissingCookie
|
||||
else -> CookieCallbackResult.MissingCookie
|
||||
}
|
||||
|
||||
private fun Uri?.getCookieCallbackResult(): CookieCallbackResult {
|
||||
if (this == null) return CookieCallbackResult.MissingCookie
|
||||
val cookies = queryParameterNames
|
||||
.asSequence()
|
||||
.filter { it != COMPLETENESS_MARKER_PARAM }
|
||||
.mapNotNull { name ->
|
||||
getQueryParameter(name)?.takeIf { it.isNotEmpty() }?.let { name to it }
|
||||
}
|
||||
.toMap()
|
||||
return if (cookies.isEmpty()) {
|
||||
CookieCallbackResult.MissingCookie
|
||||
} else {
|
||||
CookieCallbackResult.Success(cookies)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the result of a cookie callback from a deep link.
|
||||
*/
|
||||
sealed class CookieCallbackResult : Parcelable {
|
||||
/**
|
||||
* The callback did not contain any cookies.
|
||||
*/
|
||||
@Parcelize
|
||||
data object MissingCookie : CookieCallbackResult()
|
||||
|
||||
/**
|
||||
* Successfully extracted cookies from the callback.
|
||||
* @param cookies Map of cookie name to cookie value. Supports sharded cookies.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Success(val cookies: Map<String, String>) : CookieCallbackResult()
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import android.net.Uri
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "duo-callback"
|
||||
@@ -34,9 +33,7 @@ fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -11,31 +11,31 @@ import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "sso-callback"
|
||||
|
||||
const val SSO_URI: String = "bitwarden://$CALLBACK"
|
||||
|
||||
/**
|
||||
* Generates a URI for the SSO custom tab.
|
||||
*
|
||||
* @param identityBaseUrl The base URl for the identity service.
|
||||
* @param redirectUrl The redirect URI used in the SSO request.
|
||||
* @param organizationIdentifier The SSO organization identifier.
|
||||
* @param token The prevalidated SSO token.
|
||||
* @param state Random state used to verify the validity of the response.
|
||||
* @param codeVerifier A random string used to generate the code challenge.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun generateUriForSso(
|
||||
identityBaseUrl: String,
|
||||
redirectUrl: String,
|
||||
organizationIdentifier: String,
|
||||
token: String,
|
||||
state: String,
|
||||
codeVerifier: String,
|
||||
): Uri {
|
||||
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
|
||||
val redirectUri = URLEncoder.encode(redirectUrl, "UTF-8")
|
||||
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
|
||||
val encodedToken = URLEncoder.encode(token, "UTF-8")
|
||||
|
||||
@@ -81,9 +81,7 @@ fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -5,20 +5,18 @@ import android.net.Uri
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.net.URLEncoder
|
||||
import java.util.Base64
|
||||
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "webauthn-callback"
|
||||
|
||||
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
|
||||
|
||||
/**
|
||||
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
|
||||
*
|
||||
@@ -39,9 +37,7 @@ fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
@@ -79,29 +75,33 @@ private fun Uri?.getWebAuthResult(): WebAuthResult =
|
||||
/**
|
||||
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun generateUriForWebAuth(
|
||||
baseUrl: String,
|
||||
authTabData: AuthTabData,
|
||||
data: JsonObject,
|
||||
headerText: String,
|
||||
buttonText: String,
|
||||
returnButtonText: String,
|
||||
): Uri {
|
||||
val json = buildJsonObject {
|
||||
put(key = "callbackUri", value = CALLBACK_URI)
|
||||
put(key = "data", value = data.toString())
|
||||
put(key = "headerText", value = headerText)
|
||||
put(key = "btnText", value = buttonText)
|
||||
put(key = "btnReturnText", value = returnButtonText)
|
||||
put(key = "mobile", value = true)
|
||||
}
|
||||
val base64Data = Base64
|
||||
.getEncoder()
|
||||
.encodeToString(json.toString().toByteArray(Charsets.UTF_8))
|
||||
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
|
||||
val parentParam = URLEncoder.encode(authTabData.callbackUrl, "UTF-8")
|
||||
val url = baseUrl +
|
||||
"/webauthn-mobile-connector.html" +
|
||||
"?data=$base64Data" +
|
||||
"&parent=$parentParam" +
|
||||
"&v=2"
|
||||
"&client=mobile" +
|
||||
"&v=2" +
|
||||
"&deeplinkScheme=${authTabData.callbackScheme}"
|
||||
return url.toUri()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,10 @@ class AutofillActivityManagerImpl(
|
||||
braveStableStatusData = browserThirdPartyAutofillManager.stableBraveAutofillStatus,
|
||||
chromeStableStatusData = browserThirdPartyAutofillManager.stableChromeAutofillStatus,
|
||||
chromeBetaChannelStatusData = browserThirdPartyAutofillManager.betaChromeAutofillStatus,
|
||||
vivaldiStableChannelStatusData = browserThirdPartyAutofillManager
|
||||
.stableVivaldiAutofillStatus,
|
||||
defaultBrowserPackageName = browserThirdPartyAutofillManager
|
||||
.defaultBrowserPackageName,
|
||||
)
|
||||
|
||||
init {
|
||||
|
||||
@@ -29,7 +29,7 @@ internal class BrowserAutofillDialogManagerImpl(
|
||||
get() = autofillEnabledManager.isAutofillEnabled &&
|
||||
browserThirdPartyAutofillEnabledManager
|
||||
.browserThirdPartyAutofillStatus
|
||||
.isAnyIsAvailableAndDisabled &&
|
||||
.isDefaultBrowserAvailableAndDisabled &&
|
||||
!firstTimeActionManager
|
||||
.currentOrDefaultUserFirstTimeState
|
||||
.showSetupBrowserAutofillCard &&
|
||||
|
||||
@@ -39,4 +39,9 @@ private val DEFAULT_STATUS = BrowserThirdPartyAutofillStatus(
|
||||
isAvailable = false,
|
||||
isThirdPartyEnabled = false,
|
||||
),
|
||||
vivaldiStableChannelStatusData = BrowserThirdPartyAutoFillData(
|
||||
isAvailable = false,
|
||||
isThirdPartyEnabled = false,
|
||||
),
|
||||
defaultBrowserPackageName = null,
|
||||
)
|
||||
|
||||
@@ -22,4 +22,14 @@ interface BrowserThirdPartyAutofillManager {
|
||||
* The data representing the status of the beta Chrome version
|
||||
*/
|
||||
val betaChromeAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
|
||||
/**
|
||||
* The data representing the status of the Vivaldi version
|
||||
*/
|
||||
val stableVivaldiAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
|
||||
/**
|
||||
* The package name of the device's default browser, or null if it cannot be determined.
|
||||
*/
|
||||
val defaultBrowserPackageName: String?
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ package com.x8bit.bitwarden.data.autofill.manager.browser
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
|
||||
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutoFillData
|
||||
@@ -27,6 +30,18 @@ class BrowserThirdPartyAutofillManagerImpl(
|
||||
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.CHROME_STABLE)
|
||||
override val betaChromeAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.CHROME_BETA)
|
||||
override val stableVivaldiAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.VIVALDI_STABLE)
|
||||
|
||||
override val defaultBrowserPackageName: String?
|
||||
get() {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "https://example.com".toUri())
|
||||
return context
|
||||
.packageManager
|
||||
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
?.activityInfo
|
||||
?.packageName
|
||||
}
|
||||
|
||||
private fun getThirdPartyAutoFillStatusForChannel(
|
||||
releaseChannel: BrowserPackage,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.autofill.model.browser
|
||||
private const val BRAVE_CHANNEL_PACKAGE = "com.brave.browser"
|
||||
private const val CHROME_BETA_CHANNEL_PACKAGE = "com.chrome.beta"
|
||||
private const val CHROME_RELEASE_CHANNEL_PACKAGE = "com.android.chrome"
|
||||
private const val VIVALDI_RELEASE_CHANNEL_PACKAGE = "com.vivaldi.browser"
|
||||
|
||||
/**
|
||||
* Enumerated values of each browser that supports third party autofill checks.
|
||||
@@ -13,4 +14,5 @@ enum class BrowserPackage(val packageName: String) {
|
||||
BRAVE_RELEASE(BRAVE_CHANNEL_PACKAGE),
|
||||
CHROME_STABLE(CHROME_RELEASE_CHANNEL_PACKAGE),
|
||||
CHROME_BETA(CHROME_BETA_CHANNEL_PACKAGE),
|
||||
VIVALDI_STABLE(VIVALDI_RELEASE_CHANNEL_PACKAGE),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val braveStableStatusData: BrowserThirdPartyAutoFillData,
|
||||
val chromeStableStatusData: BrowserThirdPartyAutoFillData,
|
||||
val chromeBetaChannelStatusData: BrowserThirdPartyAutoFillData,
|
||||
val vivaldiStableChannelStatusData: BrowserThirdPartyAutoFillData,
|
||||
val defaultBrowserPackageName: String?,
|
||||
) {
|
||||
/**
|
||||
* The total number of available browsers.
|
||||
@@ -24,7 +26,8 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val availableCount: Int
|
||||
get() = (if (braveStableStatusData.isAvailable) 1 else 0) +
|
||||
(if (chromeStableStatusData.isAvailable) 1 else 0) +
|
||||
(if (chromeBetaChannelStatusData.isAvailable) 1 else 0)
|
||||
(if (chromeBetaChannelStatusData.isAvailable) 1 else 0) +
|
||||
(if (vivaldiStableChannelStatusData.isAvailable) 1 else 0)
|
||||
|
||||
/**
|
||||
* Whether any of the available browsers have third party autofill disabled.
|
||||
@@ -32,5 +35,28 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val isAnyIsAvailableAndDisabled: Boolean
|
||||
get() = braveStableStatusData.isAvailableButDisabled ||
|
||||
chromeStableStatusData.isAvailableButDisabled ||
|
||||
chromeBetaChannelStatusData.isAvailableButDisabled
|
||||
chromeBetaChannelStatusData.isAvailableButDisabled ||
|
||||
vivaldiStableChannelStatusData.isAvailableButDisabled
|
||||
|
||||
/**
|
||||
* Whether the device's default browser is one of the supported browsers and has third party
|
||||
* autofill disabled. Returns false if the default browser is not a supported browser or
|
||||
* cannot be determined.
|
||||
*/
|
||||
val isDefaultBrowserAvailableAndDisabled: Boolean
|
||||
get() {
|
||||
val browserPackage = defaultBrowserPackageName
|
||||
?.let { packageName ->
|
||||
BrowserPackage.entries.firstOrNull { it.packageName == packageName }
|
||||
}
|
||||
?: return false
|
||||
return when (browserPackage) {
|
||||
BrowserPackage.BRAVE_RELEASE -> braveStableStatusData.isAvailableButDisabled
|
||||
BrowserPackage.CHROME_STABLE -> chromeStableStatusData.isAvailableButDisabled
|
||||
BrowserPackage.CHROME_BETA -> chromeBetaChannelStatusData.isAvailableButDisabled
|
||||
BrowserPackage.VIVALDI_STABLE -> {
|
||||
vivaldiStableChannelStatusData.isAvailableButDisabled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,21 @@ private val BLOCK_LISTED_URIS: List<String> = listOf(
|
||||
* A map of package ids and the known associated id entry for their url bar.
|
||||
*/
|
||||
private val URL_BARS: Map<String, String> = mapOf(
|
||||
// Edge Browser Variants
|
||||
"com.microsoft.emmx" to "url_bar",
|
||||
"com.microsoft.emmx.beta" to "url_bar",
|
||||
"com.microsoft.emmx.canary" to "url_bar",
|
||||
"com.microsoft.emmx.dev" to "url_bar",
|
||||
// Samsung Internet Browser Variants
|
||||
"com.sec.android.app.sbrowser" to "location_bar_edit_text",
|
||||
"com.sec.android.app.sbrowser.beta" to "location_bar_edit_text",
|
||||
// Opera Browser Variants
|
||||
"com.opera.browser" to "url_bar",
|
||||
"com.opera.browser.beta" to "url_bar",
|
||||
// Brave Browser Variants
|
||||
"com.brave.browser" to "url_bar",
|
||||
"com.brave.browser_beta" to "url_bar",
|
||||
"com.brave.browser_nightly" to "url_bar",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
package com.x8bit.bitwarden.data.autofill.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.app.assist.AssistStructure
|
||||
import android.content.Context
|
||||
@@ -13,6 +12,10 @@ 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
|
||||
@@ -22,11 +25,6 @@ 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.
|
||||
*/
|
||||
@@ -149,12 +147,3 @@ 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
|
||||
|
||||
@@ -28,7 +28,7 @@ class OriginManagerImpl(
|
||||
callingAppInfo: CallingAppInfo,
|
||||
): ValidateOriginResult {
|
||||
return if (callingAppInfo.isOriginPopulated()) {
|
||||
validatePrivilegedAppOrigin(callingAppInfo)
|
||||
validatePrivilegedAppOrigin(relyingPartyId, callingAppInfo)
|
||||
} else {
|
||||
validateCallingApplicationAssetLinks(relyingPartyId, callingAppInfo)
|
||||
}
|
||||
@@ -64,44 +64,58 @@ class OriginManagerImpl(
|
||||
}
|
||||
|
||||
private suspend fun validatePrivilegedAppOrigin(
|
||||
relyingPartyId: String,
|
||||
callingAppInfo: CallingAppInfo,
|
||||
): ValidateOriginResult =
|
||||
validatePrivilegedAppSignatureWithGoogleList(callingAppInfo)
|
||||
validatePrivilegedAppSignatureWithGoogleList(relyingPartyId, callingAppInfo)
|
||||
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
|
||||
?: validatePrivilegedAppSignatureWithCommunityList(callingAppInfo)
|
||||
?: validatePrivilegedAppSignatureWithCommunityList(relyingPartyId, callingAppInfo)
|
||||
.takeUnless { it is ValidateOriginResult.Error.PrivilegedAppNotAllowed }
|
||||
?: validatePrivilegedAppSignatureWithUserTrustList(callingAppInfo)
|
||||
?: validatePrivilegedAppSignatureWithUserTrustList(relyingPartyId, 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(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
|
||||
|
||||
/**
|
||||
* Disk source for cookie persistence.
|
||||
*/
|
||||
interface CookieDiskSource {
|
||||
|
||||
/**
|
||||
* Gets cookie configuration for a specific [hostname].
|
||||
*
|
||||
* @param hostname The server hostname to retrieve configuration for.
|
||||
* @return The [CookieConfigurationData] if found, or null if no cookies stored.
|
||||
*/
|
||||
fun getCookieConfig(hostname: String): CookieConfigurationData?
|
||||
|
||||
/**
|
||||
* Stores cookie [config] for the given [hostname]. Pass `null` to delete the configuration.
|
||||
*
|
||||
* @param hostname The server hostname to associate with this configuration.
|
||||
* @param config The [CookieConfigurationData] to persist, or `null` to delete.
|
||||
*/
|
||||
fun storeCookieConfig(hostname: String, config: CookieConfigurationData?)
|
||||
|
||||
/**
|
||||
* Clears all stored cookie configurations across all hostnames.
|
||||
*/
|
||||
fun clearCookies()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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.
|
||||
*
|
||||
* Simple storage layer for cookies.
|
||||
*/
|
||||
class CookieDiskSourceImpl(
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val encryptedSharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : CookieDiskSource,
|
||||
BaseEncryptedDiskSource(
|
||||
sharedPreferences = sharedPreferences,
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
) {
|
||||
|
||||
override fun getCookieConfig(hostname: String): CookieConfigurationData? {
|
||||
val key = CONFIG_PREFIX.appendIdentifier(hostname)
|
||||
return getEncryptedString(key)
|
||||
?.let { json.decodeFromStringOrNull<CookieConfigurationData>(it) }
|
||||
}
|
||||
|
||||
override fun storeCookieConfig(hostname: String, config: CookieConfigurationData?) {
|
||||
val key = CONFIG_PREFIX.appendIdentifier(hostname)
|
||||
putEncryptedString(key, config?.let { json.encodeToString(it) })
|
||||
}
|
||||
|
||||
override fun clearCookies() {
|
||||
val keysToRemove = encryptedSharedPreferences
|
||||
.all
|
||||
.keys
|
||||
.filter { it.startsWith(ENCRYPTED_PREFIX) }
|
||||
encryptedSharedPreferences.edit {
|
||||
keysToRemove.forEach { key -> remove(key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Primary access point for push notification information.
|
||||
@@ -25,7 +25,7 @@ interface PushDiskSource {
|
||||
/**
|
||||
* Retrieves the last time a push token was registered for a user.
|
||||
*/
|
||||
fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime?
|
||||
fun getLastPushTokenRegistrationDate(userId: String): Instant?
|
||||
|
||||
/**
|
||||
* Sets the current token for a user.
|
||||
@@ -35,5 +35,5 @@ interface PushDiskSource {
|
||||
/**
|
||||
* Sets the last push token registration date for a user.
|
||||
*/
|
||||
fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: ZonedDateTime?)
|
||||
fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: Instant?)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import com.bitwarden.core.util.getBinaryLongFromZoneDateTime
|
||||
import com.bitwarden.core.util.getZoneDateTimeFromBinaryLong
|
||||
import com.bitwarden.core.util.getBinaryLongFromInstant
|
||||
import com.bitwarden.core.util.getInstantFromBinaryLong
|
||||
import com.bitwarden.data.datasource.disk.BaseDiskSource
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
private const val CURRENT_PUSH_TOKEN_KEY = "pushCurrentToken"
|
||||
private const val LAST_REGISTRATION_DATE_KEY = "pushLastRegistrationDate"
|
||||
@@ -35,9 +35,9 @@ class PushDiskSourceImpl(
|
||||
return getString(CURRENT_PUSH_TOKEN_KEY.appendIdentifier(userId))
|
||||
}
|
||||
|
||||
override fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? {
|
||||
override fun getLastPushTokenRegistrationDate(userId: String): Instant? {
|
||||
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId))
|
||||
?.let { getZoneDateTimeFromBinaryLong(it) }
|
||||
?.let { getInstantFromBinaryLong(it) }
|
||||
}
|
||||
|
||||
override fun storeCurrentPushToken(userId: String, pushToken: String?) {
|
||||
@@ -49,11 +49,11 @@ class PushDiskSourceImpl(
|
||||
|
||||
override fun storeLastPushTokenRegistrationDate(
|
||||
userId: String,
|
||||
registrationDate: ZonedDateTime?,
|
||||
registrationDate: Instant?,
|
||||
) {
|
||||
putLong(
|
||||
key = LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId),
|
||||
value = registrationDate?.let { getBinaryLongFromZoneDateTime(registrationDate) },
|
||||
value = registrationDate?.let { getBinaryLongFromInstant(registrationDate) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
|
||||
|
||||
/**
|
||||
* Room database for storing any persisted data for platform data.
|
||||
@@ -21,7 +21,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTyp
|
||||
AutoMigration(from = 1, to = 2),
|
||||
],
|
||||
)
|
||||
@TypeConverters(ZonedDateTimeTypeConverter::class)
|
||||
@TypeConverters(InstantTypeConverter::class)
|
||||
abstract class PlatformDatabase : RoomDatabase() {
|
||||
/**
|
||||
* Provides the DAO for accessing organization event data.
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.data.datasource.disk.FlightRecorderDiskSource
|
||||
import com.bitwarden.data.datasource.disk.di.EncryptedPreferences
|
||||
import com.bitwarden.data.datasource.disk.di.UnencryptedPreferences
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSourceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
|
||||
@@ -29,7 +31,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStor
|
||||
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.callback.DatabaseSchemeCallback
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -69,7 +71,7 @@ object PlatformDiskModule {
|
||||
name = "platform_database",
|
||||
)
|
||||
.fallbackToDestructiveMigration(dropAllTables = false)
|
||||
.addTypeConverter(ZonedDateTimeTypeConverter())
|
||||
.addTypeConverter(InstantTypeConverter())
|
||||
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
|
||||
.build()
|
||||
|
||||
@@ -155,4 +157,16 @@ object PlatformDiskModule {
|
||||
): FeatureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCookieDiskSource(
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): CookieDiskSource = CookieDiskSourceImpl(
|
||||
sharedPreferences = sharedPreferences,
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.entity
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Entity representing an organization event in the database.
|
||||
@@ -24,7 +24,7 @@ data class OrganizationEventEntity(
|
||||
val cipherId: String?,
|
||||
|
||||
@ColumnInfo(name = "date")
|
||||
val date: ZonedDateTime,
|
||||
val date: Instant,
|
||||
|
||||
@ColumnInfo(name = "organization_id")
|
||||
val organizationId: String?,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.x8bit.bitwarden.data.platform.datasource.disk.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Simple domain model for cookie storage.
|
||||
*
|
||||
* @property hostname The server hostname this configuration applies to.
|
||||
* @property cookies The list of cookies for this server configuration.
|
||||
*/
|
||||
@Serializable
|
||||
data class CookieConfigurationData(
|
||||
val hostname: String,
|
||||
val cookies: List<Cookie>,
|
||||
) {
|
||||
/**
|
||||
* Simple domain model for a cookie.
|
||||
*
|
||||
* @property name The cookie name.
|
||||
* @property value The cookie value.
|
||||
*/
|
||||
@Serializable
|
||||
data class Cookie(
|
||||
val name: String,
|
||||
val value: String,
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CL
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_CLIENT_VERSION
|
||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_USER_AGENT
|
||||
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -55,6 +56,7 @@ object PlatformNetworkModule {
|
||||
authDiskSource: AuthDiskSource,
|
||||
certificateManager: CertificateManager,
|
||||
buildInfoManager: BuildInfoManager,
|
||||
networkCookieManager: NetworkCookieManager,
|
||||
clock: Clock,
|
||||
): BitwardenServiceClient = bitwardenServiceClient(
|
||||
BitwardenServiceClientConfig(
|
||||
@@ -69,6 +71,7 @@ object PlatformNetworkModule {
|
||||
baseUrlsProvider = baseUrlsProvider,
|
||||
certificateProvider = certificateManager,
|
||||
enableHttpBodyLogging = buildInfoManager.isDevBuild,
|
||||
cookieProvider = networkCookieManager,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.x8bit.bitwarden.data.platform.error
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Thrown when SDK requires cookie acquisition before API call can proceed.
|
||||
*
|
||||
* @property hostname The server hostname requiring cookie acquisition.
|
||||
*/
|
||||
class CookiesRequiredException(
|
||||
val hostname: String,
|
||||
) : IOException("Cookie acquisition required for $hostname")
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Manager for server communication configuration state.
|
||||
*/
|
||||
interface CookieAcquisitionRequestManager {
|
||||
|
||||
/**
|
||||
* StateFlow of pending cookie acquisition.
|
||||
*
|
||||
* Emits non-null when cookie acquisition is needed, null otherwise.
|
||||
*/
|
||||
val cookieAcquisitionRequestFlow: StateFlow<CookieAcquisitionRequest?>
|
||||
|
||||
/**
|
||||
* Sets the pending cookie acquisition state.
|
||||
*
|
||||
* @param data The pending cookie acquisition data, or null to clear.
|
||||
*/
|
||||
fun setPendingCookieAcquisition(data: CookieAcquisitionRequest?)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Implementation of [CookieAcquisitionRequestManager].
|
||||
*/
|
||||
@Singleton
|
||||
class CookieAcquisitionRequestManagerImpl : CookieAcquisitionRequestManager {
|
||||
|
||||
private val mutableCookieAcquisitionRequestFlow =
|
||||
MutableStateFlow<CookieAcquisitionRequest?>(null)
|
||||
|
||||
override val cookieAcquisitionRequestFlow: StateFlow<CookieAcquisitionRequest?> =
|
||||
mutableCookieAcquisitionRequestFlow.asStateFlow()
|
||||
|
||||
override fun setPendingCookieAcquisition(data: CookieAcquisitionRequest?) {
|
||||
mutableCookieAcquisitionRequestFlow.value = data
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import timber.log.Timber
|
||||
import java.time.Clock
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import javax.inject.Inject
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
@@ -340,8 +338,7 @@ class PushManagerImpl @Inject constructor(
|
||||
private suspend fun registerPushTokenIfNecessaryInternal(userId: String, token: String) {
|
||||
val currentToken = pushDiskSource.getCurrentPushToken(userId)
|
||||
if (token == currentToken) {
|
||||
val lastRegistration =
|
||||
pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toInstant() ?: return
|
||||
val lastRegistration = pushDiskSource.getLastPushTokenRegistrationDate(userId) ?: return
|
||||
val updateTime = clock.instant().minus(PUSH_TOKEN_UPDATE_DELAY.toJavaDuration())
|
||||
if (updateTime.isBefore(lastRegistration)) return
|
||||
}
|
||||
@@ -354,7 +351,7 @@ class PushManagerImpl @Inject constructor(
|
||||
onSuccess = {
|
||||
pushDiskSource.storeLastPushTokenRegistrationDate(
|
||||
userId = userId,
|
||||
registrationDate = ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC),
|
||||
registrationDate = clock.instant(),
|
||||
)
|
||||
pushDiskSource.storeCurrentPushToken(
|
||||
userId = userId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.os.Build
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.data.manager.NativeLibraryManager
|
||||
import com.bitwarden.sdk.Client
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,7 @@ import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
|
||||
class SdkClientManagerImpl(
|
||||
nativeLibraryManager: NativeLibraryManager,
|
||||
sdkRepoFactory: SdkRepositoryFactory,
|
||||
sdkPlatformApiFactory: SdkPlatformApiFactory,
|
||||
private val featureFlagManager: FeatureFlagManager,
|
||||
private val clientProvider: suspend (userId: String?) -> Client = { userId ->
|
||||
Client(
|
||||
@@ -20,6 +22,10 @@ class SdkClientManagerImpl(
|
||||
)
|
||||
.apply {
|
||||
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
|
||||
platform().serverCommunicationConfig(
|
||||
repository = sdkRepoFactory.getServerCommunicationConfigRepository(),
|
||||
platformApi = sdkPlatformApiFactory.getServerCommunicationConfigPlatformApi(),
|
||||
)
|
||||
userId?.let {
|
||||
platform().state().apply {
|
||||
registerCipherRepository(sdkRepoFactory.getCipherRepository(userId = it))
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.bitwarden.core.data.manager.toast.ToastManager
|
||||
import com.bitwarden.core.data.manager.toast.ToastManagerImpl
|
||||
import com.bitwarden.cxf.registry.CredentialExchangeRegistry
|
||||
import com.bitwarden.cxf.registry.dsl.credentialExchangeRegistry
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.data.manager.NativeLibraryManager
|
||||
import com.bitwarden.data.repository.ServerConfigRepository
|
||||
import com.bitwarden.network.BitwardenServiceClient
|
||||
@@ -22,20 +23,21 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
|
||||
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
|
||||
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.CertificateManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.CertificateManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
|
||||
@@ -69,8 +71,12 @@ import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkCookieManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactoryImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactoryImpl
|
||||
import com.x8bit.bitwarden.data.platform.processor.AuthenticatorBridgeProcessor
|
||||
@@ -98,12 +104,6 @@ import javax.inject.Singleton
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlatformManagerModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppStateManager(
|
||||
application: Application,
|
||||
): AppStateManager = AppStateManagerImpl(application = application)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthenticatorBridgeProcessor(
|
||||
@@ -219,10 +219,12 @@ object PlatformManagerModule {
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
nativeLibraryManager: NativeLibraryManager,
|
||||
sdkRepositoryFactory: SdkRepositoryFactory,
|
||||
sdkPlatformApiFactory: SdkPlatformApiFactory,
|
||||
): SdkClientManager = SdkClientManagerImpl(
|
||||
featureFlagManager = featureFlagManager,
|
||||
nativeLibraryManager = nativeLibraryManager,
|
||||
sdkRepoFactory = sdkRepositoryFactory,
|
||||
sdkPlatformApiFactory = sdkPlatformApiFactory,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -362,10 +364,22 @@ object PlatformManagerModule {
|
||||
@Singleton
|
||||
fun provideSdkRepositoryFactory(
|
||||
vaultDiskSource: VaultDiskSource,
|
||||
bitwardenServiceClient: BitwardenServiceClient,
|
||||
cookieDiskSource: CookieDiskSource,
|
||||
configDiskSource: ConfigDiskSource,
|
||||
authDiskSource: AuthDiskSource,
|
||||
): SdkRepositoryFactory = SdkRepositoryFactoryImpl(
|
||||
vaultDiskSource = vaultDiskSource,
|
||||
bitwardenServiceClient = bitwardenServiceClient,
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
configDiskSource = configDiskSource,
|
||||
authDiskSource = authDiskSource,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSdkPlatformApiFactory(
|
||||
serverCommConfigManager: CookieAcquisitionRequestManager,
|
||||
): SdkPlatformApiFactory = SdkPlatformApiFactoryImpl(
|
||||
serverCommConfigManager = serverCommConfigManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -413,4 +427,21 @@ object PlatformManagerModule {
|
||||
credentialExchangeRegistry = credentialExchangeRegistry,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideServerCommunicationConfigManager(): CookieAcquisitionRequestManager =
|
||||
CookieAcquisitionRequestManagerImpl()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkCookieManager(
|
||||
configDiskSource: ConfigDiskSource,
|
||||
cookieDiskSource: CookieDiskSource,
|
||||
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
|
||||
): NetworkCookieManager = NetworkCookieManagerImpl(
|
||||
configDiskSource = configDiskSource,
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
cookieAcquisitionRequestManager = cookieAcquisitionRequestManager,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Clock
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* The amount of time to delay before attempting the first upload events after the app is
|
||||
@@ -78,7 +77,7 @@ class OrganizationEventManagerImpl(
|
||||
event = OrganizationEventJson(
|
||||
type = event.type,
|
||||
cipherId = event.cipherId,
|
||||
date = ZonedDateTime.now(clock),
|
||||
date = clock.instant(),
|
||||
organizationId = event.organizationId,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
/**
|
||||
* Represents pending cookie acquisition request for a specific hostname.
|
||||
*
|
||||
* @property hostname The server hostname requiring cookies.
|
||||
*/
|
||||
data class CookieAcquisitionRequest(
|
||||
val hostname: String,
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* The payload of a push notification.
|
||||
@@ -31,7 +31,7 @@ sealed class NotificationPayload {
|
||||
@JsonNames("OrganizationId", "organizationId") val organizationId: String?,
|
||||
@JsonNames("CollectionIds", "collectionIds") val collectionIds: List<String>?,
|
||||
@Contextual
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
|
||||
) : NotificationPayload()
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ sealed class NotificationPayload {
|
||||
@JsonNames("Id", "id") val folderId: String?,
|
||||
@JsonNames("UserId", "userId") override val userId: String?,
|
||||
@Contextual
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
|
||||
) : NotificationPayload()
|
||||
|
||||
/**
|
||||
@@ -55,7 +55,7 @@ sealed class NotificationPayload {
|
||||
|
||||
@Contextual
|
||||
@JsonNames("Date", "date")
|
||||
val date: ZonedDateTime?,
|
||||
val date: Instant?,
|
||||
|
||||
@JsonNames("Reason", "reason")
|
||||
val pushNotificationLogOutReason: PushNotificationLogOutReason?,
|
||||
@@ -69,7 +69,7 @@ sealed class NotificationPayload {
|
||||
@JsonNames("Id", "id") val sendId: String?,
|
||||
@JsonNames("UserId", "userId") override val userId: String?,
|
||||
@Contextual
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
|
||||
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
|
||||
) : NotificationPayload()
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Required data for sync cipher upsert operations.
|
||||
@@ -14,7 +14,7 @@ import java.time.ZonedDateTime
|
||||
data class SyncCipherUpsertData(
|
||||
val userId: String,
|
||||
val cipherId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val revisionDate: Instant,
|
||||
val organizationId: String?,
|
||||
val collectionIds: List<String>?,
|
||||
val isUpdate: Boolean,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Required data for sync folder upsert operations.
|
||||
@@ -14,6 +14,6 @@ import java.time.ZonedDateTime
|
||||
data class SyncFolderUpsertData(
|
||||
val userId: String,
|
||||
val folderId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val revisionDate: Instant,
|
||||
val isUpdate: Boolean,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.model
|
||||
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Required data for sync send upsert operations.
|
||||
@@ -14,6 +14,6 @@ import java.time.ZonedDateTime
|
||||
data class SyncSendUpsertData(
|
||||
val userId: String,
|
||||
val sendId: String,
|
||||
val revisionDate: ZonedDateTime,
|
||||
val revisionDate: Instant,
|
||||
val isUpdate: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.network
|
||||
|
||||
import com.bitwarden.network.provider.CookieProvider
|
||||
|
||||
/**
|
||||
* A manager class for handling cookies.
|
||||
*/
|
||||
interface NetworkCookieManager : CookieProvider {
|
||||
|
||||
/**
|
||||
* Stores acquired cookies for the given [hostname].
|
||||
*
|
||||
* @param hostname The hostname to associate with the cookies.
|
||||
* @param cookies A map of cookie name to cookie value.
|
||||
*/
|
||||
fun storeCookies(hostname: String, cookies: Map<String, String>)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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"
|
||||
|
||||
/**
|
||||
* Default implementation of [NetworkCookieManager].
|
||||
*/
|
||||
class NetworkCookieManagerImpl(
|
||||
private val configDiskSource: ConfigDiskSource,
|
||||
private val cookieDiskSource: CookieDiskSource,
|
||||
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
|
||||
}
|
||||
}
|
||||
?: 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk
|
||||
|
||||
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfigPlatformApi
|
||||
|
||||
/**
|
||||
* Creates and manages sdk platform api's.
|
||||
*/
|
||||
interface SdkPlatformApiFactory {
|
||||
|
||||
/**
|
||||
* Retrieves or creates a [ServerCommunicationConfigPlatformApi] for use with the Bitwarden SDK.
|
||||
*/
|
||||
fun getServerCommunicationConfigPlatformApi(): ServerCommunicationConfigPlatformApi
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk
|
||||
|
||||
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfigPlatformApi
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.platformapi.ServerCommunicationConfigPlatformApiImpl
|
||||
|
||||
/**
|
||||
* Factory for creating and managing sdk platform api's.
|
||||
*/
|
||||
class SdkPlatformApiFactoryImpl(
|
||||
private val serverCommConfigManager: CookieAcquisitionRequestManager,
|
||||
) : SdkPlatformApiFactory {
|
||||
/**
|
||||
* Retrieves or creates a [ServerCommunicationConfigPlatformApi] for use with the Bitwarden SDK.
|
||||
*/
|
||||
override fun getServerCommunicationConfigPlatformApi(): ServerCommunicationConfigPlatformApi =
|
||||
ServerCommunicationConfigPlatformApiImpl(
|
||||
serverCommConfigManager = serverCommConfigManager,
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.manager.sdk
|
||||
|
||||
import com.bitwarden.core.ClientManagedTokens
|
||||
import com.bitwarden.sdk.CipherRepository
|
||||
import com.bitwarden.sdk.ServerCommunicationConfigRepository
|
||||
|
||||
/**
|
||||
* Creates and manages sdk repositories.
|
||||
@@ -16,4 +17,9 @@ interface SdkRepositoryFactory {
|
||||
* Retrieves or creates a [ClientManagedTokens] for use with the Bitwarden SDK.
|
||||
*/
|
||||
fun getClientManagedTokens(userId: String?): ClientManagedTokens
|
||||
|
||||
/**
|
||||
* Retrieves or creates a [ServerCommunicationConfigRepository] for use with the Bitwarden SDK.
|
||||
*/
|
||||
fun getServerCommunicationConfigRepository(): ServerCommunicationConfigRepository
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk
|
||||
|
||||
import com.bitwarden.core.ClientManagedTokens
|
||||
import com.bitwarden.network.BitwardenServiceClient
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.sdk.CipherRepository
|
||||
import com.bitwarden.sdk.ServerCommunicationConfigRepository
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkCipherRepository
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.SdkTokenRepository
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.repository.ServerCommunicationConfigRepositoryImpl
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
|
||||
/**
|
||||
@@ -12,7 +16,9 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
|
||||
*/
|
||||
class SdkRepositoryFactoryImpl(
|
||||
private val vaultDiskSource: VaultDiskSource,
|
||||
private val bitwardenServiceClient: BitwardenServiceClient,
|
||||
private val cookieDiskSource: CookieDiskSource,
|
||||
private val configDiskSource: ConfigDiskSource,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
) : SdkRepositoryFactory {
|
||||
override fun getCipherRepository(
|
||||
userId: String,
|
||||
@@ -27,6 +33,12 @@ class SdkRepositoryFactoryImpl(
|
||||
): ClientManagedTokens =
|
||||
SdkTokenRepository(
|
||||
userId = userId,
|
||||
tokenProvider = bitwardenServiceClient.tokenProvider,
|
||||
authDiskSource = authDiskSource,
|
||||
)
|
||||
|
||||
override fun getServerCommunicationConfigRepository(): ServerCommunicationConfigRepository =
|
||||
ServerCommunicationConfigRepositoryImpl(
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
configDiskSource = configDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk.platformapi
|
||||
|
||||
import com.bitwarden.servercommunicationconfig.AcquiredCookie
|
||||
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfigPlatformApi
|
||||
import com.x8bit.bitwarden.data.platform.error.CookiesRequiredException
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.CookieAcquisitionRequest
|
||||
|
||||
/**
|
||||
* Implementation of SDK's [ServerCommunicationConfigPlatformApi].
|
||||
*
|
||||
* This is an SDK callback interface required by the SDK contract. The SDK's intended design is for
|
||||
* [acquireCookies] to block while fetching cookies from the server cookie vending endpoint, return
|
||||
* them, and have the SDK automatically retry the failed request. However, cookie acquisition
|
||||
* requires async user interaction (browser authentication + deep link callback), which cannot be
|
||||
* performed within a blocking suspend call.
|
||||
*
|
||||
* Because of this constraint, cookie acquisition is currently handled entirely outside the SDK
|
||||
* context: our interceptor detects 302 redirects, the UI prompts the user, cookies are obtained
|
||||
* via browser and stored directly. This implementation exists as a defensive fallback — if the SDK
|
||||
* ever invokes [acquireCookies] internally, it:
|
||||
* 1. Emits a [CookieAcquisitionRequest] via [CookieAcquisitionRequestManager] StateFlow to
|
||||
* signal the UI to navigate to the cookie acquisition screen.
|
||||
* 2. Throws [CookiesRequiredException] to abort the SDK's current call chain.
|
||||
*
|
||||
* Note: Future SDK versions may expose atomic cookie setters, removing the need for this blocking
|
||||
* acquisition pattern entirely.
|
||||
*
|
||||
* @property serverCommConfigManager Manager that exposes pending cookie acquisition state for
|
||||
* navigation.
|
||||
*/
|
||||
class ServerCommunicationConfigPlatformApiImpl(
|
||||
private val serverCommConfigManager: CookieAcquisitionRequestManager,
|
||||
) : ServerCommunicationConfigPlatformApi {
|
||||
|
||||
/**
|
||||
* SDK callback for cookie acquisition. Not invoked during normal app operation — cookie
|
||||
* acquisition is handled outside the SDK context. This serves as a defensive implementation
|
||||
* that signals the UI and aborts the SDK operation if called unexpectedly.
|
||||
*
|
||||
* This method never returns normally.
|
||||
*
|
||||
* @throws CookiesRequiredException Always thrown to abort the SDK call chain.
|
||||
*/
|
||||
override suspend fun acquireCookies(hostname: String): List<AcquiredCookie>? {
|
||||
serverCommConfigManager.setPendingCookieAcquisition(
|
||||
CookieAcquisitionRequest(
|
||||
hostname = hostname,
|
||||
),
|
||||
)
|
||||
throw CookiesRequiredException(hostname)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
|
||||
|
||||
import com.bitwarden.core.ClientManagedTokens
|
||||
import com.bitwarden.network.provider.TokenProvider
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
|
||||
/**
|
||||
* A user-scoped implementation of a Bitwarden SDK [ClientManagedTokens].
|
||||
*
|
||||
* Note: This intentionally provides the raw stored token without proactive expiration checks
|
||||
* or refresh logic. The SDK handles automatic token refresh internally.
|
||||
*/
|
||||
class SdkTokenRepository(
|
||||
private val userId: String?,
|
||||
private val tokenProvider: TokenProvider,
|
||||
private val authDiskSource: AuthDiskSource,
|
||||
) : ClientManagedTokens {
|
||||
override suspend fun getAccessToken(): String? =
|
||||
userId?.let { tokenProvider.getAccessToken(userId = it) }
|
||||
userId?.let {
|
||||
authDiskSource.getAccountTokens(userId = it)?.accessToken
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.sdk.repository
|
||||
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.sdk.ServerCommunicationConfigRepository
|
||||
import com.bitwarden.servercommunicationconfig.BootstrapConfig
|
||||
import com.bitwarden.servercommunicationconfig.ServerCommunicationConfig
|
||||
import com.bitwarden.servercommunicationconfig.SsoCookieVendorConfig
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.CookieDiskSource
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.toAcquiredCookiesList
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.toConfigurationDataCookies
|
||||
|
||||
/**
|
||||
* Implementation of SDK's [ServerCommunicationConfigRepository].
|
||||
* Bridges the SDK's storage interface to the application's [CookieDiskSource].
|
||||
*
|
||||
* @property cookieDiskSource The disk source for persisting cookie configurations.
|
||||
*/
|
||||
class ServerCommunicationConfigRepositoryImpl(
|
||||
private val cookieDiskSource: CookieDiskSource,
|
||||
private val configDiskSource: ConfigDiskSource,
|
||||
) : ServerCommunicationConfigRepository {
|
||||
|
||||
override suspend fun get(hostname: String): ServerCommunicationConfig? {
|
||||
val serverCommunicationConfig = configDiskSource
|
||||
.serverConfig
|
||||
?.serverData
|
||||
?.communication
|
||||
?: return null
|
||||
|
||||
if (serverCommunicationConfig.bootstrap.type != "ssoCookieVendor") {
|
||||
return ServerCommunicationConfig(
|
||||
bootstrap = BootstrapConfig.Direct,
|
||||
)
|
||||
}
|
||||
|
||||
val acquiredCookies = cookieDiskSource
|
||||
.getCookieConfig(hostname)
|
||||
?.cookies
|
||||
?.toAcquiredCookiesList()
|
||||
|
||||
return ServerCommunicationConfig(
|
||||
bootstrap = BootstrapConfig.SsoCookieVendor(
|
||||
v1 = SsoCookieVendorConfig(
|
||||
idpLoginUrl = serverCommunicationConfig.bootstrap.idpLoginUrl,
|
||||
cookieName = serverCommunicationConfig.bootstrap.cookieName,
|
||||
cookieDomain = serverCommunicationConfig.bootstrap.cookieDomain,
|
||||
cookieValue = acquiredCookies,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun save(hostname: String, config: ServerCommunicationConfig) =
|
||||
when (val bootstrapConfig = config.bootstrap) {
|
||||
is BootstrapConfig.SsoCookieVendor -> {
|
||||
// Only store cookies from [config]. The communication config is synced with the
|
||||
// server (api/config), which takes precedence over the local configuration.
|
||||
cookieDiskSource.storeCookieConfig(
|
||||
hostname = hostname,
|
||||
config = CookieConfigurationData(
|
||||
hostname = hostname,
|
||||
cookies = bootstrapConfig.v1.cookieValue
|
||||
?.toConfigurationDataCookies()
|
||||
.orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
BootstrapConfig.Direct -> {
|
||||
// Clear any existing cookie configuration now that the communication config
|
||||
// has been updated.
|
||||
cookieDiskSource.storeCookieConfig(hostname = hostname, config = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager.util
|
||||
|
||||
import com.bitwarden.network.model.NetworkCookie
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
|
||||
|
||||
/**
|
||||
* Converts a list of [CookieConfigurationData.Cookie] to a list of [NetworkCookie].
|
||||
*/
|
||||
fun List<CookieConfigurationData.Cookie>?.toNetworkCookieList(): List<NetworkCookie> = this
|
||||
?.map { it.toNetworkCookie() }
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
* Converts a [CookieConfigurationData.Cookie] to a [NetworkCookie].
|
||||
*/
|
||||
fun CookieConfigurationData.Cookie.toNetworkCookie(): NetworkCookie =
|
||||
NetworkCookie(
|
||||
name = name,
|
||||
value = value,
|
||||
)
|
||||
@@ -49,4 +49,9 @@ 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()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -20,6 +21,7 @@ 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)
|
||||
@@ -68,4 +70,8 @@ class DebugMenuRepositoryImpl(
|
||||
settingsDiskSource.hasUserLoggedInOrCreatedAccount = false
|
||||
userStateUpdateTrigger.invoke()
|
||||
}
|
||||
|
||||
override fun clearSsoCookies() {
|
||||
cookieDiskSource.clearCookies()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -92,10 +93,12 @@ object PlatformRepositoryModule {
|
||||
serverConfigRepository: ServerConfigRepository,
|
||||
authDiskSource: AuthDiskSource,
|
||||
settingsDiskSource: SettingsDiskSource,
|
||||
cookieDiskSource: CookieDiskSource,
|
||||
): DebugMenuRepository = DebugMenuRepositoryImpl(
|
||||
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
|
||||
serverConfigRepository = serverConfigRepository,
|
||||
authDiskSource = authDiskSource,
|
||||
settingsDiskSource = settingsDiskSource,
|
||||
cookieDiskSource = cookieDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.x8bit.bitwarden.data.platform.repository.util
|
||||
|
||||
import com.bitwarden.servercommunicationconfig.AcquiredCookie
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
|
||||
|
||||
/**
|
||||
* Converts a list of [AcquiredCookie] to a list of [CookieConfigurationData.Cookie].
|
||||
*/
|
||||
fun List<AcquiredCookie>.toConfigurationDataCookies(): List<CookieConfigurationData.Cookie> = this
|
||||
.map { it.toConfigurationCookie() }
|
||||
|
||||
/**
|
||||
* Converts an [AcquiredCookie] to a [CookieConfigurationData.Cookie].
|
||||
*/
|
||||
fun AcquiredCookie.toConfigurationCookie(): CookieConfigurationData.Cookie =
|
||||
CookieConfigurationData.Cookie(
|
||||
name = name,
|
||||
value = value,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.x8bit.bitwarden.data.platform.repository.util
|
||||
|
||||
import com.bitwarden.servercommunicationconfig.AcquiredCookie
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.model.CookieConfigurationData
|
||||
|
||||
/**
|
||||
* Converts a list of [CookieConfigurationData.Cookie] to a list of [AcquiredCookie].
|
||||
*/
|
||||
fun List<CookieConfigurationData.Cookie>.toAcquiredCookiesList() = this
|
||||
.map { it.toAcquiredCookie() }
|
||||
|
||||
/**
|
||||
* Converts a [CookieConfigurationData.Cookie] to an [AcquiredCookie].
|
||||
*/
|
||||
fun CookieConfigurationData.Cookie.toAcquiredCookie() = AcquiredCookie(
|
||||
name = this.name,
|
||||
value = this.value,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
|
||||
@@ -9,7 +10,6 @@ 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,8 +21,11 @@ fun CallingAppInfo.getSignatureFingerprintAsHexString(): String? {
|
||||
* Returns true if this [CallingAppInfo] is present in the privileged app [allowList]. Otherwise,
|
||||
* returns false.
|
||||
*/
|
||||
fun CallingAppInfo.validatePrivilegedApp(allowList: String): ValidateOriginResult {
|
||||
|
||||
fun CallingAppInfo.validatePrivilegedApp(
|
||||
relyingPartyId: String,
|
||||
allowList: String,
|
||||
isVerifiedSource: Boolean,
|
||||
): ValidateOriginResult {
|
||||
if (!allowList.contains("\"$packageName\"")) {
|
||||
return ValidateOriginResult.Error.PrivilegedAppNotAllowed
|
||||
}
|
||||
@@ -32,7 +35,15 @@ fun CallingAppInfo.validatePrivilegedApp(allowList: String): ValidateOriginResul
|
||||
if (origin.isNullOrEmpty()) {
|
||||
ValidateOriginResult.Error.PasskeyNotSupportedForApp
|
||||
} else {
|
||||
ValidateOriginResult.Success(origin)
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
} catch (_: IllegalStateException) {
|
||||
// We know the package name is in the allow list so we can infer that this exception is
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.x8bit.bitwarden.data.platform.util
|
||||
|
||||
import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
|
||||
import com.bitwarden.data.repository.model.EnvironmentRegion
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
|
||||
/**
|
||||
* Creates the appropriate Duo [AuthTabData] for the given [EnvironmentUrlDataJson].
|
||||
*/
|
||||
val EnvironmentUrlDataJson.duoAuthTabData: AuthTabData get() = authTabData(kind = "duo")
|
||||
|
||||
/**
|
||||
* Creates the appropriate WebAuthn [AuthTabData] for the given [EnvironmentUrlDataJson].
|
||||
*/
|
||||
val EnvironmentUrlDataJson.webAuthnAuthTabData: AuthTabData get() = authTabData(kind = "webauthn")
|
||||
|
||||
/**
|
||||
* Creates the appropriate SSO [AuthTabData] for the given [EnvironmentUrlDataJson].
|
||||
*/
|
||||
val EnvironmentUrlDataJson.ssoAuthTabData: AuthTabData get() = authTabData(kind = "sso")
|
||||
|
||||
private fun EnvironmentUrlDataJson.authTabData(
|
||||
kind: String,
|
||||
): AuthTabData = when (this.environmentRegion) {
|
||||
EnvironmentRegion.UNITED_STATES -> {
|
||||
// TODO: PM-26577 Update this to use a "HttpsScheme"
|
||||
AuthTabData.CustomScheme(
|
||||
callbackUrl = "bitwarden://$kind-callback",
|
||||
)
|
||||
}
|
||||
|
||||
EnvironmentRegion.EUROPEAN_UNION -> {
|
||||
// TODO: PM-26577 Update this to use a "HttpsScheme"
|
||||
AuthTabData.CustomScheme(
|
||||
callbackUrl = "bitwarden://$kind-callback",
|
||||
)
|
||||
}
|
||||
|
||||
EnvironmentRegion.INTERNAL -> {
|
||||
// TODO: PM-26577 Update this to use a "HttpsScheme"
|
||||
AuthTabData.CustomScheme(
|
||||
callbackUrl = "bitwarden://$kind-callback",
|
||||
)
|
||||
}
|
||||
|
||||
EnvironmentRegion.SELF_HOSTED -> {
|
||||
AuthTabData.CustomScheme(
|
||||
callbackUrl = "bitwarden://$kind-callback",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -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.ZonedDateTimeTypeConverter
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
|
||||
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(ZonedDateTimeTypeConverter::class)
|
||||
@TypeConverters(InstantTypeConverter::class)
|
||||
abstract class VaultDatabase : RoomDatabase() {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.ZonedDateTimeTypeConverter
|
||||
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
|
||||
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(ZonedDateTimeTypeConverter())
|
||||
.addTypeConverter(InstantTypeConverter())
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -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.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Entity representing a folder in the database.
|
||||
@@ -21,5 +21,5 @@ data class FolderEntity(
|
||||
val name: String?,
|
||||
|
||||
@ColumnInfo(name = "revision_date")
|
||||
val revisionDate: ZonedDateTime,
|
||||
val revisionDate: Instant,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.bitwarden.core.data.util.asSuccess
|
||||
import com.bitwarden.data.manager.NativeLibraryManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,7 @@ class ScopedVaultSdkSourceImpl(
|
||||
dispatcherManager: DispatcherManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
sdkRepositoryFactory: SdkRepositoryFactory,
|
||||
sdkPlatformApiFactory: SdkPlatformApiFactory,
|
||||
vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
|
||||
sdkClientManager = SdkClientManagerImpl(
|
||||
// We do not want to have the real NativeLibraryManager used here to avoid
|
||||
@@ -26,6 +28,7 @@ class ScopedVaultSdkSourceImpl(
|
||||
},
|
||||
sdkRepoFactory = sdkRepositoryFactory,
|
||||
featureFlagManager = featureFlagManager,
|
||||
sdkPlatformApiFactory = sdkPlatformApiFactory,
|
||||
),
|
||||
dispatcherManager = dispatcherManager,
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.bitwarden.sdk.Fido2CredentialStore
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
|
||||
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSourceImpl
|
||||
@@ -41,11 +42,13 @@ object VaultSdkModule {
|
||||
dispatcherManager: DispatcherManager,
|
||||
featureFlagManager: FeatureFlagManager,
|
||||
sdkRepositoryFactory: SdkRepositoryFactory,
|
||||
sdkPlatformApiFactory: SdkPlatformApiFactory,
|
||||
): ScopedVaultSdkSource =
|
||||
ScopedVaultSdkSourceImpl(
|
||||
dispatcherManager = dispatcherManager,
|
||||
featureFlagManager = featureFlagManager,
|
||||
sdkRepositoryFactory = sdkRepositoryFactory,
|
||||
sdkPlatformApiFactory = sdkPlatformApiFactory,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -12,6 +12,8 @@ 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
|
||||
|
||||
/**
|
||||
@@ -76,14 +78,45 @@ class Fido2CredentialStoreImpl(
|
||||
userId = authRepository.activeUserId ?: throw NoActiveUserException(),
|
||||
cipher = cred.cipher,
|
||||
)
|
||||
.map { decryptedCipherView ->
|
||||
decryptedCipherView.id
|
||||
?.let { vaultRepository.updateCipher(it, decryptedCipherView) }
|
||||
?: vaultRepository.createCipher(decryptedCipherView)
|
||||
.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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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].
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user