Compare commits

..

2 Commits

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

View File

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

View File

@@ -1,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

View File

@@ -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.

View File

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

View File

@@ -1,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.

View File

@@ -1,66 +0,0 @@
---
description: Guided Android development workflow through all lifecycle phases
argument-hint: <task description, plan, or Jira ticket reference>
---
# Android Development Workflow
You are guiding the developer through a complete Android development lifecycle for the Bitwarden Android project. The task to work on is:
**Task**: $ARGUMENTS
## Workflow Phases
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** If a phase fails (tests fail, lint errors, etc.), loop on that phase until resolved before advancing. The user may skip phases that are not applicable.
### Phase 1: Implement
Invoke `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.

View File

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

View File

@@ -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

View File

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

View File

@@ -1,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

View File

@@ -1,481 +0,0 @@
---
name: implementing-android-code
version: 0.1.2
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
---
# Implementing Android Code - Bitwarden Quick Reference
**This skill provides tactical guidance for Bitwarden-specific patterns.** For comprehensive architecture decisions and complete code style rules, consult `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
---
## Critical Patterns Reference
### A. ViewModel Implementation (State-Action-Event Pattern)
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
**Key Requirements:**
- Annotate with `@HiltViewModel`
- State class MUST be `@Parcelize data class : Parcelable`
- Implement `handleAction(action: A)` - MUST be synchronous
- Post internal actions from coroutines using `sendAction()`
- Save/restore state via `SavedStateHandle[KEY_STATE]`
- Private action handlers: `private fun handle*` naming convention
**Template**: See [ViewModel template](templates.md#viewmodel-template-state-action-event-pattern)
**Pattern Summary:**
```kotlin
@HiltViewModel
class ExampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
initialState = savedStateHandle[KEY_STATE] ?: ExampleState(),
) {
init {
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
}
override fun handleAction(action: ExampleAction) {
// Synchronous dispatch only
when (action) {
is Action.Click -> handleClick()
is Action.Internal.DataReceived -> handleDataReceived(action)
}
}
private fun handleClick() {
viewModelScope.launch {
val result = repository.fetchData()
sendAction(Action.Internal.DataReceived(result)) // Post internal action
}
}
private fun handleDataReceived(action: Action.Internal.DataReceived) {
mutableStateFlow.update { it.copy(data = action.result) }
}
}
```
**Reference:**
- `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
- `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt` (see class declaration)
**Critical Gotchas:**
-**NEVER** update `mutableStateFlow` directly inside coroutines
-**ALWAYS** post internal actions from coroutines, update state in `handleAction()`
-**NEVER** forget `@IgnoredOnParcel` for sensitive data (causes security leak)
-**ALWAYS** use `@Parcelize` on state classes for process death recovery
- ✅ State restoration happens automatically if properly saved to `SavedStateHandle`
---
### B. Navigation Implementation (Type-Safe)
All navigation uses **type-safe routes** with kotlinx.serialization.
**Pattern Structure:**
1. `@Serializable` route data class with parameters
2. `...Args` helper class for extracting from `SavedStateHandle`
3. `NavGraphBuilder.{screen}Destination()` extension for adding screen to graph
4. `NavController.navigateTo{Screen}()` extension for navigation calls
**Template**: See [Navigation template](templates.md#navigation-template-type-safe-routes)
**Pattern Summary:**
```kotlin
@Serializable
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)
data class ExampleArgs(val userId: String, val isEditMode: Boolean)
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
val route = this.toRoute<ExampleRoute>()
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
}
fun NavController.navigateToExample(
userId: String,
isEditMode: Boolean = false,
navOptions: NavOptions? = null,
) {
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
}
fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
composableWithSlideTransitions<ExampleRoute> {
ExampleScreen(onNavigateBack = onNavigateBack)
}
}
```
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt` (see `LoginRoute` and extensions)
**Key Benefits:**
- ✅ Type safety: Compile-time errors for missing parameters
- ✅ No string literals in navigation code
- ✅ Automatic serialization/deserialization
- ✅ Clear contract for screen dependencies
---
### C. Screen/Compose Implementation
All screens follow consistent Compose patterns.
**Template**: See [Screen/Compose template](templates.md#screencompose-template)
**Key Patterns:**
```kotlin
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
}
}
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(R.string.title),
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
// UI content
}
}
```
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
**Essential Requirements:**
- ✅ Use `hiltViewModel()` for dependency injection
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
- ✅ Use `EventsEffect(viewModel)` for one-shot events
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
**State Hoisting Rules:**
- **ViewModel state**: Data that needs to survive process death or affects business logic
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
---
### D. Data Layer Implementation
The data layer follows strict patterns for repositories, managers, and data sources.
**Interface + Implementation Separation (ALWAYS)**
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
**Pattern Summary:**
```kotlin
// Interface (injected via Hilt)
interface ExampleRepository {
suspend fun fetchData(id: String): ExampleResult
val dataFlow: StateFlow<DataState<ExampleData>>
}
// Implementation (NOT directly injected)
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
) : ExampleRepository {
override suspend fun fetchData(id: String): ExampleResult {
// NO exceptions thrown - return Result or sealed class
return exampleService.getData(id).fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
// Sealed result class (domain-specific)
sealed class ExampleResult {
data class Success(val data: ExampleData) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
// Hilt Module
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
fun provideExampleRepository(
exampleDiskSource: ExampleDiskSource,
exampleService: ExampleService,
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
}
```
**Reference:**
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
**Three-Layer Data Architecture:**
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
**Critical Rules:**
-**NEVER** throw exceptions in data layer
-**ALWAYS** use interface + `...Impl` pattern
-**ALWAYS** inject interfaces, never implementations
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
- ✅ Use `StateFlow` for continuously observed data
---
### E. UI Components
**Use Existing Components First:**
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
**Common Components:**
- `BitwardenFilledButton` - Primary action buttons
- `BitwardenOutlinedButton` - Secondary action buttons
- `BitwardenTextField` - Text input fields
- `BitwardenPasswordField` - Password input with show/hide
- `BitwardenSwitch` - Toggle switches
- `BitwardenTopAppBar` - Toolbar/app bar
- `BitwardenScaffold` - Screen container with scaffold
- `BitwardenBasicDialog` - Simple dialogs
- `BitwardenLoadingDialog` - Loading indicators
**Component Discovery:**
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
**When to Create New Reusable Components:**
- Component used in 3+ places
- Component needs consistent theming across app
- Component has semantic meaning (accessibility)
- Component has complex state management
**New Component Requirements:**
- Prefix with `Bitwarden`
- Accept themed colors/styles from `BitwardenTheme`
- Include preview composables for testing
- Support accessibility (content descriptions, semantics)
**String Resources:**
New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
- Use typographic apostrophes and quotes to avoid escape characters: `youll` not `you\'ll`, `“word”` not `\"word\"`
- Reference strings via generated `BitwardenString` resource IDs
- Do not add strings to other modules unless explicitly instructed
---
### F. Security Patterns
**Encrypted vs Unencrypted Storage:**
**Template**: See [Security templates](templates.md#security-templates)
**Pattern Summary:**
```kotlin
class ExampleDiskSourceImpl(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
fun storeAuthToken(token: String) {
putEncryptedString(KEY_TOKEN, token) // Sensitive — uses base class method
}
fun storeThemePreference(isDark: Boolean) {
putBoolean(KEY_THEME, isDark) // Non-sensitive — uses base class method
}
}
```
**Android Keystore (Biometric Keys):**
- User-scoped encryption keys: `BiometricsEncryptionManager`
- Keys stored in Android Keystore (hardware-backed when available)
- Integrity validation on biometric state changes
**Input Validation:**
```kotlin
// Validation returns boolean, NEVER throws
interface RequestValidator {
fun validate(request: Request): Boolean
}
// Sanitization removes dangerous content
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
if (this.isNullOrBlank()) return null
// Sanitize and return safe value
}
```
**Security Checklist:**
- ✅ Use `@EncryptedPreferences` for credentials, keys, tokens
- ✅ Use `@UnencryptedPreferences` for UI state, preferences
- ✅ Use `@IgnoredOnParcel` for sensitive ViewModel state
-**NEVER** log sensitive data (passwords, tokens, vault items)
- ✅ Validate all user input before processing
- ✅ Use Timber for non-sensitive logging only
---
### G. Testing Patterns
**ViewModel Testing:**
**Template**: See [Testing templates](templates.md#testing-templates)
**Pattern Summary:**
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
private val mockRepository: ExampleRepository = mockk()
@Test
fun `ButtonClick should fetch data and update state`() = runTest {
val expectedResult = ExampleResult.Success(data = "test")
coEvery { mockRepository.fetchData(any()) } returns expectedResult
val viewModel = createViewModel()
viewModel.trySendAction(ExampleAction.ButtonClick)
viewModel.stateFlow.test {
assertEquals(EXPECTED_STATE.copy(data = "test"), awaitItem())
}
}
private fun createViewModel(): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to EXPECTED_STATE)),
repository = mockRepository,
)
}
```
**Reference:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
**Key Testing Patterns:**
- ✅ Extend `BaseViewModelTest` for proper dispatcher management
- ✅ Use `runTest` from `kotlinx.coroutines.test`
- ✅ Use Turbine's `.test { awaitItem() }` for Flow assertions
- ✅ Use MockK: `coEvery` for suspend functions, `every` for sync
- ✅ Test both state changes and event emissions
- ✅ Test both success and failure Result paths
**Flow Testing with Turbine:**
```kotlin
// Test state and events simultaneously
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.Submit)
assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
}
```
**MockK Quick Reference:**
```kotlin
coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
every { diskSource.getData() } returns "cached" // Sync
coVerify { repository.fetchData("123") } // Verify
```
---
### H. Clock/Time Handling
All code needing current time must inject `Clock` for testability.
**Key Requirements:**
- ✅ Inject `Clock` via Hilt in ViewModels
- ✅ Pass `Clock` as parameter in extension functions
- ✅ Use `clock.instant()` to get current time
- ❌ Never call `Instant.now()` or `DateTime.now()` directly
- ❌ Never use `mockkStatic` for datetime classes in tests
**Pattern Summary:**
```kotlin
// ViewModel with Clock
class MyViewModel @Inject constructor(
private val clock: Clock,
) {
val timestamp = clock.instant()
}
// Extension function with Clock parameter
fun State.getTimestamp(clock: Clock): Instant =
existingTime ?: clock.instant()
// Test with fixed clock
val FIXED_CLOCK = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
```
**Reference:**
- `docs/STYLE_AND_BEST_PRACTICES.md` (see Time and Clock Handling section)
- `core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt` (see `provideClock` function)
**Critical Gotchas:**
-`Instant.now()` creates hidden dependency, non-testable
-`mockkStatic(Instant::class)` is fragile, can leak between tests
-`Clock.fixed(...)` provides deterministic test behavior
---
## Bitwarden-Specific Anti-Patterns
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
**NEVER update ViewModel state directly in coroutines**
- Post internal actions, update state synchronously in `handleAction()`
**NEVER inject `...Impl` classes**
- Only inject interfaces via Hilt
**NEVER create navigation without `@Serializable` routes**
- No string-based navigation, always type-safe
**NEVER use raw `Result<T>` in repositories**
- Use domain-specific sealed classes for better error handling
**NEVER make state classes without `@Parcelize`**
- All ViewModel state must survive process death
**NEVER skip `SavedStateHandle` persistence for ViewModels**
- Users lose form progress on process death
**NEVER forget `@IgnoredOnParcel` for passwords/tokens**
- Causes security vulnerability (sensitive data in parcel)
**NEVER use generic `Exception` catching**
- Catch specific exceptions only (`RemoteException`, `IOException`)
**NEVER call `Instant.now()` or `DateTime.now()` directly**
- Inject `Clock` via Hilt, use `clock.instant()` for testability
---
## Quick Reference
For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
**File Reference Format:**
When pointing to specific code, use: `file_path:line_number`
Example: `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)

View File

@@ -1,636 +0,0 @@
# Code Templates - Bitwarden Android
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
---
## ViewModel Template (State-Action-Event Pattern)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
### State Class
```kotlin
@Parcelize
data class ExampleState(
val isLoading: Boolean = false,
val data: String? = null,
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
val dialogState: DialogState? = null,
) : Parcelable {
/**
* Dialog states for the Example screen.
*/
sealed class DialogState : Parcelable {
@Parcelize
data class Error(
val title: Text? = null,
val message: Text,
val error: Throwable? = null,
) : DialogState()
@Parcelize
data class Loading(val message: Text) : DialogState()
}
}
```
### Event Sealed Class
```kotlin
/**
* One-shot UI events for the Example screen.
*/
sealed class ExampleEvent {
data object NavigateBack : ExampleEvent()
data class ShowToast(val message: Text) : ExampleEvent()
}
```
### Action Sealed Class (with Internal)
```kotlin
/**
* User and system actions for the Example screen.
*/
sealed class ExampleAction {
data object BackClick : ExampleAction()
data object SubmitClick : ExampleAction()
data class InputChanged(val input: String) : ExampleAction()
data object ErrorDialogDismiss : ExampleAction()
/**
* Internal actions dispatched by the ViewModel from coroutines.
*/
sealed class Internal : ExampleAction() {
data class ReceiveDataState(
val dataState: DataState<ExampleData>,
) : Internal()
data class ReceiveDataResult(
val result: ExampleResult,
) : Internal()
}
}
```
### ViewModel
```kotlin
private const val KEY_STATE = "state"
/**
* ViewModel for the Example screen.
*/
@HiltViewModel
class ExampleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val exampleRepository: ExampleRepository,
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
initialState = savedStateHandle[KEY_STATE]
?: run {
val args = savedStateHandle.toExampleArgs()
ExampleState(
data = args.itemId,
)
},
) {
init {
// Persist state for process death recovery
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
// Collect repository flows as internal actions
exampleRepository.dataFlow
.map { ExampleAction.Internal.ReceiveDataState(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: ExampleAction) {
when (action) {
ExampleAction.BackClick -> handleBackClick()
ExampleAction.SubmitClick -> handleSubmitClick()
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
is ExampleAction.InputChanged -> handleInputChanged(action)
is ExampleAction.Internal.ReceiveDataState -> {
handleReceiveDataState(action)
}
is ExampleAction.Internal.ReceiveDataResult -> {
handleReceiveDataResult(action)
}
}
}
private fun handleBackClick() {
sendEvent(ExampleEvent.NavigateBack)
}
private fun handleErrorDialogDismiss() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleSubmitClick() {
viewModelScope.launch {
val result = exampleRepository.submitData(state.data.orEmpty())
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
}
}
private fun handleInputChanged(action: ExampleAction.InputChanged) {
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
}
private fun handleReceiveDataState(
action: ExampleAction.Internal.ReceiveDataState,
) {
when (action.dataState) {
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = action.dataState.data.toString(),
)
}
}
is DataState.Loading -> {
mutableStateFlow.update { it.copy(isLoading = true) }
}
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = BitwardenString.generic_error_message.asText(),
error = action.dataState.error,
),
)
}
}
else -> Unit
}
}
private fun handleReceiveDataResult(
action: ExampleAction.Internal.ReceiveDataResult,
) {
when (val result = action.result) {
is ExampleResult.Success -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
data = result.data,
)
}
}
is ExampleResult.Error -> {
mutableStateFlow.update {
it.copy(
isLoading = false,
dialogState = ExampleState.DialogState.Error(
message = result.message?.asText()
?: BitwardenString.generic_error_message.asText(),
),
)
}
}
}
}
}
```
---
## Navigation Template (Type-Safe Routes)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
```kotlin
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.feature.example
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* Route for the Example screen.
*/
@Serializable
@OmitFromCoverage
data class ExampleRoute(
val itemId: String,
val isEditMode: Boolean = false,
)
/**
* Args extracted from [SavedStateHandle] for the Example screen.
*/
@OmitFromCoverage
data class ExampleArgs(
val itemId: String,
val isEditMode: Boolean,
)
/**
* Extracts [ExampleArgs] from the [SavedStateHandle].
*/
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
val route = this.toRoute<ExampleRoute>()
return ExampleArgs(
itemId = route.itemId,
isEditMode = route.isEditMode,
)
}
/**
* Navigate to the Example screen.
*/
fun NavController.navigateToExample(
itemId: String,
isEditMode: Boolean = false,
navOptions: NavOptions? = null,
) {
this.navigate(
route = ExampleRoute(
itemId = itemId,
isEditMode = isEditMode,
),
navOptions = navOptions,
)
}
/**
* Add the Example screen destination to the navigation graph.
*/
fun NavGraphBuilder.exampleDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions<ExampleRoute> {
ExampleScreen(
onNavigateBack = onNavigateBack,
)
}
}
```
---
## Screen/Compose Template
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
```kotlin
package com.x8bit.bitwarden.ui.feature.example
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* The Example screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
is ExampleEvent.ShowToast -> {
// Handle toast
}
}
}
// Dialogs
ExampleDialogs(
dialogState = state.dialogState,
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.example),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
ExampleScreenContent(
state = state,
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
modifier = Modifier
.fillMaxSize(),
)
}
}
```
---
## Data Layer Template (Repository + Hilt Module)
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
### Interface
```kotlin
/**
* Provides data operations for the Example feature.
*/
interface ExampleRepository {
/**
* Submits data and returns a typed result.
*/
suspend fun submitData(input: String): ExampleResult
/**
* Continuously observed data stream.
*/
val dataFlow: StateFlow<DataState<ExampleData>>
}
```
### Sealed Result Class
```kotlin
/**
* Domain-specific result for Example operations.
*/
sealed class ExampleResult {
data class Success(val data: String) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
```
### Implementation
```kotlin
/**
* Default implementation of [ExampleRepository].
*/
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
private val dispatcherManager: DispatcherManager,
) : ExampleRepository {
override val dataFlow: StateFlow<DataState<ExampleData>>
get() = // ...
override suspend fun submitData(input: String): ExampleResult {
return exampleService
.postData(input)
.fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
```
### Hilt Module
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
fun provideExampleRepository(
exampleDiskSource: ExampleDiskSource,
exampleService: ExampleService,
dispatcherManager: DispatcherManager,
): ExampleRepository = ExampleRepositoryImpl(
exampleDiskSource = exampleDiskSource,
exampleService = exampleService,
dispatcherManager = dispatcherManager,
)
}
```
---
## Security Templates
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
### Encrypted Disk Source (Module)
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object ExampleDiskModule {
@Provides
@Singleton
fun provideExampleDiskSource(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
): ExampleDiskSource = ExampleDiskSourceImpl(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
json = json,
)
}
```
### Encrypted Disk Source (Implementation)
```kotlin
/**
* Disk source for Example data using encrypted and unencrypted storage.
*/
class ExampleDiskSourceImpl(
encryptedSharedPreferences: SharedPreferences,
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
private companion object {
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
const val UNENCRYPTED_PREF_KEY = "examplePreference"
}
override var authToken: String?
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
override var uiPreference: Boolean
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
}
```
---
## Testing Templates
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
### ViewModel Test
```kotlin
class ExampleViewModelTest : BaseViewModelTest() {
// Mock dependencies
private val mockRepository = mockk<ExampleRepository>()
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
@BeforeEach
fun setup() {
every { mockRepository.dataFlow } returns mutableDataFlow
}
@Test
fun `initial state should be correct when there is no saved state`() {
val viewModel = createViewModel(state = null)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `initial state should be correct when there is a saved state`() {
val savedState = DEFAULT_STATE.copy(data = "saved")
val viewModel = createViewModel(state = savedState)
assertEquals(savedState, viewModel.stateFlow.value)
}
@Test
fun `SubmitClick should call repository and update state on success`() = runTest {
val expected = ExampleResult.Success(data = "result")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
// Initial state
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
// Updated state after result
assertEquals(
DEFAULT_STATE.copy(data = "result", isLoading = false),
awaitItem(),
)
}
}
@Test
fun `SubmitClick should show error dialog on failure`() = runTest {
val expected = ExampleResult.Error(message = "Network error")
coEvery { mockRepository.submitData(any()) } returns expected
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(ExampleAction.SubmitClick)
val errorState = awaitItem()
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
}
}
@Test
fun `BackClick should emit NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(ExampleAction.BackClick)
assertEquals(ExampleEvent.NavigateBack, awaitItem())
}
}
// Helper to create ViewModel with optional saved state
private fun createViewModel(
state: ExampleState? = DEFAULT_STATE,
): ExampleViewModel = ExampleViewModel(
savedStateHandle = SavedStateHandle(
mapOf(KEY_STATE to state),
),
exampleRepository = mockRepository,
)
companion object {
private val DEFAULT_STATE = ExampleState(
isLoading = false,
data = null,
)
}
}
```
### Flow Testing with stateEventFlow
```kotlin
@Test
fun `SubmitClick should update state and emit event`() = runTest {
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.SubmitClick)
// Assert state change
assertEquals(
DEFAULT_STATE.copy(data = "data"),
stateFlow.awaitItem(),
)
// Assert event emission
assertEquals(
ExampleEvent.ShowToast("Success".asText()),
eventFlow.awaitItem(),
)
}
}
```

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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

View File

@@ -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

View File

@@ -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

View File

@@ -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.
```

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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.

View File

@@ -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)

View File

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

View File

@@ -245,8 +245,6 @@ fun `test exception`() {
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`
@@ -284,10 +282,6 @@ module/src/testFixtures/kotlin/com/bitwarden/.../
└── 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`

View File

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

View File

@@ -1,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)

View File

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

View File

@@ -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

View File

@@ -31,6 +31,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
@@ -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

View File

@@ -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

View File

@@ -31,6 +31,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 21
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -3,8 +3,9 @@ name: Test
on:
push:
branches:
- main
- release/**/*
- "main"
- "rc"
- "hotfix-rc"
pull_request:
types: [opened, synchronize]
merge_group:
@@ -12,45 +13,16 @@ on:
workflow_dispatch:
env:
_JAVA_VERSION: 21
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
jobs:
test-sharded:
name: "Test ${{ matrix.group }}"
test:
name: Test
runs-on: ubuntu-24.04
permissions:
packages: read
strategy:
fail-fast: false
matrix:
include:
- group: static-analysis
fastlane_method: checkLint
fastlane_options: ""
# App shards
- group: app-data
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.data.*"
- group: app-ui-auth-tools
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.auth.* --tests com.x8bit.bitwarden.ui.tools.* --tests com.x8bit.bitwarden.ui.autofill.* --tests com.x8bit.bitwarden.ui.credentials.*"
- group: app-ui-platform
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.platform.*"
- group: app-ui-vault
fastlane_method: testAppShard
fastlane_options: "--tests com.x8bit.bitwarden.ui.vault.*"
# Authenticator
- group: authenticator
fastlane_method: testLibraries
fastlane_options: ":authenticator"
# Library shards
- group: lib-core-network-bridge
fastlane_method: testLibraries
fastlane_options: ":core :network :cxf :authenticatorbridge :testharness"
- group: lib-data-ui
fastlane_method: testLibraries
fastlane_options: ":data :ui"
pull-requests: write
steps:
- name: Check out repo
@@ -58,101 +30,87 @@ jobs:
with:
persist-credentials: false
- name: Setup Android Build
uses: ./.github/actions/setup-android-build
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
- name: Run tests
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
_GROUP: ${{ matrix.group }}
_FASTLANE_METHOD: ${{ matrix.fastlane_method }}
_FASTLANE_OPTIONS: ${{ matrix.fastlane_options }}
- 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')
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@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
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!"

View File

@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.9)
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.1231.0)
aws-sdk-core (3.244.0)
aws-partitions (1.1213.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -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.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-s3 (1.213.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.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -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,7 +169,7 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.19.3)
json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.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)

View File

@@ -297,7 +297,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

View File

@@ -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)
}

View File

@@ -1,14 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager
import android.content.Context
/**
* F-Droid implementation of [GmsManager]. Always returns `false` since GMS is not available.
*/
@Suppress("UnusedParameter")
class GmsManagerImpl(
context: Context,
) : GmsManager {
override fun isVersionAtLeast(version: Int): Boolean = false
}

View File

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

View File

@@ -40,18 +40,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": {

View File

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

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.cxf.model.ImportCredentialsRequestData
import com.bitwarden.cxf.util.getProviderImportCredentialsRequest
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.manager.share.ShareManager
@@ -17,7 +18,6 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.EmailTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResult
@@ -46,15 +46,14 @@ 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
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -166,9 +165,19 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
combine(
authRepository.userStateFlow,
cookieAcquisitionRequestManager.cookieAcquisitionRequestFlow,
) { userState, request ->
userState != null &&
userState.activeAccount.isVaultUnlocked &&
request != null &&
request.hostname ==
userState.activeAccount.environment.environmentUrlData
.baseWebVaultUrlOrDefault
}
.distinctUntilChanged()
.filter { it }
.map { MainAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
@@ -197,8 +206,6 @@ class MainViewModel @Inject constructor(
is MainAction.DuoResult -> handleDuoResult(action)
is MainAction.SsoResult -> handleSsoResult(action)
is MainAction.WebAuthnResult -> handleWebAuthnResult(action)
is MainAction.CookieAcquisitionResult -> handleCookieAcquisitionResult(action)
is MainAction.PremiumCheckoutResult -> handlePremiumCheckoutResult()
is MainAction.Internal -> handleInternalAction(action)
}
}
@@ -242,17 +249,6 @@ class MainViewModel @Inject constructor(
authRepository.setWebAuthResult(webAuthResult = action.authResult.getWebAuthResult())
}
private fun handleCookieAcquisitionResult(action: MainAction.CookieAcquisitionResult) {
authRepository.setCookieCallbackResult(
result = action.cookieCallbackResult.getCookieCallbackResult(),
)
}
private fun handlePremiumCheckoutResult() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
@@ -340,7 +336,6 @@ 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 importCredentialsRequest = intent.getProviderImportCredentialsRequest()
val credentialProviderRequest =
@@ -402,11 +397,6 @@ class MainViewModel @Inject constructor(
)
}
hasPremiumCheckoutCallback -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PremiumCheckoutResult
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut
@@ -554,20 +544,6 @@ sealed class MainAction {
*/
data class WebAuthnResult(val authResult: AuthTabIntent.AuthResult) : MainAction()
/**
* Receive the result from the cookie acquisition flow.
*/
data class CookieAcquisitionResult(
val cookieCallbackResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive the result from the premium checkout flow.
*/
data class PremiumCheckoutResult(
val authResult: AuthTabIntent.AuthResult,
) : MainAction()
/**
* Receive first Intent by the application.
*/

View File

@@ -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].
*/

View File

@@ -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))

View File

@@ -8,7 +8,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.Instant
import java.time.ZonedDateTime
/**
* Represents the current account information for a given user.
@@ -37,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?,
)
/**

View File

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

View File

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

View File

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

View File

@@ -1,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,
)
}
}

View File

@@ -1,16 +1,14 @@
package com.x8bit.bitwarden.data.auth.repository.util
import android.content.Intent
import android.net.Uri
import android.os.Parcelable
import androidx.browser.auth.AuthTabIntent
import kotlinx.parcelize.Parcelize
/** URI scheme for cookie vendor callback. */
private const val COOKIE_CALLBACK_SCHEME: String = "bitwarden"
/** URI host for cookie vendor callback. */
private const val COOKIE_CALLBACK_HOST: String = "sso-cookie-vendor"
private const val COOKIE_CALLBACK_HOST: String = "sso_cookie_vendor"
/** Completeness marker parameter name (filtered from cookie extraction). */
private const val COMPLETENESS_MARKER_PARAM = "d"
@@ -27,36 +25,15 @@ fun Intent.getCookieCallbackResultOrNull(): CookieCallbackResult? {
val uri = data ?: return null
if (uri.scheme != COOKIE_CALLBACK_SCHEME) return null
if (uri.host != COOKIE_CALLBACK_HOST) return null
return uri.getCookieCallbackResult()
}
/**
* Retrieves a [CookieCallbackResult] from an [AuthTabIntent.AuthResult]. There are two possible
* cases.
*
* - [CookieCallbackResult.Success]: The URI is the cookie callback with correct data.
* - [CookieCallbackResult.MissingCookie]: The URI is the cookie callback with incorrect data or a
* failure has occurred.
*/
fun AuthTabIntent.AuthResult.getCookieCallbackResult(): CookieCallbackResult =
when (this.resultCode) {
AuthTabIntent.RESULT_OK -> this.resultUri.getCookieCallbackResult()
AuthTabIntent.RESULT_CANCELED -> CookieCallbackResult.MissingCookie
AuthTabIntent.RESULT_UNKNOWN_CODE -> CookieCallbackResult.MissingCookie
AuthTabIntent.RESULT_VERIFICATION_FAILED -> CookieCallbackResult.MissingCookie
AuthTabIntent.RESULT_VERIFICATION_TIMED_OUT -> CookieCallbackResult.MissingCookie
else -> CookieCallbackResult.MissingCookie
}
private fun Uri?.getCookieCallbackResult(): CookieCallbackResult {
if (this == null) return CookieCallbackResult.MissingCookie
val cookies = queryParameterNames
val cookies = uri.queryParameterNames
.asSequence()
.filter { it != COMPLETENESS_MARKER_PARAM }
.mapNotNull { name ->
getQueryParameter(name)?.takeIf { it.isNotEmpty() }?.let { name to it }
uri.getQueryParameter(name)?.takeIf { it.isNotEmpty() }?.let { name to it }
}
.toMap()
return if (cookies.isEmpty()) {
CookieCallbackResult.MissingCookie
} else {

View File

@@ -248,7 +248,6 @@ fun UserStateJson.toUserState(
firstTimeState = firstTimeState,
isExportable = !hasPersonalOwnershipRestrictedOrg &&
!hasPersonalVaultExportRestrictedOrg,
creationDate = profile.creationDate,
)
},
hasPendingAccountAddition = hasPendingAccountAddition,

View File

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

View File

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

View File

@@ -2,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,34 +20,10 @@ 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(

View File

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

View File

@@ -62,13 +62,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

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
package com.x8bit.bitwarden.data.billing.datasource.network.di
import com.bitwarden.network.BitwardenServiceClient
import com.bitwarden.network.service.BillingService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Provides network dependencies in the billing package.
*/
@Module
@InstallIn(SingletonComponent::class)
object BillingNetworkModule {
@Provides
@Singleton
fun provideBillingService(
bitwardenServiceClient: BitwardenServiceClient,
): BillingService = bitwardenServiceClient.billingService
}

View File

@@ -1,73 +0,0 @@
package com.x8bit.bitwarden.data.billing.di
import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManagerImpl
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManagerImpl
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.BillingRepositoryImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
* Provides billing-related dependencies.
*/
@Module
@InstallIn(SingletonComponent::class)
object BillingModule {
@Provides
@Singleton
fun providePlayBillingManager(
@ApplicationContext context: Context,
dispatcherManager: DispatcherManager,
): PlayBillingManager = PlayBillingManagerImpl(
context = context,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideBillingRepository(
playBillingManager: PlayBillingManager,
billingService: BillingService,
): BillingRepository = BillingRepositoryImpl(
playBillingManager = playBillingManager,
billingService = billingService,
)
@Provides
@Singleton
fun providePremiumStateManager(
authDiskSource: AuthDiskSource,
authRepository: AuthRepository,
billingRepository: BillingRepository,
settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
featureFlagManager: FeatureFlagManager,
clock: Clock,
dispatcherManager: DispatcherManager,
): PremiumStateManager = PremiumStateManagerImpl(
authDiskSource = authDiskSource,
authRepository = authRepository,
billingRepository = billingRepository,
settingsDiskSource = settingsDiskSource,
vaultRepository = vaultRepository,
featureFlagManager = featureFlagManager,
clock = clock,
dispatcherManager = dispatcherManager,
)
}

View File

@@ -1,14 +0,0 @@
package com.x8bit.bitwarden.data.billing.manager
import kotlinx.coroutines.flow.StateFlow
/**
* Manages interactions with the Google Play Billing system.
*/
interface PlayBillingManager {
/**
* Emits `true` when in-app billing is supported, or `false` otherwise.
*/
val isInAppBillingSupportedFlow: StateFlow<Boolean>
}

View File

@@ -1,23 +0,0 @@
package com.x8bit.bitwarden.data.billing.manager
import kotlinx.coroutines.flow.StateFlow
/**
* Manages the consolidated eligibility state for the Premium upgrade banner.
*
* Combines multiple upstream signals (Premium status, billing support, feature flag,
* banner dismissal, account age, and vault item count) into a single observable flow.
*/
interface PremiumStateManager {
/**
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
* or `false` otherwise.
*/
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
/**
* Marks the Premium upgrade banner as dismissed for the current user.
*/
fun dismissPremiumUpgradeBanner()
}

View File

@@ -1,123 +0,0 @@
package com.x8bit.bitwarden.data.billing.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
import java.time.Duration
import java.time.Instant
/**
* Default implementation of [PremiumStateManager].
*
* Combines five upstream flows into a single eligibility signal using [combine].
*/
@Suppress("LongParameterList")
class PremiumStateManagerImpl(
private val authDiskSource: AuthDiskSource,
authRepository: AuthRepository,
billingRepository: BillingRepository,
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
featureFlagManager: FeatureFlagManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : PremiumStateManager {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
@OptIn(ExperimentalCoroutinesApi::class)
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
combine(
authRepository.userStateFlow,
billingRepository.isInAppBillingSupportedFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
authDiskSource.activeUserIdChangesFlow
.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
vaultRepository.vaultDataStateFlow,
) { userState,
isInAppBillingSupported,
featureFlagEnabled,
isDismissed,
vaultDataState,
->
val activeAccount = userState?.activeAccount
?: return@combine false
val isPremium = activeAccount.isPremium
val isAccountOldEnough = activeAccount.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
)
val itemCount = vaultDataState.activeVaultItemCount()
!isPremium &&
isInAppBillingSupported &&
featureFlagEnabled &&
!isDismissed &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
override fun dismissPremiumUpgradeBanner() {
val activeUserId = authDiskSource.userState?.activeUserId ?: return
settingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = activeUserId,
isDismissed = true,
)
}
}
/**
* Returns `true` if this [Instant] is older than the given number of [days] based on
* the provided [clock]. Returns `false` if the receiver is `null`.
*/
private fun Instant?.isOlderThanDays(days: Long, clock: Clock): Boolean {
this ?: return false
val now = clock.instant()
val ageInDays = Duration.between(this, now).toDays()
return ageInDays >= days
}
/**
* Extracts the count of active (non-deleted, non-archived) vault items from the
* current [DataState].
*/
private fun DataState<VaultData>.activeVaultItemCount(): Int =
data
?.decryptCipherListResult
?.successes
?.count { it.deletedDate == null && it.archivedDate == null }
?: 0
private const val PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS: Int = 5
private const val PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS: Long = 7L

View File

@@ -1,26 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for managing billing operations.
*/
interface BillingRepository {
/**
* Emits `true` when in-app billing is supported, or `false` otherwise.
*/
val isInAppBillingSupportedFlow: StateFlow<Boolean>
/**
* Creates a Stripe checkout session and returns the checkout URL.
*/
suspend fun getCheckoutSessionUrl(): CheckoutSessionResult
/**
* Retrieves the Stripe customer portal URL for managing the Premium subscription.
*/
suspend fun getPortalUrl(): CustomerPortalResult
}

View File

@@ -1,35 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import kotlinx.coroutines.flow.StateFlow
/**
* The default implementation of [BillingRepository].
*/
class BillingRepositoryImpl(
playBillingManager: PlayBillingManager,
private val billingService: BillingService,
) : BillingRepository {
override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
playBillingManager.isInAppBillingSupportedFlow
override suspend fun getCheckoutSessionUrl(): CheckoutSessionResult =
billingService
.createCheckoutSession()
.fold(
onSuccess = { CheckoutSessionResult.Success(url = it.checkoutSessionUrl) },
onFailure = { CheckoutSessionResult.Error(error = it) },
)
override suspend fun getPortalUrl(): CustomerPortalResult =
billingService
.getPortalUrl()
.fold(
onSuccess = { CustomerPortalResult.Success(url = it.url) },
onFailure = { CustomerPortalResult.Error(error = it) },
)
}

View File

@@ -1,25 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models the result of creating a Stripe checkout session.
*/
sealed class CheckoutSessionResult {
/**
* The checkout session URL was successfully retrieved.
*/
data class Success(
val url: String,
) : CheckoutSessionResult()
/**
* Generic error while creating a checkout session. The optional [errorMessage] may be
* displayed directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : CheckoutSessionResult()
}

View File

@@ -1,25 +0,0 @@
package com.x8bit.bitwarden.data.billing.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models the result of retrieving the Stripe customer portal URL.
*/
sealed class CustomerPortalResult {
/**
* The customer portal URL was successfully retrieved.
*/
data class Success(
val url: String,
) : CustomerPortalResult()
/**
* Generic error while retrieving the customer portal URL. The optional [errorMessage] may
* be displayed directly in the UI when present.
*/
data class Error(
val error: Throwable,
val errorMessage: String? = error.userFriendlyMessage,
) : CustomerPortalResult()
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import java.time.Instant
import java.time.ZonedDateTime
/**
* Primary access point for push notification information.
@@ -25,7 +25,7 @@ interface PushDiskSource {
/**
* Retrieves the last time a push token was registered for a user.
*/
fun getLastPushTokenRegistrationDate(userId: String): Instant?
fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime?
/**
* Sets the current token for a user.
@@ -35,5 +35,5 @@ interface PushDiskSource {
/**
* Sets the last push token registration date for a user.
*/
fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: Instant?)
fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: ZonedDateTime?)
}

View File

@@ -1,10 +1,10 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.util.getBinaryLongFromInstant
import com.bitwarden.core.util.getInstantFromBinaryLong
import com.bitwarden.core.util.getBinaryLongFromZoneDateTime
import com.bitwarden.core.util.getZoneDateTimeFromBinaryLong
import com.bitwarden.data.datasource.disk.BaseDiskSource
import java.time.Instant
import java.time.ZonedDateTime
private const val CURRENT_PUSH_TOKEN_KEY = "pushCurrentToken"
private const val LAST_REGISTRATION_DATE_KEY = "pushLastRegistrationDate"
@@ -35,9 +35,9 @@ class PushDiskSourceImpl(
return getString(CURRENT_PUSH_TOKEN_KEY.appendIdentifier(userId))
}
override fun getLastPushTokenRegistrationDate(userId: String): Instant? {
override fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? {
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId))
?.let { getInstantFromBinaryLong(it) }
?.let { getZoneDateTimeFromBinaryLong(it) }
}
override fun storeCurrentPushToken(userId: String, pushToken: String?) {
@@ -49,11 +49,11 @@ class PushDiskSourceImpl(
override fun storeLastPushTokenRegistrationDate(
userId: String,
registrationDate: Instant?,
registrationDate: ZonedDateTime?,
) {
putLong(
key = LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId),
value = registrationDate?.let { getBinaryLongFromInstant(registrationDate) },
value = registrationDate?.let { getBinaryLongFromZoneDateTime(registrationDate) },
)
}
}

View File

@@ -123,24 +123,6 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether the Premium upgrade banner has been dismissed.
*/
fun getPremiumUpgradeBannerDismissed(userId: String): Boolean?
/**
* Stores whether the Premium upgrade banner has been dismissed.
*/
fun storePremiumUpgradeBannerDismissed(
userId: String,
isDismissed: Boolean?,
)
/**
* Emits updates that track [getPremiumUpgradeBannerDismissed] for the given [userId].
*/
fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].
@@ -247,7 +229,7 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun storeDefaultUriMatchType(userId: String, uriMatchType: UriMatchType?)
/**
* Gets the value for whether the autofill save prompt should be disabled for the
* Gets the value for whether or not the autofill save prompt should be disabled for the
* given [userId].
*/
fun getAutofillSavePromptDisabled(userId: String): Boolean?
@@ -313,13 +295,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getUserHasSignedInPreviously(userId: String): Boolean
/**
* Gets whether the given [userId] has signaled they want to enable autofill in
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowBrowserAutofillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable the browser autofill integration in onboarding.
*/
fun storeShowBrowserAutofillSettingBadge(userId: String, showBadge: Boolean?)
@@ -330,13 +312,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowBrowserAutofillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to enable autofill in
* Gets whether or not the given [userId] has signalled they want to enable autofill in
* onboarding.
*/
fun getShowAutoFillSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* enable autofill in onboarding.
*/
fun storeShowAutoFillSettingBadge(userId: String, showBadge: Boolean?)
@@ -347,13 +329,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowAutoFillSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to enable unlock options
* Gets whether or not the given [userId] has signalled they want to enable unlock options
* later, during onboarding.
*/
fun getShowUnlockSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* set up unlock options later, during onboarding.
*/
fun storeShowUnlockSettingBadge(userId: String, showBadge: Boolean?)
@@ -364,12 +346,12 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowUnlockSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the given [userId] has signaled they want to import logins later.
* Gets whether or not the given [userId] has signalled they want to import logins later.
*/
fun getShowImportLoginsSettingBadge(userId: String): Boolean?
/**
* Stores the given value for whether the given [userId] has signaled they want to
* Stores the given value for whether or not the given [userId] has signalled they want to
* set import logins later, during first time usage.
*/
fun storeShowImportLoginsSettingBadge(userId: String, showBadge: Boolean?)
@@ -380,13 +362,13 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
fun getShowImportLoginsSettingBadgeFlow(userId: String): Flow<Boolean?>
/**
* Gets whether the application has registered for export via the credential exchange
* Gets whether or not the application has registered for export via the credential exchange
* protocol.
*/
fun getAppRegisteredForExport(): Boolean?
/**
* Stores the given value for whether the application has registered for export via
* Stores the given value for whether or not the application has registered for export via
* the credential exchange protocol.
*/
fun storeAppRegisteredForExport(isRegistered: Boolean?)

View File

@@ -51,8 +51,6 @@ private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled"
private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime"
private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED =
"introducingArchiveActionCardDismissed"
private const val PREMIUM_UPGRADE_BANNER_DISMISSED =
"premiumUpgradeBannerDismissed"
/**
* Primary implementation of [SettingsDiskSource].
@@ -94,9 +92,6 @@ class SettingsDiskSourceImpl(
private val mutableIntroducingArchiveActionCardDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradeBannerDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -251,7 +246,6 @@ class SettingsDiskSourceImpl(
// - should show add login coach mark
// - should show generator coach mark
// - should show introducing archive action card dismissed
// - Premium upgrade banner dismissed
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
@@ -274,26 +268,6 @@ class SettingsDiskSourceImpl(
getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId)
.onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) }
override fun getPremiumUpgradeBannerDismissed(userId: String): Boolean? =
getBoolean(
key = PREMIUM_UPGRADE_BANNER_DISMISSED.appendIdentifier(identifier = userId),
)
override fun storePremiumUpgradeBannerDismissed(
userId: String,
isDismissed: Boolean?,
) {
putBoolean(
key = PREMIUM_UPGRADE_BANNER_DISMISSED.appendIdentifier(identifier = userId),
value = isDismissed,
)
getMutablePremiumUpgradeBannerDismissedFlow(userId = userId).tryEmit(isDismissed)
}
override fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow<Boolean?> =
getMutablePremiumUpgradeBannerDismissedFlow(userId = userId)
.onSubscription { emit(getPremiumUpgradeBannerDismissed(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@@ -638,13 +612,6 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradeBannerDismissedFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutablePremiumUpgradeBannerDismissedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -6,7 +6,7 @@ import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao
import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
/**
* Room database for storing any persisted data for platform data.
@@ -21,7 +21,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConve
AutoMigration(from = 1, to = 2),
],
)
@TypeConverters(InstantTypeConverter::class)
@TypeConverters(ZonedDateTimeTypeConverter::class)
abstract class PlatformDatabase : RoomDatabase() {
/**
* Provides the DAO for accessing organization event data.

View File

@@ -31,7 +31,7 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStor
import com.x8bit.bitwarden.data.platform.manager.DatabaseSchemeManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.callback.DatabaseSchemeCallback
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.InstantTypeConverter
import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -71,7 +71,7 @@ object PlatformDiskModule {
name = "platform_database",
)
.fallbackToDestructiveMigration(dropAllTables = false)
.addTypeConverter(InstantTypeConverter())
.addTypeConverter(ZonedDateTimeTypeConverter())
.addCallback(DatabaseSchemeCallback(databaseSchemeManager = databaseSchemeManager))
.build()

View File

@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.Instant
import java.time.ZonedDateTime
/**
* Entity representing an organization event in the database.
@@ -24,7 +24,7 @@ data class OrganizationEventEntity(
val cipherId: String?,
@ColumnInfo(name = "date")
val date: Instant,
val date: ZonedDateTime,
@ColumnInfo(name = "organization_id")
val organizationId: String?,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
package com.x8bit.bitwarden.data.platform.manager
/**
* The minimum GMS Core version required for Credential Exchange Protocol (CXP) features.
*/
const val MINIMUM_CXP_GMS_VERSION: Int = 261031035
/**
* Manages checks against the installed Google Mobile Services (GMS) Core version.
*/
interface GmsManager {
/**
* Returns `true` if the installed GMS Core version is at least [version], or `false` if
* GMS Core is not installed or does not meet the minimum version.
*/
fun isVersionAtLeast(version: Int): Boolean
}

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
import com.x8bit.bitwarden.data.platform.manager.model.SyncFolderDeleteData
@@ -30,11 +29,6 @@ interface PushManager {
*/
val passwordlessRequestFlow: Flow<PasswordlessRequestData>
/**
* Flow that represents Premium status change notifications.
*/
val premiumStatusChangedFlow: Flow<PremiumStatusChangedData>
/**
* Flow that represents requests intended to trigger a sync cipher delete.
*/

View File

@@ -14,7 +14,6 @@ import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData
import com.x8bit.bitwarden.data.platform.manager.model.NotificationPayload
import com.x8bit.bitwarden.data.platform.manager.model.NotificationType
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
import com.x8bit.bitwarden.data.platform.manager.model.PremiumStatusChangedData
import com.x8bit.bitwarden.data.platform.manager.model.PushNotificationLogOutReason
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherDeleteData
import com.x8bit.bitwarden.data.platform.manager.model.SyncCipherUpsertData
@@ -32,6 +31,8 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
import javax.inject.Inject
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@@ -64,8 +65,6 @@ class PushManagerImpl @Inject constructor(
private val mutableLogoutSharedFlow = bufferedMutableSharedFlow<NotificationLogoutData>()
private val mutablePasswordlessRequestSharedFlow =
bufferedMutableSharedFlow<PasswordlessRequestData>()
private val mutablePremiumStatusChangedSharedFlow =
bufferedMutableSharedFlow<PremiumStatusChangedData>()
private val mutableSyncCipherDeleteSharedFlow =
bufferedMutableSharedFlow<SyncCipherDeleteData>()
private val mutableSyncCipherUpsertSharedFlow =
@@ -89,9 +88,6 @@ class PushManagerImpl @Inject constructor(
override val passwordlessRequestFlow: SharedFlow<PasswordlessRequestData>
get() = mutablePasswordlessRequestSharedFlow.asSharedFlow()
override val premiumStatusChangedFlow: SharedFlow<PremiumStatusChangedData>
get() = mutablePremiumStatusChangedSharedFlow.asSharedFlow()
override val syncCipherDeleteFlow: SharedFlow<SyncCipherDeleteData>
get() = mutableSyncCipherDeleteSharedFlow.asSharedFlow()
@@ -201,12 +197,6 @@ class PushManagerImpl @Inject constructor(
}
}
NotificationType.POLICY_CHANGED -> {
activeUserId?.let {
mutableFullSyncSharedFlow.tryEmit(it)
}
}
NotificationType.SYNC_CIPHER_DELETE,
NotificationType.SYNC_LOGIN_DELETE,
-> {
@@ -317,25 +307,6 @@ class PushManagerImpl @Inject constructor(
}
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(it) }
}
NotificationType.PREMIUM_STATUS_CHANGED -> {
json
.decodeFromString<NotificationPayload.PremiumStatusChangedNotification>(
string = notification.payload,
)
.takeIf { it.userId != null && it.isPremium != null }
?.let { payload ->
mutablePremiumStatusChangedSharedFlow.tryEmit(
PremiumStatusChangedData(
userId = requireNotNull(payload.userId),
isPremium = requireNotNull(payload.isPremium),
),
)
mutableFullSyncSharedFlow.tryEmit(
requireNotNull(payload.userId),
)
}
}
}
}
@@ -369,7 +340,8 @@ class PushManagerImpl @Inject constructor(
private suspend fun registerPushTokenIfNecessaryInternal(userId: String, token: String) {
val currentToken = pushDiskSource.getCurrentPushToken(userId)
if (token == currentToken) {
val lastRegistration = pushDiskSource.getLastPushTokenRegistrationDate(userId) ?: return
val lastRegistration =
pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toInstant() ?: return
val updateTime = clock.instant().minus(PUSH_TOKEN_UPDATE_DELAY.toJavaDuration())
if (updateTime.isBefore(lastRegistration)) return
}
@@ -382,7 +354,7 @@ class PushManagerImpl @Inject constructor(
onSuccess = {
pushDiskSource.storeLastPushTokenRegistrationDate(
userId = userId,
registrationDate = clock.instant(),
registrationDate = ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC),
)
pushDiskSource.storeCurrentPushToken(
userId = userId,

View File

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

View File

@@ -26,9 +26,11 @@ class SdkClientManagerImpl(
repository = sdkRepoFactory.getServerCommunicationConfigRepository(),
platformApi = sdkPlatformApiFactory.getServerCommunicationConfigPlatformApi(),
)
platform().state().registerClientManagedRepositories(
repositories = sdkRepoFactory.getRepositories(userId = userId),
)
userId?.let {
platform().state().apply {
registerCipherRepository(sdkRepoFactory.getCipherRepository(userId = it))
}
}
}
},
) : SdkClientManager {

View File

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

View File

@@ -30,6 +30,8 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.AppResumeManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.AppStateManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
import com.x8bit.bitwarden.data.platform.manager.AssetManagerImpl
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
@@ -47,8 +49,6 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.GmsManager
import com.x8bit.bitwarden.data.platform.manager.GmsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.LogsManager
import com.x8bit.bitwarden.data.platform.manager.LogsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -106,6 +106,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformManagerModule {
@Provides
@Singleton
fun provideAppStateManager(
application: Application,
): AppStateManager = AppStateManagerImpl(application = application)
@Provides
@Singleton
fun provideAuthenticatorBridgeProcessor(
@@ -338,12 +344,6 @@ object PlatformManagerModule {
thirdPartyAutofillEnabledManager = thirdPartyAutofillEnabledManager,
)
@Provides
@Singleton
fun provideGmsManager(
@ApplicationContext context: Context,
): GmsManager = GmsManagerImpl(context = context)
@Provides
@Singleton
fun provideDatabaseSchemeManager(

View File

@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.time.Clock
import java.time.ZonedDateTime
/**
* The amount of time to delay before attempting the first upload events after the app is
@@ -77,7 +78,7 @@ class OrganizationEventManagerImpl(
event = OrganizationEventJson(
type = event.type,
cipherId = event.cipherId,
date = clock.instant(),
date = ZonedDateTime.now(clock),
organizationId = event.organizationId,
),
)

View File

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

View File

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

View File

@@ -5,12 +5,12 @@ import kotlinx.serialization.Contextual
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import java.time.Instant
import java.time.ZonedDateTime
/**
* The payload of a push notification.
*
* Note: The data we receive is not always reliable, so everything is nullable, and we validate the
* Note: The data we receive is not always reliable, so everything is nullable and we validate the
* data in the [PushManager] as necessary.
*/
@OptIn(ExperimentalSerializationApi::class)
@@ -31,7 +31,7 @@ sealed class NotificationPayload {
@JsonNames("OrganizationId", "organizationId") val organizationId: String?,
@JsonNames("CollectionIds", "collectionIds") val collectionIds: List<String>?,
@Contextual
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -42,7 +42,7 @@ sealed class NotificationPayload {
@JsonNames("Id", "id") val folderId: String?,
@JsonNames("UserId", "userId") override val userId: String?,
@Contextual
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -55,7 +55,7 @@ sealed class NotificationPayload {
@Contextual
@JsonNames("Date", "date")
val date: Instant?,
val date: ZonedDateTime?,
@JsonNames("Reason", "reason")
val pushNotificationLogOutReason: PushNotificationLogOutReason?,
@@ -69,7 +69,7 @@ sealed class NotificationPayload {
@JsonNames("Id", "id") val sendId: String?,
@JsonNames("UserId", "userId") override val userId: String?,
@Contextual
@JsonNames("RevisionDate", "revisionDate") val revisionDate: Instant?,
@JsonNames("RevisionDate", "revisionDate") val revisionDate: ZonedDateTime?,
) : NotificationPayload()
/**
@@ -97,13 +97,4 @@ sealed class NotificationPayload {
data class SyncNotification(
@JsonNames("UserId", "userId") override val userId: String?,
) : NotificationPayload()
/**
* A notification payload for Premium status changes.
*/
@Serializable
data class PremiumStatusChangedNotification(
@JsonNames("UserId", "userId") override val userId: String?,
@JsonNames("Premium", "premium") val isPremium: Boolean?,
) : NotificationPayload()
}

View File

@@ -60,12 +60,6 @@ enum class NotificationType {
@SerialName("16")
AUTH_REQUEST_RESPONSE,
@SerialName("25")
POLICY_CHANGED,
@SerialName("27")
PREMIUM_STATUS_CHANGED,
}
@Keep

View File

@@ -135,10 +135,22 @@ sealed class OrganizationEvent {
* folder as required by the organization's personal vault ownership policy.
*/
data class ItemOrganizationAccepted(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val cipherId: String? = null
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_ACCEPTED
}
/**
* Tracks when a user chooses to leave an organization instead of migrating their personal
* ciphers to their organization's My Items folder.
*/
data class ItemOrganizationDeclined(
override val cipherId: String? = null,
override val organizationId: String,
) : OrganizationEvent() {
override val type: OrganizationEventType
get() = OrganizationEventType.ORGANIZATION_ITEM_ORGANIZATION_DECLINED
}
}

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