mirror of
https://github.com/bitwarden/android.git
synced 2026-05-09 21:39:15 -05:00
Compare commits
2 Commits
sync-min-s
...
temp-test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9beb65c4b0 | ||
|
|
58f4983790 |
@@ -1,132 +1,105 @@
|
||||
# Bitwarden Android - Claude Code Configuration
|
||||
# Claude Guidelines
|
||||
|
||||
Official Android application for Bitwarden Password Manager and Bitwarden Authenticator, providing secure password management, two-factor authentication, and credential autofill services with zero-knowledge encryption.
|
||||
Core directives for maintaining code quality and consistency in the Bitwarden Android project.
|
||||
|
||||
## Overview
|
||||
## Core Directives
|
||||
|
||||
- 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
|
||||
**You MUST follow these directives at all times.**
|
||||
|
||||
### Key Concepts
|
||||
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
|
||||
|
||||
- **Zero-Knowledge Architecture**: Server never has access to unencrypted vault data or encryption keys
|
||||
- **Bitwarden SDK**: Rust-based cryptographic SDK handling all encryption/decryption operations
|
||||
- **DataState**: Wrapper for streaming data states (Loading, Loaded, Pending, Error, NoNetwork)
|
||||
- **Result Types**: Custom sealed classes for operation results (never throw exceptions from data layer)
|
||||
- **UDF (Unidirectional Data Flow)**: State flows down, actions flow up through ViewModels
|
||||
## Code Quality Standards
|
||||
|
||||
---
|
||||
### Module Organization
|
||||
|
||||
## Architecture
|
||||
**Core Library Modules:**
|
||||
- **`:core`** - Common utilities and managers shared across multiple modules
|
||||
- **`:data`** - Data sources, database, data repositories
|
||||
- **`:network`** - Networking interfaces, API clients, network utilities
|
||||
- **`:ui`** - Reusable Bitwarden Composables, theming, UI utilities
|
||||
|
||||
```
|
||||
User Request (UI Action)
|
||||
|
|
||||
Screen (Compose)
|
||||
|
|
||||
ViewModel (State/Action/Event)
|
||||
|
|
||||
Repository (Business Logic)
|
||||
|
|
||||
+----+----+----+
|
||||
| | | |
|
||||
Disk Network SDK
|
||||
| | |
|
||||
Room Retrofit Bitwarden
|
||||
DB APIs Rust SDK
|
||||
```
|
||||
**Application Modules:**
|
||||
- **`:app`** - Password Manager application, feature screens, ViewModels, DI setup
|
||||
- **`:authenticator`** - Authenticator application for 2FA/TOTP code generation
|
||||
|
||||
### Key Principles
|
||||
**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
|
||||
|
||||
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
|
||||
### Patterns Enforcement
|
||||
|
||||
### Core Patterns
|
||||
- **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
|
||||
|
||||
- **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()`.
|
||||
## Security Requirements
|
||||
|
||||
> For complete architecture patterns, code templates, and module organization, see `docs/ARCHITECTURE.md`.
|
||||
**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
|
||||
|
||||
---
|
||||
## Workflow Practices
|
||||
|
||||
## Development Guide
|
||||
### Before Implementation
|
||||
|
||||
### Workflow Skills
|
||||
1. Read relevant architecture documentation
|
||||
2. Search for existing patterns to follow
|
||||
3. Identify affected modules and dependencies
|
||||
4. Consider security implications
|
||||
|
||||
> **Quick start**: Use the `android-architect` agent (or `/plan-android-work <task>`) to refine requirements and plan,
|
||||
> then the `android-implementer` agent (or `/work-on-android <task>`) for implementation,
|
||||
> then `/review-android <PR#>` to review the result.
|
||||
### During Implementation
|
||||
|
||||
Planning: 1–2 | Implementation: 3–7 | Review & PR: 8–10
|
||||
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
|
||||
|
||||
1. `refining-android-requirements` - Gap analysis and structured spec from any input source
|
||||
2. `planning-android-implementation` - Architecture design and phased task breakdown
|
||||
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` - Android-specific MVVM/Compose code review checklists (invoked by `/review-android`)
|
||||
9. `/review-android` - Full review workflow: PR context gathering → Android checklist → output
|
||||
10. `creating-android-pull-request` - PR creation workflow and templates
|
||||
### After Implementation
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
---
|
||||
1. Ensure all tests pass
|
||||
2. Verify compilation succeeds
|
||||
3. Review security considerations
|
||||
4. Update relevant documentation
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
In addition to the Key Principles above, follow these rules:
|
||||
**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
|
||||
|
||||
### DO
|
||||
- Map async results to internal actions before updating state
|
||||
- Inject `Clock` for time-dependent operations
|
||||
- Return early to reduce nesting
|
||||
## Communication & Decision-Making
|
||||
|
||||
### 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
|
||||
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]?"
|
||||
|
||||
---
|
||||
Defer high-impact decisions to the user:
|
||||
- Architecture/module changes, public API modifications
|
||||
- Security mechanisms, database migrations
|
||||
- Third-party library additions
|
||||
|
||||
## Quick Reference
|
||||
## Reference Documentation
|
||||
|
||||
- **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 `/review-android` for the full review workflow; `reviewing-changes` skill for checklist-only
|
||||
- **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/)
|
||||
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.**
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
name: android-architect
|
||||
description: "Plans, architects, and refines implementation details for Android features in the Bitwarden Android codebase before any code is written. Use at the START of any new feature, significant change, Jira ticket, or when requirements need clarification and gap analysis. Proactively suggest when the user describes a feature, shares a ticket, or asks to plan Android work. Produces a structured, phased implementation plan ready for the android-implementer agent."
|
||||
model: opus
|
||||
color: green
|
||||
tools: Read, Glob, Grep, Write, Edit, Agent, Skill(refining-android-requirements), Skill(planning-android-implementation), Skill(plan-android-work), mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue_comments, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_issues, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page
|
||||
---
|
||||
|
||||
You are the Android Architect — an elite software architect and senior Android engineer with deep mastery of the Bitwarden Android codebase. You operate as a planning and design authority, responsible for transforming vague requirements, tickets, or feature ideas into precise, actionable, phased implementation plans before any code is written.
|
||||
|
||||
Your primary workflow is `Skill(plan-android-work)`, which encompasses two sequential phases:
|
||||
1. **`Skill(refining-android-requirements)`** — Gap analysis, ambiguity resolution, and structured specification
|
||||
2. **`Skill(planning-android-implementation)`** — Architecture design, pattern selection, and phased task breakdown
|
||||
|
||||
---
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
### Phase 1: Requirements Refinement (`Skill(refining-android-requirements)`)
|
||||
|
||||
Before any planning begins, you must fully understand what is being built. You will:
|
||||
|
||||
1. **Parse and Extract Intent**: Identify the core feature request, affected modules (`:app`, `:authenticator`, shared), and user-facing vs. internal scope.
|
||||
|
||||
2. **Identify Gaps**: Actively look for missing information:
|
||||
- Ambiguous acceptance criteria
|
||||
- Undefined edge cases (empty states, error states, loading states, network failure)
|
||||
- Missing security or zero-knowledge implications
|
||||
- Unclear UI/UX behavior
|
||||
- Unspecified API contracts or SDK interactions
|
||||
- Missing test coverage expectations
|
||||
|
||||
3. **Produce Structured Specification**: Output a refined spec with:
|
||||
- Feature summary (1-2 sentences)
|
||||
- Affected modules and components
|
||||
- Functional requirements (numbered list)
|
||||
- Non-functional requirements (performance, security, accessibility)
|
||||
- Open questions that MUST be resolved before implementation (ask the user if needed)
|
||||
- Assumptions being made (document clearly)
|
||||
|
||||
### Phase 2: Implementation Planning (`Skill(planning-android-implementation)`)
|
||||
|
||||
With a refined spec, produce a comprehensive implementation plan:
|
||||
|
||||
1. **Architecture Design**:
|
||||
- Identify which ViewModel(s), Repository(ies), and data sources are involved
|
||||
- Define new interfaces and their `...Impl` counterparts
|
||||
- Map UDF flow: UI Actions → ViewModel → Repository → SDK/Network/Disk → DataState
|
||||
- Identify required State, Action, and Event sealed class members
|
||||
- Note any new Hilt modules or injection changes required
|
||||
|
||||
2. **Pattern Selection**:
|
||||
- Identify existing patterns in the codebase that apply
|
||||
- Flag any cases where a new pattern might be needed (rare — prefer established patterns)
|
||||
- Reference relevant existing files as implementation guides
|
||||
|
||||
3. **Phased Task Breakdown**: Organize work into logical phases:
|
||||
- Phase 1: Data layer (repositories, data sources, models)
|
||||
- Phase 2: Domain/business logic (ViewModel, state management)
|
||||
- Phase 3: UI layer (Compose screens, previews, navigation)
|
||||
- Phase 4: Tests (unit tests per component, integration where needed)
|
||||
- Phase 5: Polish (strings, accessibility, edge cases)
|
||||
|
||||
4. **Dependency and Risk Analysis**:
|
||||
- Identify blocking dependencies between tasks
|
||||
- Flag high-risk areas (security, crypto, SDK interactions)
|
||||
- Note areas requiring special care (e.g., DataState streaming, coroutine context)
|
||||
|
||||
5. **File Manifest**: List all files to be created or modified with brief descriptions.
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden Android Expertise
|
||||
|
||||
You have deep knowledge of this codebase and must apply it in every plan:
|
||||
|
||||
### Architecture Constraints
|
||||
- **No exceptions from data layer**: All suspending functions must return `Result<T>` or sealed classes
|
||||
- **State hoisting**: All behavior-affecting state lives in ViewModel's state — never in composables
|
||||
- **Interface-based DI**: Every implementation has an interface counterpart with Hilt injection
|
||||
- **UDF strictly enforced**: State flows down, actions flow up — no bidirectional data flow
|
||||
- **Internal actions for coroutines**: Never update state directly inside `launch` blocks; map results to `Internal` actions first
|
||||
|
||||
### Zero-Knowledge Security Rules (NON-NEGOTIABLE)
|
||||
- Never transmit unencrypted vault data or master passwords to the server
|
||||
- All encryption via Bitwarden SDK — never implement custom crypto
|
||||
- Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user context leakage
|
||||
- On logout, all sensitive data cleared via `UserLogoutManager.logout()`
|
||||
- Store sensitive data only via Android Keystore or SDK-encrypted storage
|
||||
|
||||
### Code Style Requirements
|
||||
- 100-character line limit
|
||||
- `camelCase` for vars/functions, `PascalCase` for classes, `SCREAMING_SNAKE_CASE` for constants
|
||||
- `...Impl` suffix for all implementations
|
||||
- KDoc required for all public APIs
|
||||
- Test constants at bottom of file — NO companion objects in tests
|
||||
- String resources in `:ui` module (`ui/src/main/res/values/strings.xml`) using typographic quotes
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
Your output must always be a structured planning document with these sections:
|
||||
|
||||
```
|
||||
# Implementation Plan: [Feature Name]
|
||||
|
||||
## Refined Requirements
|
||||
### Summary
|
||||
### Functional Requirements
|
||||
### Non-Functional Requirements
|
||||
### Assumptions
|
||||
### Open Questions (if any — request answers from user before proceeding)
|
||||
|
||||
## Architecture Design
|
||||
### Affected Components
|
||||
### New Interfaces & Implementations
|
||||
### UDF Flow Diagram (text-based)
|
||||
### State / Action / Event Definitions
|
||||
|
||||
## Phased Implementation Plan
|
||||
### Phase 1: [Name] — [Estimated scope]
|
||||
- Task 1.1: ...
|
||||
- Task 1.2: ...
|
||||
### Phase 2: ...
|
||||
...
|
||||
|
||||
## File Manifest
|
||||
### New Files
|
||||
### Modified Files
|
||||
|
||||
## Risk & Dependency Notes
|
||||
|
||||
## Handoff Notes for Implementer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
### DO
|
||||
- Explore the codebase (via sub-agents) to understand existing patterns before designing — never assume file locations or implementations
|
||||
- Ask clarifying questions BEFORE producing a plan if critical information is missing
|
||||
- Reference specific existing files and patterns as implementation guides in your plan
|
||||
- Apply security considerations proactively — flag any zero-knowledge implications
|
||||
- Produce plans detailed enough that an implementer needs no additional context
|
||||
- Note when existing patterns should be reused vs. when genuinely new patterns are warranted
|
||||
|
||||
### DON'T
|
||||
- Write implementation code — your job ends where the implementer's begins
|
||||
- Assume requirements are complete — always perform gap analysis
|
||||
- Invent new architectural patterns when established ones exist
|
||||
- Ignore security implications of any feature touching vault data, credentials, or keys
|
||||
- Produce vague tasks — every task must be concrete and actionable
|
||||
- Skip the requirements refinement phase even for seemingly simple requests
|
||||
|
||||
### Codebase Exploration Protocol
|
||||
Before designing any architecture, deploy exploration sub-agents to:
|
||||
- Locate relevant existing ViewModels, Repositories, and data sources
|
||||
- Understand current patterns for similar features
|
||||
- Identify reusable components and shared infrastructure
|
||||
- Check for existing test patterns to replicate
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
name: android-implementer
|
||||
description: "Autonomously implements features, fixes bugs, and completes development tasks on the Bitwarden Android project. Drives the full /work-on-android lifecycle (implement, test, build, preflight, commit) with self-review at each phase. Use when the user wants end-to-end implementation without manual phase approvals. Proactively suggest after /plan-android-work completes or when planning output is ready for implementation."
|
||||
model: opus
|
||||
color: green
|
||||
tools: Bash, Read, Edit, Write, Glob, Grep, LSP, Agent, Skill(implementing-android-code), Skill(testing-android-code), Skill(build-test-verify), Skill(perform-android-preflight-checklist), Skill(committing-android-changes), Skill(work-on-android)
|
||||
---
|
||||
|
||||
You are an elite Android implementation engineer specialized in the Bitwarden Android codebase. Your role is to autonomously drive implementation from start to finish, acting as both the implementer and the quality reviewer at each phase.
|
||||
|
||||
## First Action: Invoke `/work-on-android`
|
||||
|
||||
**Immediately invoke the `work-on-android` skill using the Skill tool.** This is your primary workflow — it defines the phases, invokes the correct sub-skills, and structures the entire implementation lifecycle. Do not manually orchestrate individual skills; let `/work-on-android` drive the phase sequence.
|
||||
|
||||
Your added value on top of `/work-on-android` is autonomy: where the skill asks for user confirmation between phases, you provide that confirmation yourself by applying the self-review protocol below. Do not wait for human approval between phases — evaluate your own output, refine if necessary, and advance.
|
||||
|
||||
## Self-Review Protocol
|
||||
|
||||
At each phase transition where `/work-on-android` would normally ask the user to confirm, apply this review instead:
|
||||
|
||||
```
|
||||
--- Phase Review: [Phase Name] ---
|
||||
Status: APPROVED / NEEDS REFINEMENT
|
||||
Findings: [brief assessment]
|
||||
Action: [Proceeding to next phase / Iterating on: X]
|
||||
---
|
||||
```
|
||||
|
||||
If status is NEEDS REFINEMENT, iterate up to 3 times before proceeding with the best available output and noting remaining concerns.
|
||||
|
||||
**Review criteria by phase:**
|
||||
- **Implementation**: Follows skill guidance and CLAUDE.md anti-patterns list?
|
||||
- **Testing**: Covers happy path, error cases, and edge cases?
|
||||
- **Build & Verify**: All tests pass? No compilation errors or warnings?
|
||||
- **Preflight**: Would this pass code review by a senior engineer?
|
||||
- **Commit**: Message clear, properly formatted, and accurate?
|
||||
|
||||
## Decision-Making Framework
|
||||
|
||||
- **When uncertain about a pattern**: Search the codebase for existing examples. Follow what exists rather than inventing.
|
||||
- **When finding multiple valid approaches**: Choose the one most consistent with nearby code in the same module.
|
||||
- **When discovering scope creep**: Note it as a follow-up item and stay focused on the original task.
|
||||
- **When tests fail**: Diagnose the root cause, fix it, and re-run. Don't skip failing tests.
|
||||
- **When a phase produces subpar output**: Iterate. Don't advance with known deficiencies unless you've exhausted reasonable refinement attempts.
|
||||
|
||||
## Communication Style
|
||||
|
||||
- Be concise and direct in phase transition summaries
|
||||
- Provide detailed technical reasoning only when making non-obvious decisions
|
||||
- Flag any genuine blockers that require human input clearly and specifically
|
||||
- At completion, provide a summary of what was implemented, what was tested, and any follow-up items
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. **Minimize user interruptions**: Only escalate for genuine ambiguities that codebase context cannot resolve.
|
||||
2. **Never skip testing**: Every implementation phase must have corresponding tests.
|
||||
3. **Never invent new patterns**: Use established codebase patterns. Search for examples first.
|
||||
4. **Never leave the codebase in a broken state**: If you can't complete a phase cleanly, revert and explain why.
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
description: Guided requirements refinement and implementation planning for Bitwarden Android
|
||||
argument-hint: <Jira ticket (PM-12345), Confluence URL, or free-text description>
|
||||
---
|
||||
|
||||
# Android Planning Workflow
|
||||
|
||||
You are guiding the developer through requirements refinement and implementation planning for the Bitwarden Android project. The input to plan from is:
|
||||
|
||||
**Input**: $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Jira/Confluence access**: Fetching tickets and wiki pages requires the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin. If the plugin is not installed, Jira ticket IDs and Confluence URLs cannot be fetched automatically.
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable. If starting from a partially completed plan, skip to the appropriate phase.
|
||||
|
||||
### Phase 1: Ingest Requirements
|
||||
|
||||
Parse the input to detect and fetch all available sources:
|
||||
|
||||
**Source Detection Rules:**
|
||||
- **Jira tickets** (patterns like `PM-\d+`, `BWA-\d+`, `EC-\d+`): Fetch via `get_issue` and `get_issue_comments`. Also fetch linked issue summaries (parent epic, sub-tasks, blockers) for context.
|
||||
- **Confluence URLs** (containing `atlassian.net/wiki` or confluence page IDs): Extract page ID and fetch via `get_confluence_page`. If the page is a parent page, fetch child pages via `get_child_pages` and ask the user which are relevant.
|
||||
- **Free text**: Treat as direct requirements — no fetching needed.
|
||||
- **Multiple inputs**: All are first-class sources. Fetch each independently and consolidate.
|
||||
- **Tool unavailable**: If `get_issue`, `get_confluence_page`, or other Atlassian tools are not available, inform the user that the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required and prompt them to install and configure it. In the meantime, ask the user to paste the relevant content directly. Treat pasted content as free-text input.
|
||||
|
||||
**Present a structured summary:**
|
||||
1. Sources identified and fetched (with links)
|
||||
2. Raw requirements extracted from each source
|
||||
3. Initial scope assessment (small / medium / large)
|
||||
|
||||
**Edge cases:**
|
||||
- Jira ticket with no description → flag as critical gap that Phase 2 must address
|
||||
- Multiple tickets → fetch all, consolidate, flag any contradictions
|
||||
- Ticket + free text → both treated as first-class; free text supplements ticket
|
||||
|
||||
**Gate**: User confirms the summary is complete and may add additional sources or context before proceeding.
|
||||
|
||||
### Phase 2: Refine Requirements
|
||||
|
||||
Invoke the `refining-android-requirements` skill and use it to perform gap analysis on the raw requirements from Phase 1.
|
||||
|
||||
The skill will:
|
||||
1. Consolidate all sources into a working document
|
||||
2. Evaluate requirements against a structured rubric (functional, technical, security, UX, cross-cutting)
|
||||
3. Present categorized gaps as blocking or non-blocking questions
|
||||
4. After user answers, produce a structured specification with numbered IDs
|
||||
|
||||
**Gate**: User approves the refined specification. They may request changes or provide additional answers.
|
||||
|
||||
### Phase 3: Plan Implementation
|
||||
|
||||
Invoke the `planning-android-implementation` skill and use it to design the implementation approach based on the refined spec from Phase 2.
|
||||
|
||||
The skill will:
|
||||
1. Classify the change type
|
||||
2. Explore the codebase for reference implementations and integration points
|
||||
3. Design the architecture with component relationships
|
||||
4. Produce a file inventory and phased implementation plan
|
||||
5. Assess risks and define verification criteria
|
||||
|
||||
**Gate**: User reviews the implementation plan and may request changes to architecture, phasing, or scope.
|
||||
|
||||
### Phase 4: Finalize & Save
|
||||
|
||||
Merge the outputs from Phase 2 (specification) and Phase 3 (implementation plan) into a single design document using this template:
|
||||
|
||||
```markdown
|
||||
# [Feature Name] — Design Document
|
||||
|
||||
**Feature**: [concise description]
|
||||
**Date**: [current date]
|
||||
**Status**: Ready for Implementation
|
||||
**Jira**: [ticket ID if available]
|
||||
**Sources**: [list of all input sources with links]
|
||||
|
||||
---
|
||||
|
||||
## Requirements Specification
|
||||
|
||||
[Full output from Phase 2 — the refined specification with numbered IDs]
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
[Full output from Phase 3 — architecture, file inventory, phases, risks]
|
||||
|
||||
---
|
||||
|
||||
## Executing This Plan
|
||||
|
||||
To implement this plan, run:
|
||||
|
||||
/work-on-android [ticket or feature reference]
|
||||
|
||||
Reference this design document during implementation for architecture decisions,
|
||||
file locations, and phase ordering.
|
||||
```
|
||||
|
||||
**Save the document:**
|
||||
- With ticket: `.claude/outputs/plans/PM-XXXXX-FEATURE-NAME-PLAN.md`
|
||||
- Without ticket: `.claude/outputs/plans/FEATURE-NAME-PLAN.md`
|
||||
- Feature name should be uppercase with hyphens (e.g., `BIOMETRIC-TIMEOUT-CONFIG-PLAN.md`)
|
||||
- Create the output directory if it does not exist
|
||||
|
||||
**On completion**: Present the saved file path and remind the user they can execute the plan with `/work-on-android`.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be explicit about which phase you are in at all times.
|
||||
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
|
||||
- When fetching from Jira/Confluence, summarize what was found rather than dumping raw content.
|
||||
- Questions in Phase 2 should be specific and actionable, not generic.
|
||||
- The implementation plan in Phase 3 should reference concrete files in the codebase, not abstract descriptions.
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
description: Guided Android code review workflow through context gathering, Android-specific review, and output
|
||||
argument-hint: [PR# | PR URL | "local"]
|
||||
---
|
||||
|
||||
# Android Code Review Workflow
|
||||
|
||||
You are guiding the developer through a comprehensive Android code review for the Bitwarden Android project.
|
||||
|
||||
**Input**: $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Jira/Confluence access**: The `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required to fetch linked Jira tickets. If unavailable, skip ticket context.
|
||||
- **GitHub CLI**: Required for fetching PR metadata. Verify with `gh auth status`.
|
||||
|
||||
## 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.
|
||||
|
||||
### Phase 1: Ingest
|
||||
|
||||
Parse the input to determine review context:
|
||||
|
||||
**Source Detection Rules:**
|
||||
- **PR number** (`123`, `PR #123`, `https://github.com/.../pull/123`): Extract the numeric ID. Fetch PR metadata via `gh pr view <N> --json title,body,headRefName,baseRefName,author,files`. Fetch existing review threads to avoid duplicate comments via `gh api graphql` with `reviewThreads(first: 100)`.
|
||||
- **"local"** or no argument: Review current branch changes via `git diff main...HEAD` and `git log main...HEAD --oneline --no-merges`.
|
||||
- **No input**: Ask the user whether to review a PR (provide number/URL) or local branch changes.
|
||||
|
||||
**Additional context:**
|
||||
- Detect Jira ticket references in PR title/body (patterns like `PM-\d+`, `BWA-\d+`). Fetch via `get_issue` if the MCP plugin is available.
|
||||
- Summarize what was fetched rather than dumping raw content.
|
||||
|
||||
**Present a structured summary:**
|
||||
1. What is being reviewed (PR title/number, branch, or local changes description)
|
||||
2. Jira ticket context if found (summary and acceptance criteria)
|
||||
3. Files changed (count and modules affected)
|
||||
4. Existing review thread count (PR reviews only — avoids duplicate comments)
|
||||
|
||||
**Gate**: User confirms the summary is complete before proceeding.
|
||||
|
||||
### Phase 2: Review
|
||||
|
||||
Invoke the `reviewing-changes` skill and use it to perform the Android-specific code review. Use the PR context from Phase 1 (change type, files affected, modules, Jira requirements) to inform the skill's change type detection and checklist selection.
|
||||
|
||||
The skill will:
|
||||
1. Detect the change type based on files and PR context from Phase 1
|
||||
2. Load the appropriate type-specific checklist
|
||||
3. Execute the multi-pass review strategy
|
||||
4. Consult reference materials as needed
|
||||
|
||||
**Before advancing**: Share a summary of key findings (critical issues if any, overall assessment) and confirm the user is ready to output the review.
|
||||
|
||||
### Phase 3: Output
|
||||
|
||||
Write the completed review to local files:
|
||||
|
||||
- `review-summary.md` — Overall assessment (APPROVE / REQUEST CHANGES) plus critical issues list
|
||||
- `review-inline-comments.md` — All inline findings with `<details>` tags
|
||||
|
||||
Follow the exact output format from `.claude/skills/reviewing-changes/examples/review-outputs.md`.
|
||||
|
||||
For PR reviews: offer to post the review to GitHub using `gh pr review <N> --comment -b "$(cat review-summary.md)"` for the summary. For inline comments, use the GitHub API or the `bitwarden-code-review` plugin if installed.
|
||||
|
||||
**Before advancing**: Confirm the files were written successfully and ask if the user wants to post to GitHub (PR reviews only).
|
||||
|
||||
## 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 review (e.g., review already written), skip to the appropriate phase.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
description: Guided Android development workflow through all lifecycle phases
|
||||
argument-hint: <task description, plan, or Jira ticket reference>
|
||||
---
|
||||
|
||||
# Android Development Workflow
|
||||
|
||||
You are guiding the developer through a complete Android development lifecycle for the Bitwarden Android project. The task to work on is:
|
||||
|
||||
**Task**: $ARGUMENTS
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** If a phase fails (tests fail, lint errors, etc.), loop on that phase until resolved before advancing. The user may skip phases that are not applicable.
|
||||
|
||||
### Phase 1: Implement
|
||||
|
||||
Invoke `Skill(implementing-android-code)` 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 `Skill(testing-android-code)` 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 `Skill(build-test-verify)` 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 `Skill(perform-android-preflight-checklist)` 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 `Skill(committing-android-changes)` 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 `Skill(creating-android-pull-request)` 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,14 +0,0 @@
|
||||
{
|
||||
"attribution": {
|
||||
"commit": "",
|
||||
"pr": ""
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "bitwarden/ai-plugins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
name: build-test-verify
|
||||
version: 0.1.0
|
||||
description: Build, test, lint, and deploy commands for the Bitwarden Android project. Use when running tests, building APKs/AABs, running lint/detekt, deploying, using fastlane, or discovering codebase structure. Triggered by "run tests", "build", "gradle", "lint", "detekt", "deploy", "fastlane", "assemble", "verify", "coverage".
|
||||
---
|
||||
|
||||
# Build, Test & Verify
|
||||
|
||||
## Environment Setup
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages auth for SDK (`read:packages` scope) |
|
||||
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
|
||||
| Build types | - | `debug`, `beta`, `release` |
|
||||
|
||||
If builds fail resolving the Bitwarden SDK, verify `GITHUB_TOKEN` in `user.properties` or environment and check connectivity to `maven.pkg.github.com`.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Debug builds
|
||||
./gradlew app:assembleDebug
|
||||
./gradlew authenticator:assembleDebug
|
||||
|
||||
# Release builds (requires signing keys)
|
||||
./gradlew app:assembleStandardRelease
|
||||
./gradlew app:bundleStandardRelease
|
||||
|
||||
# F-Droid builds
|
||||
./gradlew app:assembleFdroidRelease
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
**IMPORTANT**: The app module uses the `standard` flavor. Always use `testStandardDebugUnitTest`, NOT `testDebugUnitTest`.
|
||||
|
||||
**IMPORTANT**: Always pipe test output through a filter that captures failures on the first run. Gradle suppresses detailed failure output by default, so use `2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30` to see pass/fail results and assertion details without needing a second run.
|
||||
|
||||
```bash
|
||||
# App module tests (correct flavor!)
|
||||
./gradlew app:testStandardDebugUnitTest 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
|
||||
|
||||
# Run specific test classes
|
||||
./gradlew app:testStandardDebugUnitTest --tests "com.x8bit.bitwarden.SomeTest" 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
### Reading Test Reports
|
||||
|
||||
If you need full failure details beyond what grep captures, check the HTML test report:
|
||||
|
||||
```bash
|
||||
# After a test run, open the report at:
|
||||
# app/build/reports/tests/testStandardDebugUnitTest/index.html
|
||||
# Or read individual failure XML:
|
||||
find app/build/test-results -name "*.xml" -exec grep -l "failure" {} \;
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
**IMPORTANT**: Prefer running detekt on modified files only — a full project scan is slow and unnecessary during development. The project supports a `-Pprecommit=true` flag that limits detekt to staged files.
|
||||
|
||||
**IMPORTANT**: Always pipe detekt output through a filter to capture errors on the first run. Detekt prints violation details to stderr/stdout but Gradle can obscure them. Use the grep pattern below to see violations immediately.
|
||||
|
||||
```bash
|
||||
# Detekt on staged files only (preferred during development)
|
||||
git add -u && ./gradlew -Pprecommit=true detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
|
||||
|
||||
# Detekt on all files (full scan, use sparingly)
|
||||
./gradlew detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
|
||||
|
||||
# Android Lint
|
||||
./gradlew lint
|
||||
|
||||
# Full validation suite (detekt + lint + tests + coverage)
|
||||
./fastlane check
|
||||
```
|
||||
|
||||
### How `-Pprecommit=true` Works
|
||||
|
||||
The root `build.gradle.kts` configures detekt tasks to use `git diff --name-only --cached` when this property is set, limiting analysis to staged files only. This is the same mechanism used by the project's pre-commit hook. Stage your changes with `git add` before running.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
name: committing-android-changes
|
||||
version: 0.1.0
|
||||
description: Git commit conventions and workflow for Bitwarden Android. Use when committing code, writing commit messages, or preparing changes for commit. Triggered by "commit", "git commit", "commit message", "prepare commit", "stage changes".
|
||||
---
|
||||
|
||||
# Git Commit Conventions
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
```
|
||||
[PM-XXXXX] <type>: <imperative summary>
|
||||
|
||||
<optional body explaining why, not what>
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Ticket prefix**: Always include `[PM-XXXXX]` matching the Jira ticket
|
||||
2. **Type keyword**: Include a conventional commit type after the ticket prefix (see table below)
|
||||
3. **Imperative mood**: "Add feature" not "Added feature" or "Adds feature"
|
||||
4. **Short summary**: Under 72 characters for the first line
|
||||
5. **Body**: Explain the "why" not the "what" — the diff shows the what
|
||||
|
||||
### Type Keywords
|
||||
|
||||
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
[PM-12345] feat: Add biometric unlock timeout configuration
|
||||
|
||||
Users reported confusion about when biometric prompts appear.
|
||||
This adds a configurable timeout setting to the security preferences.
|
||||
```
|
||||
|
||||
### Followup Commits
|
||||
|
||||
Only the first commit on a branch needs the full format (ticket prefix, type keyword, body). Subsequent commits — whether addressing review feedback, making intermediate changes, or iterating locally — can use a short, descriptive summary with no prefix or body required.
|
||||
|
||||
```
|
||||
Update error handling in login flow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-Commit Checklist
|
||||
|
||||
Run the `perform-android-preflight-checklist` skill for the full quality gate. At minimum, before staging and committing:
|
||||
|
||||
1. **Run affected module tests** (use `build-test-verify` skill for correct commands)
|
||||
2. **Check lint**: `./gradlew detekt` on changed modules
|
||||
3. **Review staged changes**: `git diff --staged` — verify no unintended modifications
|
||||
4. **Verify no secrets**: No API keys, tokens, passwords, or `.env` files staged
|
||||
5. **Verify no generated files**: No build outputs, `.idea/` changes, or generated code
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Commit
|
||||
|
||||
- `.env` files or `user.properties` with real tokens
|
||||
- Credential files or signing keystores
|
||||
- Build outputs (`build/`, `*.apk`, `*.aab`)
|
||||
- IDE-specific files (`.idea/` changes, `*.iml`)
|
||||
- Large binary files
|
||||
|
||||
---
|
||||
|
||||
## Staging Best Practices
|
||||
|
||||
- **Stage specific files** by name rather than `git add -A` or `git add .`
|
||||
- Put each file path on its own line for readability:
|
||||
```bash
|
||||
git add \
|
||||
path/to/first/File.kt \
|
||||
path/to/second/File.kt \
|
||||
path/to/third/File.kt
|
||||
```
|
||||
- Review each file being staged to avoid accidentally including sensitive data
|
||||
- Use `git status` (without `-uall` flag) to see the working tree state
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: creating-android-pull-request
|
||||
version: 0.1.0
|
||||
description: Pull request creation workflow for Bitwarden Android. Use when creating PRs, writing PR descriptions, or preparing branches for review. Triggered by "create PR", "pull request", "open PR", "gh pr create", "PR description".
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
## PR Title Format
|
||||
|
||||
```
|
||||
[PM-XXXXX] <type>: <short imperative summary>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- `[PM-12345] feat: Add autofill support for passkeys`
|
||||
- `[PM-12345] fix: Resolve crash during vault sync`
|
||||
- `[PM-12345] refactor: Simplify authentication flow`
|
||||
|
||||
**Rules:**
|
||||
- Include Jira ticket prefix
|
||||
- Keep under 70 characters total
|
||||
- Use imperative mood in the summary
|
||||
|
||||
**Type keywords** (triggers automatic `t:` label via CI):
|
||||
|
||||
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
|
||||
|
||||
---
|
||||
|
||||
## PR Body Template
|
||||
|
||||
**IMPORTANT:** Always follow the repo's PR template at `.github/PULL_REQUEST_TEMPLATE.md`. Delete the Screenshots section entirely if there are no UI changes.
|
||||
|
||||
---
|
||||
|
||||
## Pre-PR Checklist
|
||||
|
||||
1. **All tests pass**: Run `./gradlew app:testStandardDebugUnitTest` (and other affected modules)
|
||||
2. **Lint clean**: Run `./gradlew detekt`
|
||||
3. **Self-review done**: Use `perform-android-preflight-checklist` skill
|
||||
4. **No unintended changes**: Check `git diff origin/main...HEAD` for unexpected files
|
||||
5. **Branch up to date**: Rebase on `main` if needed
|
||||
|
||||
---
|
||||
|
||||
## Creating the PR
|
||||
|
||||
```bash
|
||||
# Ensure branch is pushed
|
||||
git push -u origin <branch-name>
|
||||
|
||||
# Create PR as draft by default (body follows .github/PULL_REQUEST_TEMPLATE.md)
|
||||
gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "<fill in from PR template>"
|
||||
```
|
||||
|
||||
**Default to draft PRs.** Only create a non-draft (ready for review) PR if the user explicitly requests it.
|
||||
|
||||
---
|
||||
|
||||
## AI Review Label
|
||||
|
||||
Before running `gh pr create`, **always** use the `AskUserQuestion` tool to ask whether to add an AI review label:
|
||||
|
||||
- **Question**: "Would you like to add an AI review label to this PR?"
|
||||
- **Options**: `ai-review-vnext`, `ai-review`, `No label`
|
||||
|
||||
If the user selects a label, include it via the `--label` flag:
|
||||
|
||||
```bash
|
||||
gh pr create --draft --label "ai-review-vnext" --title "..." --body "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base Branch
|
||||
|
||||
- Default target: `main`
|
||||
- Check with team if targeting a feature branch instead
|
||||
@@ -1,481 +0,0 @@
|
||||
---
|
||||
name: implementing-android-code
|
||||
version: 0.1.2
|
||||
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
---
|
||||
|
||||
# Implementing Android Code - Bitwarden Quick Reference
|
||||
|
||||
**This skill provides tactical guidance for Bitwarden-specific patterns.** For comprehensive architecture decisions and complete code style rules, consult `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Critical Patterns Reference
|
||||
|
||||
### A. ViewModel Implementation (State-Action-Event Pattern)
|
||||
|
||||
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
|
||||
|
||||
**Key Requirements:**
|
||||
- Annotate with `@HiltViewModel`
|
||||
- State class MUST be `@Parcelize data class : Parcelable`
|
||||
- Implement `handleAction(action: A)` - MUST be synchronous
|
||||
- Post internal actions from coroutines using `sendAction()`
|
||||
- Save/restore state via `SavedStateHandle[KEY_STATE]`
|
||||
- Private action handlers: `private fun handle*` naming convention
|
||||
|
||||
**Template**: See [ViewModel template](templates.md#viewmodel-template-state-action-event-pattern)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: ExampleState(),
|
||||
) {
|
||||
init {
|
||||
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
// Synchronous dispatch only
|
||||
when (action) {
|
||||
is Action.Click -> handleClick()
|
||||
is Action.Internal.DataReceived -> handleDataReceived(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClick() {
|
||||
viewModelScope.launch {
|
||||
val result = repository.fetchData()
|
||||
sendAction(Action.Internal.DataReceived(result)) // Post internal action
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDataReceived(action: Action.Internal.DataReceived) {
|
||||
mutableStateFlow.update { it.copy(data = action.result) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt` (see class declaration)
|
||||
|
||||
**Critical Gotchas:**
|
||||
- ❌ **NEVER** update `mutableStateFlow` directly inside coroutines
|
||||
- ✅ **ALWAYS** post internal actions from coroutines, update state in `handleAction()`
|
||||
- ❌ **NEVER** forget `@IgnoredOnParcel` for sensitive data (causes security leak)
|
||||
- ✅ **ALWAYS** use `@Parcelize` on state classes for process death recovery
|
||||
- ✅ State restoration happens automatically if properly saved to `SavedStateHandle`
|
||||
|
||||
---
|
||||
|
||||
### B. Navigation Implementation (Type-Safe)
|
||||
|
||||
All navigation uses **type-safe routes** with kotlinx.serialization.
|
||||
|
||||
**Pattern Structure:**
|
||||
1. `@Serializable` route data class with parameters
|
||||
2. `...Args` helper class for extracting from `SavedStateHandle`
|
||||
3. `NavGraphBuilder.{screen}Destination()` extension for adding screen to graph
|
||||
4. `NavController.navigateTo{Screen}()` extension for navigation calls
|
||||
|
||||
**Template**: See [Navigation template](templates.md#navigation-template-type-safe-routes)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)
|
||||
|
||||
data class ExampleArgs(val userId: String, val isEditMode: Boolean)
|
||||
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
|
||||
}
|
||||
|
||||
fun NavController.navigateToExample(
|
||||
userId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(onNavigateBack = onNavigateBack)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt` (see `LoginRoute` and extensions)
|
||||
|
||||
**Key Benefits:**
|
||||
- ✅ Type safety: Compile-time errors for missing parameters
|
||||
- ✅ No string literals in navigation code
|
||||
- ✅ Automatic serialization/deserialization
|
||||
- ✅ Clear contract for screen dependencies
|
||||
|
||||
---
|
||||
|
||||
### C. Screen/Compose Implementation
|
||||
|
||||
All screens follow consistent Compose patterns.
|
||||
|
||||
**Template**: See [Screen/Compose template](templates.md#screencompose-template)
|
||||
|
||||
**Key Patterns:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.title),
|
||||
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
// UI content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
|
||||
|
||||
**Essential Requirements:**
|
||||
- ✅ Use `hiltViewModel()` for dependency injection
|
||||
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
|
||||
- ✅ Use `EventsEffect(viewModel)` for one-shot events
|
||||
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
|
||||
|
||||
**State Hoisting Rules:**
|
||||
- **ViewModel state**: Data that needs to survive process death or affects business logic
|
||||
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
|
||||
|
||||
---
|
||||
|
||||
### D. Data Layer Implementation
|
||||
|
||||
The data layer follows strict patterns for repositories, managers, and data sources.
|
||||
|
||||
**Interface + Implementation Separation (ALWAYS)**
|
||||
|
||||
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// Interface (injected via Hilt)
|
||||
interface ExampleRepository {
|
||||
suspend fun fetchData(id: String): ExampleResult
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
|
||||
// Implementation (NOT directly injected)
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
) : ExampleRepository {
|
||||
override suspend fun fetchData(id: String): ExampleResult {
|
||||
// NO exceptions thrown - return Result or sealed class
|
||||
return exampleService.getData(id).fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealed result class (domain-specific)
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: ExampleData) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
|
||||
// Hilt Module
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
**Three-Layer Data Architecture:**
|
||||
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
|
||||
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
|
||||
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
|
||||
|
||||
**Critical Rules:**
|
||||
- ❌ **NEVER** throw exceptions in data layer
|
||||
- ✅ **ALWAYS** use interface + `...Impl` pattern
|
||||
- ✅ **ALWAYS** inject interfaces, never implementations
|
||||
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
|
||||
- ✅ Use `StateFlow` for continuously observed data
|
||||
|
||||
---
|
||||
|
||||
### E. UI Components
|
||||
|
||||
**Use Existing Components First:**
|
||||
|
||||
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
|
||||
|
||||
**Common Components:**
|
||||
- `BitwardenFilledButton` - Primary action buttons
|
||||
- `BitwardenOutlinedButton` - Secondary action buttons
|
||||
- `BitwardenTextField` - Text input fields
|
||||
- `BitwardenPasswordField` - Password input with show/hide
|
||||
- `BitwardenSwitch` - Toggle switches
|
||||
- `BitwardenTopAppBar` - Toolbar/app bar
|
||||
- `BitwardenScaffold` - Screen container with scaffold
|
||||
- `BitwardenBasicDialog` - Simple dialogs
|
||||
- `BitwardenLoadingDialog` - Loading indicators
|
||||
|
||||
**Component Discovery:**
|
||||
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
|
||||
|
||||
**When to Create New Reusable Components:**
|
||||
- Component used in 3+ places
|
||||
- Component needs consistent theming across app
|
||||
- Component has semantic meaning (accessibility)
|
||||
- Component has complex state management
|
||||
|
||||
**New Component Requirements:**
|
||||
- Prefix with `Bitwarden`
|
||||
- Accept themed colors/styles from `BitwardenTheme`
|
||||
- Include preview composables for testing
|
||||
- Support accessibility (content descriptions, semantics)
|
||||
|
||||
**String Resources:**
|
||||
|
||||
New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
|
||||
|
||||
- Use typographic apostrophes and quotes to avoid escape characters: `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)
|
||||
|
||||
@@ -1,636 +0,0 @@
|
||||
# Code Templates - Bitwarden Android
|
||||
|
||||
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
|
||||
|
||||
---
|
||||
|
||||
## ViewModel Template (State-Action-Event Pattern)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
|
||||
|
||||
### State Class
|
||||
|
||||
```kotlin
|
||||
@Parcelize
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
|
||||
val dialogState: DialogState? = null,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Dialog states for the Example screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
@Parcelize
|
||||
data class Loading(val message: Text) : DialogState()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Sealed Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* One-shot UI events for the Example screen.
|
||||
*/
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
|
||||
data class ShowToast(val message: Text) : ExampleEvent()
|
||||
}
|
||||
```
|
||||
|
||||
### Action Sealed Class (with Internal)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* User and system actions for the Example screen.
|
||||
*/
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
|
||||
data object SubmitClick : ExampleAction()
|
||||
|
||||
data class InputChanged(val input: String) : ExampleAction()
|
||||
|
||||
data object ErrorDialogDismiss : ExampleAction()
|
||||
|
||||
/**
|
||||
* Internal actions dispatched by the ViewModel from coroutines.
|
||||
*/
|
||||
sealed class Internal : ExampleAction() {
|
||||
data class ReceiveDataState(
|
||||
val dataState: DataState<ExampleData>,
|
||||
) : Internal()
|
||||
|
||||
data class ReceiveDataResult(
|
||||
val result: ExampleResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel
|
||||
|
||||
```kotlin
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Example screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val exampleRepository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val args = savedStateHandle.toExampleArgs()
|
||||
ExampleState(
|
||||
data = args.itemId,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
// Persist state for process death recovery
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// Collect repository flows as internal actions
|
||||
exampleRepository.dataFlow
|
||||
.map { ExampleAction.Internal.ReceiveDataState(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
when (action) {
|
||||
ExampleAction.BackClick -> handleBackClick()
|
||||
ExampleAction.SubmitClick -> handleSubmitClick()
|
||||
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
|
||||
is ExampleAction.InputChanged -> handleInputChanged(action)
|
||||
is ExampleAction.Internal.ReceiveDataState -> {
|
||||
handleReceiveDataState(action)
|
||||
}
|
||||
is ExampleAction.Internal.ReceiveDataResult -> {
|
||||
handleReceiveDataResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(ExampleEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleErrorDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleSubmitClick() {
|
||||
viewModelScope.launch {
|
||||
val result = exampleRepository.submitData(state.data.orEmpty())
|
||||
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInputChanged(action: ExampleAction.InputChanged) {
|
||||
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
|
||||
}
|
||||
|
||||
private fun handleReceiveDataState(
|
||||
action: ExampleAction.Internal.ReceiveDataState,
|
||||
) {
|
||||
when (action.dataState) {
|
||||
is DataState.Loaded -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = action.dataState.data.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loading -> {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
}
|
||||
|
||||
is DataState.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = action.dataState.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveDataResult(
|
||||
action: ExampleAction.Internal.ReceiveDataResult,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is ExampleResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = result.data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ExampleResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = result.message?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Template (Type-Safe Routes)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
|
||||
|
||||
```kotlin
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Route for the Example screen.
|
||||
*/
|
||||
@Serializable
|
||||
@OmitFromCoverage
|
||||
data class ExampleRoute(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Args extracted from [SavedStateHandle] for the Example screen.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
data class ExampleArgs(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts [ExampleArgs] from the [SavedStateHandle].
|
||||
*/
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(
|
||||
itemId = route.itemId,
|
||||
isEditMode = route.isEditMode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Example screen.
|
||||
*/
|
||||
fun NavController.navigateToExample(
|
||||
itemId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = ExampleRoute(
|
||||
itemId = itemId,
|
||||
isEditMode = isEditMode,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Example screen destination to the navigation graph.
|
||||
*/
|
||||
fun NavGraphBuilder.exampleDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen/Compose Template
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
|
||||
|
||||
```kotlin
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* The Example screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
is ExampleEvent.ShowToast -> {
|
||||
// Handle toast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
ExampleDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.example),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
ExampleScreenContent(
|
||||
state = state,
|
||||
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
|
||||
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Layer Template (Repository + Hilt Module)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
### Interface
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Provides data operations for the Example feature.
|
||||
*/
|
||||
interface ExampleRepository {
|
||||
/**
|
||||
* Submits data and returns a typed result.
|
||||
*/
|
||||
suspend fun submitData(input: String): ExampleResult
|
||||
|
||||
/**
|
||||
* Continuously observed data stream.
|
||||
*/
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
```
|
||||
|
||||
### Sealed Result Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Domain-specific result for Example operations.
|
||||
*/
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: String) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Default implementation of [ExampleRepository].
|
||||
*/
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : ExampleRepository {
|
||||
|
||||
override val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
get() = // ...
|
||||
|
||||
override suspend fun submitData(input: String): ExampleResult {
|
||||
return exampleService
|
||||
.postData(input)
|
||||
.fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hilt Module
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): ExampleRepository = ExampleRepositoryImpl(
|
||||
exampleDiskSource = exampleDiskSource,
|
||||
exampleService = exampleService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Templates
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
|
||||
|
||||
### Encrypted Disk Source (Module)
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleDiskSource(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): ExampleDiskSource = ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Encrypted Disk Source (Implementation)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Disk source for Example data using encrypted and unencrypted storage.
|
||||
*/
|
||||
class ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
|
||||
private companion object {
|
||||
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
|
||||
const val UNENCRYPTED_PREF_KEY = "examplePreference"
|
||||
}
|
||||
|
||||
override var authToken: String?
|
||||
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
|
||||
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
|
||||
|
||||
override var uiPreference: Boolean
|
||||
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
|
||||
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Templates
|
||||
|
||||
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
### ViewModel Test
|
||||
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository = mockk<ExampleRepository>()
|
||||
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockRepository.dataFlow } returns mutableDataFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is no saved state`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is a saved state`() {
|
||||
val savedState = DEFAULT_STATE.copy(data = "saved")
|
||||
val viewModel = createViewModel(state = savedState)
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should call repository and update state on success`() = runTest {
|
||||
val expected = ExampleResult.Success(data = "result")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
// Initial state
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Updated state after result
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "result", isLoading = false),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should show error dialog on failure`() = runTest {
|
||||
val expected = ExampleResult.Error(message = "Network error")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
val errorState = awaitItem()
|
||||
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExampleAction.BackClick)
|
||||
assertEquals(ExampleEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create ViewModel with optional saved state
|
||||
private fun createViewModel(
|
||||
state: ExampleState? = DEFAULT_STATE,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to state),
|
||||
),
|
||||
exampleRepository = mockRepository,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Testing with stateEventFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `SubmitClick should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Assert state change
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "data"),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
|
||||
// Assert event emission
|
||||
assertEquals(
|
||||
ExampleEvent.ShowToast("Success".asText()),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: labeling-android-changes
|
||||
version: 0.1.0
|
||||
description: Conventional commit type keywords for PR titles and commit messages. Use when determining the change type for commits or PRs. Triggered by "what type", "label", "change type", "conventional commit", "t: label".
|
||||
---
|
||||
|
||||
# Labeling Changes
|
||||
|
||||
PR titles and commit messages must include a conventional commit type keyword. This keyword drives automatic `t:` label assignment via CI (`.github/workflows/sdlc-label-pr.yml`).
|
||||
|
||||
## Format
|
||||
|
||||
The type keyword appears after the Jira ticket prefix:
|
||||
|
||||
```
|
||||
[PM-XXXXX] <type>: <imperative summary>
|
||||
```
|
||||
|
||||
## Type Keywords
|
||||
|
||||
| Type | Label | Use for |
|
||||
|------|-------|---------|
|
||||
| `feat` | `t:feature` | New features or functionality |
|
||||
| `fix` | `t:bug` | Bug fixes |
|
||||
| `refactor` | `t:tech-debt` | Code restructuring without behavior change |
|
||||
| `chore` | `t:tech-debt` | Maintenance, cleanup, minor tweaks |
|
||||
| `test` | `t:tech-debt` | Adding or updating tests |
|
||||
| `perf` | `t:tech-debt` | Performance improvements |
|
||||
| `docs` | `t:docs` | Documentation changes |
|
||||
| `ci` / `build` | `t:ci` | CI/CD and build system changes |
|
||||
| `deps` | `t:deps` | Dependency updates |
|
||||
| `llm` | `t:llm` | LLM/Claude configuration changes |
|
||||
| `breaking` | `t:breaking-change` | Breaking changes requiring migration |
|
||||
| `misc` | `t:misc` | Changes that do not fit other categories |
|
||||
|
||||
## Selecting a Type
|
||||
|
||||
Infer the type from the task description and changes made. **If the type cannot be confidently determined, ask the user.**
|
||||
|
||||
The CI labeling script matches `<type>:` or `<type>(` in the lowercased PR title, so the keyword must be followed by a colon or parenthesis. CI also accepts additional aliases (e.g., `revert`, `bugfix`, `cleanup`). See `.github/label-pr.json` for the full mapping.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: perform-android-preflight-checklist
|
||||
version: 0.1.0
|
||||
description: Quality gate checklist to run before committing or creating a PR. Use when finishing implementation, checking work quality, or preparing to commit. Triggered by "self review", "check my work", "ready to commit", "done implementing", "review checklist", "quality check".
|
||||
---
|
||||
|
||||
# Self-Review Checklist
|
||||
|
||||
Run through this checklist before committing or opening a PR.
|
||||
|
||||
## Tests
|
||||
- [ ] Tests pass with correct flavor: `./gradlew app:testStandardDebugUnitTest`
|
||||
- [ ] New code has corresponding test coverage
|
||||
- [ ] Tests for affected modules also pass (`:core:test`, `:data:test`, etc.)
|
||||
|
||||
## Code Quality
|
||||
- [ ] Lint/detekt clean: `./gradlew detekt`
|
||||
- [ ] No unintended file changes (`git diff` review)
|
||||
- [ ] KDoc on all new public APIs
|
||||
- [ ] No TODO comments left behind (or they reference a ticket)
|
||||
|
||||
## Security
|
||||
- [ ] No plaintext keys, tokens, or secrets in code
|
||||
- [ ] User input validated before processing
|
||||
- [ ] Sensitive data uses encrypted storage patterns
|
||||
- [ ] No logging of sensitive data (passwords, keys, tokens)
|
||||
|
||||
## Bitwarden Patterns
|
||||
- [ ] String resources in `:ui` module with typographic quotes
|
||||
- [ ] Navigation route is `@Serializable` and registered in graph
|
||||
- [ ] New implementations have Hilt `@Binds` or `@Provides` in a module
|
||||
- [ ] ViewModel extends `BaseViewModel<S, E, A>` with proper state persistence
|
||||
- [ ] Async results mapped through internal actions (not direct state updates)
|
||||
|
||||
## Files
|
||||
- [ ] No accidental `.idea/`, build output, or generated files staged
|
||||
- [ ] No credential files or `.env` files included
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
name: planning-android-implementation
|
||||
version: 0.1.0
|
||||
description: Architecture design and phased implementation planning for Bitwarden Android. Use when planning implementation, designing architecture, creating file inventories, or breaking features into phases. Triggered by "plan implementation", "architecture design", "implementation plan", "break this into phases", "what files do I need", "design the architecture".
|
||||
---
|
||||
|
||||
# Implementation Planning
|
||||
|
||||
This skill takes a refined specification (ideally from the `refining-android-requirements` skill) and produces a phased implementation plan with architecture design, file inventory, and risk assessment.
|
||||
|
||||
**Prerequisite**: A clear set of requirements. If requirements are vague or incomplete, invoke the `refining-android-requirements` skill first.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Classify Change
|
||||
|
||||
Determine the change type to guide scope and planning depth:
|
||||
|
||||
| Type | Description | Typical Scope |
|
||||
|------|-------------|---------------|
|
||||
| **New Feature** | Entirely new functionality, screens, or flows | New files + modifications, multi-phase |
|
||||
| **Enhancement** | Extending existing feature with new capabilities | Mostly modifications, 1-2 phases |
|
||||
| **Bug Fix** | Correcting incorrect behavior | Targeted modifications, single phase |
|
||||
| **Refactoring** | Restructuring without behavior change | Modifications only, migration-aware |
|
||||
| **Infrastructure** | Build, CI, tooling, or dependency changes | Config files, minimal code changes |
|
||||
|
||||
State the classification and rationale before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Codebase Exploration
|
||||
|
||||
Search the codebase to find reference implementations and integration points. Use the discovery commands from the `build-test-verify` skill as needed.
|
||||
|
||||
### Find Pattern Anchors
|
||||
|
||||
Identify 2-3 existing files that serve as templates for the planned work:
|
||||
|
||||
```
|
||||
**Pattern Anchors:**
|
||||
1. [file path] — [why this is a good reference]
|
||||
2. [file path] — [why this is a good reference]
|
||||
3. [file path] — [why this is a good reference]
|
||||
```
|
||||
|
||||
### Map Integration Points
|
||||
|
||||
Identify files that must be modified to integrate the new work:
|
||||
|
||||
- **Navigation**: Nav graph registrations, route definitions
|
||||
- **Dependency Injection**: Hilt modules, `@Provides` / `@Binds` functions
|
||||
- **Data Layer**: Repository interfaces, data source interfaces, Room DAOs
|
||||
- **API Layer**: Retrofit service interfaces, request/response models
|
||||
- **Feature Flags**: Feature flag definitions and checks
|
||||
- **Managers**: Single-responsibility data layer classes (see `docs/ARCHITECTURE.md` Managers section)
|
||||
- **Test Fixtures**: Shared test utilities in `src/testFixtures/` directories
|
||||
- **Product Flavor Source Sets**: Code in `src/standard/` vs `src/main/` for Play Services dependencies
|
||||
|
||||
### Document Existing Patterns
|
||||
|
||||
Note the specific patterns used by the pattern anchors:
|
||||
- State class structure (sealed class, data class fields)
|
||||
- Action/Event naming conventions
|
||||
- Repository method signatures and return types
|
||||
- Test structure and assertion patterns
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Architecture Design
|
||||
|
||||
Produce an ASCII diagram showing component relationships for the planned work:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Screen │ ← Compose UI
|
||||
│ (Composable) │
|
||||
└────────┬────────┘
|
||||
│ State / Action / Event
|
||||
┌────────▼────────┐
|
||||
│ ViewModel │ ← Business logic orchestration
|
||||
└────────┬────────┘
|
||||
│ Repository calls
|
||||
┌────────▼────────┐
|
||||
│ Repository │ ← Data coordination (sealed class results)
|
||||
└───┬────┬────┬───┘
|
||||
│ │ │
|
||||
┌───▼───┐ │ ┌─▼──────┐
|
||||
│Manager│ │ │Manager │ ← Single-responsibility (optional)
|
||||
└───┬───┘ │ └─┬──────┘
|
||||
│ │ │
|
||||
┌───▼─────▼───▼────┐
|
||||
│ Data Sources │ ← Raw data (Result<T>, never throw)
|
||||
└─┬────┬────┬──────┘
|
||||
│ │ │
|
||||
Room Retrofit SDK
|
||||
```
|
||||
|
||||
Adapt the diagram to show the actual components planned. _Consult `docs/ARCHITECTURE.md` for full data layer patterns and conventions._
|
||||
|
||||
### Design Decisions
|
||||
|
||||
Document key architectural decisions in a table:
|
||||
|
||||
| Decision | Resolution | Rationale |
|
||||
|----------|-----------|-----------|
|
||||
| [What needed deciding] | [What was chosen] | [Why] |
|
||||
|
||||
---
|
||||
|
||||
## Step 4: File Inventory
|
||||
|
||||
### Files to Create
|
||||
|
||||
| File Path | Type | Pattern Reference |
|
||||
|-----------|------|-------------------|
|
||||
| [full path] | [ViewModel / Screen / Repository / etc.] | [pattern anchor file] |
|
||||
|
||||
**Include in file inventory:**
|
||||
- `...Navigation.kt` files for new screens
|
||||
- `...Module.kt` Hilt module files for new DI bindings
|
||||
- Paired test files (`...Test.kt`) for each new class
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File Path | Change Description | Risk Level |
|
||||
|-----------|-------------------|------------|
|
||||
| [full path] | [what changes] | Low / Medium / High |
|
||||
|
||||
**Risk levels:**
|
||||
- **Low**: Additive changes (new entries in nav graph, new bindings in Hilt module)
|
||||
- **Medium**: Modifying existing logic (adding parameters, new branches)
|
||||
- **High**: Changing interfaces, data models, or shared utilities
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Implementation Phases
|
||||
|
||||
Break the work into sequential phases. Each phase should be independently testable and committable.
|
||||
|
||||
**Phase ordering principle**: Foundation → SDK/Data → Network → UI (tests accompany each phase)
|
||||
|
||||
For each phase:
|
||||
|
||||
```markdown
|
||||
### Phase N: [Name]
|
||||
|
||||
**Goal**: [What this phase accomplishes]
|
||||
|
||||
**Files**:
|
||||
- Create: [list]
|
||||
- Modify: [list]
|
||||
|
||||
**Tasks**:
|
||||
1. [Specific implementation task]
|
||||
2. [Specific implementation task]
|
||||
3. ...
|
||||
|
||||
**Verification**:
|
||||
- [Test command or manual verification step]
|
||||
|
||||
**Skills**: [Which workflow skills apply — e.g., `implementing-android-code`, `testing-android-code`]
|
||||
```
|
||||
|
||||
### Phase Guidelines
|
||||
|
||||
- Each phase should be small enough to be independently testable and committable
|
||||
- Tests are written within the same phase as the code they verify (not deferred to a "testing phase")
|
||||
- UI phases come after their data dependencies are in place
|
||||
- If a phase has more than 5 tasks, consider splitting it
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Risk & Verification
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| [What could go wrong] | Low/Med/High | Low/Med/High | [How to prevent or handle] |
|
||||
|
||||
### Verification Plan
|
||||
|
||||
**Automated Verification:**
|
||||
- Unit test commands (from `build-test-verify` skill)
|
||||
- Lint/detekt commands
|
||||
- Build verification
|
||||
|
||||
**Manual Verification:**
|
||||
- [Specific manual test scenarios]
|
||||
- [Edge cases to manually verify]
|
||||
- Verify ViewModel state survives process death (test via `SavedStateHandle` persistence and `Don't keep activities` developer option)
|
||||
@@ -1,181 +0,0 @@
|
||||
---
|
||||
name: refining-android-requirements
|
||||
version: 0.1.0
|
||||
description: Requirements gap analysis and structured specification for Bitwarden Android. Use when refining requirements, analyzing specs, identifying gaps, or producing structured specifications from tickets or descriptions. Triggered by "refine requirements", "gap analysis", "spec review", "requirements analysis", "what's missing from this spec", "analyze this ticket".
|
||||
---
|
||||
|
||||
# Requirements Refinement
|
||||
|
||||
This skill takes raw requirements (from Jira tickets, Confluence pages, or free-text descriptions) and produces a structured, implementation-ready specification through systematic gap analysis.
|
||||
|
||||
**Key principle**: This skill identifies gaps and produces specifications. It does NOT propose solutions or architecture — that is the responsibility of the `planning-android-implementation` skill.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Source Consolidation
|
||||
|
||||
Combine all input sources into a single working document. For each requirement, note its source:
|
||||
|
||||
```
|
||||
- [Source: PM-12345] User must be able to configure timeout
|
||||
- [Source: Confluence] Timeout range is 1-60 minutes
|
||||
- [Source: User] Default timeout should be 15 minutes
|
||||
```
|
||||
|
||||
Flag any contradictions between sources for immediate resolution.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Gap Analysis
|
||||
|
||||
Evaluate the consolidated requirements against the following 5-category rubric. For each category, check every item and note whether it is **covered**, **partially covered**, or **missing**.
|
||||
|
||||
### A. Functional Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| User actions defined? | What specific user actions trigger this feature? |
|
||||
| All states covered? (empty, loading, error, success) | What should the user see in [empty/loading/error] state? |
|
||||
| Edge cases identified? | What happens when [boundary condition]? |
|
||||
| Cancellation/back navigation flows? | Can the user cancel mid-flow? What happens to partial data? |
|
||||
| Input validation rules? | What are the valid ranges/formats for [input]? |
|
||||
| Success/failure criteria? | How does the user know the operation succeeded or failed? |
|
||||
| Offline behavior? | What happens if this is attempted offline? |
|
||||
|
||||
### B. Technical Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Module scope identified? (`:app`, `:authenticator`, shared) | Which module(s) does this feature belong to? |
|
||||
| SDK dependencies? | Does this require Bitwarden SDK operations? Which ones? |
|
||||
| Data storage approach? (Room, DataStore, in-memory) | Where is the data for this feature persisted? |
|
||||
| Network API endpoints? | Which API endpoints are involved? Are they existing or new? |
|
||||
| Process death handling? | What state needs to survive process death? |
|
||||
| Migration requirements? | Does existing data need migration? |
|
||||
| Feature flag needed? | Should this be behind a feature flag for staged rollout? |
|
||||
| Product flavors (standard vs fdroid)? | Does this feature depend on Google Play Services? Available on F-Droid? |
|
||||
| Data layer tier? | Does this need a new Manager (single-responsibility) or only Repository/DataSource? Consult `docs/ARCHITECTURE.md` Data Layer section. |
|
||||
| Streaming vs discrete data? | Is data continuously observed (`DataState<T>` + `StateFlow`) or a one-shot operation (custom sealed class)? See `docs/ARCHITECTURE.md` Repositories section. |
|
||||
|
||||
### C. Security Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Data sensitivity classified? | What sensitivity level does this data have? (vault-level, account-level, non-sensitive) |
|
||||
| Storage encryption required? | Must this data be encrypted at rest? Via SDK or Android Keystore? |
|
||||
| Logout cleanup behavior? | What must be cleared when the user logs out? |
|
||||
| Auth-gating? | Does accessing this feature require active authentication? |
|
||||
| Input sanitization? | Are there URL or credential inputs that need validation? |
|
||||
| Sensitive data in ViewModel state? | Will passwords, tokens, or keys appear in state? Must use `@IgnoredOnParcel`. See `implementing-android-code` skill Section F. |
|
||||
| SDK crypto context isolation? | Does this use vault encryption? Must use `ScopedVaultSdkSource` for multi-account safety. See CLAUDE.md Security Rules. |
|
||||
|
||||
### D. UX/UI Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| UI copy/strings defined? | What text should appear for [label/button/message]? |
|
||||
| Error messages specified? | What should the error message say when [failure case]? |
|
||||
| Loading states designed? | Should loading show a spinner, skeleton, or shimmer? |
|
||||
| Navigation flow clear? | Where does the user go after [action]? Back stack behavior? |
|
||||
| Accessibility considerations? | Are there content descriptions or focus order requirements? |
|
||||
| Toast/snackbar/dialog for feedback? | What feedback mechanism for [action result]? |
|
||||
|
||||
### E. Cross-Cutting Concerns
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Multi-account behavior? | How does this behave with multiple accounts? Per-account or global? |
|
||||
| Backwards compatibility? | Does this affect existing users? Migration path? |
|
||||
| Feature flag strategy? | Is this behind a server-side or local feature flag? |
|
||||
| Analytics/logging? | Are there analytics events to track? |
|
||||
| Bitwarden Authenticator impact? | Does this affect the `:authenticator` module? |
|
||||
| F-Droid compatibility? | Does this degrade gracefully without Google Play Services (no push notifications, no Play Integrity)? |
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Present Gaps
|
||||
|
||||
Organize all identified gaps into two categories:
|
||||
|
||||
### Blocking Questions
|
||||
|
||||
Questions that **must** be answered before implementation can begin because they change the architecture, data model, or core flow.
|
||||
|
||||
Format each question as:
|
||||
|
||||
```
|
||||
**G[N]** ([Category]) — [Question text]
|
||||
Context: [Why this matters / what depends on the answer]
|
||||
```
|
||||
|
||||
### Non-Blocking Questions
|
||||
|
||||
Questions that have **reasonable defaults** and can be resolved during implementation. Note the assumed default.
|
||||
|
||||
Format each question as:
|
||||
|
||||
```
|
||||
**G[N]** ([Category]) — [Question text]
|
||||
Default assumption: [What we'll assume if not answered]
|
||||
Context: [Why this matters]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Produce Specification
|
||||
|
||||
After the user answers blocking questions (and optionally non-blocking ones), produce a structured specification:
|
||||
|
||||
```markdown
|
||||
## Overview
|
||||
|
||||
[1-2 paragraph summary of the feature, its purpose, and scope]
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| FR1 | [requirement] | [source] | [any notes] |
|
||||
| FR2 | ... | ... | ... |
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| TR1 | [requirement] | [source] | [any notes] |
|
||||
| TR2 | ... | ... | ... |
|
||||
|
||||
## Security Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| SR1 | [requirement] | [source] | [any notes] |
|
||||
|
||||
## UX Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| UX1 | [requirement] | [source] | [any notes] |
|
||||
|
||||
## Open Items
|
||||
|
||||
Non-blocking items with assumed defaults that may be revisited:
|
||||
|
||||
| ID | Question | Assumed Default | Category |
|
||||
|----|----------|----------------|----------|
|
||||
| G[N] | [question] | [default] | [category] |
|
||||
|
||||
## Source Documentation
|
||||
|
||||
| Source | Type | Link |
|
||||
|--------|------|------|
|
||||
| [name] | Jira / Confluence / User-provided | [link if available] |
|
||||
```
|
||||
|
||||
### Output Guidelines
|
||||
|
||||
- Requirements use numbered IDs (FR1, TR1, SR1, UX1) for traceability through implementation
|
||||
- Each requirement cites its source (ticket, page, or user-provided)
|
||||
- Technical requirements use table format for structured key/value data
|
||||
- Interface signatures are included as fenced code blocks when applicable
|
||||
- Open items preserve the gap ID (G[N]) for cross-referencing
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: reviewing-changes
|
||||
description: Android-specific code review checklist and MVVM/Compose pattern validation for Bitwarden Android — use this for any review task, even if the user doesn't explicitly ask for a "checklist". Detects change type automatically and loads the right review strategy (feature additions, bug fixes, UI refinements, refactoring, dependency updates, infrastructure). Triggered by "review PR", "review changes", "review this code", "check this code", "Android review", code review requests on Kotlin/ViewModel/Composable/Repository/Gradle files, or any time someone asks to look at a diff, PR, or code changes in bitwarden/android.
|
||||
version: 3.0.0
|
||||
description: Guides Android code reviews with type-specific checklists and MVVM/Compose pattern validation. Use when reviewing Android PRs, pull requests, diffs, or local changes involving Kotlin, ViewModel, Composable, Repository, or Gradle files. Triggered by "review PR", "review changes", "check this code", "Android review", or code review requests mentioning bitwarden/android. Loads specialized checklists for feature additions, bug fixes, UI refinements, refactoring, dependency updates, and infrastructure changes.
|
||||
---
|
||||
|
||||
# Reviewing Changes - Android Additions
|
||||
@@ -9,10 +10,16 @@ This skill provides Android-specific workflow additions that complement the base
|
||||
|
||||
## Instructions
|
||||
|
||||
**IMPORTANT**: Work systematically through each step before providing feedback. Each checklist file includes structured thinking guidance for its review passes.
|
||||
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback.
|
||||
|
||||
### Step 1: Retrieve Additional Details
|
||||
|
||||
<thinking>
|
||||
Determine if more context is available for the changes:
|
||||
1. Are there JIRA tickets or GitHub Issues mentioned in the PR title or body?
|
||||
2. Are there other GitHub pull requests mentioned in the PR title or body?
|
||||
</thinking>
|
||||
|
||||
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
|
||||
|
||||
If pull request title and message do not provide enough context, request additional details from the reviewer:
|
||||
@@ -21,12 +28,16 @@ If pull request title and message do not provide enough context, request additio
|
||||
- Link to another pull request
|
||||
- Add more detail to the PR title or body
|
||||
|
||||
**Android metadata checks** — flag as ❓ if any of these are missing:
|
||||
- PR includes `*Screen.kt` or Composable changes but has no screenshots
|
||||
- PR adds new `ViewModel` or `Repository` but has no test plan or test file changes
|
||||
|
||||
### Step 2: Detect Change Type with Android Refinements
|
||||
|
||||
<thinking>
|
||||
Analyze the changeset systematically:
|
||||
1. What files were modified? (code vs config vs docs)
|
||||
2. What is the PR/commit title indicating?
|
||||
3. Is there new functionality or just modifications?
|
||||
4. What's the risk level of these changes?
|
||||
</thinking>
|
||||
|
||||
Use the base change type detection from the agent, with Android-specific refinements:
|
||||
|
||||
**Android-specific patterns:**
|
||||
@@ -54,13 +65,21 @@ The checklist provides:
|
||||
|
||||
### Step 4: Execute Review Following Checklist
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. What are the highest-risk areas of this change?
|
||||
2. Which architectural patterns need verification?
|
||||
3. What security implications exist?
|
||||
4. How should I prioritize my findings?
|
||||
5. What tone is appropriate for this feedback?
|
||||
</thinking>
|
||||
|
||||
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
|
||||
|
||||
### Step 5: Consult Android Reference Materials As Needed
|
||||
|
||||
Load reference files only when needed for specific questions:
|
||||
|
||||
- **Re-reviews** → invoke `reviewing-incremental-changes` agent skill; scope to changed lines only, do not flag new issues in unchanged code
|
||||
- **Issue prioritization** → `reference/priority-framework.md` (Critical vs Suggested vs Optional)
|
||||
- **Phrasing feedback** → `reference/review-psychology.md` (questions vs commands, I-statements)
|
||||
- **Architecture questions** → `reference/architectural-patterns.md` (MVVM, Hilt DI, module org, error handling)
|
||||
@@ -72,7 +91,6 @@ Load reference files only when needed for specific questions:
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Priority order**: Security → Correctness → Breaking Changes → Performance → Maintainability
|
||||
- **Appropriate depth**: Match review rigor to change complexity and risk
|
||||
- **Specific references**: Always use `file:line_number` format for precise location
|
||||
- **Actionable feedback**: Say what to do and why, not just what's wrong
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Understand the Bug
|
||||
|
||||
<thinking>
|
||||
Before evaluating the fix:
|
||||
1. What was the original bug/broken behavior?
|
||||
2. What is the expected correct behavior?
|
||||
3. What was the root cause?
|
||||
4. How was the bug discovered? (user report, test, production)
|
||||
5. What's the severity? (crash, data loss, UI glitch, minor annoyance)
|
||||
</thinking>
|
||||
|
||||
**1. Understand root cause:**
|
||||
- What was the broken behavior?
|
||||
- What caused it?
|
||||
@@ -20,6 +29,15 @@
|
||||
|
||||
### Second Pass: Verify the Fix
|
||||
|
||||
<thinking>
|
||||
Evaluate the fix systematically:
|
||||
1. Does this fix address the root cause or just symptoms?
|
||||
2. Are there edge cases not covered?
|
||||
3. Could this break other functionality?
|
||||
4. Is the fix localized or does it ripple through the codebase?
|
||||
5. How do we prevent this bug from returning?
|
||||
</thinking>
|
||||
|
||||
**4. Code changes:**
|
||||
- Does the fix make sense?
|
||||
- Is it the simplest solution?
|
||||
@@ -83,7 +101,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Identify and Assess
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. Which dependencies were updated?
|
||||
2. What are the version changes? (patch, minor, major)
|
||||
3. Are any security-sensitive libraries involved? (crypto, auth, networking)
|
||||
4. Any pre-release versions (alpha, beta, RC)?
|
||||
5. What's the blast radius if something breaks?
|
||||
</thinking>
|
||||
|
||||
**1. Identify the change:**
|
||||
- Which library? Old version → New version?
|
||||
- Major (X.0.0), Minor (0.X.0), or Patch (0.0.X) version change?
|
||||
@@ -16,6 +25,15 @@
|
||||
|
||||
### Second Pass: Deep Analysis
|
||||
|
||||
<thinking>
|
||||
For each dependency update:
|
||||
1. What changes are in this release?
|
||||
2. Are there breaking changes?
|
||||
3. Are there security fixes?
|
||||
4. Do we use the affected APIs?
|
||||
5. How does this affect our codebase?
|
||||
</thinking>
|
||||
|
||||
**3. Review release notes** (if available):
|
||||
- Breaking changes mentioned?
|
||||
- Security fixes included?
|
||||
@@ -74,7 +92,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Reviews
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: High-Level Assessment
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. What is this feature supposed to do?
|
||||
2. How does it fit into the existing architecture?
|
||||
3. What are the security implications?
|
||||
4. What's the scope? (files touched, modules affected)
|
||||
5. What are the highest-risk areas?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the feature:**
|
||||
- Read PR description - what problem does this solve?
|
||||
- Identify user-facing changes vs internal changes
|
||||
@@ -21,6 +30,15 @@
|
||||
|
||||
### Second Pass: Architecture Deep-Dive
|
||||
|
||||
<thinking>
|
||||
Verify architectural integrity:
|
||||
1. Does this follow MVVM + UDF pattern?
|
||||
2. Is Hilt DI used correctly?
|
||||
3. Is state management proper (StateFlow, immutability)?
|
||||
4. Are modules organized correctly?
|
||||
5. Is error handling robust (Result types)?
|
||||
</thinking>
|
||||
|
||||
**4. MVVM + UDF Pattern Compliance:**
|
||||
- ViewModels properly structured?
|
||||
- State management using StateFlow?
|
||||
@@ -42,6 +60,15 @@
|
||||
|
||||
### Third Pass: Details and Quality
|
||||
|
||||
<thinking>
|
||||
Check quality and completeness:
|
||||
1. Is code quality high? (null safety, documentation, naming)
|
||||
2. Are tests comprehensive? (unit + integration)
|
||||
3. Are there edge cases not covered?
|
||||
4. Is documentation clear?
|
||||
5. Are there any code smells or anti-patterns?
|
||||
</thinking>
|
||||
|
||||
**8. Testing:**
|
||||
- Unit tests for ViewModels and repositories?
|
||||
- Test coverage for edge cases and error scenarios?
|
||||
@@ -59,13 +86,144 @@
|
||||
|
||||
## Architecture Review
|
||||
|
||||
Read `reference/architectural-patterns.md` for full patterns and code examples.
|
||||
### MVVM Pattern Compliance
|
||||
|
||||
**Check these four areas:**
|
||||
- **MVVM/UDF**: ViewModel exposes `StateFlow` (not `MutableStateFlow`), business logic in Repository, UI is stateless
|
||||
- **Hilt DI**: `@HiltViewModel` + `@Inject constructor`, inject interfaces not implementations, no manual instantiation
|
||||
- **Module placement**: UI in `:ui`/`:app`, data in `:data`, network in `:network`, no circular dependencies
|
||||
- **Error handling**: `Result<T>` / `runCatching` throughout — no thrown exceptions from data layer
|
||||
Read `reference/architectural-patterns.md` for detailed patterns.
|
||||
|
||||
**ViewModels must:**
|
||||
- Use `@HiltViewModel` annotation
|
||||
- Use `@Inject constructor`
|
||||
- Expose `StateFlow<T>`, NOT `MutableStateFlow<T>` publicly
|
||||
- Delegate business logic to Repository/Manager
|
||||
- Avoid direct Android framework dependencies (except ViewModel, SavedStateHandle)
|
||||
|
||||
**Common Violations:**
|
||||
```kotlin
|
||||
// ❌ BAD - Exposes mutable state
|
||||
class FeatureViewModel @Inject constructor() : ViewModel() {
|
||||
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
|
||||
}
|
||||
|
||||
// ✅ GOOD - Exposes immutable state
|
||||
class FeatureViewModel @Inject constructor() : ViewModel() {
|
||||
private val _state = MutableStateFlow<State>(State.Initial)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
}
|
||||
|
||||
// ❌ BAD - Business logic in ViewModel
|
||||
fun onSubmit() {
|
||||
val encrypted = encryptionManager.encrypt(password) // Should be in Repository
|
||||
_state.value = State.Success
|
||||
}
|
||||
|
||||
// ✅ GOOD - Business logic in Repository, state updated via internal event
|
||||
fun onSubmit() {
|
||||
viewModelScope.launch {
|
||||
// The result of the async operation is captured
|
||||
val result = repository.submitData(password)
|
||||
// A single event is sent with the result, not updating state directly
|
||||
sendAction(FeatureAction.Internal.SubmissionComplete(result))
|
||||
}
|
||||
}
|
||||
|
||||
// The ViewModel has a handler that processes the internal event
|
||||
private fun handleInternalAction(action: FeatureAction.Internal) {
|
||||
when (action) {
|
||||
is FeatureAction.Internal.SubmissionComplete -> {
|
||||
// The event handler evaluates the result and updates state
|
||||
action.result.fold(
|
||||
onSuccess = { _state.value = State.Success },
|
||||
onFailure = { _state.value = State.Error(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI Layer must:**
|
||||
- Only observe state, never modify
|
||||
- Pass user actions as events to ViewModel
|
||||
- Contain no business logic
|
||||
- Use existing UI components from `:ui` module where possible
|
||||
|
||||
### Hilt Dependency Injection
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#dependency-injection`
|
||||
|
||||
**Required Patterns:**
|
||||
- ViewModels: `@HiltViewModel` + `@Inject constructor`
|
||||
- Repositories: `@Inject constructor` on implementation
|
||||
- Inject interfaces, not concrete implementations
|
||||
- Modules must provide proper scoping (`@Singleton`, `@ViewModelScoped`)
|
||||
|
||||
**Common Violations:**
|
||||
```kotlin
|
||||
// ❌ BAD - Manual instantiation
|
||||
class FeatureViewModel : ViewModel() {
|
||||
private val repository = FeatureRepositoryImpl()
|
||||
}
|
||||
|
||||
// ✅ GOOD - Injected interface
|
||||
@HiltViewModel
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository // Interface, not implementation
|
||||
) : ViewModel()
|
||||
|
||||
// ❌ BAD - Injecting implementation
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepositoryImpl // Should inject interface
|
||||
)
|
||||
|
||||
// ✅ GOOD - Interface injection
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository // Interface
|
||||
)
|
||||
```
|
||||
|
||||
### Module Organization
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#module-structure`
|
||||
|
||||
**Correct Placement:**
|
||||
- `:core` - Shared utilities (cryptography, analytics, logging)
|
||||
- `:data` - Repositories, database, domain models
|
||||
- `:network` - API clients, network utilities
|
||||
- `:ui` - Reusable Compose components, theme
|
||||
- `:app` - Feature screens, ViewModels, navigation
|
||||
- `:authenticator` - Authenticator app (separate from password manager)
|
||||
|
||||
**Check:**
|
||||
- UI code in `:ui` or `:app` modules
|
||||
- Data models in `:data`
|
||||
- Network clients in `:network`
|
||||
- No circular dependencies between modules
|
||||
|
||||
### Error Handling
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#error-handling`
|
||||
|
||||
**Required Pattern - Use Result types:**
|
||||
```kotlin
|
||||
// ✅ GOOD - Result type
|
||||
suspend fun fetchData(): Result<Data> = runCatching {
|
||||
apiService.getData()
|
||||
}
|
||||
|
||||
// ViewModel handles Result
|
||||
repository.fetchData().fold(
|
||||
onSuccess = { data -> _state.value = State.Success(data) },
|
||||
onFailure = { error -> _state.value = State.Error(error) }
|
||||
)
|
||||
|
||||
// ❌ BAD - Exception-based in business logic
|
||||
suspend fun fetchData(): Data {
|
||||
try {
|
||||
return apiService.getData()
|
||||
} catch (e: Exception) {
|
||||
throw FeatureException(e) // Don't throw in business logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Review
|
||||
|
||||
@@ -208,4 +366,15 @@ Use `reference/review-psychology.md` for phrasing guidance.
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
See `examples/review-outputs.md` for comprehensive feature review example.
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Understand the Change
|
||||
|
||||
<thinking>
|
||||
Assess infrastructure change:
|
||||
1. What problem does this solve?
|
||||
2. Does this affect production builds, CI/CD, or dev workflow?
|
||||
3. What's the risk if this breaks?
|
||||
4. Can this be tested before merge?
|
||||
5. What's the rollback plan?
|
||||
</thinking>
|
||||
|
||||
**1. Identify the goal:**
|
||||
- What problem does this solve?
|
||||
- Is this optimization, fix, or new capability?
|
||||
@@ -21,6 +30,15 @@
|
||||
|
||||
### Second Pass: Verify Implementation
|
||||
|
||||
<thinking>
|
||||
Verify configuration and impact:
|
||||
1. Is the configuration syntax valid?
|
||||
2. Are secrets/credentials handled securely?
|
||||
3. What's the impact on build times and CI performance?
|
||||
4. How will this affect the team's workflow?
|
||||
5. Is there adequate testing/validation?
|
||||
</thinking>
|
||||
|
||||
**4. Configuration correctness:**
|
||||
- Syntax valid?
|
||||
- References correct?
|
||||
@@ -171,7 +189,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Understand the Refactoring
|
||||
|
||||
<thinking>
|
||||
Analyze the refactoring scope:
|
||||
1. What pattern is being improved?
|
||||
2. Why is this refactoring needed?
|
||||
3. Does this change behavior or just structure?
|
||||
4. What's the scope? (files affected, migration completeness)
|
||||
5. What are the risks if something breaks?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the goal:**
|
||||
- What pattern is being improved?
|
||||
- Why is this refactoring needed?
|
||||
@@ -21,6 +30,15 @@
|
||||
|
||||
### Second Pass: Verify Consistency
|
||||
|
||||
<thinking>
|
||||
Verify refactoring quality:
|
||||
1. Is the new pattern applied consistently throughout?
|
||||
2. Are there missed instances of the old pattern?
|
||||
3. Do tests still pass with same behavior?
|
||||
4. Is the migration complete or partial?
|
||||
5. Does this introduce any new issues?
|
||||
</thinking>
|
||||
|
||||
**4. Pattern consistency:**
|
||||
- Is the new pattern applied consistently throughout?
|
||||
- Are there missed instances of the old pattern?
|
||||
@@ -151,7 +169,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Reviews
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Visual Changes
|
||||
|
||||
<thinking>
|
||||
Analyze the UI changes:
|
||||
1. What visual/UX problem is being solved?
|
||||
2. Are there designs or screenshots to reference?
|
||||
3. Is this affecting existing screens or new ones?
|
||||
4. What's the scope of visual changes?
|
||||
5. Are design tokens (colors, spacing, typography) being used correctly?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the changes:**
|
||||
- What visual/UX problem is being solved?
|
||||
- Are there designs or screenshots to reference?
|
||||
@@ -16,6 +25,15 @@
|
||||
|
||||
### Second Pass: Implementation Review
|
||||
|
||||
<thinking>
|
||||
Check implementation quality:
|
||||
1. Are Compose best practices followed?
|
||||
2. Is state hoisting applied correctly?
|
||||
3. Are existing components reused where possible?
|
||||
4. Is accessibility properly handled?
|
||||
5. Does this follow design system patterns?
|
||||
</thinking>
|
||||
|
||||
**3. Compose best practices:**
|
||||
- Composables properly structured?
|
||||
- State hoisted correctly?
|
||||
@@ -169,7 +187,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -50,34 +50,21 @@ Reference: [docs link if applicable]
|
||||
- ⚠️ **IMPORTANT** - Should fix (missing tests, quality issues)
|
||||
- ♻️ **DEBT** - Technical debt (duplication, convention violations, future rework needed)
|
||||
- 🎨 **SUGGESTED** - Nice to have (refactoring, improvements)
|
||||
- ❓ **QUESTION** - Seeking clarification (requirements, design decisions)
|
||||
- 💭 **QUESTION** - Seeking clarification (requirements, design decisions)
|
||||
|
||||
### Summary Comment Format
|
||||
|
||||
Uses the agent's `posting-review-summary` skill format. Surface ❌ CRITICAL issues at the top level for immediate visibility, wrap the full findings list in `<details>` for scannability.
|
||||
|
||||
**Required format for ALL PRs:**
|
||||
```
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
[1-2 neutral sentences describing what was reviewed]
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- ❌ [One-line summary with file:line]
|
||||
- [issue with file:line]
|
||||
|
||||
<details>
|
||||
<summary>All findings</summary>
|
||||
|
||||
- ❌ **CRITICAL**: [description] (`file:line`)
|
||||
- ⚠️ **IMPORTANT**: [description] (`file:line`)
|
||||
- ♻️ **DEBT**: [description] (`file:line`)
|
||||
- 🎨 **SUGGESTED**: [description] (`file:line`)
|
||||
- ❓ **QUESTION**: [description] (`file:line`)
|
||||
</details>
|
||||
See inline comments for details.
|
||||
```
|
||||
|
||||
For clean PRs with no findings, omit both sections entirely — verdict + 1-2 sentences is sufficient.
|
||||
|
||||
**GitHub pitfall**: Never use `#` followed by a number in comment text (e.g., `#42`, `#PR123`). GitHub autolinks these to issues/PRs. Use `Finding 1:` or `item 42` instead.
|
||||
All PRs use the same minimal format - no exceptions for size or complexity. Summary must be 5-10 lines maximum.
|
||||
|
||||
---
|
||||
|
||||
@@ -281,7 +268,7 @@ Would add security layer against brute force. Consider discussing threat model w
|
||||
|
||||
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
|
||||
```markdown
|
||||
❓ **QUESTION**: Can we use BitwardenTextField?
|
||||
💭 **QUESTION**: Can we use BitwardenTextField?
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ Use this framework to classify findings during code review. Clear prioritization
|
||||
- [⚠️ IMPORTANT (Should Fix)](#important-should-fix)
|
||||
- [♻️ DEBT (Technical Debt)](#debt-technical-debt)
|
||||
- [🎨 SUGGESTED (Nice to Have)](#suggested-nice-to-have)
|
||||
- [❓ QUESTION (Seeking Clarification)](#question-seeking-clarification)
|
||||
- [💭 QUESTION (Seeking Clarification)](#question-seeking-clarification)
|
||||
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
|
||||
|
||||
**Guidelines:**
|
||||
@@ -170,12 +170,13 @@ Will require rework when experimentation framework launches.
|
||||
|
||||
## 🎨 **SUGGESTED** (Nice to Have)
|
||||
|
||||
Improvements with measurable value only. A finding qualifies as SUGGESTED if it provides: security gain, cyclomatic complexity reduction, bug class prevention, or elimination of an O(n²) pattern. Subjective style preferences, vague simplifications, and naming nitpicks do not qualify — leave those out entirely or raise in conversation.
|
||||
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
|
||||
|
||||
### Code Quality
|
||||
- Extractable duplicated logic that reduces measurable complexity or improves testability
|
||||
- Patterns that would prevent a recurring bug class in this module
|
||||
- Architecture improvements that eliminate tight coupling with measurable impact
|
||||
- Minor style inconsistencies (if not caught by linter)
|
||||
- Opportunities for DRY improvements
|
||||
- Better variable naming for clarity
|
||||
- Simplification opportunities
|
||||
|
||||
**Example**:
|
||||
```
|
||||
@@ -207,7 +208,7 @@ Could be extracted to separate validator class for reusability and testing.
|
||||
|
||||
---
|
||||
|
||||
## ❓ **QUESTION** (Seeking Clarification)
|
||||
## 💭 **QUESTION** (Seeking Clarification)
|
||||
|
||||
Questions about requirements, unclear intent, or potential conflicts that require human knowledge to answer. Open inquiries that cannot be resolved through code inspection alone.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Effective code review feedback is clear, actionable, and constructive. This guid
|
||||
## Table of Contents
|
||||
|
||||
**Guidelines:**
|
||||
- [Core Directives](#core-directives)
|
||||
- [Phrasing Templates](#phrasing-templates)
|
||||
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
|
||||
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
|
||||
@@ -15,6 +16,17 @@ Effective code review feedback is clear, actionable, and constructive. This guid
|
||||
|
||||
---
|
||||
|
||||
## Core Directives
|
||||
|
||||
- **Keep positive feedback minimal**: For clean PRs with no issues, use 2-3 line approval only. When acknowledging good practices in PRs with issues, use single bullet list with no elaboration. Never create elaborate sections praising correct implementations.
|
||||
- Ask questions for design decisions, be prescriptive for clear violations
|
||||
- Focus on code, not people ("This code..." not "You...")
|
||||
- Use I-statements for subjective feedback ("Hard for me to understand...")
|
||||
- Explain rationale with every recommendation
|
||||
- Avoid: "just", "simply", "obviously", "easy"
|
||||
|
||||
---
|
||||
|
||||
## Phrasing Templates
|
||||
|
||||
### Critical Issues (Prescriptive)
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
---
|
||||
name: testing-android-code
|
||||
description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Testing Android Code - Bitwarden Testing Patterns
|
||||
|
||||
**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Test Framework Configuration
|
||||
|
||||
**Required Dependencies:**
|
||||
- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine)
|
||||
- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test**
|
||||
|
||||
**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales.
|
||||
|
||||
---
|
||||
|
||||
## A. ViewModel Testing Patterns
|
||||
|
||||
### Base Class: BaseViewModelTest
|
||||
|
||||
**Always extend `BaseViewModelTest` for ViewModel tests.**
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
|
||||
|
||||
@Test
|
||||
fun `ButtonClick should fetch data and update state`() = runTest {
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
|
||||
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
viewModel.trySendAction(ExampleAction.ButtonClick)
|
||||
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete examples:** See `references/test-base-classes.md`
|
||||
|
||||
### StateFlow vs EventFlow (Critical Distinction)
|
||||
|
||||
| Flow Type | Replay | First Action | Pattern |
|
||||
|-----------|--------|--------------|---------|
|
||||
| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new |
|
||||
| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event |
|
||||
|
||||
**For detailed patterns:** See `references/flow-testing-patterns.md`
|
||||
|
||||
---
|
||||
|
||||
## B. Compose UI Testing Patterns
|
||||
|
||||
### Base Class: BitwardenComposeTest
|
||||
|
||||
**Always extend `BitwardenComposeTest` for Compose screen tests.**
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed Clock for deterministic time-based tests
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed.
|
||||
|
||||
**For complete base class details:** See `references/test-base-classes.md`
|
||||
|
||||
---
|
||||
|
||||
## C. Repository and Service Testing
|
||||
|
||||
### Service Testing with MockWebServer
|
||||
|
||||
**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`)
|
||||
|
||||
```kotlin
|
||||
class ExampleServiceTest : BaseServiceTest() {
|
||||
private val api: ExampleApi = retrofit.create()
|
||||
private val service = ExampleServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
val result = service.getConfig()
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Testing Pattern
|
||||
|
||||
```kotlin
|
||||
class ExampleRepositoryTest {
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
private val mockDiskSource: ExampleDiskSource = mockk()
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
exampleDiskSource = mockDiskSource,
|
||||
exampleService = mockService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fetchData should return success when service succeeds`() = runTest {
|
||||
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
|
||||
val result = repository.fetchData(userId)
|
||||
assertTrue(result.isSuccess)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers.
|
||||
|
||||
---
|
||||
|
||||
## D. Test Data Builders
|
||||
|
||||
### Builder Pattern with Number Parameter
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/`
|
||||
|
||||
```kotlin
|
||||
fun createMockCipher(
|
||||
number: Int,
|
||||
id: String = "mockId-$number",
|
||||
name: String? = "mockName-$number",
|
||||
// ... more parameters with defaults
|
||||
): SyncResponseJson.Cipher
|
||||
|
||||
// Usage:
|
||||
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
|
||||
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
|
||||
val custom = createMockCipher(number = 3, name = "Custom")
|
||||
```
|
||||
|
||||
**Available Builders (35+):**
|
||||
- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()`
|
||||
- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()`
|
||||
- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()`
|
||||
- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()`
|
||||
- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()`
|
||||
|
||||
See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list.
|
||||
|
||||
---
|
||||
|
||||
## E. Result Type Testing
|
||||
|
||||
**Locations:**
|
||||
- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt`
|
||||
- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt`
|
||||
|
||||
```kotlin
|
||||
// Create results
|
||||
"data".asSuccess() // Result.success("data")
|
||||
throwable.asFailure() // Result.failure<T>(throwable)
|
||||
|
||||
// Assertions
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(expectedValue, result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## F. Test Utilities and Helpers
|
||||
|
||||
### Fake Implementations
|
||||
|
||||
| Fake | Location | Purpose |
|
||||
|------|----------|---------|
|
||||
| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution |
|
||||
| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage |
|
||||
| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences |
|
||||
|
||||
### Exception Testing (CRITICAL)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - Call directly, NOT inside runTest
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<IllegalStateException> {
|
||||
repository.throwingFunction()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern.
|
||||
|
||||
---
|
||||
|
||||
## G. Critical Gotchas
|
||||
|
||||
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
|
||||
|
||||
> **⛔ STOP — `@Suppress("MaxLineLength")`**: Do NOT add this annotation unless the `fun` declaration line **actually exceeds 100 characters**. Count the characters first. Do not copy it from nearby tests. Detekt will tell you if it's needed — when in doubt, leave it off.
|
||||
|
||||
**Core Patterns:**
|
||||
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
|
||||
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
|
||||
- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first
|
||||
- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl`
|
||||
- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests
|
||||
|
||||
**Assertion Patterns:**
|
||||
- **Complete state assertions** - Assert entire state objects, not individual fields
|
||||
- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()`
|
||||
- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions
|
||||
|
||||
**Test Design:**
|
||||
- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths
|
||||
- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic
|
||||
- **Null stream testing** - Test null returns from ContentResolver operations
|
||||
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
|
||||
- **Test factory methods** - Accept domain state types, not SavedStateHandle
|
||||
|
||||
---
|
||||
|
||||
## H. Test File Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
module/src/test/kotlin/com/bitwarden/.../
|
||||
├── ui/*ScreenTest.kt, *ViewModelTest.kt
|
||||
├── data/repository/*RepositoryTest.kt
|
||||
└── network/service/*ServiceTest.kt
|
||||
|
||||
module/src/testFixtures/kotlin/com/bitwarden/.../
|
||||
├── util/TestHelpers.kt
|
||||
├── base/Base*Test.kt
|
||||
└── model/*Util.kt
|
||||
```
|
||||
|
||||
### Test Constants Placement
|
||||
|
||||
Declare test constants as top-level `private const val` at the **bottom** of the file, after the class closing brace. Do NOT use `companion object` for test constants.
|
||||
|
||||
### Test Naming
|
||||
|
||||
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`
|
||||
- Functions: `` `given state when action should result` ``
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Key Bitwarden-specific testing patterns:
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - Consistent `number: Int` parameter pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`
|
||||
|
||||
**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations.
|
||||
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
For detailed information, see:
|
||||
|
||||
- `references/test-base-classes.md` - Detailed base class documentation and usage patterns
|
||||
- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow
|
||||
- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips
|
||||
|
||||
**Complete Examples:**
|
||||
- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow
|
||||
- `examples/compose-screen-test-example.md` - Full Compose screen test
|
||||
- `examples/repository-test-example.md` - Full repository test with mocks and fakes
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* Complete Compose Screen Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BitwardenComposeTest
|
||||
* - Mocking ViewModel with flows
|
||||
* - Testing UI interactions
|
||||
* - Testing navigation callbacks
|
||||
* - Using bufferedMutableSharedFlow for events
|
||||
* - Testing dialogs with isDialog() and hasAnyAncestor()
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.util.assertNoDialogExists
|
||||
import com.bitwarden.ui.util.isProgressBar
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
|
||||
// Track navigation callbacks
|
||||
private var haveCalledNavigateBack = false
|
||||
private var haveCalledNavigateToNext = false
|
||||
|
||||
// Use bufferedMutableSharedFlow for events (default replay = 0)
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
||||
// Mock ViewModel with relaxed = true
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
haveCalledNavigateBack = false
|
||||
haveCalledNavigateToNext = false
|
||||
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
onNavigateToNext = { haveCalledNavigateToNext = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Back button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Submit button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on submit click should send SubmitClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Submit")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading state shows progress indicator
|
||||
*/
|
||||
@Test
|
||||
fun `loading state should display progress indicator`() {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
|
||||
composeTestRule
|
||||
.onNode(isProgressBar)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Data state shows content
|
||||
*/
|
||||
@Test
|
||||
fun `data state should display content`() {
|
||||
mutableStateFlow.update { it.copy(data = "Test Data") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Data")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error state shows error message
|
||||
*/
|
||||
@Test
|
||||
fun `error state should display error message`() {
|
||||
mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Something went wrong")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateBack event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledNavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateToNext event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateToNext event should call onNavigateToNext`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
|
||||
|
||||
assertTrue(haveCalledNavigateToNext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Item in list can be clicked
|
||||
*/
|
||||
@Test
|
||||
fun `on item click should send ItemClick action`() {
|
||||
val itemId = "item-123"
|
||||
mutableStateFlow.update {
|
||||
it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item")))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Item")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) }
|
||||
}
|
||||
|
||||
// ==================== DIALOG TESTS ====================
|
||||
|
||||
/**
|
||||
* Test: No dialog exists when dialogState is null
|
||||
*/
|
||||
@Test
|
||||
fun `no dialog should exist when dialogState is null`() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading dialog displays when state updates
|
||||
* PATTERN: Use isDialog() to check dialog exists
|
||||
*/
|
||||
@Test
|
||||
fun `loading dialog should display when dialogState is Loading`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ExampleState.DialogState.Loading("Please wait..."))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify loading text within dialog using hasAnyAncestor(isDialog())
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Please wait...")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error dialog displays title and message
|
||||
* PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog should display title and message`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "An error has occurred",
|
||||
message = "Something went wrong. Please try again.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify dialog exists
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify title within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify message within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Something went wrong. Please try again.")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Dialog button click sends action
|
||||
* PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog dismiss button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "Error",
|
||||
message = "An error occurred",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click dismiss button within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Confirmation dialog with multiple buttons
|
||||
* PATTERN: Test both confirm and cancel actions
|
||||
*/
|
||||
@Test
|
||||
fun `confirmation dialog confirm button should send ConfirmAction`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure you want to proceed?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click confirm button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Confirm")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmation dialog cancel button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click cancel button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
items = emptyList(),
|
||||
dialogState = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val items: List<ExampleItem> = emptyList(),
|
||||
val dialogState: DialogState? = null,
|
||||
) {
|
||||
/**
|
||||
* PATTERN: Nested sealed class for dialog states.
|
||||
* Common dialog types: Loading, Error, Confirmation
|
||||
*/
|
||||
sealed class DialogState {
|
||||
data class Loading(val message: String) : DialogState()
|
||||
data class Error(val title: String, val message: String) : DialogState()
|
||||
data class Confirmation(val title: String, val message: String) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
data class ExampleItem(val id: String, val name: String)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data class ItemClick(val itemId: String) : ExampleAction()
|
||||
data object DismissDialog : ExampleAction()
|
||||
data object ConfirmAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Complete Repository Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Fake for disk sources, Mock for network services
|
||||
* - Using FakeDispatcherManager for deterministic coroutines
|
||||
* - Using fixed Clock for deterministic time
|
||||
* - Testing Result types with .asSuccess() / .asFailure()
|
||||
* - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
|
||||
* - Testing Flow emissions with Turbine
|
||||
*/
|
||||
package com.bitwarden.example.data.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class ExampleRepositoryTest {
|
||||
|
||||
// Fixed clock for deterministic time-based tests
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
// Use FakeDispatcherManager for deterministic coroutine execution
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
|
||||
// Mock service (network layer is always mocked)
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
/**
|
||||
* PATTERN: Use Fake for disk source in happy path tests.
|
||||
* This is the Bitwarden convention for repository testing.
|
||||
*/
|
||||
private val fakeDiskSource = FakeExampleDiskSource()
|
||||
|
||||
private lateinit var repository: ExampleRepositoryImpl
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
service = mockService,
|
||||
diskSource = fakeDiskSource,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== HAPPY PATH TESTS (use Fake) ====================
|
||||
|
||||
/**
|
||||
* Test: Successful fetch returns data and saves to disk
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return success and save to disk when service succeeds`() = runTest {
|
||||
val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns expectedData.asSuccess()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(expectedData, result.getOrThrow())
|
||||
// Fake automatically stores the data - verify it's there
|
||||
assertEquals(expectedData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Service failure returns failure without saving
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service fails`() = runTest {
|
||||
val exception = Exception("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake was not updated
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Repository flow emits when disk source updates
|
||||
*/
|
||||
@Test
|
||||
fun `dataFlow should emit when disk source updates`() = runTest {
|
||||
val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant())
|
||||
val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial null value from Fake
|
||||
assertNull(awaitItem())
|
||||
|
||||
// Update via Fake property setter (triggers emission)
|
||||
fakeDiskSource.storedData = data1
|
||||
assertEquals(data1, awaitItem())
|
||||
|
||||
// Another update
|
||||
fakeDiskSource.storedData = data2
|
||||
assertEquals(data2, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Refresh fetches and saves new data
|
||||
*/
|
||||
@Test
|
||||
fun `refresh should fetch new data and update disk source`() = runTest {
|
||||
val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns newData.asSuccess()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertEquals(Unit, result.getOrThrow())
|
||||
coVerify { mockService.getData() }
|
||||
assertEquals(newData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Delete clears data from disk
|
||||
*/
|
||||
@Test
|
||||
fun `deleteData should clear disk source`() = runTest {
|
||||
// Pre-populate the fake
|
||||
fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.deleteData()
|
||||
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Cached data returns from disk when available
|
||||
*/
|
||||
@Test
|
||||
fun `getCachedData should return disk data without network call`() = runTest {
|
||||
val cachedData = ExampleData(
|
||||
id = "cached",
|
||||
name = "Cached",
|
||||
updatedAt = fixedClock.instant(),
|
||||
)
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
val result = repository.getCachedData()
|
||||
|
||||
assertEquals(cachedData, result)
|
||||
coVerify(exactly = 0) { mockService.getData() }
|
||||
}
|
||||
|
||||
// ==================== ERROR PATH TESTS ====================
|
||||
|
||||
/**
|
||||
* PATTERN: For error paths, reconfigure the class-level mock per-test.
|
||||
* Use coEvery to change mock behavior for each specific test case.
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service returns error`() = runTest {
|
||||
val exception = Exception("Server unavailable")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake state unchanged on failure
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh should return failure and preserve cached data when service fails`() = runTest {
|
||||
// Pre-populate cache via Fake
|
||||
val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant())
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
// Reconfigure mock to return failure
|
||||
coEvery { mockService.getData() } returns Exception("Network error").asFailure()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
// Cached data preserved on failure
|
||||
assertEquals(cachedData, fakeDiskSource.storedData)
|
||||
}
|
||||
}
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleData(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
interface ExampleService {
|
||||
suspend fun getData(): Result<ExampleData>
|
||||
}
|
||||
|
||||
interface ExampleDiskSource {
|
||||
val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?>
|
||||
fun getData(): ExampleData?
|
||||
fun saveData(data: ExampleData)
|
||||
fun clearData()
|
||||
}
|
||||
|
||||
/**
|
||||
* PATTERN: Fake implementation for happy path testing.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
|
||||
* - Uses .onSubscription { emit(state) } for immediate state emission
|
||||
* - Private storage with override property setter that emits to flow
|
||||
* - Test assertions done via the override property getter
|
||||
*/
|
||||
class FakeExampleDiskSource : ExampleDiskSource {
|
||||
private var storedDataValue: ExampleData? = null
|
||||
private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
|
||||
|
||||
/**
|
||||
* Override property with getter/setter. Setter emits to flow automatically.
|
||||
* Tests can read this property for assertions and write to trigger emissions.
|
||||
*/
|
||||
var storedData: ExampleData?
|
||||
get() = storedDataValue
|
||||
set(value) {
|
||||
storedDataValue = value
|
||||
mutableDataFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val dataFlow: Flow<ExampleData?>
|
||||
get() = mutableDataFlow.onSubscription { emit(storedData) }
|
||||
|
||||
override fun getData(): ExampleData? = storedData
|
||||
|
||||
override fun saveData(data: ExampleData) {
|
||||
storedData = data
|
||||
}
|
||||
|
||||
override fun clearData() {
|
||||
storedData = null
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Complete ViewModel Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BaseViewModelTest
|
||||
* - Testing StateFlow with Turbine
|
||||
* - Testing EventFlow with Turbine
|
||||
* - Using stateEventFlow() for simultaneous testing
|
||||
* - MockK mocking patterns
|
||||
* - Test factory method design (accepts domain state, not SavedStateHandle)
|
||||
* - Complete state assertions (assert entire state objects)
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val mockAuthDiskSource: AuthDiskSource = mockk {
|
||||
every { userStateFlow } returns MutableStateFlow(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* StateFlow has replay=1, so first awaitItem() returns current state
|
||||
*/
|
||||
@Test
|
||||
fun `initial state should be default state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state transitions: initial -> loading -> success
|
||||
*/
|
||||
@Test
|
||||
fun `LoadData action should update state from idle to loading to success`() = runTest {
|
||||
val expectedData = "loaded data"
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.LoadData)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFlow has no replay - MUST call expectNoEvents() first
|
||||
*/
|
||||
@Test
|
||||
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // CRITICAL for EventFlow
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
assertEquals(ExampleEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use stateEventFlow() helper for simultaneous testing
|
||||
*/
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
|
||||
assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state restoration from saved state.
|
||||
* Note: Use initialState parameter, NOT SavedStateHandle directly.
|
||||
*/
|
||||
@Test
|
||||
fun `initial state from saved state should be preserved`() = runTest {
|
||||
// Build complete expected state - always assert full objects
|
||||
val savedState = ExampleState(
|
||||
isLoading = false,
|
||||
data = "restored data",
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(savedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method accepts domain state, NOT SavedStateHandle.
|
||||
* This hides Android framework details from test logic.
|
||||
*/
|
||||
private fun createViewModel(
|
||||
initialState: ExampleState? = null,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
authDiskSource = mockAuthDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object LoadData : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data object ComplexAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
data class ShowToast(val message: String) : ExampleEvent()
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
# Critical Gotchas and Anti-Patterns
|
||||
|
||||
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
|
||||
|
||||
## ❌ NEVER wrap assertCoroutineThrows in runTest
|
||||
|
||||
### The Problem
|
||||
|
||||
`runTest` catches exceptions and rethrows them, which breaks the `assertCoroutineThrows` assertion pattern.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() = runTest {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Won't work - exception is caught by runTest!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Works correctly
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Happens
|
||||
|
||||
`runTest` provides a coroutine scope and catches exceptions to provide better error messages. However, `assertCoroutineThrows` needs to catch the exception itself to verify it was thrown. When wrapped in `runTest`, the exception is caught twice, breaking the assertion.
|
||||
|
||||
## ❌ ALWAYS unmock static functions
|
||||
|
||||
### The Problem
|
||||
|
||||
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
// Forgot @After - subsequent tests will fail mysteriously!
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
|
||||
}
|
||||
```
|
||||
|
||||
### Common Static Functions to Watch
|
||||
|
||||
```kotlin
|
||||
// Platform version checks
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
unmockkStatic(::isBuildVersionAtLeast)
|
||||
|
||||
// URI parsing
|
||||
mockkStatic(Uri::class)
|
||||
unmockkStatic(Uri::class)
|
||||
|
||||
// Static utility functions
|
||||
mockkStatic(MyUtilClass::class)
|
||||
unmockkStatic(MyUtilClass::class)
|
||||
```
|
||||
|
||||
### Debugging Tip
|
||||
|
||||
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
|
||||
|
||||
## ❌ Don't confuse StateFlow and EventFlow testing
|
||||
|
||||
### StateFlow (replay = 1)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - StateFlow always has current value
|
||||
viewModel.stateFlow.test {
|
||||
val initial = awaitItem() // Gets current state immediately
|
||||
viewModel.trySendAction(action)
|
||||
val updated = awaitItem() // Gets new state
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (no replay)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - EventFlow has no initial value
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // MUST do this first
|
||||
viewModel.trySendAction(action)
|
||||
val event = awaitItem() // Gets emitted event
|
||||
}
|
||||
```
|
||||
|
||||
### Common Mistake
|
||||
|
||||
```kotlin
|
||||
// WRONG - Forgetting expectNoEvents() on EventFlow
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May cause flaky tests
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
## ❌ Don't mix real and test dispatchers
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test will have timing issues - real dispatcher != test dispatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test runs deterministically
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
|
||||
- Non-deterministic test failures
|
||||
- Real delays in tests (slow test suite)
|
||||
- Race conditions
|
||||
|
||||
### Always Use
|
||||
|
||||
- `FakeDispatcherManager()` for repositories
|
||||
- `UnconfinedTestDispatcher()` when manually creating dispatchers
|
||||
- `runTest` for coroutine tests (provides TestDispatcher automatically)
|
||||
|
||||
## ❌ Don't forget to use runTest for coroutine tests
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() {
|
||||
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
|
||||
}
|
||||
```
|
||||
|
||||
This causes:
|
||||
- Test completes before coroutines finish
|
||||
- False positives (test passes but assertions never run)
|
||||
- Mysterious failures
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() = runTest {
|
||||
viewModel.stateFlow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### When runTest is Required
|
||||
|
||||
- Testing ViewModels (they use `viewModelScope`)
|
||||
- Testing Flows with Turbine `.test {}`
|
||||
- Testing repositories with suspend functions
|
||||
- Any test calling suspend functions
|
||||
|
||||
### Exception: assertCoroutineThrows
|
||||
|
||||
As noted above, `assertCoroutineThrows` should NOT be wrapped in `runTest`.
|
||||
|
||||
## ❌ Don't forget relaxed = true for complex mocks
|
||||
|
||||
### Without relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
|
||||
|
||||
// Error: "no answer found for: stateFlow"
|
||||
```
|
||||
|
||||
### With relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
// Only mock what you care about
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use relaxed
|
||||
|
||||
- Mocking ViewModels in Compose tests
|
||||
- Mocking complex objects with many methods
|
||||
- When you only care about specific method calls
|
||||
|
||||
### When NOT to Use relaxed
|
||||
|
||||
- Mocking repository interfaces (be explicit about behavior)
|
||||
- When you want to verify NO unexpected calls
|
||||
- Testing error paths (want test to fail if unexpected method called)
|
||||
|
||||
## ❌ Don't assert individual fields when complete state is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Asserting individual state fields can miss unintended side effects on other fields.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals(null, state.dialog) // Only checks one field!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val expected = SomeState(
|
||||
isLoading = false,
|
||||
data = "result",
|
||||
dialog = null,
|
||||
)
|
||||
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Catches unintended mutations to other state fields
|
||||
- Makes expected state explicit and readable
|
||||
- Prevents silent regressions when state structure changes
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use Kotlin assert() for boolean checks
|
||||
|
||||
### The Problem
|
||||
|
||||
Kotlin's `assert()` doesn't follow JUnit conventions and provides poor failure messages.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assert(onNavigateCalled) // Kotlin assert - bad failure messages
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
|
||||
}
|
||||
```
|
||||
|
||||
### Always Use JUnit Assertions
|
||||
|
||||
- `assertTrue()` / `assertFalse()` for booleans
|
||||
- `assertEquals()` for value comparisons
|
||||
- `assertNotNull()` / `assertNull()` for nullability
|
||||
- `assertThrows<T>()` for exceptions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't pass SavedStateHandle to test factory methods
|
||||
|
||||
### The Problem
|
||||
|
||||
Exposing `SavedStateHandle` in test factory methods leaks Android framework details into test logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
|
||||
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
initialState: MyState? = null, // Domain type only
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Cleaner, more intuitive test code
|
||||
- Hides SavedStateHandle implementation details
|
||||
- Follows Bitwarden conventions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't test SavedStateHandle persistence in unit tests
|
||||
|
||||
### The Problem
|
||||
|
||||
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `state should persist to SavedStateHandle`() = runTest {
|
||||
val savedStateHandle = SavedStateHandle()
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
val savedState = savedStateHandle.get<MyState>("state")
|
||||
assertEquals(expectedState, savedState) // Testing framework, not logic!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Focus on testing business logic and state transformations:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state correctly`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use static mocking when DI pattern is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Static mocking (`mockkStatic`) is harder to maintain and less testable than dependency injection.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class ParserTest {
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(UUID::class)
|
||||
every { UUID.randomUUID() } returns mockk {
|
||||
every { toString() } returns "fixed-uuid"
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(UUID::class)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Extract an interface and inject it:
|
||||
|
||||
```kotlin
|
||||
// Production code
|
||||
interface UuidManager {
|
||||
fun generateUuid(): String
|
||||
}
|
||||
|
||||
class UuidManagerImpl : UuidManager {
|
||||
override fun generateUuid(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
class Parser(private val uuidManager: UuidManager) { ... }
|
||||
|
||||
// Test code
|
||||
class ParserTest {
|
||||
private val mockUuidManager = mockk<UuidManager>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
|
||||
}
|
||||
|
||||
// No tearDown needed - no static mocking!
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use This Pattern
|
||||
|
||||
- UUID generation
|
||||
- Timestamp/Clock operations
|
||||
- System property access
|
||||
- Any static function that needs deterministic testing
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't forget to test null stream returns from Android APIs
|
||||
|
||||
### The Problem
|
||||
|
||||
Android's `ContentResolver.openOutputStream()` and `openInputStream()` can return null, not just throw exceptions.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
// Missing: test for null return!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stringToUri with null stream should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } returns null
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result) // CRITICAL: must handle null!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Android APIs That Return Null
|
||||
|
||||
- `ContentResolver.openOutputStream()` / `openInputStream()`
|
||||
- `Context.getExternalFilesDir()`
|
||||
- `PackageManager.getApplicationInfo()` (can throw)
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden Mocking Guidelines
|
||||
|
||||
**Mock at architectural boundaries:**
|
||||
- Repository → ViewModel (mock repository)
|
||||
- Service → Repository (mock service)
|
||||
- API → Service (use MockWebServer, not mocks)
|
||||
- DiskSource → Repository (mock disk source)
|
||||
|
||||
**Fake vs Mock Strategy (IMPORTANT):**
|
||||
- **Happy paths**: Use Fake implementations (`FakeAuthenticatorDiskSource`, `FakeVaultDiskSource`)
|
||||
- **Error paths**: Use MockK with isolated repository instances
|
||||
|
||||
```kotlin
|
||||
// Happy path - use Fake
|
||||
private val fakeDiskSource = FakeAuthenticatorDiskSource()
|
||||
|
||||
@Test
|
||||
fun `createItem should return Success`() = runTest {
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Success, result)
|
||||
}
|
||||
|
||||
// Error path - use isolated Mock
|
||||
@Test
|
||||
fun `createItem with exception should return Error`() = runTest {
|
||||
val mockDiskSource = mockk<AuthenticatorDiskSource> {
|
||||
coEvery { saveItem(any()) } throws RuntimeException()
|
||||
}
|
||||
val repository = RepositoryImpl(diskSource = mockDiskSource)
|
||||
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Error, result)
|
||||
}
|
||||
```
|
||||
|
||||
**Use Fakes for:**
|
||||
- `FakeDispatcherManager` - deterministic coroutines
|
||||
- `FakeConfigDiskSource` - in-memory config storage
|
||||
- `FakeSharedPreferences` - memory-backed preferences
|
||||
- `FakeAuthenticatorDiskSource` - in-memory authenticator storage
|
||||
|
||||
**Create real instances for:**
|
||||
- Data classes, value objects (User, Config, CipherView)
|
||||
- Test data builders (`createMockCipher(number = 1)`)
|
||||
|
||||
## ❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
|
||||
|
||||
### The Problem
|
||||
|
||||
Fake data sources using `MutableSharedFlow` won't emit cached state to new subscribers without explicit handling.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = MutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection gets nothing!
|
||||
repository.dataFlow.test {
|
||||
// Hangs or fails - no initial emission
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
.onSubscription { emit(storedItems.toList()) }
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems.toList())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection receives current state
|
||||
repository.dataFlow.test {
|
||||
assertEquals(emptyList(), awaitItem()) // Works!
|
||||
}
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- Use `bufferedMutableSharedFlow()` from `core/data/repository/util/`
|
||||
- Add `.onSubscription { emit(currentState) }` for immediate state emission
|
||||
- This ensures new collectors receive the current cached state
|
||||
|
||||
---
|
||||
|
||||
## ✅ Use Result extension functions for assertions
|
||||
|
||||
### The Pattern
|
||||
|
||||
Use `asSuccess()` and `asFailure()` extensions from `com.bitwarden.core.data.util` for cleaner Result assertions.
|
||||
|
||||
### Success Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData should return success`() = runTest {
|
||||
val result = repository.getData()
|
||||
val expected = expectedData.asSuccess()
|
||||
|
||||
assertEquals(expected.getOrNull(), result.getOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Failure Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData with error should return failure`() = runTest {
|
||||
val exception = IOException("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.getData()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Redundant Assertions
|
||||
|
||||
```kotlin
|
||||
// WRONG - redundant success checks
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(expected.isSuccess)
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
|
||||
// CORRECT - final assertion is sufficient
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
Before submitting tests, verify:
|
||||
|
||||
**Core Patterns:**
|
||||
- [ ] No `assertCoroutineThrows` inside `runTest`
|
||||
- [ ] All static mocks have `unmockk` in `@After`
|
||||
- [ ] EventFlow tests start with `expectNoEvents()`
|
||||
- [ ] Using FakeDispatcherManager, not real dispatchers
|
||||
- [ ] All coroutine tests use `runTest`
|
||||
|
||||
**Assertion Patterns:**
|
||||
- [ ] Assert complete state objects, not individual fields
|
||||
- [ ] Use JUnit `assertTrue()`, not Kotlin `assert()`
|
||||
- [ ] Use `asSuccess()` for Result type assertions
|
||||
- [ ] Avoid redundant assertion patterns
|
||||
|
||||
**Test Design:**
|
||||
- [ ] Test factory methods accept domain types, not SavedStateHandle
|
||||
- [ ] Use Fakes for happy paths, Mocks for error paths
|
||||
- [ ] Prefer DI patterns over static mocking
|
||||
- [ ] Test null returns from Android APIs (streams, files)
|
||||
- [ ] Fakes use `bufferedMutableSharedFlow()` with `.onSubscription`
|
||||
|
||||
**General:**
|
||||
- [ ] Tests don't depend on execution order
|
||||
- [ ] Complex mocks use `relaxed = true`
|
||||
- [ ] Test data is created fresh for each test
|
||||
- [ ] Mocking behavior, not value objects
|
||||
- [ ] Testing observable behavior, not implementation
|
||||
|
||||
When tests fail mysteriously, check these gotchas first.
|
||||
@@ -1,274 +0,0 @@
|
||||
# Flow Testing with Turbine
|
||||
|
||||
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
|
||||
|
||||
## StateFlow vs EventFlow
|
||||
|
||||
### StateFlow (Replayed)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 1` - Always emits current value to new collectors
|
||||
- First `awaitItem()` returns the current/initial state
|
||||
- Survives configuration changes
|
||||
- Used for UI state that needs to be immediately available
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
// First awaitItem() gets CURRENT state
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.LoadData)
|
||||
|
||||
// Next awaitItem() gets UPDATED state
|
||||
assertEquals(LOADING_STATE, awaitItem())
|
||||
assertEquals(SUCCESS_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (No Replay)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 0` - Only emits new events after subscription
|
||||
- No initial value emission
|
||||
- One-time events (navigation, toasts, dialogs)
|
||||
- Does not survive configuration changes
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
// MUST call expectNoEvents() first - nothing emitted yet
|
||||
expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
// Now expect the event
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Always call `expectNoEvents()` before triggering actions on EventFlow. Forgetting this causes flaky tests.
|
||||
|
||||
## Testing State and Events Simultaneously
|
||||
|
||||
Use the `stateEventFlow()` helper from `BaseViewModelTest`:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.ComplexAction)
|
||||
|
||||
// Verify state progression
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emission
|
||||
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Repository Flow Testing
|
||||
|
||||
### Testing Database Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `dataFlow should emit when database updates`() = runTest {
|
||||
val dataFlow = MutableStateFlow(initialData)
|
||||
every { mockDiskSource.dataFlow } returns dataFlow
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial value
|
||||
assertEquals(initialData, awaitItem())
|
||||
|
||||
// Update disk source
|
||||
dataFlow.value = updatedData
|
||||
|
||||
// Verify emission
|
||||
assertEquals(updatedData, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Transformed Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `flow transformation should map correctly`() = runTest {
|
||||
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
|
||||
every { mockDao.observeUser() } returns sourceFlow
|
||||
|
||||
// Repository transforms entity to domain model
|
||||
repository.userFlow.test {
|
||||
val expectedUser = User(id = "1", name = "John")
|
||||
assertEquals(expectedUser, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Testing Initial State + Action
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data should update from idle to loading to success`() = runTest {
|
||||
coEvery { repository.getData() } returns "data".asSuccess()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Testing Error States
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data with error should emit failure state`() = runTest {
|
||||
val error = Exception("Network error")
|
||||
coEvery { repository.getData() } returns error.asFailure()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Testing Event Sequences
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `submit should emit validation then navigation events`() = runTest {
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
assertEquals(MyEvent.ShowValidation, awaitItem())
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Testing Cancellation
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `cancelling collection should stop emissions`() = runTest {
|
||||
val flow = flow {
|
||||
repeat(100) {
|
||||
emit(it)
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
flow.test {
|
||||
assertEquals(0, awaitItem())
|
||||
assertEquals(1, awaitItem())
|
||||
|
||||
// Cancel after 2 items
|
||||
cancel()
|
||||
|
||||
// No more items received
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Forgetting expectNoEvents() on EventFlow
|
||||
|
||||
```kotlin
|
||||
// WRONG
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // ALWAYS do this first
|
||||
viewModel.trySendAction(action)
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Not Using runTest
|
||||
|
||||
```kotlin
|
||||
// WRONG - Missing runTest
|
||||
@Test
|
||||
fun `test flow`() {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
@Test
|
||||
fun `test flow`() = runTest {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mixing StateFlow and EventFlow Patterns
|
||||
|
||||
```kotlin
|
||||
// WRONG - Treating StateFlow like EventFlow
|
||||
stateFlow.test {
|
||||
expectNoEvents() // Unnecessary - StateFlow always has value
|
||||
/* ... */
|
||||
}
|
||||
|
||||
// WRONG - Treating EventFlow like StateFlow
|
||||
eventFlow.test {
|
||||
val item = awaitItem() // Will hang - no initial value!
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
**ViewModel with StateFlow and EventFlow:**
|
||||
`app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
**Repository Flow Testing:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt`
|
||||
|
||||
**Complex Flow Transformations:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt`
|
||||
@@ -1,259 +0,0 @@
|
||||
# Test Base Classes Reference
|
||||
|
||||
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
|
||||
|
||||
## BaseViewModelTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
|
||||
|
||||
### Automatic Configuration
|
||||
- Registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Ensures deterministic coroutine execution in tests
|
||||
- All coroutines complete immediately without real delays
|
||||
|
||||
### Key Feature: stateEventFlow() Helper
|
||||
|
||||
**Use Case:** When you need to test both StateFlow and EventFlow simultaneously.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Verify initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
// Verify state updated
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emitted
|
||||
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: MyRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to INITIAL_STATE)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test action`() = runTest {
|
||||
val viewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository
|
||||
)
|
||||
|
||||
// Test with automatic dispatcher setup
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BitwardenComposeTest
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
|
||||
|
||||
### Automatic Configuration
|
||||
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed `Clock` for deterministic time-based tests
|
||||
- Extends `BaseComposeTest` for Robolectric and dispatcher setup
|
||||
|
||||
### Key Features
|
||||
|
||||
**Pre-configured Managers:**
|
||||
- `FeatureFlagManager` - Controls feature flag behavior
|
||||
- `AuthTabManager` - Manages auth tab state
|
||||
- `BiometricsManager` - Handles biometric authentication
|
||||
- `ClipboardManager` - Clipboard operations
|
||||
- `NotificationManager` - Notification display
|
||||
|
||||
**Fixed Clock:**
|
||||
All tests use a fixed clock for deterministic time-based testing:
|
||||
```kotlin
|
||||
// Tests use consistent time: 2023-10-27T12:00:00Z
|
||||
val fixedClock: Clock
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<MyViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
MyScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(MyAction.BackClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading state should show progress`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
|
||||
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important: bufferedMutableSharedFlow for Events
|
||||
|
||||
In Compose tests, use `bufferedMutableSharedFlow` instead of regular `MutableSharedFlow` (default replay is 0):
|
||||
|
||||
```kotlin
|
||||
// Correct for Compose tests
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
|
||||
// This allows triggering events and having the UI react
|
||||
mutableEventFlow.tryEmit(MyEvent.NavigateBack)
|
||||
```
|
||||
|
||||
## BaseServiceTest
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides MockWebServer setup for testing API service implementations.
|
||||
|
||||
### Automatic Configuration
|
||||
- `server: MockWebServer` - Auto-started before each test, stopped after
|
||||
- `retrofit: Retrofit` - Pre-configured with:
|
||||
- JSON converter (kotlinx.serialization)
|
||||
- NetworkResultCallAdapter for Result<T> responses
|
||||
- Base URL pointing to MockWebServer
|
||||
- `json: Json` - kotlinx.serialization JSON instance
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyServiceTest : BaseServiceTest() {
|
||||
private val api: MyApi = retrofit.create()
|
||||
private val service = MyServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
// Enqueue mock response
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify result
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConfig should return failure when API fails`() = runTest {
|
||||
// Enqueue error response
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify failure
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MockWebServer Patterns
|
||||
|
||||
**Enqueue successful response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))
|
||||
```
|
||||
|
||||
**Enqueue error response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
```
|
||||
|
||||
**Enqueue delayed response:**
|
||||
```kotlin
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody("""{"key": "value"}""")
|
||||
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
|
||||
)
|
||||
```
|
||||
|
||||
**Verify request details:**
|
||||
```kotlin
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/config", request.path)
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("Bearer token", request.getHeader("Authorization"))
|
||||
```
|
||||
|
||||
## BaseComposeTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Base class for Compose tests that extends `BaseRobolectricTest` and provides `setTestContent()` helper.
|
||||
|
||||
### Features
|
||||
- Robolectric configuration for Compose
|
||||
- Proper dispatcher setup
|
||||
- `composeTestRule` for UI testing
|
||||
- `setTestContent()` helper wraps content in theme
|
||||
|
||||
### Usage
|
||||
Typically you'll extend `BitwardenComposeTest` which extends this class. Use `BaseComposeTest` directly only for tests that don't need Bitwarden-specific manager configuration.
|
||||
|
||||
## When to Use Each Base Class
|
||||
|
||||
| Test Type | Base Class | Use When |
|
||||
|-----------|------------|----------|
|
||||
| ViewModel tests | `BaseViewModelTest` | Testing ViewModel state and events |
|
||||
| Compose screen tests | `BitwardenComposeTest` | Testing Compose UI with Bitwarden components |
|
||||
| API service tests | `BaseServiceTest` | Testing network layer with MockWebServer |
|
||||
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
|
||||
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
|
||||
|
||||
## Complete Examples
|
||||
|
||||
**ViewModel Test:**
|
||||
`../examples/viewmodel-test-example.md`
|
||||
|
||||
**Compose Screen Test:**
|
||||
`../examples/compose-screen-test-example.md`
|
||||
|
||||
**Repository Test:**
|
||||
`../examples/repository-test-example.md`
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,3 +9,27 @@
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## ⏰ Reminders before review
|
||||
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Protected functional changes with optionality (feature flags)
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
||||
23
.github/actions/setup-android-build/action.yml
vendored
23
.github/actions/setup-android-build/action.yml
vendored
@@ -8,8 +8,27 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
|
||||
21
.github/label-pr.json
vendored
21
.github/label-pr.json
vendored
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"title_patterns": {
|
||||
"t:feature": ["feat", "feature", "tool"],
|
||||
"t:feature-app": ["feat", "feature"],
|
||||
"t:feature-tool": ["tool"],
|
||||
"t:bug": ["fix", "bug", "bugfix"],
|
||||
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
|
||||
"t:docs": ["docs"],
|
||||
"t:ci": ["ci", "build", "chore(ci)"],
|
||||
"t:deps": ["deps"],
|
||||
"t:breaking-change": ["breaking", "breaking-change"],
|
||||
"t:misc": ["misc"],
|
||||
"t:llm": ["llm"]
|
||||
"t:misc": ["misc"]
|
||||
},
|
||||
"path_patterns": {
|
||||
"app:shared": [
|
||||
@@ -28,14 +28,12 @@
|
||||
"app:authenticator": [
|
||||
"authenticator/"
|
||||
],
|
||||
"t:feature": [
|
||||
"app/src/main/assets/fido2_privileged_community.json",
|
||||
"app/src/main/assets/fido2_privileged_google.json",
|
||||
"t:feature-tool": [
|
||||
"testharness/"
|
||||
],
|
||||
"t:tech-debt": [
|
||||
"gradle.properties",
|
||||
"keystore/"
|
||||
"t:feature-app": [
|
||||
"app/src/main/assets/fido2_privileged_community.json",
|
||||
"app/src/main/assets/fido2_privileged_google.json"
|
||||
],
|
||||
"t:ci": [
|
||||
".checkmarx/",
|
||||
@@ -43,6 +41,7 @@
|
||||
"scripts/",
|
||||
"fastlane/",
|
||||
".gradle/",
|
||||
".claude/",
|
||||
"detekt-config.yml"
|
||||
],
|
||||
"t:docs": [
|
||||
@@ -51,8 +50,8 @@
|
||||
"t:deps": [
|
||||
"gradle/"
|
||||
],
|
||||
"t:llm": [
|
||||
".claude/"
|
||||
"t:misc": [
|
||||
"keystore/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
7
.github/release.yml
vendored
7
.github/release.yml
vendored
@@ -6,13 +6,14 @@ changelog:
|
||||
- title: '✨ Community Highlight'
|
||||
labels:
|
||||
- community-pr
|
||||
- title: ':shipit: Feature Development'
|
||||
- title: '🚀 New Features & Enhancements'
|
||||
labels:
|
||||
- t:feature
|
||||
- t:feature-app
|
||||
- t:feature-tool
|
||||
- t:new-feature
|
||||
- t:enhancement
|
||||
- title: ':shipit: Tools'
|
||||
labels:
|
||||
- t:feature-tool
|
||||
- title: '❗ Breaking Changes'
|
||||
labels:
|
||||
- t:breaking-change
|
||||
|
||||
15
.github/renovate.json
vendored
15
.github/renovate.json
vendored
@@ -3,7 +3,6 @@
|
||||
"extends": [
|
||||
"github>bitwarden/renovate-config"
|
||||
],
|
||||
"ignoreDeps": ["com.bitwarden:sdk-android"],
|
||||
"enabledManagers": [
|
||||
"github-actions",
|
||||
"gradle",
|
||||
@@ -20,6 +19,20 @@
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "gradle minor",
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"matchManagers": [
|
||||
"gradle"
|
||||
],
|
||||
"excludePackageNames": [
|
||||
"com.github.bumptech.glide:compose",
|
||||
"com.bitwarden:sdk-android"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "kotlin",
|
||||
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",
|
||||
|
||||
150
.github/scripts/gh_release_update_issues.py
vendored
150
.github/scripts/gh_release_update_issues.py
vendored
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Requires Python 3.9+
|
||||
"""
|
||||
Comment GitHub issues linked to Pull Requests mentioned in a given release.
|
||||
|
||||
Usage:
|
||||
python gh_release_update_issues.py <release_url> [--dry-run]
|
||||
|
||||
Arguments:
|
||||
release-url: The URL of the release to comment on
|
||||
--dry-run: Run without actually updating issues
|
||||
|
||||
Examples:
|
||||
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0
|
||||
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0 --dry-run
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import json
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
def parse_release_url(release_url: str) -> Tuple[str, str, str]:
|
||||
"""Extract owner, repo name, and tag from a GitHub release URL.
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo_name, release_tag)
|
||||
"""
|
||||
match = re.search(r'github\.com/([\w-]+)/([\w.-]+)/releases/tag/(.+)$', release_url)
|
||||
if not match:
|
||||
raise ValueError(f"Cannot parse release URL: {release_url}")
|
||||
return match.group(1), match.group(2), match.group(3)
|
||||
|
||||
def extract_pr_numbers(release_notes: str) -> List[int]:
|
||||
return [int(n) for n in re.findall(r'/pull/(\d+)', release_notes)]
|
||||
|
||||
def build_issue_comment(repo: str, release_name: str, release_link: str, pr_numbers: List[int]) -> str:
|
||||
if len(pr_numbers) == 0:
|
||||
return ""
|
||||
|
||||
pr_links = [f"* https://github.com/{repo}/pull/{pr_number}" for pr_number in pr_numbers]
|
||||
|
||||
return f":shipit: Pull Request(s) linked to this issue released in [{release_name}]({release_link}):\n\n"+ "\n".join(pr_links)
|
||||
|
||||
def gh_fetch_release(repo: str, release_tag: str) -> Tuple[str, str]:
|
||||
result = subprocess.run(
|
||||
['gh', 'release', 'view', release_tag, '--repo', repo, '--json', 'name,body'],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
return data['name'], data['body']
|
||||
|
||||
def gh_comment_issue(repo: str, issue_number: int, comment: str) -> None:
|
||||
"""Use GitHub CLI to comment on an issue.
|
||||
"""
|
||||
subprocess.run([
|
||||
'gh', 'issue', 'comment', str(issue_number), '--body', comment, '--repo', repo
|
||||
], check=True)
|
||||
|
||||
def gh_fetch_linked_issues_batched(owner: str, repo_name: str, pr_numbers: List[int]) -> Dict[int, List[int]]:
|
||||
"""Batch-fetch linked issues for all PRs in a single GraphQL call.
|
||||
|
||||
Returns:
|
||||
Dict mapping each PR number to its list of linked issue numbers.
|
||||
"""
|
||||
if not pr_numbers:
|
||||
return {}
|
||||
|
||||
tmpl = 'pr_%d: pullRequest(number: %d) { closingIssuesReferences(first: 100) { nodes { number } } }'
|
||||
pr_fragments = "\n".join(tmpl % (pr, pr) for pr in pr_numbers)
|
||||
query = """
|
||||
query ($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
%s
|
||||
}
|
||||
}
|
||||
""" % pr_fragments
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
'gh', 'api', 'graphql',
|
||||
'-F', f'owner={owner}',
|
||||
'-F', f'repo={repo_name}',
|
||||
'-f', f'query={query}',
|
||||
],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
repo_data = data['data']['repository']
|
||||
|
||||
pr_issues_map: Dict[int, List[int]] = {}
|
||||
for pr_number in pr_numbers:
|
||||
nodes = repo_data.get(f'pr_{pr_number}', {}).get('closingIssuesReferences', {}).get('nodes', [])
|
||||
pr_issues = [node['number'] for node in nodes]
|
||||
pr_issues_map[pr_number] = pr_issues
|
||||
return pr_issues_map
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"::error::Error batch-fetching linked issues: {e.stderr}")
|
||||
raise
|
||||
|
||||
def map_issues_to_prs(pr_issues_map: Dict[int, List[int]]) -> Dict[int, List[int]]:
|
||||
"""Invert a PR->issues map into an issue->PRs map."""
|
||||
issue_pr_map: Dict[int, List[int]] = defaultdict(list)
|
||||
for pr_number, issue_numbers in pr_issues_map.items():
|
||||
for issue_number in issue_numbers:
|
||||
issue_pr_map[issue_number].append(pr_number)
|
||||
return dict(issue_pr_map)
|
||||
|
||||
def comment_issues(repo: str, issue_pr_map: Dict[int, List[int]], release_name: str, release_url: str, dry_run: bool) -> None:
|
||||
for issue_number, linked_prs in issue_pr_map.items():
|
||||
comment = build_issue_comment(repo, release_name, release_url, linked_prs)
|
||||
print(f"{'Dry run - ' if dry_run else ''}Commenting on issue {issue_number}:\n{comment}\n")
|
||||
if not dry_run and comment:
|
||||
gh_comment_issue(repo, issue_number, comment)
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Comment GitHub issues linked to Pull Requests mentioned in a given release.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'release_url',
|
||||
help='Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Run without actually commenting issues'
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_args()
|
||||
|
||||
owner, repo_name, release_tag = parse_release_url(args.release_url)
|
||||
repo = f"{owner}/{repo_name}"
|
||||
print(f"📋 Release URL: {args.release_url}")
|
||||
|
||||
release_name, release_notes = gh_fetch_release(repo, release_tag)
|
||||
print(f"📋 Release Name: {release_name}")
|
||||
|
||||
pr_numbers = extract_pr_numbers(release_notes)
|
||||
print(f"📋 PR Numbers parsed from release notes: {pr_numbers}")
|
||||
pr_issues_map = gh_fetch_linked_issues_batched(owner, repo_name, pr_numbers)
|
||||
print(f"📋 PRs with linked issues: {[pr for pr, issues in pr_issues_map.items() if issues]}\n")
|
||||
issue_pr_map = map_issues_to_prs(pr_issues_map)
|
||||
comment_issues(repo, issue_pr_map, release_name, args.release_url, args.dry_run)
|
||||
23
.github/scripts/set-build-version.sh
vendored
23
.github/scripts/set-build-version.sh
vendored
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Runs fastlane setBuildVersionInfo and appends Version Name/Number to GITHUB_STEP_SUMMARY.
|
||||
# Usage: set-build-version.sh <version_code> [version_name] [toml_path]
|
||||
|
||||
VERSION_CODE="${1:?Usage: $0 <version_code> [version_name] [toml_path]}"
|
||||
VERSION_NAME="${2:-}"
|
||||
TOML_FILE="${3:-gradle/libs.versions.toml}"
|
||||
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME"
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
VERSION_NAME=""
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat "$TOML_FILE")" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
2
.github/workflows/_version.yml
vendored
2
.github/workflows/_version.yml
vendored
@@ -167,7 +167,7 @@ jobs:
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload version info artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: version-info
|
||||
path: version_info.json
|
||||
|
||||
129
.github/workflows/build-authenticator.yml
vendored
129
.github/workflows/build-authenticator.yml
vendored
@@ -31,6 +31,7 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
|
||||
@@ -49,10 +50,70 @@ jobs:
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build Authenticator
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
uses: bitwarden/android/.github/actions/log-inputs@main
|
||||
with:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
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: Check Authenticator
|
||||
run: bundle exec fastlane check
|
||||
|
||||
- name: Build Authenticator
|
||||
run: bundle exec fastlane buildAuthenticatorDebug
|
||||
|
||||
publish_playstore:
|
||||
name: Publish Authenticator Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -67,6 +128,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -126,15 +197,40 @@ jobs:
|
||||
- name: AZ Logout
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key \
|
||||
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
./scripts/update_app_ci_build_info.sh \
|
||||
@@ -146,9 +242,22 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
@@ -176,7 +285,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.aab
|
||||
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
|
||||
@@ -184,7 +293,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.apk
|
||||
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
|
||||
@@ -204,7 +313,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: authenticator-android-apk-sha256.txt
|
||||
path: ./authenticator-android-apk-sha256.txt
|
||||
@@ -212,7 +321,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
|
||||
64
.github/workflows/build-testharness.yml
vendored
64
.github/workflows/build-testharness.yml
vendored
@@ -20,6 +20,7 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -52,20 +53,69 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "(.+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build Test Harness Debug APK
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
- name: Upload Test Harness APK
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev-debug.apk
|
||||
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
|
||||
@@ -77,7 +127,7 @@ jobs:
|
||||
> ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
- name: Upload Test Harness SHA file
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
path: ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
198
.github/workflows/build.yml
vendored
198
.github/workflows/build.yml
vendored
@@ -31,6 +31,7 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
@@ -51,10 +52,77 @@ jobs:
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
uses: bitwarden/android/.github/actions/log-inputs@main
|
||||
with:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
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: Check
|
||||
run: bundle exec fastlane check
|
||||
|
||||
- name: Build
|
||||
run: bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload test reports on failure
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
|
||||
publish_playstore:
|
||||
name: Publish Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -69,6 +137,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -121,8 +199,33 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -135,9 +238,13 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
|
||||
@@ -192,7 +299,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab
|
||||
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
|
||||
@@ -200,7 +307,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab
|
||||
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
|
||||
@@ -208,7 +315,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk
|
||||
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
|
||||
@@ -216,7 +323,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk
|
||||
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
|
||||
@@ -225,7 +332,7 @@ jobs:
|
||||
# When building variants other than 'prod'
|
||||
- name: Upload to GitHub Artifacts - dev.apk
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
|
||||
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
|
||||
@@ -263,7 +370,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.apk-sha256.txt
|
||||
@@ -271,7 +378,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
@@ -279,7 +386,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.aab-sha256.txt
|
||||
@@ -287,7 +394,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
@@ -295,7 +402,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
@@ -338,6 +445,7 @@ jobs:
|
||||
name: Publish F-Droid artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -347,6 +455,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -385,8 +503,33 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -399,9 +542,20 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Generate F-Droid artifacts
|
||||
env:
|
||||
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
|
||||
@@ -424,7 +578,7 @@ jobs:
|
||||
keyPassword:$FDROID_BETA_KEY_PASSWORD
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
|
||||
@@ -436,14 +590,14 @@ jobs:
|
||||
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
|
||||
@@ -455,7 +609,7 @@ jobs:
|
||||
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
2
.github/workflows/crowdin-pull.yml
vendored
2
.github/workflows/crowdin-pull.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
|
||||
@@ -18,7 +18,6 @@ jobs:
|
||||
workflow_name: "publish-github-release-bwa.yml"
|
||||
credentials_filename: "authenticator_play_store-creds.json"
|
||||
project_type: android
|
||||
make_latest: false
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
|
||||
secrets: inherit
|
||||
|
||||
@@ -19,7 +19,6 @@ jobs:
|
||||
workflow_name: "publish-github-release-bwpm.yml"
|
||||
credentials_filename: "play_creds.json"
|
||||
project_type: android
|
||||
make_latest: true
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
|
||||
secrets: inherit
|
||||
|
||||
64
.github/workflows/sdlc-enforce-labels.yml
vendored
64
.github/workflows/sdlc-enforce-labels.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: SDLC / Enforce PR labels
|
||||
run-name: Enforce labels for PR ${{ github.event.pull_request.number }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, reopened, edited, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
enforce-label:
|
||||
name: Enforce Label
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Enforce banned labels (e.g. hold, needs-qa)
|
||||
env:
|
||||
_HOLD_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'hold') }}
|
||||
_NEEDS_QA_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'needs-qa') }}
|
||||
run: |
|
||||
if [ "$_HOLD_LABEL" = "true" ]; then
|
||||
echo "::error::PR has banned label: hold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$_NEEDS_QA_LABEL" = "true" ]; then
|
||||
echo "::error::PR has banned label: needs-qa"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No banned labels found."
|
||||
|
||||
- name: Enforce exactly one Change Type (t:*) label
|
||||
env:
|
||||
_PR_ACTION: ${{ github.event.action }}
|
||||
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
|
||||
_REPO: ${{ github.repository }}
|
||||
_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "$_PR_ACTION" = "opened" ] || [ "$_PR_ACTION" = "reopened" ]; then
|
||||
echo "⏳ Waiting 15s for labeler to run..."
|
||||
sleep 15
|
||||
_PR_LABELS=$(gh api "repos/$_REPO/pulls/$_PR_NUMBER" --jq '.labels')
|
||||
echo "Labels fetched from PR: $_PR_LABELS"
|
||||
fi
|
||||
_IGNORE_FOR_RELEASE_LABEL=$(echo "$_PR_LABELS" | jq 'any(.[]; .name == "ignore-for-release")')
|
||||
if [ "$_IGNORE_FOR_RELEASE_LABEL" = "true" ]; then
|
||||
echo "⏭️ Skipping type label check - 'ignore-for-release' label present"
|
||||
exit 0
|
||||
fi
|
||||
_T_LABEL_COUNT=$(echo "$_PR_LABELS" | jq '[.[] | select(.name | startswith("t:"))] | length')
|
||||
case "$_T_LABEL_COUNT" in
|
||||
1)
|
||||
echo "✅ PR has exactly one Change Type (t:*) label"
|
||||
;;
|
||||
0)
|
||||
echo "::error::PR is missing a Change Type (t:*) label. PRs must have exactly one Change Type (t:*) label"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::PR has $_T_LABEL_COUNT Change Type (t:*) labels. PRs must have exactly one Change Type (t:*) label"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,37 +0,0 @@
|
||||
name: SDLC / Update Linked Issues on Release
|
||||
run-name: ${{ inputs.dry-run && '(Dry Run) ' || '' }}Update Linked Issues on Release - ${{ github.event.release.name || inputs.release_url }}
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_url:
|
||||
description: 'Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
|
||||
required: true
|
||||
dry-run:
|
||||
description: 'Dry run'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
update-linked-issues:
|
||||
name: Update Linked Issues
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update Linked Issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_RELEASE_URL: ${{ github.event.release.html_url || inputs.release_url }}
|
||||
_DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }}
|
||||
run: |
|
||||
python3 .github/scripts/gh_release_update_issues.py "$_RELEASE_URL" $_DRY_RUN
|
||||
2
.github/workflows/sdlc-sdk-update.yml
vendored
2
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
|
||||
176
.github/workflows/test.yml
vendored
176
.github/workflows/test.yml
vendored
@@ -3,8 +3,9 @@ name: Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/**/*
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
merge_group:
|
||||
@@ -12,45 +13,16 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
_JAVA_VERSION: 21
|
||||
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
|
||||
|
||||
jobs:
|
||||
test-sharded:
|
||||
name: "Test ${{ matrix.group }}"
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: static-analysis
|
||||
fastlane_method: checkLint
|
||||
fastlane_options: ""
|
||||
# App shards
|
||||
- group: app-data
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.data.*"
|
||||
- group: app-ui-auth-tools
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.auth.* --tests com.x8bit.bitwarden.ui.tools.* --tests com.x8bit.bitwarden.ui.autofill.* --tests com.x8bit.bitwarden.ui.credentials.*"
|
||||
- group: app-ui-platform
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.platform.*"
|
||||
- group: app-ui-vault
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.vault.*"
|
||||
# Authenticator
|
||||
- group: authenticator
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":authenticator"
|
||||
# Library shards
|
||||
- group: lib-core-network-bridge
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":core :network :cxf :authenticatorbridge :testharness"
|
||||
- group: lib-data-ui
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":data :ui"
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
@@ -58,101 +30,87 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_GROUP: ${{ matrix.group }}
|
||||
_FASTLANE_METHOD: ${{ matrix.fastlane_method }}
|
||||
_FASTLANE_OPTIONS: ${{ matrix.fastlane_options }}
|
||||
- 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: |
|
||||
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
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
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')
|
||||
- name: Build and test
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
|
||||
run: |
|
||||
bundle exec fastlane generateCoverageReport
|
||||
bundle exec fastlane check
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
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/
|
||||
|
||||
- name: Upload to codecov.io
|
||||
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
id: upload-to-codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports-${{ matrix.group }}
|
||||
path: |
|
||||
**/build/reports/tests/
|
||||
app/build/reports/lint-results-*.html
|
||||
app/build/reports/detekt/
|
||||
if-no-files-found: warn
|
||||
|
||||
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@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
run_command: send-notifications
|
||||
|
||||
- name: Comment PR if coverage notification failed
|
||||
if: steps.codecov-notify.outcome == 'failure'
|
||||
- name: Comment PR if tests failed
|
||||
if: steps.upload-to-codecov.outcome == 'failure' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RUN_ACTOR: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
echo "> [!WARNING]" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "> Uploading code coverage report failed. Please check the \"Notify Codecov\" step for more details." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Coverage Notification" step of [Test]('$_GITHUB_ACTION_RUN_URL') for more details.'
|
||||
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Upload to codecov.io" step of [Process Test Reports job]('$_GITHUB_ACTION_RUN_URL') for more details.'
|
||||
gh pr comment --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --body "$message"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-24.04
|
||||
permissions: {}
|
||||
needs: test-sharded
|
||||
if: always()
|
||||
steps:
|
||||
- name: Ensure sharded tests passed
|
||||
env:
|
||||
TESTS_RESULT: ${{ needs.test-sharded.result }}
|
||||
run: |
|
||||
if [ "$TESTS_RESULT" != "success" ]; then
|
||||
echo "❌ Tests failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All tests passed!"
|
||||
|
||||
32
Gemfile.lock
32
Gemfile.lock
@@ -3,13 +3,13 @@ GEM
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1237.0)
|
||||
aws-sdk-core (3.244.0)
|
||||
aws-partitions (1.1206.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -17,18 +17,18 @@ GEM
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.123.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.219.0)
|
||||
aws-sdk-core (~> 3, >= 3.244.0)
|
||||
aws-sdk-s3 (1.212.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (4.1.1)
|
||||
bigdecimal (4.0.1)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
@@ -43,7 +43,7 @@ GEM
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@@ -68,10 +68,10 @@ GEM
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.4)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.1)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.229.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
@@ -148,7 +148,7 @@ GEM
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.6.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
@@ -169,13 +169,13 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.19.3)
|
||||
json (2.18.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.20.1)
|
||||
multi_json (1.19.1)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
@@ -185,13 +185,13 @@ GEM
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.5)
|
||||
public_suffix (7.0.2)
|
||||
rake (13.3.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.4.1)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> {
|
||||
android {
|
||||
namespace = "com.bitwarden.annotation"
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdk.get().toInt())
|
||||
}
|
||||
minSdk = libs.versions.minSdkBwa.get().toInt()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
@@ -40,6 +37,6 @@ configure<LibraryExtension> {
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
}
|
||||
}
|
||||
|
||||
21
annotation/proguard-rules.pro
vendored
21
annotation/proguard-rules.pro
vendored
@@ -1,21 +0,0 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -1,10 +1,9 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
import com.android.utils.cxx.io.removeExtensionIfPresent
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFileIdTask
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
|
||||
import com.google.gms.googleservices.GoogleServicesTask
|
||||
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import dagger.hilt.android.plugin.util.capitalize
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
@@ -16,6 +15,7 @@ plugins {
|
||||
// standardDebug builds in the merged manifest.
|
||||
alias(libs.plugins.crashlytics)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose.compiler)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
@@ -43,35 +43,27 @@ val ciProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
base {
|
||||
// Set the base archive name for publishing purposes. This is used to derive the
|
||||
// APK and AAB artifact names when uploading to Firebase and Play Store.
|
||||
archivesName.set("com.x8bit.bitwarden")
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
configure<ApplicationExtension> {
|
||||
android {
|
||||
namespace = "com.x8bit.bitwarden"
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.x8bit.bitwarden"
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdk.get().toInt())
|
||||
}
|
||||
targetSdk {
|
||||
version = release(libs.versions.targetSdk.get().toInt())
|
||||
}
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = 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",
|
||||
@@ -149,6 +141,39 @@ configure<ApplicationExtension> {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -175,50 +200,9 @@ configure<ApplicationExtension> {
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { appVariant ->
|
||||
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
|
||||
val applicationId = appVariant.applicationId.get()
|
||||
val flavorName = appVariant.flavorName
|
||||
val variantName = appVariant.name
|
||||
val buildType = appVariant.buildType
|
||||
appVariant
|
||||
.outputs
|
||||
.mapNotNull { it as? VariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val fileNameWithoutExtension = when (flavorName) {
|
||||
"fdroid" -> "$applicationId-$flavorName"
|
||||
"standard" -> applicationId
|
||||
else -> output.outputFileName.get().removeExtensionIfPresent(".apk")
|
||||
}
|
||||
|
||||
// Set the APK output filename.
|
||||
output.outputFileName.set("$fileNameWithoutExtension.apk")
|
||||
|
||||
val renameTaskName = "rename${variantName.uppercaseFirstChar()}AabFiles"
|
||||
tasks.register(renameTaskName) {
|
||||
group = "build"
|
||||
description = "Renames the bundle files for $variantName variant"
|
||||
doLast {
|
||||
val namespace = appVariant.namespace.get()
|
||||
renameFile(
|
||||
"$bundlesDir/$variantName/$namespace-$flavorName-$buildType.aab",
|
||||
"$fileNameWithoutExtension.aab",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Force renaming task to execute after the variant is built.
|
||||
val bundleTaskName = "bundle${variantName.uppercaseFirstChar()}"
|
||||
tasks
|
||||
.named { it == bundleTaskName }
|
||||
.configureEach { finalizedBy(renameTaskName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
jvmTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,8 +260,6 @@ dependencies {
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.bitwarden.sdk)
|
||||
implementation(libs.bumptech.glide)
|
||||
implementation(libs.bumptech.glide.okhttp)
|
||||
ksp(libs.bumptech.glide.compiler)
|
||||
implementation(libs.google.hilt.android)
|
||||
ksp(libs.google.hilt.compiler)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
@@ -297,7 +279,6 @@ dependencies {
|
||||
standardImplementation(libs.google.firebase.cloud.messaging)
|
||||
standardImplementation(platform(libs.google.firebase.bom))
|
||||
standardImplementation(libs.google.firebase.crashlytics)
|
||||
standardImplementation(libs.google.billing)
|
||||
standardImplementation(libs.google.play.review)
|
||||
|
||||
// Pull in test fixtures from other modules
|
||||
@@ -318,6 +299,18 @@ dependencies {
|
||||
testImplementation(libs.square.turbine)
|
||||
}
|
||||
|
||||
tasks {
|
||||
withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "2g"
|
||||
maxParallelForks = Runtime.getRuntime().availableProcessors()
|
||||
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC" +
|
||||
// Explicitly setting the user Country and Language because tests assume en-US
|
||||
"-Duser.country=US" +
|
||||
"-Duser.language=en"
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
// Disable Fdroid-specific tasks that we want to exclude
|
||||
val fdroidTasksToDisable = tasks.withType<GoogleServicesTask>() +
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.billing.manager
|
||||
|
||||
import android.content.Context
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* F-Droid implementation of [PlayBillingManager]. Always returns `true` since
|
||||
* F-Droid users are eligible for the Premium upgrade flow.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@Suppress("UnusedParameter")
|
||||
class PlayBillingManagerImpl(
|
||||
context: Context,
|
||||
dispatcherManager: DispatcherManager,
|
||||
) : PlayBillingManager {
|
||||
|
||||
override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
|
||||
MutableStateFlow(true)
|
||||
}
|
||||
@@ -84,6 +84,15 @@
|
||||
<data android:host="*.bitwarden.eu" />
|
||||
<data android:pathPattern="/redirect-connector.*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -111,35 +120,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Credential Provider Activity for handling passkey and password credential operations.
|
||||
This activity is NOT exported to protect against external apps attempting to extract
|
||||
vault credentials by sending malicious intents. Only our own PendingIntents can
|
||||
launch this activity.
|
||||
|
||||
This is a transparent trampoline activity that launches MainActivity for credential
|
||||
operations and forwards results back to the Credential Manager framework.
|
||||
Uses Theme.Translucent.NoTitleBar for invisibility while allowing normal lifecycle
|
||||
(Theme.NoDisplay requires finish() before onResume(), incompatible with ActivityResult).
|
||||
|
||||
Note: Unlike AuthCallbackActivity, this does NOT use noHistory="true" because it
|
||||
must remain in the back stack to receive the ActivityResult callback from
|
||||
MainActivity. -->
|
||||
<activity
|
||||
android:name=".CredentialProviderActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".AccessibilityActivity"
|
||||
android:exported="false"
|
||||
@@ -203,16 +183,6 @@
|
||||
android:host="webauthn-callback"
|
||||
android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="sso-cookie-vendor"
|
||||
android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.iode.firefox",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "C9:96:DA:AB:86:A8:CD:32:53:77:49:A5:EE:1D:C2:F9:84:F2:9D:43:F3:06:7D:2C:0A:54:BF:8B:BF:AB:62:C0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
@@ -24,7 +12,7 @@
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -40,18 +28,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "org.calyxos.chromium",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "CB:33:EE:73:84:2F:2F:BD:C3:E3:52:5F:D1:C3:74:07:41:82:6F:33:84:9B:C9:6F:95:4D:76:18:17:D3:00:EB"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
|
||||
@@ -815,38 +815,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.zoho.primeum.stable",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "A9:D6:D0:A2:AF:DB:15:84:9B:8C:D3:1D:51:FE:73:B8:E1:B1:70:BA:A5:70:C2:F8:F2:A3:F8:65:28:29:CB:BD"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.amazon.cloud9",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "2F:19:AD:EB:28:4E:B3:6F:7F:07:78:61:52:B9:A1:D1:4B:21:65:32:03:AD:0B:04:EB:BF:9C:73:AB:6D:76:25"
|
||||
},
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "70:D5:68:EC:6A:E6:F3:38:BC:1A:63:99:A6:53:7E:E0:69:08:CA:1D:72:FB:8F:F0:48:74:AB:95:43:3B:25:0E"
|
||||
},
|
||||
{
|
||||
"build": "userdebug",
|
||||
"cert_fingerprint_sha256": "7C:AC:39:19:37:98:1B:61:34:BD:CE:1F:D9:83:4C:25:31:81:F5:AB:F9:1D:ED:60:78:21:0D:0F:91:AC:E3:60"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.x8bit.bitwarden
|
||||
import android.content.Intent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResultOrNull
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
|
||||
@@ -29,7 +28,6 @@ class AuthCallbackViewModel @Inject constructor(
|
||||
val webAuthResult = action.intent.getWebAuthResultOrNull()
|
||||
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
|
||||
val ssoCallbackResult = action.intent.getSsoCallbackResult()
|
||||
val cookieCallbackResult = action.intent.getCookieCallbackResultOrNull()
|
||||
when {
|
||||
yubiKeyResult != null -> {
|
||||
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
|
||||
@@ -47,12 +45,6 @@ class AuthCallbackViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
cookieCallbackResult != null -> {
|
||||
authRepository.setCookieCallbackResult(
|
||||
result = cookieCallbackResult,
|
||||
)
|
||||
}
|
||||
|
||||
webAuthResult != null -> {
|
||||
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.LogsManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConfigManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.network.NetworkConnectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
@@ -23,6 +24,9 @@ class BitwardenApplication : Application() {
|
||||
@Inject
|
||||
lateinit var logsManager: LogsManager
|
||||
|
||||
@Inject
|
||||
lateinit var networkConnectionManager: NetworkConnectionManager
|
||||
|
||||
@Inject
|
||||
lateinit var networkConfigManager: NetworkConfigManager
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.app.ComponentCaller
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.util.validate
|
||||
import com.x8bit.bitwarden.data.credentials.BitwardenCredentialProviderService
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* Transparent trampoline activity for handling credential provider operations.
|
||||
*
|
||||
* This activity is declared as `exported="false"` in the manifest to ensure only
|
||||
* our own PendingIntents can launch it. This protects against external apps attempting
|
||||
* to extract vault credentials by sending malicious intents via CredentialManager.
|
||||
*
|
||||
* All credential flows (FIDO2 passkeys, password credentials) are routed through this
|
||||
* activity when triggered by the Android CredentialManager framework via our
|
||||
* [BitwardenCredentialProviderService].
|
||||
*
|
||||
* ## Architecture
|
||||
*
|
||||
* This activity does not host any UI itself. It acts as a trampoline that:
|
||||
* 1. Receives the credential intent from the CredentialManager framework
|
||||
* 2. Sets the pending credential request via [CredentialProviderViewModel], which stores
|
||||
* it in `CredentialProviderRequestManager` for secure relay to [MainViewModel]
|
||||
* 3. Launches [MainActivity] to handle the actual credential UI
|
||||
* 4. Forwards the result back to the CredentialManager framework
|
||||
*
|
||||
* This preserves the single-Activity architecture where all UI is hosted by MainActivity,
|
||||
* while still allowing the CredentialManager framework to receive results properly.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@AndroidEntryPoint
|
||||
class CredentialProviderActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: CredentialProviderViewModel by viewModels()
|
||||
|
||||
/**
|
||||
* Launcher for MainActivity that forwards the result back to Credential Manager.
|
||||
*/
|
||||
private val mainActivityLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
// Forward result back to Credential Manager framework
|
||||
setResult(result.resultCode, result.data)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent = intent.validate()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
// Process credential intent (sets pending request on CredentialProviderRequestManager)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveFirstIntent(intent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
// On restoration (process death), result comes via mainActivityLauncher callback
|
||||
}
|
||||
|
||||
private fun launchMainActivityForResult() {
|
||||
val mainIntent = Intent(this, MainActivity::class.java).apply {
|
||||
// Pending credential request is retrieved by MainViewModel from
|
||||
// CredentialProviderRequestManager, triggering appropriate navigation.
|
||||
// CredentialProviderCompletionManager handles setResult/finish.
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
mainActivityLauncher.launch(mainIntent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
val newIntent = intent.validate()
|
||||
super.onNewIntent(newIntent)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent, caller: ComponentCaller) {
|
||||
val newIntent = intent.validate()
|
||||
super.onNewIntent(newIntent, caller)
|
||||
viewModel.trySendAction(CredentialProviderAction.ReceiveNewIntent(newIntent))
|
||||
launchMainActivityForResult()
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.content.Intent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.CreateCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.Fido2CredentialAssertionRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.GetCredentialsRequest
|
||||
import com.x8bit.bitwarden.data.credentials.model.ProviderGetPasswordCredentialRequest
|
||||
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
|
||||
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A view model that handles credential provider operations for [CredentialProviderActivity].
|
||||
*
|
||||
* This ViewModel processes credential-related intents and sets the pending credential request
|
||||
* on [CredentialProviderRequestManager] for relay to [MainViewModel]. This ensures credential
|
||||
* data is never passed through intent extras to exported activities, providing security
|
||||
* hardening against malicious intent attacks.
|
||||
*
|
||||
* Since [CredentialProviderActivity] is a transparent trampoline with no UI, this ViewModel only
|
||||
* handles intent processing. All UI state management (theme, feature flags, auth flows) is
|
||||
* handled by [MainActivity].
|
||||
*
|
||||
* @see RootNavViewModel for navigation based on SpecialCircumstance.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class CredentialProviderViewModel @Inject constructor(
|
||||
private val credentialProviderRequestManager: CredentialProviderRequestManager,
|
||||
private val authRepository: AuthRepository,
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
) : BaseViewModel<Unit, Unit, CredentialProviderAction>(initialState = Unit) {
|
||||
|
||||
override fun handleAction(action: CredentialProviderAction) {
|
||||
when (action) {
|
||||
is CredentialProviderAction.ReceiveFirstIntent -> handleIntent(action.intent)
|
||||
is CredentialProviderAction.ReceiveNewIntent -> handleIntent(action.intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
intent.getCreateCredentialRequestOrNull()?.let { handleCreateCredential(it) }
|
||||
?: intent.getFido2AssertionRequestOrNull()?.let { handleFido2Assertion(it) }
|
||||
?: intent.getProviderGetPasswordRequestOrNull()?.let { handlePasswordGet(it) }
|
||||
?: intent.getGetCredentialsRequestOrNull()?.let { handleGetCredentials(it) }
|
||||
}
|
||||
|
||||
private fun handleCreateCredential(request: CreateCredentialRequest) {
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
// Switch accounts if the selected user is not the active user
|
||||
if (authRepository.activeUserId != null &&
|
||||
authRepository.activeUserId != request.userId
|
||||
) {
|
||||
authRepository.switchAccount(request.userId)
|
||||
}
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.CreateCredential(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleFido2Assertion(request: Fido2CredentialAssertionRequest) {
|
||||
// Set the user's verification status when a new FIDO 2 request is received
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.Fido2Assertion(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePasswordGet(request: ProviderGetPasswordCredentialRequest) {
|
||||
// Set the user's verification status when a new GetPassword request is received
|
||||
bitwardenCredentialManager.isUserVerified = request.isUserPreVerified
|
||||
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.GetPassword(request),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleGetCredentials(request: GetCredentialsRequest) {
|
||||
credentialProviderRequestManager.setPendingCredentialRequest(
|
||||
CredentialProviderRequest.GetCredentials(request),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the [CredentialProviderViewModel].
|
||||
*/
|
||||
sealed class CredentialProviderAction {
|
||||
|
||||
/**
|
||||
* Receive the first intent when the activity is created.
|
||||
*/
|
||||
data class ReceiveFirstIntent(val intent: Intent) : CredentialProviderAction()
|
||||
|
||||
/**
|
||||
* Receive a new intent when the activity receives onNewIntent.
|
||||
*/
|
||||
data class ReceiveNewIntent(val intent: Intent) : CredentialProviderAction()
|
||||
}
|
||||
@@ -12,11 +12,9 @@ import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
@@ -35,8 +33,6 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
|
||||
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
|
||||
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
|
||||
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
|
||||
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
|
||||
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
|
||||
@@ -84,14 +80,6 @@ class MainActivity : AppCompatActivity() {
|
||||
mainViewModel.trySendAction(MainAction.WebAuthnResult(it))
|
||||
}
|
||||
|
||||
private val cookieLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
|
||||
mainViewModel.trySendAction(MainAction.CookieAcquisitionResult(it))
|
||||
}
|
||||
|
||||
private val premiumCheckoutLauncher = AuthTabIntent.registerActivityResultLauncher(this) {
|
||||
mainViewModel.trySendAction(MainAction.PremiumCheckoutResult(it))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
intent = intent.validate()
|
||||
var shouldShowSplashScreen = true
|
||||
@@ -118,8 +106,6 @@ class MainActivity : AppCompatActivity() {
|
||||
duo = duoLauncher,
|
||||
sso = ssoLauncher,
|
||||
webAuthn = webAuthnLauncher,
|
||||
cookie = cookieLauncher,
|
||||
premiumCheckout = premiumCheckoutLauncher,
|
||||
),
|
||||
) {
|
||||
ObserveScreenDataEffect(
|
||||
@@ -134,22 +120,15 @@ class MainActivity : AppCompatActivity() {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = RootNavigationRoute,
|
||||
modifier = Modifier
|
||||
.background(color = BitwardenTheme.colorScheme.background.primary),
|
||||
) {
|
||||
// Root navigation, debug menu, and cookie acquisition exist at
|
||||
// this top level. They can appear on top of the rest of the app
|
||||
// without interacting with the state-based navigation used by
|
||||
// RootNavScreen.
|
||||
// Both root navigation and debug menu exist at this top level.
|
||||
// The debug menu can appear on top of the rest of the app without
|
||||
// interacting with the state-based navigation used by RootNavScreen.
|
||||
rootNavDestination { shouldShowSplashScreen = false }
|
||||
debugMenuDestination(
|
||||
onNavigateBack = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
cookieAcquisitionDestination(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,8 +202,6 @@ class MainActivity : AppCompatActivity() {
|
||||
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
|
||||
MainEvent.Recreate -> handleRecreate()
|
||||
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
|
||||
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
|
||||
|
||||
is MainEvent.UpdateAppLocale -> {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(event.localeName),
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.bitwarden.vault.CipherView
|
||||
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResult
|
||||
@@ -27,10 +26,12 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
|
||||
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
|
||||
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
|
||||
import com.x8bit.bitwarden.data.credentials.manager.BitwardenCredentialManager
|
||||
import com.x8bit.bitwarden.data.credentials.util.getCreateCredentialRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getFido2AssertionRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getGetCredentialsRequestOrNull
|
||||
import com.x8bit.bitwarden.data.credentials.util.getProviderGetPasswordRequestOrNull
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
|
||||
@@ -46,7 +47,6 @@ import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
|
||||
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
|
||||
import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut
|
||||
import com.x8bit.bitwarden.ui.platform.util.isPremiumCheckoutCallback
|
||||
import com.x8bit.bitwarden.ui.vault.util.getTotpDataOrNull
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -54,7 +54,6 @@ 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
|
||||
@@ -78,11 +77,10 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
|
||||
class MainViewModel @Inject constructor(
|
||||
accessibilitySelectionManager: AccessibilitySelectionManager,
|
||||
autofillSelectionManager: AutofillSelectionManager,
|
||||
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
|
||||
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
|
||||
private val specialCircumstanceManager: SpecialCircumstanceManager,
|
||||
private val garbageCollectionManager: GarbageCollectionManager,
|
||||
private val credentialProviderRequestManager: CredentialProviderRequestManager,
|
||||
private val bitwardenCredentialManager: BitwardenCredentialManager,
|
||||
private val shareManager: ShareManager,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
@@ -166,13 +164,6 @@ 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 {
|
||||
@@ -197,8 +188,6 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.DuoResult -> handleDuoResult(action)
|
||||
is MainAction.SsoResult -> handleSsoResult(action)
|
||||
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
|
||||
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
|
||||
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult()
|
||||
is MainAction.Internal -> handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +209,6 @@ class MainViewModel @Inject constructor(
|
||||
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
|
||||
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
|
||||
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
|
||||
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,17 +230,6 @@ class MainViewModel @Inject constructor(
|
||||
authRepository.setWebAuthResult(webAuthResult = action.authResult.getWebAuthResult())
|
||||
}
|
||||
|
||||
private fun handleCookieAcquisitionResult(action: MainAction.CookieAcquisitionResult) {
|
||||
authRepository.setCookieCallbackResult(
|
||||
result = action.cookieCallbackResult.getCookieCallbackResult(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handlePremiumCheckoutResult() {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.PremiumCheckoutResult
|
||||
}
|
||||
|
||||
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
|
||||
when (val data = action.screenResumeData) {
|
||||
null -> appResumeManager.clearResumeScreen()
|
||||
@@ -296,10 +273,6 @@ class MainViewModel @Inject constructor(
|
||||
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
|
||||
}
|
||||
|
||||
private fun handleCookieAcquisitionReady() {
|
||||
sendEvent(MainEvent.NavigateToCookieAcquisition)
|
||||
}
|
||||
|
||||
private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) {
|
||||
handleIntent(
|
||||
intent = action.intent,
|
||||
@@ -340,11 +313,12 @@ class MainViewModel @Inject constructor(
|
||||
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
|
||||
val hasVaultShortcut = intent.isMyVaultShortcut
|
||||
val hasAccountSecurityShortcut = intent.isAccountSecurityShortcut
|
||||
val hasPremiumCheckoutCallback = intent.isPremiumCheckoutCallback
|
||||
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
|
||||
val createCredentialRequest = intent.getCreateCredentialRequestOrNull()
|
||||
val getCredentialsRequest = intent.getGetCredentialsRequestOrNull()
|
||||
val fido2AssertCredentialRequest = intent.getFido2AssertionRequestOrNull()
|
||||
val providerGetPasswordRequest = intent.getProviderGetPasswordRequestOrNull()
|
||||
val importCredentialsRequest = intent.getProviderImportCredentialsRequest()
|
||||
val credentialProviderRequest =
|
||||
credentialProviderRequestManager.getPendingCredentialRequest()
|
||||
when {
|
||||
passwordlessRequestData != null -> {
|
||||
authRepository.activeUserId?.let {
|
||||
@@ -402,9 +376,57 @@ class MainViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
hasPremiumCheckoutCallback -> {
|
||||
createCredentialRequest != null -> {
|
||||
// Set the user's verification status when a new FIDO 2 request is received to force
|
||||
// explicit verification if the user's vault is unlocked when the request is
|
||||
// received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
createCredentialRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.PremiumCheckoutResult
|
||||
SpecialCircumstance.ProviderCreateCredential(
|
||||
createCredentialRequest = createCredentialRequest,
|
||||
)
|
||||
|
||||
// Switch accounts if the selected user is not the active user.
|
||||
if (authRepository.activeUserId != null &&
|
||||
authRepository.activeUserId != createCredentialRequest.userId
|
||||
) {
|
||||
authRepository.switchAccount(createCredentialRequest.userId)
|
||||
}
|
||||
}
|
||||
|
||||
fido2AssertCredentialRequest != null -> {
|
||||
// Set the user's verification status when a new FIDO 2 request is received to force
|
||||
// explicit verification if the user's vault is unlocked when the request is
|
||||
// received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
fido2AssertCredentialRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.Fido2Assertion(
|
||||
fido2AssertionRequest = fido2AssertCredentialRequest,
|
||||
)
|
||||
}
|
||||
|
||||
providerGetPasswordRequest != null -> {
|
||||
// Set the user's verification status when a new GetPassword request is
|
||||
// received to force explicit verification if the user's vault is
|
||||
// unlocked when the request is received.
|
||||
bitwardenCredentialManager.isUserVerified =
|
||||
providerGetPasswordRequest.isUserPreVerified
|
||||
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderGetPasswordRequest(
|
||||
passwordGetRequest = providerGetPasswordRequest,
|
||||
)
|
||||
}
|
||||
|
||||
getCredentialsRequest != null -> {
|
||||
specialCircumstanceManager.specialCircumstance =
|
||||
SpecialCircumstance.ProviderGetCredentials(
|
||||
getCredentialsRequest = getCredentialsRequest,
|
||||
)
|
||||
}
|
||||
|
||||
hasGeneratorShortcut -> {
|
||||
@@ -426,52 +448,10 @@ class MainViewModel @Inject constructor(
|
||||
SpecialCircumstance.CredentialExchangeExport(
|
||||
data = ImportCredentialsRequestData(
|
||||
uri = importCredentialsRequest.uri,
|
||||
credentialTypes = importCredentialsRequest.request.credentialTypes,
|
||||
knownExtensions = importCredentialsRequest.request.knownExtensions,
|
||||
requestJson = importCredentialsRequest.request.requestJson,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
credentialProviderRequest != null -> {
|
||||
handleCredentialRequest(credentialProviderRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a credential request relayed from [CredentialProviderActivity] via
|
||||
* [CredentialProviderRequestManager].
|
||||
*
|
||||
* This method converts the [CredentialProviderRequest] into the appropriate
|
||||
* [SpecialCircumstance] for routing by [RootNavViewModel]. The credential data is trusted
|
||||
* because it was set by our own [CredentialProviderActivity] through the internal manager,
|
||||
* not parsed from intent extras.
|
||||
*/
|
||||
private fun handleCredentialRequest(request: CredentialProviderRequest) {
|
||||
specialCircumstanceManager.specialCircumstance = when (request) {
|
||||
is CredentialProviderRequest.CreateCredential -> {
|
||||
SpecialCircumstance.ProviderCreateCredential(
|
||||
createCredentialRequest = request.request,
|
||||
)
|
||||
}
|
||||
|
||||
is CredentialProviderRequest.Fido2Assertion -> {
|
||||
SpecialCircumstance.Fido2Assertion(
|
||||
fido2AssertionRequest = request.request,
|
||||
)
|
||||
}
|
||||
|
||||
is CredentialProviderRequest.GetPassword -> {
|
||||
SpecialCircumstance.ProviderGetPasswordRequest(
|
||||
passwordGetRequest = request.request,
|
||||
)
|
||||
}
|
||||
|
||||
is CredentialProviderRequest.GetCredentials -> {
|
||||
SpecialCircumstance.ProviderGetCredentials(
|
||||
getCredentialsRequest = request.request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,20 +534,6 @@ sealed class MainAction {
|
||||
*/
|
||||
data class WebAuthnResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive the result from the cookie acquisition flow.
|
||||
*/
|
||||
data class CookieAcquisitionResult(
|
||||
val cookieCallbackResult: AuthTabIntent.AuthResult,
|
||||
) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive the result from the premium checkout flow.
|
||||
*/
|
||||
data class PremiumCheckoutResult(
|
||||
val authResult: AuthTabIntent.AuthResult,
|
||||
) : MainAction()
|
||||
|
||||
/**
|
||||
* Receive first Intent by the application.
|
||||
*/
|
||||
@@ -638,12 +604,6 @@ sealed class MainAction {
|
||||
data class DynamicColorsUpdate(
|
||||
val isDynamicColorsEnabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the cookie acquisition conditions are met and navigation
|
||||
* should proceed.
|
||||
*/
|
||||
data object CookieAcquisitionReady : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,11 +633,6 @@ sealed class MainEvent {
|
||||
*/
|
||||
data object NavigateToDebugMenu : MainEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the cookie acquisition screen.
|
||||
*/
|
||||
data object NavigateToCookieAcquisition : MainEvent()
|
||||
|
||||
/**
|
||||
* Indicates that the app language has been updated.
|
||||
*/
|
||||
|
||||
@@ -124,16 +124,6 @@ interface AuthDiskSource : AppIdProvider {
|
||||
*/
|
||||
fun storeUserKey(userId: String, userKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves the local user data key for the given [userId].
|
||||
*/
|
||||
fun getLocalUserDataKey(userId: String): String?
|
||||
|
||||
/**
|
||||
* Stores the local user data key for a given [userId].
|
||||
*/
|
||||
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves a private key using a [userId].
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,6 @@ private const val REMEMBERED_ORG_IDENTIFIER_KEY = "rememberedOrgIdentifier"
|
||||
private const val STATE_KEY = "state"
|
||||
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
|
||||
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
|
||||
private const val LOCAL_USER_DATA_KEY = "localUserDataKey"
|
||||
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
|
||||
private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey"
|
||||
private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope"
|
||||
@@ -145,7 +144,6 @@ class AuthDiskSourceImpl(
|
||||
override fun clearData(userId: String) {
|
||||
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
|
||||
storeUserKey(userId = userId, userKey = null)
|
||||
storeLocalUserDataKey(userId = userId, wrappedKey = null)
|
||||
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
|
||||
storePrivateKey(userId = userId, privateKey = null)
|
||||
storeAccountKeys(userId = userId, accountKeys = null)
|
||||
@@ -239,13 +237,6 @@ class AuthDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getLocalUserDataKey(userId: String): String? =
|
||||
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
|
||||
|
||||
override fun storeLocalUserDataKey(userId: String, wrappedKey: String?) {
|
||||
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
|
||||
}
|
||||
|
||||
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
|
||||
override fun getPrivateKey(userId: String): String? =
|
||||
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Represents the current account information for a given user.
|
||||
@@ -37,12 +37,12 @@ data class AccountJson(
|
||||
*
|
||||
* @property userId The ID of the user.
|
||||
* @property email The user's email address.
|
||||
* @property isEmailVerified Whether the user's email is verified.
|
||||
* @property isTwoFactorEnabled If the profile has two-factor authentication enabled.
|
||||
* @property isEmailVerified Whether or not the user's email is verified.
|
||||
* @property isTwoFactorEnabled If the profile has two factor authentication enabled.
|
||||
* @property name The user's name (if applicable).
|
||||
* @property stamp The account's security stamp (if applicable).
|
||||
* @property organizationId The ID of the associated organization (if applicable).
|
||||
* @property hasPremium True if the user has a Premium account.
|
||||
* @property hasPremium True if the user has a premium account.
|
||||
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
|
||||
* @property forcePasswordResetReason Describes the reason for a forced password reset.
|
||||
* @property kdfType The KDF type.
|
||||
@@ -103,7 +103,7 @@ data class AccountJson(
|
||||
|
||||
@SerialName("creationDate")
|
||||
@Contextual
|
||||
val creationDate: Instant?,
|
||||
val creationDate: ZonedDateTime?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,21 +27,25 @@ class AuthSdkSourceImpl(
|
||||
override suspend fun getNewAuthRequest(
|
||||
email: String,
|
||||
): Result<AuthRequestResponse> = runCatchingWithLogs {
|
||||
useClient { auth().newAuthRequest(email = email.lowercase()) }
|
||||
getClient()
|
||||
.auth()
|
||||
.newAuthRequest(
|
||||
email = email.lowercase(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getUserFingerprint(
|
||||
email: String,
|
||||
publicKey: String,
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
useClient {
|
||||
platform().fingerprint(
|
||||
getClient()
|
||||
.platform()
|
||||
.fingerprint(
|
||||
req = FingerprintRequest(
|
||||
fingerprintMaterial = email.lowercase(),
|
||||
publicKey = publicKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun hashPassword(
|
||||
@@ -50,19 +54,21 @@ class AuthSdkSourceImpl(
|
||||
kdf: Kdf,
|
||||
purpose: HashPurpose,
|
||||
): Result<String> = runCatchingWithLogs {
|
||||
useClient {
|
||||
auth().hashPassword(
|
||||
getClient()
|
||||
.auth()
|
||||
.hashPassword(
|
||||
email = email,
|
||||
password = password,
|
||||
kdfParams = kdf,
|
||||
purpose = purpose,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun makeKeyConnectorKeys(): Result<KeyConnectorResponse> =
|
||||
runCatchingWithLogs {
|
||||
useClient { auth().makeKeyConnectorKeys() }
|
||||
getClient()
|
||||
.auth()
|
||||
.makeKeyConnectorKeys()
|
||||
}
|
||||
|
||||
override suspend fun makeRegisterKeys(
|
||||
@@ -70,13 +76,13 @@ class AuthSdkSourceImpl(
|
||||
password: String,
|
||||
kdf: Kdf,
|
||||
): Result<RegisterKeyResponse> = runCatchingWithLogs {
|
||||
useClient {
|
||||
auth().makeRegisterKeys(
|
||||
getClient()
|
||||
.auth()
|
||||
.makeRegisterKeys(
|
||||
email = email,
|
||||
password = password,
|
||||
kdf = kdf,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun makeRegisterTdeKeysAndUnlockVault(
|
||||
@@ -99,16 +105,15 @@ class AuthSdkSourceImpl(
|
||||
password: String,
|
||||
additionalInputs: List<String>,
|
||||
): Result<PasswordStrength> = runCatchingWithLogs {
|
||||
useClient {
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
auth()
|
||||
.passwordStrength(
|
||||
password = password,
|
||||
email = email,
|
||||
additionalInputs = additionalInputs,
|
||||
)
|
||||
.toPasswordStrengthOrNull()!!
|
||||
}
|
||||
@Suppress("UnsafeCallOnNullableType")
|
||||
getClient()
|
||||
.auth()
|
||||
.passwordStrength(
|
||||
password = password,
|
||||
email = email,
|
||||
additionalInputs = additionalInputs,
|
||||
)
|
||||
.toPasswordStrengthOrNull()!!
|
||||
}
|
||||
|
||||
override suspend fun satisfiesPolicy(
|
||||
@@ -116,12 +121,12 @@ class AuthSdkSourceImpl(
|
||||
passwordStrength: PasswordStrength,
|
||||
policy: MasterPasswordPolicyOptions,
|
||||
): Result<Boolean> = runCatchingWithLogs {
|
||||
useClient {
|
||||
auth().satisfiesPolicy(
|
||||
getClient()
|
||||
.auth()
|
||||
.satisfiesPolicy(
|
||||
password = password,
|
||||
strength = passwordStrength.toUByte(),
|
||||
policy = policy,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
clearPendingAuthRequest()
|
||||
@@ -198,6 +199,7 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
isComplete = true
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Represents a Login Approval request.
|
||||
@@ -27,8 +27,8 @@ data class AuthRequest(
|
||||
val ipAddress: String,
|
||||
val key: String?,
|
||||
val masterPasswordHash: String?,
|
||||
val creationDate: Instant,
|
||||
val responseDate: Instant?,
|
||||
val creationDate: ZonedDateTime,
|
||||
val responseDate: ZonedDateTime?,
|
||||
val requestApproved: Boolean,
|
||||
val originUrl: String,
|
||||
val fingerprint: String,
|
||||
|
||||
@@ -12,7 +12,7 @@ sealed class CreateAuthRequestResult {
|
||||
) : CreateAuthRequestResult()
|
||||
|
||||
/**
|
||||
* Models the data returned when an auth request has been approved.
|
||||
* Models the data returned when a auth request has been approved.
|
||||
*/
|
||||
data class Success(
|
||||
val authRequest: AuthRequest,
|
||||
@@ -21,7 +21,7 @@ sealed class CreateAuthRequestResult {
|
||||
) : CreateAuthRequestResult()
|
||||
|
||||
/**
|
||||
* There was a generic error creating the auth request.
|
||||
* There was a generic error getting the user's auth requests.
|
||||
*/
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository
|
||||
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.TwoFactorDataModel
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
@@ -16,7 +17,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
@@ -34,7 +34,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
@@ -45,7 +44,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Provides an API for observing and modifying authentication state.
|
||||
* Provides an API for observing an modifying authentication state.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface AuthRepository :
|
||||
@@ -71,12 +70,6 @@ interface AuthRepository :
|
||||
*/
|
||||
val ssoCallbackResultFlow: Flow<SsoCallbackResult>
|
||||
|
||||
/**
|
||||
* Flow of the current [CookieCallbackResult]. Subscribers should listen to the flow in order
|
||||
* to receive updates whenever [setCookieCallbackResult] is called.
|
||||
*/
|
||||
val cookieCallbackResultFlow: Flow<CookieCallbackResult>
|
||||
|
||||
/**
|
||||
* Flow of the current [YubiKeyResult]. Subscribers should listen to the flow in order to
|
||||
* receive updates whenever [setYubiKeyResult] is called.
|
||||
@@ -133,10 +126,10 @@ interface AuthRepository :
|
||||
/**
|
||||
* The organization for the active user.
|
||||
*/
|
||||
val organizations: List<Organization>
|
||||
val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
|
||||
/**
|
||||
* Whether the welcome carousel should be displayed, based on the feature flag and
|
||||
* Whether or not the welcome carousel should be displayed, based on the feature flag and
|
||||
* whether the user has ever logged in or created an account before.
|
||||
*/
|
||||
val showWelcomeCarousel: Boolean
|
||||
@@ -290,7 +283,7 @@ interface AuthRepository :
|
||||
): PasswordHintResult
|
||||
|
||||
/**
|
||||
* Removes the users password from the account. This is used when migrating from master
|
||||
* Removes the users password from the account. This used used when migrating from master
|
||||
* password login to key connector login.
|
||||
*/
|
||||
suspend fun removePassword(masterPassword: String): RemovePasswordResult
|
||||
@@ -349,11 +342,6 @@ interface AuthRepository :
|
||||
*/
|
||||
fun setSsoCallbackResult(result: SsoCallbackResult)
|
||||
|
||||
/**
|
||||
* Set the value of [cookieCallbackResultFlow].
|
||||
*/
|
||||
fun setCookieCallbackResult(result: CookieCallbackResult)
|
||||
|
||||
/**
|
||||
* Get a [Boolean] indicating whether this is a known device.
|
||||
*/
|
||||
@@ -397,7 +385,7 @@ interface AuthRepository :
|
||||
): SendVerificationEmailResult
|
||||
|
||||
/**
|
||||
* Validates the given [token] for the given [email]. Part of the new account registration flow.
|
||||
* Validates the given [token] for the given [email]. Part of th new account registration flow.
|
||||
*/
|
||||
suspend fun validateEmailToken(
|
||||
email: String,
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.bitwarden.core.InitUserCryptoMethod
|
||||
import com.bitwarden.core.RegisterTdeKeyResponse
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.toast.ToastManager
|
||||
import com.bitwarden.core.data.repository.error.MissingPropertyException
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
@@ -14,10 +13,8 @@ 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.AccountKeysJson
|
||||
import com.bitwarden.network.model.CreateAccountKeysResponseJson
|
||||
import com.bitwarden.network.model.DeleteAccountResponseJson
|
||||
import com.bitwarden.network.model.GetTokenResponseJson
|
||||
@@ -50,7 +47,6 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
|
||||
import com.bitwarden.network.service.IdentityService
|
||||
import com.bitwarden.network.service.OrganizationService
|
||||
import com.bitwarden.network.util.isSslHandShakeError
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
|
||||
@@ -76,7 +72,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.LeaveOrganizationResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.NewSsoUserResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordHintResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
|
||||
@@ -96,14 +91,11 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifiedOrganizationDomainSsoDetailsResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
|
||||
@@ -125,6 +117,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -177,7 +170,6 @@ class AuthRepositoryImpl(
|
||||
private val policyManager: PolicyManager,
|
||||
private val userStateManager: UserStateManager,
|
||||
private val kdfManager: KdfManager,
|
||||
private val toastManager: ToastManager,
|
||||
logsManager: LogsManager,
|
||||
pushManager: PushManager,
|
||||
dispatcherManager: DispatcherManager,
|
||||
@@ -271,10 +263,6 @@ class AuthRepositoryImpl(
|
||||
override val ssoCallbackResultFlow: Flow<SsoCallbackResult> =
|
||||
mutableSsoCallbackResultFlow.asSharedFlow()
|
||||
|
||||
private val mutableCookieCallbackResultFlow = bufferedMutableSharedFlow<CookieCallbackResult>()
|
||||
override val cookieCallbackResultFlow: Flow<CookieCallbackResult> =
|
||||
mutableCookieCallbackResultFlow.asSharedFlow()
|
||||
|
||||
override var rememberedEmailAddress: String? by authDiskSource::rememberedEmailAddress
|
||||
|
||||
override var rememberedOrgIdentifier: String? by authDiskSource::rememberedOrgIdentifier
|
||||
@@ -300,11 +288,8 @@ class AuthRepositoryImpl(
|
||||
?.profile
|
||||
?.forcePasswordResetReason
|
||||
|
||||
override val organizations: List<Organization>
|
||||
get() = activeUserId
|
||||
?.let { authDiskSource.getOrganizations(it) }
|
||||
.orEmpty()
|
||||
.toOrganizations()
|
||||
override val organizations: List<SyncResponseJson.Profile.Organization>
|
||||
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
|
||||
|
||||
override val showWelcomeCarousel: Boolean
|
||||
get() = !settingsRepository.hasUserLoggedInOrCreatedAccount
|
||||
@@ -556,6 +541,9 @@ class AuthRepositoryImpl(
|
||||
errorMessage = null,
|
||||
error = MissingPropertyException("Private Key"),
|
||||
)
|
||||
val signingKey = accountKeys?.signatureKeyPair?.wrappedSigningKey
|
||||
val securityState = accountKeys?.securityState?.securityState
|
||||
val signedPublicKey = accountKeys?.publicKeyEncryptionKeyPair?.signedPublicKey
|
||||
|
||||
checkForVaultUnlockError(
|
||||
onVaultUnlockError = { error ->
|
||||
@@ -563,8 +551,11 @@ class AuthRepositoryImpl(
|
||||
},
|
||||
) {
|
||||
unlockVault(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
@@ -733,27 +724,18 @@ class AuthRepositoryImpl(
|
||||
when (refreshTokenResponse) {
|
||||
is RefreshTokenResponseJson.Error -> {
|
||||
if (refreshTokenResponse.isInvalidGrant) {
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.InvalidGrant,
|
||||
)
|
||||
logout(userId = userId, reason = LogoutReason.InvalidGrant)
|
||||
}
|
||||
IllegalStateException(refreshTokenResponse.error).asFailure()
|
||||
}
|
||||
|
||||
is RefreshTokenResponseJson.Forbidden -> {
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.RefreshForbidden,
|
||||
)
|
||||
logout(userId = userId, reason = LogoutReason.RefreshForbidden)
|
||||
refreshTokenResponse.error.asFailure()
|
||||
}
|
||||
|
||||
is RefreshTokenResponseJson.Unauthorized -> {
|
||||
userLogoutManager.softLogout(
|
||||
userId = userId,
|
||||
reason = LogoutReason.RefreshUnauthorized,
|
||||
)
|
||||
logout(userId = userId, reason = LogoutReason.RefreshUnauthorized)
|
||||
refreshTokenResponse.error.asFailure()
|
||||
}
|
||||
|
||||
@@ -993,8 +975,8 @@ class AuthRepositoryImpl(
|
||||
val keyConnectorUrl = organizations
|
||||
.find {
|
||||
it.shouldUseKeyConnector &&
|
||||
it.role != OrganizationType.OWNER &&
|
||||
it.role != OrganizationType.ADMIN
|
||||
it.type != OrganizationType.OWNER &&
|
||||
it.type != OrganizationType.ADMIN
|
||||
}
|
||||
?.keyConnectorUrl
|
||||
?: return RemovePasswordResult.Error(
|
||||
@@ -1056,10 +1038,9 @@ class AuthRepositoryImpl(
|
||||
onSuccess = { it },
|
||||
)
|
||||
}
|
||||
val userId = activeAccount.profile.userId
|
||||
return vaultSdkSource
|
||||
.updatePassword(
|
||||
userId = userId,
|
||||
userId = activeAccount.profile.userId,
|
||||
newPassword = newPassword,
|
||||
)
|
||||
.flatMap { updatePasswordResponse ->
|
||||
@@ -1085,15 +1066,14 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.onSuccess { passwordHash ->
|
||||
authDiskSource.storeMasterPasswordHash(
|
||||
userId = userId,
|
||||
userId = activeAccount.profile.userId,
|
||||
passwordHash = passwordHash,
|
||||
)
|
||||
}
|
||||
|
||||
toastManager.show(BitwardenString.updated_master_password)
|
||||
// Log out the user after successful password reset.
|
||||
// This clears all user state including forcePasswordResetReason.
|
||||
logout(reason = LogoutReason.PasswordReset, userId = userId)
|
||||
logout(reason = LogoutReason.PasswordReset)
|
||||
|
||||
// Return the success.
|
||||
ResetPasswordResult.Success
|
||||
@@ -1258,10 +1238,6 @@ class AuthRepositoryImpl(
|
||||
mutableSsoCallbackResultFlow.tryEmit(result)
|
||||
}
|
||||
|
||||
override fun setCookieCallbackResult(result: CookieCallbackResult) {
|
||||
mutableCookieCallbackResultFlow.tryEmit(result)
|
||||
}
|
||||
|
||||
override suspend fun getIsKnownDevice(emailAddress: String): KnownDeviceResult =
|
||||
devicesService
|
||||
.getIsKnownDevice(
|
||||
@@ -1587,7 +1563,6 @@ class AuthRepositoryImpl(
|
||||
): LoginResult = identityService
|
||||
.getToken(
|
||||
uniqueAppId = authDiskSource.uniqueAppId,
|
||||
deeplinkScheme = environmentRepository.environment.environmentUrlData.appLinksScheme,
|
||||
email = email,
|
||||
authModel = authModel,
|
||||
twoFactorData = twoFactorData ?: getRememberedTwoFactorData(email),
|
||||
@@ -1693,7 +1668,7 @@ class AuthRepositoryImpl(
|
||||
val isNewKeyConnectorUser =
|
||||
loginResponse.userDecryptionOptions?.hasMasterPassword == false &&
|
||||
loginResponse.key == null &&
|
||||
loginResponse.privateKeyOrNull() == null
|
||||
loginResponse.privateKey == null
|
||||
val isNotConfirmed = !userConfirmedKeyConnector
|
||||
|
||||
// If a new KeyConnector user is logging in for the first time,
|
||||
@@ -1768,7 +1743,7 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
// We continue to store the private key for backwards compatibility. Key connector
|
||||
// conversion still relies on the private key.
|
||||
loginResponse.privateKeyOrNull()?.let {
|
||||
loginResponse.privateKey?.let {
|
||||
// Only set the value if it's present, since we may have set it already
|
||||
// when we completed the key connector conversion.
|
||||
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
|
||||
@@ -1858,7 +1833,7 @@ class AuthRepositoryImpl(
|
||||
loginResponse: GetTokenResponseJson.Success,
|
||||
): VaultUnlockResult? {
|
||||
val key = loginResponse.key
|
||||
val privateKey = loginResponse.privateKeyOrNull()
|
||||
val privateKey = loginResponse.privateKey
|
||||
return if (loginResponse.userDecryptionOptions?.hasMasterPassword != false) {
|
||||
// This user has a master password, so we skip the key-connector logic as it is not
|
||||
// setup yet. The user can still unlock the vault with their master password.
|
||||
@@ -1872,9 +1847,18 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
.map {
|
||||
unlockVault(
|
||||
accountCryptographicState = loginResponse
|
||||
.accountKeys
|
||||
.toAccountCryptographicState(privateKey = privateKey),
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
masterKey = it.masterKey,
|
||||
@@ -1902,8 +1886,17 @@ class AuthRepositoryImpl(
|
||||
.map { keyConnectorResponse ->
|
||||
val accountKeys = loginResponse.accountKeys
|
||||
val result = unlockVault(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = keyConnectorResponse.keys.private,
|
||||
securityState = accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
|
||||
@@ -1961,8 +1954,17 @@ class AuthRepositoryImpl(
|
||||
)
|
||||
|
||||
return unlockVault(
|
||||
accountCryptographicState = loginResponse.accountKeys.toAccountCryptographicState(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = initUserCryptoMethod,
|
||||
@@ -1985,9 +1987,18 @@ class AuthRepositoryImpl(
|
||||
if (privateKey != null && key != null) {
|
||||
deviceData?.let { model ->
|
||||
return unlockVault(
|
||||
accountCryptographicState = loginResponse
|
||||
.accountKeys
|
||||
.toAccountCryptographicState(privateKey = privateKey),
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = loginResponse.accountKeys
|
||||
?.securityState
|
||||
?.securityState,
|
||||
signingKey = loginResponse.accountKeys
|
||||
?.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
signedPublicKey = loginResponse.accountKeys
|
||||
?.publicKeyEncryptionKeyPair
|
||||
?.signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
requestPrivateKey = model.privateKey,
|
||||
@@ -2013,14 +2024,36 @@ class AuthRepositoryImpl(
|
||||
.userDecryptionOptions
|
||||
?.trustedDeviceUserDecryptionOptions
|
||||
?.let { options ->
|
||||
loginResponse.privateKeyOrNull()?.let { privateKey ->
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = privateKey,
|
||||
accountKeys = loginResponse.accountKeys,
|
||||
)
|
||||
}
|
||||
loginResponse.accountKeys
|
||||
?.let { accountKeys ->
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.wrappedPrivateKey,
|
||||
securityState = accountKeys
|
||||
.securityState
|
||||
?.securityState,
|
||||
signedPublicKey = accountKeys
|
||||
.publicKeyEncryptionKeyPair
|
||||
.signedPublicKey,
|
||||
signingKey = accountKeys
|
||||
.signatureKeyPair
|
||||
?.wrappedSigningKey,
|
||||
)
|
||||
}
|
||||
?: loginResponse.privateKey
|
||||
?.let { privateKey ->
|
||||
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
|
||||
options = options,
|
||||
profile = profile,
|
||||
privateKey = privateKey,
|
||||
securityState = null,
|
||||
signedPublicKey = null,
|
||||
signingKey = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2032,7 +2065,9 @@ class AuthRepositoryImpl(
|
||||
options: TrustedDeviceUserDecryptionOptionsJson,
|
||||
profile: AccountJson.Profile,
|
||||
privateKey: String,
|
||||
accountKeys: AccountKeysJson?,
|
||||
securityState: String?,
|
||||
signedPublicKey: String?,
|
||||
signingKey: String?,
|
||||
): VaultUnlockResult? {
|
||||
var vaultUnlockResult: VaultUnlockResult? = null
|
||||
val userId = profile.userId
|
||||
@@ -2049,8 +2084,11 @@ class AuthRepositoryImpl(
|
||||
// For approved requests the key will always be present.
|
||||
val userKey = requireNotNull(request.key)
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
|
||||
@@ -2078,8 +2116,11 @@ class AuthRepositoryImpl(
|
||||
}
|
||||
|
||||
vaultUnlockResult = unlockVault(
|
||||
accountCryptographicState = accountKeys.toAccountCryptographicState(
|
||||
accountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = securityState,
|
||||
signingKey = signingKey,
|
||||
signedPublicKey = signedPublicKey,
|
||||
),
|
||||
accountProfile = profile,
|
||||
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.di
|
||||
|
||||
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
|
||||
import com.bitwarden.core.data.manager.toast.ToastManager
|
||||
import com.bitwarden.data.datasource.disk.ConfigDiskSource
|
||||
import com.bitwarden.network.service.AccountsService
|
||||
import com.bitwarden.network.service.DevicesService
|
||||
@@ -72,7 +71,6 @@ object AuthRepositoryModule {
|
||||
logsManager: LogsManager,
|
||||
userStateManager: UserStateManager,
|
||||
kdfManager: KdfManager,
|
||||
toastManager: ToastManager,
|
||||
): AuthRepository = AuthRepositoryImpl(
|
||||
clock = clock,
|
||||
accountsService = accountsService,
|
||||
@@ -99,7 +97,6 @@ object AuthRepositoryModule {
|
||||
logsManager = logsManager,
|
||||
userStateManager = userStateManager,
|
||||
kdfManager = kdfManager,
|
||||
toastManager = toastManager,
|
||||
)
|
||||
|
||||
@Provides
|
||||
|
||||
@@ -14,16 +14,14 @@ import com.bitwarden.network.model.OrganizationType
|
||||
* @property keyConnectorUrl The key connector domain (if applicable).
|
||||
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
|
||||
* @property limitItemDeletion Indicates that the organization limits item deletion.
|
||||
* @property shouldUseEvents Indicates if the organization uses tracking events.
|
||||
*/
|
||||
data class Organization(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val name: String?,
|
||||
val shouldManageResetPassword: Boolean,
|
||||
val shouldUseKeyConnector: Boolean,
|
||||
val role: OrganizationType,
|
||||
val keyConnectorUrl: String?,
|
||||
val userIsClaimedByOrganization: Boolean,
|
||||
val limitItemDeletion: Boolean,
|
||||
val shouldUseEvents: Boolean,
|
||||
val limitItemDeletion: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ data class UserAccountTokens(
|
||||
val refreshToken: String?,
|
||||
) {
|
||||
/**
|
||||
* Returns `true` if the user is logged in, `false` otherwise.
|
||||
* Returns `true` if the user is logged in, `false otherwise.
|
||||
*/
|
||||
val isLoggedIn: Boolean get() = accessToken != null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents the overall "user state" of the current active user as well as any users that may be
|
||||
@@ -42,10 +40,10 @@ data class UserState(
|
||||
* @property name The user's name (if applicable).
|
||||
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
|
||||
* @property environment The [Environment] associated with the user's account.
|
||||
* @property isPremium `true` if the account has a Premium membership.
|
||||
* @property isPremium `true` if the account has a premium membership.
|
||||
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
|
||||
* authentication to view their vault.
|
||||
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
|
||||
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
|
||||
* @property needsPasswordReset If the user needs to reset their password.
|
||||
* @property needsMasterPassword Indicates whether the user needs to create a password (e.g.
|
||||
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
|
||||
@@ -57,7 +55,6 @@ data class UserState(
|
||||
* user's vault is enabled.
|
||||
* @property vaultUnlockType The mechanism by which the user's vault may be unlocked.
|
||||
* @property isUsingKeyConnector Indicates if the account is currently using a key connector.
|
||||
* @property creationDate The date the account was created, if available.
|
||||
*/
|
||||
data class Account(
|
||||
val userId: String,
|
||||
@@ -79,7 +76,6 @@ data class UserState(
|
||||
val onboardingStatus: OnboardingStatus,
|
||||
val firstTimeState: FirstTimeState,
|
||||
val isExportable: Boolean,
|
||||
val creationDate: Instant?,
|
||||
) {
|
||||
/**
|
||||
* Indicates that the user does or does not have a means to manually unlock the vault.
|
||||
@@ -100,33 +96,4 @@ data class UserState(
|
||||
val hasLoginApprovingDevice: Boolean,
|
||||
val hasResetPasswordPermission: Boolean,
|
||||
)
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* A basic empty account model.
|
||||
*/
|
||||
val EMPTY_ACCOUNT: Account = Account(
|
||||
userId = "",
|
||||
name = null,
|
||||
email = "",
|
||||
avatarColorHex = "".toHexColorRepresentation(),
|
||||
environment = Environment.Us,
|
||||
isPremium = false,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
needsPasswordReset = false,
|
||||
organizations = emptyList(),
|
||||
isBiometricsEnabled = false,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
needsMasterPassword = false,
|
||||
hasMasterPassword = true,
|
||||
trustedDevice = null,
|
||||
isUsingKeyConnector = false,
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
firstTimeState = FirstTimeState(),
|
||||
isExportable = false,
|
||||
creationDate = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.util
|
||||
|
||||
import com.bitwarden.core.WrappedAccountCryptographicState
|
||||
import com.bitwarden.network.model.AccountKeysJson
|
||||
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
|
||||
|
||||
/**
|
||||
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
|
||||
*
|
||||
* Returns [WrappedAccountCryptographicState.V2] if signing key, signed public key, and security
|
||||
* state are all present, otherwise returns [WrappedAccountCryptographicState.V1].
|
||||
*
|
||||
* @receiver The users account keys.
|
||||
* @param privateKey The user's wrapped private key.
|
||||
*/
|
||||
fun AccountKeysJson?.toAccountCryptographicState(
|
||||
privateKey: String,
|
||||
): WrappedAccountCryptographicState = createWrappedAccountCryptographicState(
|
||||
privateKey = privateKey,
|
||||
securityState = this?.securityState?.securityState,
|
||||
signingKey = this?.signatureKeyPair?.wrappedSigningKey,
|
||||
signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey,
|
||||
)
|
||||
@@ -1,83 +0,0 @@
|
||||
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,7 +5,8 @@ import android.net.Uri
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "duo-callback"
|
||||
@@ -33,7 +34,9 @@ fun Intent.getDuoCallbackTokenResult(): DuoCallbackTokenResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getDuoCallbackTokenResult()
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -11,31 +11,31 @@ import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
import java.util.Base64
|
||||
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "sso-callback"
|
||||
|
||||
const val SSO_URI: String = "bitwarden://$CALLBACK"
|
||||
|
||||
/**
|
||||
* Generates a URI for the SSO custom tab.
|
||||
*
|
||||
* @param identityBaseUrl The base URl for the identity service.
|
||||
* @param redirectUrl The redirect URI used in the SSO request.
|
||||
* @param organizationIdentifier The SSO organization identifier.
|
||||
* @param token The prevalidated SSO token.
|
||||
* @param state Random state used to verify the validity of the response.
|
||||
* @param codeVerifier A random string used to generate the code challenge.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun generateUriForSso(
|
||||
identityBaseUrl: String,
|
||||
redirectUrl: String,
|
||||
organizationIdentifier: String,
|
||||
token: String,
|
||||
state: String,
|
||||
codeVerifier: String,
|
||||
): Uri {
|
||||
val redirectUri = URLEncoder.encode(redirectUrl, "UTF-8")
|
||||
val redirectUri = URLEncoder.encode(SSO_URI, "UTF-8")
|
||||
val encodedOrganizationIdentifier = URLEncoder.encode(organizationIdentifier, "UTF-8")
|
||||
val encodedToken = URLEncoder.encode(token, "UTF-8")
|
||||
|
||||
@@ -81,7 +81,9 @@ fun Intent.getSsoCallbackResult(): SsoCallbackResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getSsoCallbackResult()
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -13,30 +13,26 @@ private val JSON = Json {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization] or `null` if the
|
||||
* [SyncResponseJson.Profile.Organization.name] is not present.
|
||||
* Maps the given [SyncResponseJson.Profile.Organization] to an [Organization].
|
||||
*/
|
||||
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
|
||||
this.name?.let {
|
||||
Organization(
|
||||
id = this.id,
|
||||
name = it,
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
|
||||
limitItemDeletion = this.limitItemDeletion,
|
||||
shouldUseEvents = this.shouldUseEvents,
|
||||
)
|
||||
}
|
||||
fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
|
||||
Organization(
|
||||
id = this.id,
|
||||
name = this.name,
|
||||
shouldUseKeyConnector = this.shouldUseKeyConnector,
|
||||
role = this.type,
|
||||
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
|
||||
keyConnectorUrl = this.keyConnectorUrl,
|
||||
userIsClaimedByOrganization = this.userIsClaimedByOrganization,
|
||||
limitItemDeletion = this.limitItemDeletion,
|
||||
)
|
||||
|
||||
/**
|
||||
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
|
||||
* [Organization]s.
|
||||
*/
|
||||
fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organization> =
|
||||
this.mapNotNull { it.toOrganization() }
|
||||
this.map { it.toOrganization() }
|
||||
|
||||
/**
|
||||
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.
|
||||
|
||||
@@ -58,7 +58,6 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
val userId = syncProfile.id
|
||||
val account = this.accounts[userId] ?: return this
|
||||
val profile = account.profile
|
||||
val masterPasswordUnlockKdf = syncResponse.userDecryption?.masterPasswordUnlock?.kdf
|
||||
val userDecryptionOptions = syncResponse
|
||||
.userDecryption
|
||||
?.let { syncUserDecryption ->
|
||||
@@ -84,14 +83,6 @@ fun UserStateJson.toUpdatedUserStateJson(
|
||||
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
|
||||
creationDate = syncProfile.creationDate,
|
||||
userDecryptionOptions = userDecryptionOptions,
|
||||
kdfType = masterPasswordUnlockKdf?.kdfType
|
||||
?: profile.kdfType,
|
||||
kdfIterations = masterPasswordUnlockKdf?.iterations
|
||||
?: profile.kdfIterations,
|
||||
kdfMemory = masterPasswordUnlockKdf?.memory
|
||||
?: profile.kdfMemory,
|
||||
kdfParallelism = masterPasswordUnlockKdf?.parallelism
|
||||
?: profile.kdfParallelism,
|
||||
)
|
||||
val updatedAccount = account.copy(profile = updatedProfile)
|
||||
return this
|
||||
@@ -257,7 +248,6 @@ fun UserStateJson.toUserState(
|
||||
firstTimeState = firstTimeState,
|
||||
isExportable = !hasPersonalOwnershipRestrictedOrg &&
|
||||
!hasPersonalVaultExportRestrictedOrg,
|
||||
creationDate = profile.creationDate,
|
||||
)
|
||||
},
|
||||
hasPendingAccountAddition = hasPendingAccountAddition,
|
||||
|
||||
@@ -5,18 +5,20 @@ import android.net.Uri
|
||||
import androidx.browser.auth.AuthTabIntent
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.net.URLEncoder
|
||||
import java.util.Base64
|
||||
|
||||
private val BITWARDEN_HOSTS: List<String> = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
|
||||
private const val BITWARDEN_EU_HOST: String = "bitwarden.eu"
|
||||
private const val BITWARDEN_US_HOST: String = "bitwarden.com"
|
||||
private const val APP_LINK_SCHEME: String = "https"
|
||||
private const val DEEPLINK_SCHEME: String = "bitwarden"
|
||||
private const val CALLBACK: String = "webauthn-callback"
|
||||
|
||||
private const val CALLBACK_URI = "bitwarden://$CALLBACK"
|
||||
|
||||
/**
|
||||
* Retrieves an [WebAuthResult] from an [Intent]. There are three possible cases.
|
||||
*
|
||||
@@ -37,7 +39,9 @@ fun Intent.getWebAuthResultOrNull(): WebAuthResult? {
|
||||
}
|
||||
|
||||
APP_LINK_SCHEME -> {
|
||||
if (localData.host in BITWARDEN_HOSTS && localData.path == "/$CALLBACK") {
|
||||
if ((localData.host == BITWARDEN_US_HOST || localData.host == BITWARDEN_EU_HOST) &&
|
||||
localData.path == "/$CALLBACK"
|
||||
) {
|
||||
localData.getWebAuthResult()
|
||||
} else {
|
||||
null
|
||||
@@ -75,33 +79,29 @@ private fun Uri?.getWebAuthResult(): WebAuthResult =
|
||||
/**
|
||||
* Generates a [Uri] to display a web authn challenge for Bitwarden authentication.
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
fun generateUriForWebAuth(
|
||||
baseUrl: String,
|
||||
authTabData: AuthTabData,
|
||||
data: JsonObject,
|
||||
headerText: String,
|
||||
buttonText: String,
|
||||
returnButtonText: String,
|
||||
): Uri {
|
||||
val json = buildJsonObject {
|
||||
put(key = "callbackUri", value = CALLBACK_URI)
|
||||
put(key = "data", value = data.toString())
|
||||
put(key = "headerText", value = headerText)
|
||||
put(key = "btnText", value = buttonText)
|
||||
put(key = "btnReturnText", value = returnButtonText)
|
||||
put(key = "mobile", value = true)
|
||||
}
|
||||
val base64Data = Base64
|
||||
.getEncoder()
|
||||
.encodeToString(json.toString().toByteArray(Charsets.UTF_8))
|
||||
val parentParam = URLEncoder.encode(authTabData.callbackUrl, "UTF-8")
|
||||
val parentParam = URLEncoder.encode(CALLBACK_URI, "UTF-8")
|
||||
val url = baseUrl +
|
||||
"/webauthn-mobile-connector.html" +
|
||||
"?data=$base64Data" +
|
||||
"&parent=$parentParam" +
|
||||
"&client=mobile" +
|
||||
"&v=2" +
|
||||
"&deeplinkScheme=${authTabData.callbackScheme}"
|
||||
"&v=2"
|
||||
return url.toUri()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.manager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A container for values specifying whether the accessibility service is enabled.
|
||||
* A container for values specifying whether or not the accessibility service is enabled.
|
||||
*/
|
||||
interface AccessibilityEnabledManager {
|
||||
/**
|
||||
|
||||
@@ -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,13 +2,12 @@ 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
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Primary implementation of [AutofillActivityManager].
|
||||
@@ -21,44 +20,16 @@ class AutofillActivityManagerImpl(
|
||||
lifecycleScope: LifecycleCoroutineScope,
|
||||
browserThirdPartyAutofillEnabledManager: BrowserThirdPartyAutofillEnabledManager,
|
||||
) : AutofillActivityManager {
|
||||
private val autofillManagerIsEnabled: Boolean
|
||||
get() = try {
|
||||
autofillManager.isEnabled
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
|
||||
Timber.e(e, "autofillManager.isEnabled failed")
|
||||
false
|
||||
}
|
||||
|
||||
private val autofillManagerHasEnabledAutofillServices: Boolean
|
||||
get() = try {
|
||||
autofillManager.hasEnabledAutofillServices()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
|
||||
Timber.e(e, "autofillManager.hasEnabledAutofillServices() failed")
|
||||
false
|
||||
}
|
||||
|
||||
private val autofillManagerIsAutofillSupported: Boolean
|
||||
get() = try {
|
||||
autofillManager.isAutofillSupported
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: RuntimeException) {
|
||||
Timber.e(e, "autofillManager.isAutofillSupported() failed")
|
||||
false
|
||||
}
|
||||
|
||||
private val isAutofillEnabledAndSupported: Boolean
|
||||
get() = autofillManagerIsEnabled &&
|
||||
autofillManagerHasEnabledAutofillServices &&
|
||||
autofillManagerIsAutofillSupported
|
||||
get() = autofillManager.isEnabled &&
|
||||
autofillManager.hasEnabledAutofillServices() &&
|
||||
autofillManager.isAutofillSupported
|
||||
|
||||
private val browserAutofillStatus: BrowserThirdPartyAutofillStatus
|
||||
get() = BrowserThirdPartyAutofillStatus(
|
||||
braveStableStatusData = browserThirdPartyAutofillManager.stableBraveAutofillStatus,
|
||||
chromeStableStatusData = browserThirdPartyAutofillManager.stableChromeAutofillStatus,
|
||||
chromeBetaChannelStatusData = browserThirdPartyAutofillManager.betaChromeAutofillStatus,
|
||||
vivaldiStableChannelStatusData = browserThirdPartyAutofillManager
|
||||
.stableVivaldiAutofillStatus,
|
||||
defaultBrowserPackageName = browserThirdPartyAutofillManager
|
||||
.defaultBrowserPackageName,
|
||||
)
|
||||
|
||||
init {
|
||||
|
||||
@@ -3,12 +3,12 @@ package com.x8bit.bitwarden.data.autofill.manager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A container for values specifying whether autofill is enabled. These values should be
|
||||
* A container for values specifying whether or not autofill is enabled. These values should be
|
||||
* filled by an [AutofillActivityManager].
|
||||
*/
|
||||
interface AutofillEnabledManager {
|
||||
/**
|
||||
* Whether autofill should be considered enabled.
|
||||
* Whether or not autofill should be considered enabled.
|
||||
*
|
||||
* Note that changing this does not enable or disable autofill; it is only an indicator that
|
||||
* this has occurred elsewhere.
|
||||
|
||||
@@ -29,7 +29,7 @@ internal class BrowserAutofillDialogManagerImpl(
|
||||
get() = autofillEnabledManager.isAutofillEnabled &&
|
||||
browserThirdPartyAutofillEnabledManager
|
||||
.browserThirdPartyAutofillStatus
|
||||
.isDefaultBrowserAvailableAndDisabled &&
|
||||
.isAnyIsAvailableAndDisabled &&
|
||||
!firstTimeActionManager
|
||||
.currentOrDefaultUserFirstTimeState
|
||||
.showSetupBrowserAutofillCard &&
|
||||
|
||||
@@ -39,9 +39,4 @@ private val DEFAULT_STATUS = BrowserThirdPartyAutofillStatus(
|
||||
isAvailable = false,
|
||||
isThirdPartyEnabled = false,
|
||||
),
|
||||
vivaldiStableChannelStatusData = BrowserThirdPartyAutoFillData(
|
||||
isAvailable = false,
|
||||
isThirdPartyEnabled = false,
|
||||
),
|
||||
defaultBrowserPackageName = null,
|
||||
)
|
||||
|
||||
@@ -22,14 +22,4 @@ interface BrowserThirdPartyAutofillManager {
|
||||
* The data representing the status of the beta Chrome version
|
||||
*/
|
||||
val betaChromeAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
|
||||
/**
|
||||
* The data representing the status of the Vivaldi version
|
||||
*/
|
||||
val stableVivaldiAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
|
||||
/**
|
||||
* The package name of the device's default browser, or null if it cannot be determined.
|
||||
*/
|
||||
val defaultBrowserPackageName: String?
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ package com.x8bit.bitwarden.data.autofill.manager.browser
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserPackage
|
||||
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutoFillData
|
||||
@@ -30,18 +27,6 @@ class BrowserThirdPartyAutofillManagerImpl(
|
||||
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.CHROME_STABLE)
|
||||
override val betaChromeAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.CHROME_BETA)
|
||||
override val stableVivaldiAutofillStatus: BrowserThirdPartyAutoFillData
|
||||
get() = getThirdPartyAutoFillStatusForChannel(BrowserPackage.VIVALDI_STABLE)
|
||||
|
||||
override val defaultBrowserPackageName: String?
|
||||
get() {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "https://example.com".toUri())
|
||||
return context
|
||||
.packageManager
|
||||
.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
?.activityInfo
|
||||
?.packageName
|
||||
}
|
||||
|
||||
private fun getThirdPartyAutoFillStatusForChannel(
|
||||
releaseChannel: BrowserPackage,
|
||||
@@ -62,13 +47,11 @@ class BrowserThirdPartyAutofillManagerImpl(
|
||||
)
|
||||
var thirdPartyEnabled = false
|
||||
val isThirdPartyAvailable = cursor
|
||||
?.use {
|
||||
?.let {
|
||||
it.moveToFirst()
|
||||
thirdPartyEnabled = it
|
||||
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
|
||||
.takeUnless { columnIndex -> columnIndex == -1 }
|
||||
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
|
||||
?: false
|
||||
val columnIndex = it.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
|
||||
thirdPartyEnabled = it.getInt(columnIndex) != 0
|
||||
it.close()
|
||||
true
|
||||
}
|
||||
?: false
|
||||
|
||||
@@ -14,7 +14,7 @@ sealed class AutofillCipher {
|
||||
abstract val iconRes: Int
|
||||
|
||||
/**
|
||||
* Whether TOTP is enabled for this cipher.
|
||||
* Whether or not TOTP is enabled for this cipher.
|
||||
*/
|
||||
abstract val isTotpEnabled: Boolean
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.autofill.model.browser
|
||||
private const val BRAVE_CHANNEL_PACKAGE = "com.brave.browser"
|
||||
private const val CHROME_BETA_CHANNEL_PACKAGE = "com.chrome.beta"
|
||||
private const val CHROME_RELEASE_CHANNEL_PACKAGE = "com.android.chrome"
|
||||
private const val VIVALDI_RELEASE_CHANNEL_PACKAGE = "com.vivaldi.browser"
|
||||
|
||||
/**
|
||||
* Enumerated values of each browser that supports third party autofill checks.
|
||||
@@ -14,5 +13,4 @@ enum class BrowserPackage(val packageName: String) {
|
||||
BRAVE_RELEASE(BRAVE_CHANNEL_PACKAGE),
|
||||
CHROME_STABLE(CHROME_RELEASE_CHANNEL_PACKAGE),
|
||||
CHROME_BETA(CHROME_BETA_CHANNEL_PACKAGE),
|
||||
VIVALDI_STABLE(VIVALDI_RELEASE_CHANNEL_PACKAGE),
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val braveStableStatusData: BrowserThirdPartyAutoFillData,
|
||||
val chromeStableStatusData: BrowserThirdPartyAutoFillData,
|
||||
val chromeBetaChannelStatusData: BrowserThirdPartyAutoFillData,
|
||||
val vivaldiStableChannelStatusData: BrowserThirdPartyAutoFillData,
|
||||
val defaultBrowserPackageName: String?,
|
||||
) {
|
||||
/**
|
||||
* The total number of available browsers.
|
||||
@@ -26,8 +24,7 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val availableCount: Int
|
||||
get() = (if (braveStableStatusData.isAvailable) 1 else 0) +
|
||||
(if (chromeStableStatusData.isAvailable) 1 else 0) +
|
||||
(if (chromeBetaChannelStatusData.isAvailable) 1 else 0) +
|
||||
(if (vivaldiStableChannelStatusData.isAvailable) 1 else 0)
|
||||
(if (chromeBetaChannelStatusData.isAvailable) 1 else 0)
|
||||
|
||||
/**
|
||||
* Whether any of the available browsers have third party autofill disabled.
|
||||
@@ -35,28 +32,5 @@ data class BrowserThirdPartyAutofillStatus(
|
||||
val isAnyIsAvailableAndDisabled: Boolean
|
||||
get() = braveStableStatusData.isAvailableButDisabled ||
|
||||
chromeStableStatusData.isAvailableButDisabled ||
|
||||
chromeBetaChannelStatusData.isAvailableButDisabled ||
|
||||
vivaldiStableChannelStatusData.isAvailableButDisabled
|
||||
|
||||
/**
|
||||
* Whether the device's default browser is one of the supported browsers and has third party
|
||||
* autofill disabled. Returns false if the default browser is not a supported browser or
|
||||
* cannot be determined.
|
||||
*/
|
||||
val isDefaultBrowserAvailableAndDisabled: Boolean
|
||||
get() {
|
||||
val browserPackage = defaultBrowserPackageName
|
||||
?.let { packageName ->
|
||||
BrowserPackage.entries.firstOrNull { it.packageName == packageName }
|
||||
}
|
||||
?: return false
|
||||
return when (browserPackage) {
|
||||
BrowserPackage.BRAVE_RELEASE -> braveStableStatusData.isAvailableButDisabled
|
||||
BrowserPackage.CHROME_STABLE -> chromeStableStatusData.isAvailableButDisabled
|
||||
BrowserPackage.CHROME_BETA -> chromeBetaChannelStatusData.isAvailableButDisabled
|
||||
BrowserPackage.VIVALDI_STABLE -> {
|
||||
vivaldiStableChannelStatusData.isAvailableButDisabled
|
||||
}
|
||||
}
|
||||
}
|
||||
chromeBetaChannelStatusData.isAvailableButDisabled
|
||||
}
|
||||
|
||||
@@ -33,21 +33,14 @@ private val BLOCK_LISTED_URIS: List<String> = listOf(
|
||||
* A map of package ids and the known associated id entry for their url bar.
|
||||
*/
|
||||
private val URL_BARS: Map<String, String> = mapOf(
|
||||
// Edge Browser Variants
|
||||
"com.microsoft.emmx" to "url_bar",
|
||||
"com.microsoft.emmx.beta" to "url_bar",
|
||||
"com.microsoft.emmx.canary" to "url_bar",
|
||||
"com.microsoft.emmx.dev" to "url_bar",
|
||||
// Samsung Internet Browser Variants
|
||||
"com.sec.android.app.sbrowser" to "location_bar_edit_text",
|
||||
"com.sec.android.app.sbrowser.beta" to "location_bar_edit_text",
|
||||
// Opera Browser Variants
|
||||
"com.opera.browser" to "url_bar",
|
||||
"com.opera.browser.beta" to "url_bar",
|
||||
// Brave Browser Variants
|
||||
"com.brave.browser" to "url_bar",
|
||||
"com.brave.browser_beta" to "url_bar",
|
||||
"com.brave.browser_nightly" to "url_bar",
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,6 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
|
||||
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
|
||||
import com.x8bit.bitwarden.data.platform.util.isActive
|
||||
import com.x8bit.bitwarden.data.platform.util.subtitle
|
||||
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
@@ -67,8 +66,10 @@ class AutofillCipherProviderImpl(
|
||||
.takeIf {
|
||||
// Must be card type.
|
||||
it.type is CipherListViewType.Card &&
|
||||
// Must still be active.
|
||||
it.isActive &&
|
||||
// Must not be deleted.
|
||||
it.deletedDate == null &&
|
||||
// Must not be archived.
|
||||
it.archivedDate == null &&
|
||||
// Must not require a reprompt.
|
||||
it.reprompt == CipherRepromptType.NONE &&
|
||||
// Must not be restricted by organization.
|
||||
@@ -105,8 +106,10 @@ class AutofillCipherProviderImpl(
|
||||
.filter {
|
||||
// Must be login type
|
||||
it.type is CipherListViewType.Login &&
|
||||
// Must still be active.
|
||||
it.isActive &&
|
||||
// Must not be deleted.
|
||||
it.deletedDate == null &&
|
||||
// Must not be archived.
|
||||
it.archivedDate == null &&
|
||||
// Must not require a reprompt.
|
||||
it.reprompt == CipherRepromptType.NONE
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
package com.x8bit.bitwarden.data.autofill.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PendingIntent
|
||||
import android.app.assist.AssistStructure
|
||||
import android.content.Context
|
||||
@@ -12,10 +13,6 @@ import android.view.autofill.AutofillManager
|
||||
import androidx.core.os.bundleOf
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.core.util.toPendingIntentMutabilityFlag
|
||||
import com.bitwarden.data.autofill.util.AUTOFILL_BUNDLE_KEY
|
||||
import com.bitwarden.data.autofill.util.AUTOFILL_CALLBACK_DATA_KEY
|
||||
import com.bitwarden.data.autofill.util.AUTOFILL_SAVE_ITEM_DATA_KEY
|
||||
import com.bitwarden.data.autofill.util.AUTOFILL_SELECTION_DATA_KEY
|
||||
import com.bitwarden.ui.platform.util.getSafeParcelableExtra
|
||||
import com.x8bit.bitwarden.AutofillCallbackActivity
|
||||
import com.x8bit.bitwarden.MainActivity
|
||||
@@ -25,6 +22,11 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
|
||||
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val AUTOFILL_SAVE_ITEM_DATA_KEY = "autofill-save-item-data"
|
||||
private const val AUTOFILL_SELECTION_DATA_KEY = "autofill-selection-data"
|
||||
private const val AUTOFILL_CALLBACK_DATA_KEY = "autofill-callback-data"
|
||||
private const val AUTOFILL_BUNDLE_KEY = "autofill-bundle-key"
|
||||
|
||||
/**
|
||||
* Creates an [Intent] in order to send the user to a manual selection process for autofill.
|
||||
*/
|
||||
@@ -147,3 +149,12 @@ fun Intent.getAutofillSelectionDataOrNull(): AutofillSelectionData? =
|
||||
fun Intent.getAutofillCallbackIntentOrNull(): AutofillCallbackData? =
|
||||
getBundleExtra(AUTOFILL_BUNDLE_KEY)
|
||||
?.getSafeParcelableExtra(AUTOFILL_CALLBACK_DATA_KEY)
|
||||
|
||||
/**
|
||||
* Checks if the given [Activity] was created for Autofill. This is useful to avoid locking the
|
||||
* vault if one of the Autofill services starts the only instance of the [MainActivity].
|
||||
*/
|
||||
val Activity.createdForAutofill: Boolean
|
||||
get() = intent.getAutofillSelectionDataOrNull() != null ||
|
||||
intent.getAutofillSaveItemOrNull() != null ||
|
||||
intent.getAutofillAssistStructureOrNull() != null
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user