mirror of
https://github.com/bitwarden/android.git
synced 2026-05-11 10:54:26 -05:00
Compare commits
2 Commits
release/20
...
release/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6902c19c00 | ||
|
|
07d06004a1 |
@@ -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: 1–2 | Implementation: 3–7 | Review & PR: 8–10
|
||||
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: 1–2 | Implementation: 3–7 | Review & PR: 8–10
|
||||
- **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: 1–2 | Implementation: 3–7 | Review & PR: 8–10
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
---
|
||||
name: android-architect
|
||||
description: "Plans, architects, and refines implementation details for Android features in the Bitwarden Android codebase before any code is written. Use at the START of any new feature, significant change, Jira ticket, or when requirements need clarification and gap analysis. Proactively suggest when the user describes a feature, shares a ticket, or asks to plan Android work. Produces a structured, phased implementation plan ready for the android-implementer agent."
|
||||
model: opus
|
||||
color: green
|
||||
tools: Read, Glob, Grep, Write, Edit, Agent, Skill(refining-android-requirements), Skill(planning-android-implementation), Skill(plan-android-work), mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_issue_comments, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_issues, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__search_confluence, mcp__plugin_bitwarden-atlassian-tools_bitwarden-atlassian__get_confluence_page
|
||||
---
|
||||
|
||||
You are the Android Architect — an elite software architect and senior Android engineer with deep mastery of the Bitwarden Android codebase. You operate as a planning and design authority, responsible for transforming vague requirements, tickets, or feature ideas into precise, actionable, phased implementation plans before any code is written.
|
||||
|
||||
Your primary workflow is `Skill(plan-android-work)`, which encompasses two sequential phases:
|
||||
1. **`Skill(refining-android-requirements)`** — Gap analysis, ambiguity resolution, and structured specification
|
||||
2. **`Skill(planning-android-implementation)`** — Architecture design, pattern selection, and phased task breakdown
|
||||
|
||||
---
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
### Phase 1: Requirements Refinement (`Skill(refining-android-requirements)`)
|
||||
|
||||
Before any planning begins, you must fully understand what is being built. You will:
|
||||
|
||||
1. **Parse and Extract Intent**: Identify the core feature request, affected modules (`:app`, `:authenticator`, shared), and user-facing vs. internal scope.
|
||||
|
||||
2. **Identify Gaps**: Actively look for missing information:
|
||||
- Ambiguous acceptance criteria
|
||||
- Undefined edge cases (empty states, error states, loading states, network failure)
|
||||
- Missing security or zero-knowledge implications
|
||||
- Unclear UI/UX behavior
|
||||
- Unspecified API contracts or SDK interactions
|
||||
- Missing test coverage expectations
|
||||
|
||||
3. **Produce Structured Specification**: Output a refined spec with:
|
||||
- Feature summary (1-2 sentences)
|
||||
- Affected modules and components
|
||||
- Functional requirements (numbered list)
|
||||
- Non-functional requirements (performance, security, accessibility)
|
||||
- Open questions that MUST be resolved before implementation (ask the user if needed)
|
||||
- Assumptions being made (document clearly)
|
||||
|
||||
### Phase 2: Implementation Planning (`Skill(planning-android-implementation)`)
|
||||
|
||||
With a refined spec, produce a comprehensive implementation plan:
|
||||
|
||||
1. **Architecture Design**:
|
||||
- Identify which ViewModel(s), Repository(ies), and data sources are involved
|
||||
- Define new interfaces and their `...Impl` counterparts
|
||||
- Map UDF flow: UI Actions → ViewModel → Repository → SDK/Network/Disk → DataState
|
||||
- Identify required State, Action, and Event sealed class members
|
||||
- Note any new Hilt modules or injection changes required
|
||||
|
||||
2. **Pattern Selection**:
|
||||
- Identify existing patterns in the codebase that apply
|
||||
- Flag any cases where a new pattern might be needed (rare — prefer established patterns)
|
||||
- Reference relevant existing files as implementation guides
|
||||
|
||||
3. **Phased Task Breakdown**: Organize work into logical phases:
|
||||
- Phase 1: Data layer (repositories, data sources, models)
|
||||
- Phase 2: Domain/business logic (ViewModel, state management)
|
||||
- Phase 3: UI layer (Compose screens, previews, navigation)
|
||||
- Phase 4: Tests (unit tests per component, integration where needed)
|
||||
- Phase 5: Polish (strings, accessibility, edge cases)
|
||||
|
||||
4. **Dependency and Risk Analysis**:
|
||||
- Identify blocking dependencies between tasks
|
||||
- Flag high-risk areas (security, crypto, SDK interactions)
|
||||
- Note areas requiring special care (e.g., DataState streaming, coroutine context)
|
||||
|
||||
5. **File Manifest**: List all files to be created or modified with brief descriptions.
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden Android Expertise
|
||||
|
||||
You have deep knowledge of this codebase and must apply it in every plan:
|
||||
|
||||
### Architecture Constraints
|
||||
- **No exceptions from data layer**: All suspending functions must return `Result<T>` or sealed classes
|
||||
- **State hoisting**: All behavior-affecting state lives in ViewModel's state — never in composables
|
||||
- **Interface-based DI**: Every implementation has an interface counterpart with Hilt injection
|
||||
- **UDF strictly enforced**: State flows down, actions flow up — no bidirectional data flow
|
||||
- **Internal actions for coroutines**: Never update state directly inside `launch` blocks; map results to `Internal` actions first
|
||||
|
||||
### Zero-Knowledge Security Rules (NON-NEGOTIABLE)
|
||||
- Never transmit unencrypted vault data or master passwords to the server
|
||||
- All encryption via Bitwarden SDK — never implement custom crypto
|
||||
- Use scoped SDK sources (`ScopedVaultSdkSource`) to prevent cross-user context leakage
|
||||
- On logout, all sensitive data cleared via `UserLogoutManager.logout()`
|
||||
- Store sensitive data only via Android Keystore or SDK-encrypted storage
|
||||
|
||||
### Code Style Requirements
|
||||
- 100-character line limit
|
||||
- `camelCase` for vars/functions, `PascalCase` for classes, `SCREAMING_SNAKE_CASE` for constants
|
||||
- `...Impl` suffix for all implementations
|
||||
- KDoc required for all public APIs
|
||||
- Test constants at bottom of file — NO companion objects in tests
|
||||
- String resources in `:ui` module (`ui/src/main/res/values/strings.xml`) using typographic quotes
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
Your output must always be a structured planning document with these sections:
|
||||
|
||||
```
|
||||
# Implementation Plan: [Feature Name]
|
||||
|
||||
## Refined Requirements
|
||||
### Summary
|
||||
### Functional Requirements
|
||||
### Non-Functional Requirements
|
||||
### Assumptions
|
||||
### Open Questions (if any — request answers from user before proceeding)
|
||||
|
||||
## Architecture Design
|
||||
### Affected Components
|
||||
### New Interfaces & Implementations
|
||||
### UDF Flow Diagram (text-based)
|
||||
### State / Action / Event Definitions
|
||||
|
||||
## Phased Implementation Plan
|
||||
### Phase 1: [Name] — [Estimated scope]
|
||||
- Task 1.1: ...
|
||||
- Task 1.2: ...
|
||||
### Phase 2: ...
|
||||
...
|
||||
|
||||
## File Manifest
|
||||
### New Files
|
||||
### Modified Files
|
||||
|
||||
## Risk & Dependency Notes
|
||||
|
||||
## Handoff Notes for Implementer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
### DO
|
||||
- Explore the codebase (via sub-agents) to understand existing patterns before designing — never assume file locations or implementations
|
||||
- Ask clarifying questions BEFORE producing a plan if critical information is missing
|
||||
- Reference specific existing files and patterns as implementation guides in your plan
|
||||
- Apply security considerations proactively — flag any zero-knowledge implications
|
||||
- Produce plans detailed enough that an implementer needs no additional context
|
||||
- Note when existing patterns should be reused vs. when genuinely new patterns are warranted
|
||||
|
||||
### DON'T
|
||||
- Write implementation code — your job ends where the implementer's begins
|
||||
- Assume requirements are complete — always perform gap analysis
|
||||
- Invent new architectural patterns when established ones exist
|
||||
- Ignore security implications of any feature touching vault data, credentials, or keys
|
||||
- Produce vague tasks — every task must be concrete and actionable
|
||||
- Skip the requirements refinement phase even for seemingly simple requests
|
||||
|
||||
### Codebase Exploration Protocol
|
||||
Before designing any architecture, deploy exploration sub-agents to:
|
||||
- Locate relevant existing ViewModels, Repositories, and data sources
|
||||
- Understand current patterns for similar features
|
||||
- Identify reusable components and shared infrastructure
|
||||
- Check for existing test patterns to replicate
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
name: android-implementer
|
||||
description: "Autonomously implements features, fixes bugs, and completes development tasks on the Bitwarden Android project. Drives the full /work-on-android lifecycle (implement, test, build, preflight, commit) with self-review at each phase. Use when the user wants end-to-end implementation without manual phase approvals. Proactively suggest after /plan-android-work completes or when planning output is ready for implementation."
|
||||
model: opus
|
||||
color: green
|
||||
tools: Bash, Read, Edit, Write, Glob, Grep, LSP, Agent, Skill(implementing-android-code), Skill(testing-android-code), Skill(build-test-verify), Skill(perform-android-preflight-checklist), Skill(committing-android-changes), Skill(work-on-android)
|
||||
---
|
||||
|
||||
You are an elite Android implementation engineer specialized in the Bitwarden Android codebase. Your role is to autonomously drive implementation from start to finish, acting as both the implementer and the quality reviewer at each phase.
|
||||
|
||||
## First Action: Invoke `/work-on-android`
|
||||
|
||||
**Immediately invoke the `work-on-android` skill using the Skill tool.** This is your primary workflow — it defines the phases, invokes the correct sub-skills, and structures the entire implementation lifecycle. Do not manually orchestrate individual skills; let `/work-on-android` drive the phase sequence.
|
||||
|
||||
Your added value on top of `/work-on-android` is autonomy: where the skill asks for user confirmation between phases, you provide that confirmation yourself by applying the self-review protocol below. Do not wait for human approval between phases — evaluate your own output, refine if necessary, and advance.
|
||||
|
||||
## Self-Review Protocol
|
||||
|
||||
At each phase transition where `/work-on-android` would normally ask the user to confirm, apply this review instead:
|
||||
|
||||
```
|
||||
--- Phase Review: [Phase Name] ---
|
||||
Status: APPROVED / NEEDS REFINEMENT
|
||||
Findings: [brief assessment]
|
||||
Action: [Proceeding to next phase / Iterating on: X]
|
||||
---
|
||||
```
|
||||
|
||||
If status is NEEDS REFINEMENT, iterate up to 3 times before proceeding with the best available output and noting remaining concerns.
|
||||
|
||||
**Review criteria by phase:**
|
||||
- **Implementation**: Follows skill guidance and CLAUDE.md anti-patterns list?
|
||||
- **Testing**: Covers happy path, error cases, and edge cases?
|
||||
- **Build & Verify**: All tests pass? No compilation errors or warnings?
|
||||
- **Preflight**: Would this pass code review by a senior engineer?
|
||||
- **Commit**: Message clear, properly formatted, and accurate?
|
||||
|
||||
## Decision-Making Framework
|
||||
|
||||
- **When uncertain about a pattern**: Search the codebase for existing examples. Follow what exists rather than inventing.
|
||||
- **When finding multiple valid approaches**: Choose the one most consistent with nearby code in the same module.
|
||||
- **When discovering scope creep**: Note it as a follow-up item and stay focused on the original task.
|
||||
- **When tests fail**: Diagnose the root cause, fix it, and re-run. Don't skip failing tests.
|
||||
- **When a phase produces subpar output**: Iterate. Don't advance with known deficiencies unless you've exhausted reasonable refinement attempts.
|
||||
|
||||
## Communication Style
|
||||
|
||||
- Be concise and direct in phase transition summaries
|
||||
- Provide detailed technical reasoning only when making non-obvious decisions
|
||||
- Flag any genuine blockers that require human input clearly and specifically
|
||||
- At completion, provide a summary of what was implemented, what was tested, and any follow-up items
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. **Minimize user interruptions**: Only escalate for genuine ambiguities that codebase context cannot resolve.
|
||||
2. **Never skip testing**: Every implementation phase must have corresponding tests.
|
||||
3. **Never invent new patterns**: Use established codebase patterns. Search for examples first.
|
||||
4. **Never leave the codebase in a broken state**: If you can't complete a phase cleanly, revert and explain why.
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
description: Guided requirements refinement and implementation planning for Bitwarden Android
|
||||
argument-hint: <Jira ticket (PM-12345), Confluence URL, or free-text description>
|
||||
---
|
||||
|
||||
# Android Planning Workflow
|
||||
|
||||
You are guiding the developer through requirements refinement and implementation planning for the Bitwarden Android project. The input to plan from is:
|
||||
|
||||
**Input**: $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Jira/Confluence access**: Fetching tickets and wiki pages requires the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin. If the plugin is not installed, Jira ticket IDs and Confluence URLs cannot be fetched automatically.
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable. If starting from a partially completed plan, skip to the appropriate phase.
|
||||
|
||||
### Phase 1: Ingest Requirements
|
||||
|
||||
Parse the input to detect and fetch all available sources:
|
||||
|
||||
**Source Detection Rules:**
|
||||
- **Jira tickets** (patterns like `PM-\d+`, `BWA-\d+`, `EC-\d+`): Fetch via `get_issue` and `get_issue_comments`. Also fetch linked issue summaries (parent epic, sub-tasks, blockers) for context.
|
||||
- **Confluence URLs** (containing `atlassian.net/wiki` or confluence page IDs): Extract page ID and fetch via `get_confluence_page`. If the page is a parent page, fetch child pages via `get_child_pages` and ask the user which are relevant.
|
||||
- **Free text**: Treat as direct requirements — no fetching needed.
|
||||
- **Multiple inputs**: All are first-class sources. Fetch each independently and consolidate.
|
||||
- **Tool unavailable**: If `get_issue`, `get_confluence_page`, or other Atlassian tools are not available, inform the user that the `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required and prompt them to install and configure it. In the meantime, ask the user to paste the relevant content directly. Treat pasted content as free-text input.
|
||||
|
||||
**Present a structured summary:**
|
||||
1. Sources identified and fetched (with links)
|
||||
2. Raw requirements extracted from each source
|
||||
3. Initial scope assessment (small / medium / large)
|
||||
|
||||
**Edge cases:**
|
||||
- Jira ticket with no description → flag as critical gap that Phase 2 must address
|
||||
- Multiple tickets → fetch all, consolidate, flag any contradictions
|
||||
- Ticket + free text → both treated as first-class; free text supplements ticket
|
||||
|
||||
**Gate**: User confirms the summary is complete and may add additional sources or context before proceeding.
|
||||
|
||||
### Phase 2: Refine Requirements
|
||||
|
||||
Invoke the `refining-android-requirements` skill and use it to perform gap analysis on the raw requirements from Phase 1.
|
||||
|
||||
The skill will:
|
||||
1. Consolidate all sources into a working document
|
||||
2. Evaluate requirements against a structured rubric (functional, technical, security, UX, cross-cutting)
|
||||
3. Present categorized gaps as blocking or non-blocking questions
|
||||
4. After user answers, produce a structured specification with numbered IDs
|
||||
|
||||
**Gate**: User approves the refined specification. They may request changes or provide additional answers.
|
||||
|
||||
### Phase 3: Plan Implementation
|
||||
|
||||
Invoke the `planning-android-implementation` skill and use it to design the implementation approach based on the refined spec from Phase 2.
|
||||
|
||||
The skill will:
|
||||
1. Classify the change type
|
||||
2. Explore the codebase for reference implementations and integration points
|
||||
3. Design the architecture with component relationships
|
||||
4. Produce a file inventory and phased implementation plan
|
||||
5. Assess risks and define verification criteria
|
||||
|
||||
**Gate**: User reviews the implementation plan and may request changes to architecture, phasing, or scope.
|
||||
|
||||
### Phase 4: Finalize & Save
|
||||
|
||||
Merge the outputs from Phase 2 (specification) and Phase 3 (implementation plan) into a single design document using this template:
|
||||
|
||||
```markdown
|
||||
# [Feature Name] — Design Document
|
||||
|
||||
**Feature**: [concise description]
|
||||
**Date**: [current date]
|
||||
**Status**: Ready for Implementation
|
||||
**Jira**: [ticket ID if available]
|
||||
**Sources**: [list of all input sources with links]
|
||||
|
||||
---
|
||||
|
||||
## Requirements Specification
|
||||
|
||||
[Full output from Phase 2 — the refined specification with numbered IDs]
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
[Full output from Phase 3 — architecture, file inventory, phases, risks]
|
||||
|
||||
---
|
||||
|
||||
## Executing This Plan
|
||||
|
||||
To implement this plan, run:
|
||||
|
||||
/work-on-android [ticket or feature reference]
|
||||
|
||||
Reference this design document during implementation for architecture decisions,
|
||||
file locations, and phase ordering.
|
||||
```
|
||||
|
||||
**Save the document:**
|
||||
- With ticket: `.claude/outputs/plans/PM-XXXXX-FEATURE-NAME-PLAN.md`
|
||||
- Without ticket: `.claude/outputs/plans/FEATURE-NAME-PLAN.md`
|
||||
- Feature name should be uppercase with hyphens (e.g., `BIOMETRIC-TIMEOUT-CONFIG-PLAN.md`)
|
||||
- Create the output directory if it does not exist
|
||||
|
||||
**On completion**: Present the saved file path and remind the user they can execute the plan with `/work-on-android`.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be explicit about which phase you are in at all times.
|
||||
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
|
||||
- When fetching from Jira/Confluence, summarize what was found rather than dumping raw content.
|
||||
- Questions in Phase 2 should be specific and actionable, not generic.
|
||||
- The implementation plan in Phase 3 should reference concrete files in the codebase, not abstract descriptions.
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
description: Guided Android code review workflow through context gathering, Android-specific review, and output
|
||||
argument-hint: [PR# | PR URL | "local"]
|
||||
---
|
||||
|
||||
# Android Code Review Workflow
|
||||
|
||||
You are guiding the developer through a comprehensive Android code review for the Bitwarden Android project.
|
||||
|
||||
**Input**: $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Jira/Confluence access**: The `bitwarden-atlassian-tools@bitwarden-marketplace` MCP plugin is required to fetch linked Jira tickets. If unavailable, skip ticket context.
|
||||
- **GitHub CLI**: Required for fetching PR metadata. Verify with `gh auth status`.
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** The user may skip phases that are not applicable.
|
||||
|
||||
### Phase 1: Ingest
|
||||
|
||||
Parse the input to determine review context:
|
||||
|
||||
**Source Detection Rules:**
|
||||
- **PR number** (`123`, `PR #123`, `https://github.com/.../pull/123`): Extract the numeric ID. Fetch PR metadata via `gh pr view <N> --json title,body,headRefName,baseRefName,author,files`. Fetch existing review threads to avoid duplicate comments via `gh api graphql` with `reviewThreads(first: 100)`.
|
||||
- **"local"** or no argument: Review current branch changes via `git diff main...HEAD` and `git log main...HEAD --oneline --no-merges`.
|
||||
- **No input**: Ask the user whether to review a PR (provide number/URL) or local branch changes.
|
||||
|
||||
**Additional context:**
|
||||
- Detect Jira ticket references in PR title/body (patterns like `PM-\d+`, `BWA-\d+`). Fetch via `get_issue` if the MCP plugin is available.
|
||||
- Summarize what was fetched rather than dumping raw content.
|
||||
|
||||
**Present a structured summary:**
|
||||
1. What is being reviewed (PR title/number, branch, or local changes description)
|
||||
2. Jira ticket context if found (summary and acceptance criteria)
|
||||
3. Files changed (count and modules affected)
|
||||
4. Existing review thread count (PR reviews only — avoids duplicate comments)
|
||||
|
||||
**Gate**: User confirms the summary is complete before proceeding.
|
||||
|
||||
### Phase 2: Review
|
||||
|
||||
Invoke the `reviewing-changes` skill and use it to perform the Android-specific code review. Use the PR context from Phase 1 (change type, files affected, modules, Jira requirements) to inform the skill's change type detection and checklist selection.
|
||||
|
||||
The skill will:
|
||||
1. Detect the change type based on files and PR context from Phase 1
|
||||
2. Load the appropriate type-specific checklist
|
||||
3. Execute the multi-pass review strategy
|
||||
4. Consult reference materials as needed
|
||||
|
||||
**Before advancing**: Share a summary of key findings (critical issues if any, overall assessment) and confirm the user is ready to output the review.
|
||||
|
||||
### Phase 3: Output
|
||||
|
||||
Write the completed review to local files:
|
||||
|
||||
- `review-summary.md` — Overall assessment (APPROVE / REQUEST CHANGES) plus critical issues list
|
||||
- `review-inline-comments.md` — All inline findings with `<details>` tags
|
||||
|
||||
Follow the exact output format from `.claude/skills/reviewing-changes/examples/review-outputs.md`.
|
||||
|
||||
For PR reviews: offer to post the review to GitHub using `gh pr review <N> --comment -b "$(cat review-summary.md)"` for the summary. For inline comments, use the GitHub API or the `bitwarden-code-review` plugin if installed.
|
||||
|
||||
**Before advancing**: Confirm the files were written successfully and ask if the user wants to post to GitHub (PR reviews only).
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be explicit about which phase you are in at all times.
|
||||
- Never proceed to another phase without user confirmation.
|
||||
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
|
||||
- If starting from a partially completed review (e.g., review already written), skip to the appropriate phase.
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
description: Guided Android development workflow through all lifecycle phases
|
||||
argument-hint: <task description, plan, or Jira ticket reference>
|
||||
---
|
||||
|
||||
# Android Development Workflow
|
||||
|
||||
You are guiding the developer through a complete Android development lifecycle for the Bitwarden Android project. The task to work on is:
|
||||
|
||||
**Task**: $ARGUMENTS
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
Work through each phase sequentially. **Confirm with the user before advancing to the next phase.** If a phase fails (tests fail, lint errors, etc.), loop on that phase until resolved before advancing. The user may skip phases that are not applicable.
|
||||
|
||||
### Phase 1: Implement
|
||||
|
||||
Invoke `Skill(implementing-android-code)` to guide your implementation of the task. Understand what needs to be done, explore the relevant code, and write the implementation.
|
||||
|
||||
**Before advancing**: Summarize what was implemented and confirm the user is ready to move to testing.
|
||||
|
||||
### Phase 2: Test
|
||||
|
||||
Invoke `Skill(testing-android-code)` to write tests for the changes made in Phase 1. Follow the project's test patterns and conventions.
|
||||
|
||||
**Before advancing**: Summarize what tests were written and confirm readiness for build verification.
|
||||
|
||||
### Phase 3: Build & Verify
|
||||
|
||||
Invoke `Skill(build-test-verify)` to run tests, lint, and detekt. Ensure everything passes.
|
||||
|
||||
**If failures occur**: Fix the issues and re-run verification. Do not advance until all checks pass.
|
||||
|
||||
**Before advancing**: Report build/test/lint results and confirm readiness for self-review.
|
||||
|
||||
### Phase 4: Self-Review
|
||||
|
||||
Invoke `Skill(perform-android-preflight-checklist)` to perform a quality gate check on all changes. Address any issues found.
|
||||
|
||||
**Before advancing**: Share the self-review results and confirm readiness to commit.
|
||||
|
||||
### Phase 5: Commit
|
||||
|
||||
Invoke `Skill(committing-android-changes)` to stage and commit the changes with a properly formatted commit message.
|
||||
|
||||
**Before advancing**: Confirm the commit was successful and ask if the user wants to proceed to review and PR creation, or stop here.
|
||||
|
||||
### Phase 6: Review
|
||||
|
||||
**Pre-requisites:**
|
||||
- `bitwarden-code-review` from the [Bitwarden Plugin Marketplace](https://github.com/bitwarden/ai-plugins) must be installed in order to perform this phase. If it is not installed prompt the user to install it, or skip the review phase.
|
||||
|
||||
Launch a subagent with the `/bitwarden-code-review:code-review-local` command to perform a **local** code review of the committed diff. Validate and address any issues found before proceeding.
|
||||
|
||||
**Before advancing**: Share review findings and confirm readiness for PR creation.
|
||||
|
||||
### Phase 7: Pull Request
|
||||
|
||||
Prompt the user to invoke `Skill(creating-android-pull-request)` to create the pull request with proper description and formatting. **Create as a draft PR by default** unless the user has explicitly requested a ready-for-review PR.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Be explicit about which phase you are in at all times.
|
||||
- Never proceed to another phase without user confirmation.
|
||||
- If the user wants to skip a phase, acknowledge and move to the next applicable phase.
|
||||
- If starting from a partially completed task (e.g., code already written), skip to the appropriate phase.
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"attribution": {
|
||||
"commit": "",
|
||||
"pr": ""
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
---
|
||||
name: build-test-verify
|
||||
version: 0.1.0
|
||||
description: Build, test, lint, and deploy commands for the Bitwarden Android project. Use when running tests, building APKs/AABs, running lint/detekt, deploying, using fastlane, or discovering codebase structure. Triggered by "run tests", "build", "gradle", "lint", "detekt", "deploy", "fastlane", "assemble", "verify", "coverage".
|
||||
---
|
||||
|
||||
# Build, Test & Verify
|
||||
|
||||
## Environment Setup
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GITHUB_TOKEN` | Yes (CI) | GitHub Packages auth for SDK (`read:packages` scope) |
|
||||
| Build flavors | - | `standard` (Play Store), `fdroid` (no Google services) |
|
||||
| Build types | - | `debug`, `beta`, `release` |
|
||||
|
||||
If builds fail resolving the Bitwarden SDK, verify `GITHUB_TOKEN` in `user.properties` or environment and check connectivity to `maven.pkg.github.com`.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Debug builds
|
||||
./gradlew app:assembleDebug
|
||||
./gradlew authenticator:assembleDebug
|
||||
|
||||
# Release builds (requires signing keys)
|
||||
./gradlew app:assembleStandardRelease
|
||||
./gradlew app:bundleStandardRelease
|
||||
|
||||
# F-Droid builds
|
||||
./gradlew app:assembleFdroidRelease
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
**IMPORTANT**: The app module uses the `standard` flavor. Always use `testStandardDebugUnitTest`, NOT `testDebugUnitTest`.
|
||||
|
||||
**IMPORTANT**: Always pipe test output through a filter that captures failures on the first run. Gradle suppresses detailed failure output by default, so use `2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30` to see pass/fail results and assertion details without needing a second run.
|
||||
|
||||
```bash
|
||||
# App module tests (correct flavor!)
|
||||
./gradlew app:testStandardDebugUnitTest 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
|
||||
|
||||
# Run specific test classes
|
||||
./gradlew app:testStandardDebugUnitTest --tests "com.x8bit.bitwarden.SomeTest" 2>&1 | grep -E "FAILED|BUILD|expected:|actual:|AssertionError|failures" | head -30
|
||||
|
||||
# Run all unit tests across all modules
|
||||
./gradlew test
|
||||
|
||||
# Individual shared modules (no flavor needed)
|
||||
./gradlew :core:test
|
||||
./gradlew :data:test
|
||||
./gradlew :network:test
|
||||
./gradlew :ui:test
|
||||
|
||||
# Authenticator module
|
||||
./gradlew authenticator:testStandardDebugUnitTest
|
||||
```
|
||||
|
||||
### Reading Test Reports
|
||||
|
||||
If you need full failure details beyond what grep captures, check the HTML test report:
|
||||
|
||||
```bash
|
||||
# After a test run, open the report at:
|
||||
# app/build/reports/tests/testStandardDebugUnitTest/index.html
|
||||
# Or read individual failure XML:
|
||||
find app/build/test-results -name "*.xml" -exec grep -l "failure" {} \;
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
```
|
||||
app/src/test/ # App unit tests
|
||||
app/src/testFixtures/ # App test utilities
|
||||
core/src/testFixtures/ # Core test utilities (FakeDispatcherManager)
|
||||
data/src/testFixtures/ # Data test utilities (FakeSharedPreferences)
|
||||
network/src/testFixtures/ # Network test utilities (BaseServiceTest)
|
||||
ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseComposeTest)
|
||||
```
|
||||
|
||||
### Test Quick Reference
|
||||
|
||||
- **Dispatcher Control**: `FakeDispatcherManager` from `:core:testFixtures`
|
||||
- **MockK**: `mockk<T> { every { } returns }`, `coEvery { }` for suspend
|
||||
- **Flow Testing**: Turbine with `stateEventFlow()` helper from `BaseViewModelTest`
|
||||
- **Time Control**: Inject `Clock` for deterministic time testing
|
||||
|
||||
---
|
||||
|
||||
## Lint & Static Analysis
|
||||
|
||||
**IMPORTANT**: Prefer running detekt on modified files only — a full project scan is slow and unnecessary during development. The project supports a `-Pprecommit=true` flag that limits detekt to staged files.
|
||||
|
||||
**IMPORTANT**: Always pipe detekt output through a filter to capture errors on the first run. Detekt prints violation details to stderr/stdout but Gradle can obscure them. Use the grep pattern below to see violations immediately.
|
||||
|
||||
```bash
|
||||
# Detekt on staged files only (preferred during development)
|
||||
git add -u && ./gradlew -Pprecommit=true detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
|
||||
|
||||
# Detekt on all files (full scan, use sparingly)
|
||||
./gradlew detekt 2>&1 | grep -E "FAILED|BUILD|Line |Rule |Signature|detekt" | head -40
|
||||
|
||||
# Android Lint
|
||||
./gradlew lint
|
||||
|
||||
# Full validation suite (detekt + lint + tests + coverage)
|
||||
./fastlane check
|
||||
```
|
||||
|
||||
### How `-Pprecommit=true` Works
|
||||
|
||||
The root `build.gradle.kts` configures detekt tasks to use `git diff --name-only --cached` when this property is set, limiting analysis to staged files only. This is the same mechanism used by the project's pre-commit hook. Stage your changes with `git add` before running.
|
||||
|
||||
---
|
||||
|
||||
## Codebase Discovery
|
||||
|
||||
```bash
|
||||
# Find existing Bitwarden UI components
|
||||
find ui/src/main/kotlin/com/bitwarden/ui/platform/components/ -name "Bitwarden*.kt" | sort
|
||||
|
||||
# Find all ViewModels
|
||||
grep -rl "BaseViewModel<" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find all Navigation files with @Serializable routes
|
||||
find app/src/main/kotlin/ -name "*Navigation.kt" | sort
|
||||
|
||||
# Find all Hilt modules
|
||||
find app/src/main/kotlin/ -name "*Module.kt" -path "*/di/*" | sort
|
||||
|
||||
# Find all repository interfaces
|
||||
find app/src/main/kotlin/ -name "*Repository.kt" -not -name "*Impl.kt" -path "*/repository/*" | sort
|
||||
|
||||
# Find encrypted disk source examples
|
||||
grep -rl "EncryptedPreferences" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Find Clock injection usage
|
||||
grep -rl "private val clock: Clock" app/src/main/kotlin/ --include="*.kt"
|
||||
|
||||
# Search existing strings before adding new ones
|
||||
grep -n "search_term" ui/src/main/res/values/strings.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment & Versioning
|
||||
|
||||
**Version location**: `gradle/libs.versions.toml`
|
||||
```toml
|
||||
appVersionCode = "1"
|
||||
appVersionName = "2025.11.1"
|
||||
```
|
||||
Pattern: `YEAR.MONTH.PATCH`
|
||||
|
||||
**Publishing channels**:
|
||||
- **Play Store**: GitHub Actions workflow with signed AAB
|
||||
- **F-Droid**: Dedicated workflow with F-Droid signing keys
|
||||
- **Firebase App Distribution**: Beta testing
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
name: committing-android-changes
|
||||
version: 0.1.0
|
||||
description: Git commit conventions and workflow for Bitwarden Android. Use when committing code, writing commit messages, or preparing changes for commit. Triggered by "commit", "git commit", "commit message", "prepare commit", "stage changes".
|
||||
---
|
||||
|
||||
# Git Commit Conventions
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
```
|
||||
[PM-XXXXX] <type>: <imperative summary>
|
||||
|
||||
<optional body explaining why, not what>
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Ticket prefix**: Always include `[PM-XXXXX]` matching the Jira ticket
|
||||
2. **Type keyword**: Include a conventional commit type after the ticket prefix (see table below)
|
||||
3. **Imperative mood**: "Add feature" not "Added feature" or "Adds feature"
|
||||
4. **Short summary**: Under 72 characters for the first line
|
||||
5. **Body**: Explain the "why" not the "what" — the diff shows the what
|
||||
|
||||
### Type Keywords
|
||||
|
||||
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
[PM-12345] feat: Add biometric unlock timeout configuration
|
||||
|
||||
Users reported confusion about when biometric prompts appear.
|
||||
This adds a configurable timeout setting to the security preferences.
|
||||
```
|
||||
|
||||
### Followup Commits
|
||||
|
||||
Only the first commit on a branch needs the full format (ticket prefix, type keyword, body). Subsequent commits — whether addressing review feedback, making intermediate changes, or iterating locally — can use a short, descriptive summary with no prefix or body required.
|
||||
|
||||
```
|
||||
Update error handling in login flow
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-Commit Checklist
|
||||
|
||||
Run the `perform-android-preflight-checklist` skill for the full quality gate. At minimum, before staging and committing:
|
||||
|
||||
1. **Run affected module tests** (use `build-test-verify` skill for correct commands)
|
||||
2. **Check lint**: `./gradlew detekt` on changed modules
|
||||
3. **Review staged changes**: `git diff --staged` — verify no unintended modifications
|
||||
4. **Verify no secrets**: No API keys, tokens, passwords, or `.env` files staged
|
||||
5. **Verify no generated files**: No build outputs, `.idea/` changes, or generated code
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Commit
|
||||
|
||||
- `.env` files or `user.properties` with real tokens
|
||||
- Credential files or signing keystores
|
||||
- Build outputs (`build/`, `*.apk`, `*.aab`)
|
||||
- IDE-specific files (`.idea/` changes, `*.iml`)
|
||||
- Large binary files
|
||||
|
||||
---
|
||||
|
||||
## Staging Best Practices
|
||||
|
||||
- **Stage specific files** by name rather than `git add -A` or `git add .`
|
||||
- Put each file path on its own line for readability:
|
||||
```bash
|
||||
git add \
|
||||
path/to/first/File.kt \
|
||||
path/to/second/File.kt \
|
||||
path/to/third/File.kt
|
||||
```
|
||||
- Review each file being staged to avoid accidentally including sensitive data
|
||||
- Use `git status` (without `-uall` flag) to see the working tree state
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: creating-android-pull-request
|
||||
version: 0.1.0
|
||||
description: Pull request creation workflow for Bitwarden Android. Use when creating PRs, writing PR descriptions, or preparing branches for review. Triggered by "create PR", "pull request", "open PR", "gh pr create", "PR description".
|
||||
---
|
||||
|
||||
# Create Pull Request
|
||||
|
||||
## PR Title Format
|
||||
|
||||
```
|
||||
[PM-XXXXX] <type>: <short imperative summary>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- `[PM-12345] feat: Add autofill support for passkeys`
|
||||
- `[PM-12345] fix: Resolve crash during vault sync`
|
||||
- `[PM-12345] refactor: Simplify authentication flow`
|
||||
|
||||
**Rules:**
|
||||
- Include Jira ticket prefix
|
||||
- Keep under 70 characters total
|
||||
- Use imperative mood in the summary
|
||||
|
||||
**Type keywords** (triggers automatic `t:` label via CI):
|
||||
|
||||
Invoke the `labeling-android-changes` skill for the full type keyword table and selection guidance.
|
||||
|
||||
---
|
||||
|
||||
## PR Body Template
|
||||
|
||||
**IMPORTANT:** Always follow the repo's PR template at `.github/PULL_REQUEST_TEMPLATE.md`. Delete the Screenshots section entirely if there are no UI changes.
|
||||
|
||||
---
|
||||
|
||||
## Pre-PR Checklist
|
||||
|
||||
1. **All tests pass**: Run `./gradlew app:testStandardDebugUnitTest` (and other affected modules)
|
||||
2. **Lint clean**: Run `./gradlew detekt`
|
||||
3. **Self-review done**: Use `perform-android-preflight-checklist` skill
|
||||
4. **No unintended changes**: Check `git diff origin/main...HEAD` for unexpected files
|
||||
5. **Branch up to date**: Rebase on `main` if needed
|
||||
|
||||
---
|
||||
|
||||
## Creating the PR
|
||||
|
||||
```bash
|
||||
# Ensure branch is pushed
|
||||
git push -u origin <branch-name>
|
||||
|
||||
# Create PR as draft by default (body follows .github/PULL_REQUEST_TEMPLATE.md)
|
||||
gh pr create --draft --title "[PM-XXXXX] feat: Short summary" --body "<fill in from PR template>"
|
||||
```
|
||||
|
||||
**Default to draft PRs.** Only create a non-draft (ready for review) PR if the user explicitly requests it.
|
||||
|
||||
---
|
||||
|
||||
## AI Review Label
|
||||
|
||||
Before running `gh pr create`, **always** use the `AskUserQuestion` tool to ask whether to add an AI review label:
|
||||
|
||||
- **Question**: "Would you like to add an AI review label to this PR?"
|
||||
- **Options**: `ai-review-vnext`, `ai-review`, `No label`
|
||||
|
||||
If the user selects a label, include it via the `--label` flag:
|
||||
|
||||
```bash
|
||||
gh pr create --draft --label "ai-review-vnext" --title "..." --body "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Base Branch
|
||||
|
||||
- Default target: `main`
|
||||
- Check with team if targeting a feature branch instead
|
||||
@@ -1,481 +0,0 @@
|
||||
---
|
||||
name: implementing-android-code
|
||||
version: 0.1.2
|
||||
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
---
|
||||
|
||||
# Implementing Android Code - Bitwarden Quick Reference
|
||||
|
||||
**This skill provides tactical guidance for Bitwarden-specific patterns.** For comprehensive architecture decisions and complete code style rules, consult `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Critical Patterns Reference
|
||||
|
||||
### A. ViewModel Implementation (State-Action-Event Pattern)
|
||||
|
||||
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
|
||||
|
||||
**Key Requirements:**
|
||||
- Annotate with `@HiltViewModel`
|
||||
- State class MUST be `@Parcelize data class : Parcelable`
|
||||
- Implement `handleAction(action: A)` - MUST be synchronous
|
||||
- Post internal actions from coroutines using `sendAction()`
|
||||
- Save/restore state via `SavedStateHandle[KEY_STATE]`
|
||||
- Private action handlers: `private fun handle*` naming convention
|
||||
|
||||
**Template**: See [ViewModel template](templates.md#viewmodel-template-state-action-event-pattern)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: ExampleState(),
|
||||
) {
|
||||
init {
|
||||
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
// Synchronous dispatch only
|
||||
when (action) {
|
||||
is Action.Click -> handleClick()
|
||||
is Action.Internal.DataReceived -> handleDataReceived(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClick() {
|
||||
viewModelScope.launch {
|
||||
val result = repository.fetchData()
|
||||
sendAction(Action.Internal.DataReceived(result)) // Post internal action
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDataReceived(action: Action.Internal.DataReceived) {
|
||||
mutableStateFlow.update { it.copy(data = action.result) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt` (see class declaration)
|
||||
|
||||
**Critical Gotchas:**
|
||||
- ❌ **NEVER** update `mutableStateFlow` directly inside coroutines
|
||||
- ✅ **ALWAYS** post internal actions from coroutines, update state in `handleAction()`
|
||||
- ❌ **NEVER** forget `@IgnoredOnParcel` for sensitive data (causes security leak)
|
||||
- ✅ **ALWAYS** use `@Parcelize` on state classes for process death recovery
|
||||
- ✅ State restoration happens automatically if properly saved to `SavedStateHandle`
|
||||
|
||||
---
|
||||
|
||||
### B. Navigation Implementation (Type-Safe)
|
||||
|
||||
All navigation uses **type-safe routes** with kotlinx.serialization.
|
||||
|
||||
**Pattern Structure:**
|
||||
1. `@Serializable` route data class with parameters
|
||||
2. `...Args` helper class for extracting from `SavedStateHandle`
|
||||
3. `NavGraphBuilder.{screen}Destination()` extension for adding screen to graph
|
||||
4. `NavController.navigateTo{Screen}()` extension for navigation calls
|
||||
|
||||
**Template**: See [Navigation template](templates.md#navigation-template-type-safe-routes)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)
|
||||
|
||||
data class ExampleArgs(val userId: String, val isEditMode: Boolean)
|
||||
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
|
||||
}
|
||||
|
||||
fun NavController.navigateToExample(
|
||||
userId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(onNavigateBack = onNavigateBack)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt` (see `LoginRoute` and extensions)
|
||||
|
||||
**Key Benefits:**
|
||||
- ✅ Type safety: Compile-time errors for missing parameters
|
||||
- ✅ No string literals in navigation code
|
||||
- ✅ Automatic serialization/deserialization
|
||||
- ✅ Clear contract for screen dependencies
|
||||
|
||||
---
|
||||
|
||||
### C. Screen/Compose Implementation
|
||||
|
||||
All screens follow consistent Compose patterns.
|
||||
|
||||
**Template**: See [Screen/Compose template](templates.md#screencompose-template)
|
||||
|
||||
**Key Patterns:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.title),
|
||||
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
// UI content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
|
||||
|
||||
**Essential Requirements:**
|
||||
- ✅ Use `hiltViewModel()` for dependency injection
|
||||
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
|
||||
- ✅ Use `EventsEffect(viewModel)` for one-shot events
|
||||
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
|
||||
|
||||
**State Hoisting Rules:**
|
||||
- **ViewModel state**: Data that needs to survive process death or affects business logic
|
||||
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
|
||||
|
||||
---
|
||||
|
||||
### D. Data Layer Implementation
|
||||
|
||||
The data layer follows strict patterns for repositories, managers, and data sources.
|
||||
|
||||
**Interface + Implementation Separation (ALWAYS)**
|
||||
|
||||
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// Interface (injected via Hilt)
|
||||
interface ExampleRepository {
|
||||
suspend fun fetchData(id: String): ExampleResult
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
|
||||
// Implementation (NOT directly injected)
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
) : ExampleRepository {
|
||||
override suspend fun fetchData(id: String): ExampleResult {
|
||||
// NO exceptions thrown - return Result or sealed class
|
||||
return exampleService.getData(id).fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealed result class (domain-specific)
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: ExampleData) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
|
||||
// Hilt Module
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
**Three-Layer Data Architecture:**
|
||||
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
|
||||
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
|
||||
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
|
||||
|
||||
**Critical Rules:**
|
||||
- ❌ **NEVER** throw exceptions in data layer
|
||||
- ✅ **ALWAYS** use interface + `...Impl` pattern
|
||||
- ✅ **ALWAYS** inject interfaces, never implementations
|
||||
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
|
||||
- ✅ Use `StateFlow` for continuously observed data
|
||||
|
||||
---
|
||||
|
||||
### E. UI Components
|
||||
|
||||
**Use Existing Components First:**
|
||||
|
||||
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
|
||||
|
||||
**Common Components:**
|
||||
- `BitwardenFilledButton` - Primary action buttons
|
||||
- `BitwardenOutlinedButton` - Secondary action buttons
|
||||
- `BitwardenTextField` - Text input fields
|
||||
- `BitwardenPasswordField` - Password input with show/hide
|
||||
- `BitwardenSwitch` - Toggle switches
|
||||
- `BitwardenTopAppBar` - Toolbar/app bar
|
||||
- `BitwardenScaffold` - Screen container with scaffold
|
||||
- `BitwardenBasicDialog` - Simple dialogs
|
||||
- `BitwardenLoadingDialog` - Loading indicators
|
||||
|
||||
**Component Discovery:**
|
||||
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
|
||||
|
||||
**When to Create New Reusable Components:**
|
||||
- Component used in 3+ places
|
||||
- Component needs consistent theming across app
|
||||
- Component has semantic meaning (accessibility)
|
||||
- Component has complex state management
|
||||
|
||||
**New Component Requirements:**
|
||||
- Prefix with `Bitwarden`
|
||||
- Accept themed colors/styles from `BitwardenTheme`
|
||||
- Include preview composables for testing
|
||||
- Support accessibility (content descriptions, semantics)
|
||||
|
||||
**String Resources:**
|
||||
|
||||
New strings belong in the `:ui` module: `ui/src/main/res/values/strings.xml`
|
||||
|
||||
- Use typographic apostrophes and quotes to avoid escape characters: `you’ll` not `you\'ll`, `“word”` not `\"word\"`
|
||||
- Reference strings via generated `BitwardenString` resource IDs
|
||||
- Do not add strings to other modules unless explicitly instructed
|
||||
|
||||
---
|
||||
|
||||
### F. Security Patterns
|
||||
|
||||
**Encrypted vs Unencrypted Storage:**
|
||||
|
||||
**Template**: See [Security templates](templates.md#security-templates)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
class ExampleDiskSourceImpl(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
fun storeAuthToken(token: String) {
|
||||
putEncryptedString(KEY_TOKEN, token) // Sensitive — uses base class method
|
||||
}
|
||||
|
||||
fun storeThemePreference(isDark: Boolean) {
|
||||
putBoolean(KEY_THEME, isDark) // Non-sensitive — uses base class method
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Android Keystore (Biometric Keys):**
|
||||
- User-scoped encryption keys: `BiometricsEncryptionManager`
|
||||
- Keys stored in Android Keystore (hardware-backed when available)
|
||||
- Integrity validation on biometric state changes
|
||||
|
||||
**Input Validation:**
|
||||
```kotlin
|
||||
// Validation returns boolean, NEVER throws
|
||||
interface RequestValidator {
|
||||
fun validate(request: Request): Boolean
|
||||
}
|
||||
|
||||
// Sanitization removes dangerous content
|
||||
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
|
||||
if (this.isNullOrBlank()) return null
|
||||
// Sanitize and return safe value
|
||||
}
|
||||
```
|
||||
|
||||
**Security Checklist:**
|
||||
- ✅ Use `@EncryptedPreferences` for credentials, keys, tokens
|
||||
- ✅ Use `@UnencryptedPreferences` for UI state, preferences
|
||||
- ✅ Use `@IgnoredOnParcel` for sensitive ViewModel state
|
||||
- ❌ **NEVER** log sensitive data (passwords, tokens, vault items)
|
||||
- ✅ Validate all user input before processing
|
||||
- ✅ Use Timber for non-sensitive logging only
|
||||
|
||||
---
|
||||
|
||||
### G. Testing Patterns
|
||||
|
||||
**ViewModel Testing:**
|
||||
|
||||
**Template**: See [Testing templates](templates.md#testing-templates)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
|
||||
@Test
|
||||
fun `ButtonClick should fetch data and update state`() = runTest {
|
||||
val expectedResult = ExampleResult.Success(data = "test")
|
||||
coEvery { mockRepository.fetchData(any()) } returns expectedResult
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.trySendAction(ExampleAction.ButtonClick)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(EXPECTED_STATE.copy(data = "test"), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to EXPECTED_STATE)),
|
||||
repository = mockRepository,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
**Key Testing Patterns:**
|
||||
- ✅ Extend `BaseViewModelTest` for proper dispatcher management
|
||||
- ✅ Use `runTest` from `kotlinx.coroutines.test`
|
||||
- ✅ Use Turbine's `.test { awaitItem() }` for Flow assertions
|
||||
- ✅ Use MockK: `coEvery` for suspend functions, `every` for sync
|
||||
- ✅ Test both state changes and event emissions
|
||||
- ✅ Test both success and failure Result paths
|
||||
|
||||
**Flow Testing with Turbine:**
|
||||
```kotlin
|
||||
// Test state and events simultaneously
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.Submit)
|
||||
assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
|
||||
assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
**MockK Quick Reference:**
|
||||
```kotlin
|
||||
coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
|
||||
every { diskSource.getData() } returns "cached" // Sync
|
||||
coVerify { repository.fetchData("123") } // Verify
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H. Clock/Time Handling
|
||||
|
||||
All code needing current time must inject `Clock` for testability.
|
||||
|
||||
**Key Requirements:**
|
||||
- ✅ Inject `Clock` via Hilt in ViewModels
|
||||
- ✅ Pass `Clock` as parameter in extension functions
|
||||
- ✅ Use `clock.instant()` to get current time
|
||||
- ❌ Never call `Instant.now()` or `DateTime.now()` directly
|
||||
- ❌ Never use `mockkStatic` for datetime classes in tests
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// ViewModel with Clock
|
||||
class MyViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
) {
|
||||
val timestamp = clock.instant()
|
||||
}
|
||||
|
||||
// Extension function with Clock parameter
|
||||
fun State.getTimestamp(clock: Clock): Instant =
|
||||
existingTime ?: clock.instant()
|
||||
|
||||
// Test with fixed clock
|
||||
val FIXED_CLOCK = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` (see Time and Clock Handling section)
|
||||
- `core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt` (see `provideClock` function)
|
||||
|
||||
**Critical Gotchas:**
|
||||
- ❌ `Instant.now()` creates hidden dependency, non-testable
|
||||
- ❌ `mockkStatic(Instant::class)` is fragile, can leak between tests
|
||||
- ✅ `Clock.fixed(...)` provides deterministic test behavior
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden-Specific Anti-Patterns
|
||||
|
||||
**General anti-patterns are documented in CLAUDE.md.** This section covers violations specific to Bitwarden's State-Action-Event, navigation, and data layer patterns:
|
||||
|
||||
❌ **NEVER update ViewModel state directly in coroutines**
|
||||
- Post internal actions, update state synchronously in `handleAction()`
|
||||
|
||||
❌ **NEVER inject `...Impl` classes**
|
||||
- Only inject interfaces via Hilt
|
||||
|
||||
❌ **NEVER create navigation without `@Serializable` routes**
|
||||
- No string-based navigation, always type-safe
|
||||
|
||||
❌ **NEVER use raw `Result<T>` in repositories**
|
||||
- Use domain-specific sealed classes for better error handling
|
||||
|
||||
❌ **NEVER make state classes without `@Parcelize`**
|
||||
- All ViewModel state must survive process death
|
||||
|
||||
❌ **NEVER skip `SavedStateHandle` persistence for ViewModels**
|
||||
- Users lose form progress on process death
|
||||
|
||||
❌ **NEVER forget `@IgnoredOnParcel` for passwords/tokens**
|
||||
- Causes security vulnerability (sensitive data in parcel)
|
||||
|
||||
❌ **NEVER use generic `Exception` catching**
|
||||
- Catch specific exceptions only (`RemoteException`, `IOException`)
|
||||
|
||||
❌ **NEVER call `Instant.now()` or `DateTime.now()` directly**
|
||||
- Inject `Clock` via Hilt, use `clock.instant()` for testability
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
For build, test, and codebase discovery commands, use the **`build-test-verify`** skill.
|
||||
|
||||
**File Reference Format:**
|
||||
When pointing to specific code, use: `file_path:line_number`
|
||||
|
||||
Example: `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
|
||||
|
||||
@@ -1,636 +0,0 @@
|
||||
# Code Templates - Bitwarden Android
|
||||
|
||||
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
|
||||
|
||||
---
|
||||
|
||||
## ViewModel Template (State-Action-Event Pattern)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
|
||||
|
||||
### State Class
|
||||
|
||||
```kotlin
|
||||
@Parcelize
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
|
||||
val dialogState: DialogState? = null,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Dialog states for the Example screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
@Parcelize
|
||||
data class Loading(val message: Text) : DialogState()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Sealed Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* One-shot UI events for the Example screen.
|
||||
*/
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
|
||||
data class ShowToast(val message: Text) : ExampleEvent()
|
||||
}
|
||||
```
|
||||
|
||||
### Action Sealed Class (with Internal)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* User and system actions for the Example screen.
|
||||
*/
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
|
||||
data object SubmitClick : ExampleAction()
|
||||
|
||||
data class InputChanged(val input: String) : ExampleAction()
|
||||
|
||||
data object ErrorDialogDismiss : ExampleAction()
|
||||
|
||||
/**
|
||||
* Internal actions dispatched by the ViewModel from coroutines.
|
||||
*/
|
||||
sealed class Internal : ExampleAction() {
|
||||
data class ReceiveDataState(
|
||||
val dataState: DataState<ExampleData>,
|
||||
) : Internal()
|
||||
|
||||
data class ReceiveDataResult(
|
||||
val result: ExampleResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel
|
||||
|
||||
```kotlin
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Example screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val exampleRepository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val args = savedStateHandle.toExampleArgs()
|
||||
ExampleState(
|
||||
data = args.itemId,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
// Persist state for process death recovery
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// Collect repository flows as internal actions
|
||||
exampleRepository.dataFlow
|
||||
.map { ExampleAction.Internal.ReceiveDataState(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
when (action) {
|
||||
ExampleAction.BackClick -> handleBackClick()
|
||||
ExampleAction.SubmitClick -> handleSubmitClick()
|
||||
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
|
||||
is ExampleAction.InputChanged -> handleInputChanged(action)
|
||||
is ExampleAction.Internal.ReceiveDataState -> {
|
||||
handleReceiveDataState(action)
|
||||
}
|
||||
is ExampleAction.Internal.ReceiveDataResult -> {
|
||||
handleReceiveDataResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(ExampleEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleErrorDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleSubmitClick() {
|
||||
viewModelScope.launch {
|
||||
val result = exampleRepository.submitData(state.data.orEmpty())
|
||||
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInputChanged(action: ExampleAction.InputChanged) {
|
||||
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
|
||||
}
|
||||
|
||||
private fun handleReceiveDataState(
|
||||
action: ExampleAction.Internal.ReceiveDataState,
|
||||
) {
|
||||
when (action.dataState) {
|
||||
is DataState.Loaded -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = action.dataState.data.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loading -> {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
}
|
||||
|
||||
is DataState.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = action.dataState.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveDataResult(
|
||||
action: ExampleAction.Internal.ReceiveDataResult,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is ExampleResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = result.data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ExampleResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = result.message?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Template (Type-Safe Routes)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
|
||||
|
||||
```kotlin
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Route for the Example screen.
|
||||
*/
|
||||
@Serializable
|
||||
@OmitFromCoverage
|
||||
data class ExampleRoute(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Args extracted from [SavedStateHandle] for the Example screen.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
data class ExampleArgs(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts [ExampleArgs] from the [SavedStateHandle].
|
||||
*/
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(
|
||||
itemId = route.itemId,
|
||||
isEditMode = route.isEditMode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Example screen.
|
||||
*/
|
||||
fun NavController.navigateToExample(
|
||||
itemId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = ExampleRoute(
|
||||
itemId = itemId,
|
||||
isEditMode = isEditMode,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Example screen destination to the navigation graph.
|
||||
*/
|
||||
fun NavGraphBuilder.exampleDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen/Compose Template
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
|
||||
|
||||
```kotlin
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* The Example screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
is ExampleEvent.ShowToast -> {
|
||||
// Handle toast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
ExampleDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.example),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
|
||||
)
|
||||
},
|
||||
) {
|
||||
ExampleScreenContent(
|
||||
state = state,
|
||||
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
|
||||
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Layer Template (Repository + Hilt Module)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
### Interface
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Provides data operations for the Example feature.
|
||||
*/
|
||||
interface ExampleRepository {
|
||||
/**
|
||||
* Submits data and returns a typed result.
|
||||
*/
|
||||
suspend fun submitData(input: String): ExampleResult
|
||||
|
||||
/**
|
||||
* Continuously observed data stream.
|
||||
*/
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
```
|
||||
|
||||
### Sealed Result Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Domain-specific result for Example operations.
|
||||
*/
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: String) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Default implementation of [ExampleRepository].
|
||||
*/
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : ExampleRepository {
|
||||
|
||||
override val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
get() = // ...
|
||||
|
||||
override suspend fun submitData(input: String): ExampleResult {
|
||||
return exampleService
|
||||
.postData(input)
|
||||
.fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hilt Module
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): ExampleRepository = ExampleRepositoryImpl(
|
||||
exampleDiskSource = exampleDiskSource,
|
||||
exampleService = exampleService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Templates
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
|
||||
|
||||
### Encrypted Disk Source (Module)
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleDiskSource(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): ExampleDiskSource = ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Encrypted Disk Source (Implementation)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Disk source for Example data using encrypted and unencrypted storage.
|
||||
*/
|
||||
class ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
|
||||
private companion object {
|
||||
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
|
||||
const val UNENCRYPTED_PREF_KEY = "examplePreference"
|
||||
}
|
||||
|
||||
override var authToken: String?
|
||||
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
|
||||
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
|
||||
|
||||
override var uiPreference: Boolean
|
||||
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
|
||||
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Templates
|
||||
|
||||
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
### ViewModel Test
|
||||
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository = mockk<ExampleRepository>()
|
||||
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockRepository.dataFlow } returns mutableDataFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is no saved state`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is a saved state`() {
|
||||
val savedState = DEFAULT_STATE.copy(data = "saved")
|
||||
val viewModel = createViewModel(state = savedState)
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should call repository and update state on success`() = runTest {
|
||||
val expected = ExampleResult.Success(data = "result")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
// Initial state
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Updated state after result
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "result", isLoading = false),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should show error dialog on failure`() = runTest {
|
||||
val expected = ExampleResult.Error(message = "Network error")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
val errorState = awaitItem()
|
||||
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExampleAction.BackClick)
|
||||
assertEquals(ExampleEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create ViewModel with optional saved state
|
||||
private fun createViewModel(
|
||||
state: ExampleState? = DEFAULT_STATE,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to state),
|
||||
),
|
||||
exampleRepository = mockRepository,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Testing with stateEventFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `SubmitClick should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Assert state change
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "data"),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
|
||||
// Assert event emission
|
||||
assertEquals(
|
||||
ExampleEvent.ShowToast("Success".asText()),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: labeling-android-changes
|
||||
version: 0.1.0
|
||||
description: Conventional commit type keywords for PR titles and commit messages. Use when determining the change type for commits or PRs. Triggered by "what type", "label", "change type", "conventional commit", "t: label".
|
||||
---
|
||||
|
||||
# Labeling Changes
|
||||
|
||||
PR titles and commit messages must include a conventional commit type keyword. This keyword drives automatic `t:` label assignment via CI (`.github/workflows/sdlc-label-pr.yml`).
|
||||
|
||||
## Format
|
||||
|
||||
The type keyword appears after the Jira ticket prefix:
|
||||
|
||||
```
|
||||
[PM-XXXXX] <type>: <imperative summary>
|
||||
```
|
||||
|
||||
## Type Keywords
|
||||
|
||||
| Type | Label | Use for |
|
||||
|------|-------|---------|
|
||||
| `feat` | `t:feature` | New features or functionality |
|
||||
| `fix` | `t:bug` | Bug fixes |
|
||||
| `refactor` | `t:tech-debt` | Code restructuring without behavior change |
|
||||
| `chore` | `t:tech-debt` | Maintenance, cleanup, minor tweaks |
|
||||
| `test` | `t:tech-debt` | Adding or updating tests |
|
||||
| `perf` | `t:tech-debt` | Performance improvements |
|
||||
| `docs` | `t:docs` | Documentation changes |
|
||||
| `ci` / `build` | `t:ci` | CI/CD and build system changes |
|
||||
| `deps` | `t:deps` | Dependency updates |
|
||||
| `llm` | `t:llm` | LLM/Claude configuration changes |
|
||||
| `breaking` | `t:breaking-change` | Breaking changes requiring migration |
|
||||
| `misc` | `t:misc` | Changes that do not fit other categories |
|
||||
|
||||
## Selecting a Type
|
||||
|
||||
Infer the type from the task description and changes made. **If the type cannot be confidently determined, ask the user.**
|
||||
|
||||
The CI labeling script matches `<type>:` or `<type>(` in the lowercased PR title, so the keyword must be followed by a colon or parenthesis. CI also accepts additional aliases (e.g., `revert`, `bugfix`, `cleanup`). See `.github/label-pr.json` for the full mapping.
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: perform-android-preflight-checklist
|
||||
version: 0.1.0
|
||||
description: Quality gate checklist to run before committing or creating a PR. Use when finishing implementation, checking work quality, or preparing to commit. Triggered by "self review", "check my work", "ready to commit", "done implementing", "review checklist", "quality check".
|
||||
---
|
||||
|
||||
# Self-Review Checklist
|
||||
|
||||
Run through this checklist before committing or opening a PR.
|
||||
|
||||
## Tests
|
||||
- [ ] Tests pass with correct flavor: `./gradlew app:testStandardDebugUnitTest`
|
||||
- [ ] New code has corresponding test coverage
|
||||
- [ ] Tests for affected modules also pass (`:core:test`, `:data:test`, etc.)
|
||||
|
||||
## Code Quality
|
||||
- [ ] Lint/detekt clean: `./gradlew detekt`
|
||||
- [ ] No unintended file changes (`git diff` review)
|
||||
- [ ] KDoc on all new public APIs
|
||||
- [ ] No TODO comments left behind (or they reference a ticket)
|
||||
|
||||
## Security
|
||||
- [ ] No plaintext keys, tokens, or secrets in code
|
||||
- [ ] User input validated before processing
|
||||
- [ ] Sensitive data uses encrypted storage patterns
|
||||
- [ ] No logging of sensitive data (passwords, keys, tokens)
|
||||
|
||||
## Bitwarden Patterns
|
||||
- [ ] String resources in `:ui` module with typographic quotes
|
||||
- [ ] Navigation route is `@Serializable` and registered in graph
|
||||
- [ ] New implementations have Hilt `@Binds` or `@Provides` in a module
|
||||
- [ ] ViewModel extends `BaseViewModel<S, E, A>` with proper state persistence
|
||||
- [ ] Async results mapped through internal actions (not direct state updates)
|
||||
|
||||
## Files
|
||||
- [ ] No accidental `.idea/`, build output, or generated files staged
|
||||
- [ ] No credential files or `.env` files included
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
name: planning-android-implementation
|
||||
version: 0.1.0
|
||||
description: Architecture design and phased implementation planning for Bitwarden Android. Use when planning implementation, designing architecture, creating file inventories, or breaking features into phases. Triggered by "plan implementation", "architecture design", "implementation plan", "break this into phases", "what files do I need", "design the architecture".
|
||||
---
|
||||
|
||||
# Implementation Planning
|
||||
|
||||
This skill takes a refined specification (ideally from the `refining-android-requirements` skill) and produces a phased implementation plan with architecture design, file inventory, and risk assessment.
|
||||
|
||||
**Prerequisite**: A clear set of requirements. If requirements are vague or incomplete, invoke the `refining-android-requirements` skill first.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Classify Change
|
||||
|
||||
Determine the change type to guide scope and planning depth:
|
||||
|
||||
| Type | Description | Typical Scope |
|
||||
|------|-------------|---------------|
|
||||
| **New Feature** | Entirely new functionality, screens, or flows | New files + modifications, multi-phase |
|
||||
| **Enhancement** | Extending existing feature with new capabilities | Mostly modifications, 1-2 phases |
|
||||
| **Bug Fix** | Correcting incorrect behavior | Targeted modifications, single phase |
|
||||
| **Refactoring** | Restructuring without behavior change | Modifications only, migration-aware |
|
||||
| **Infrastructure** | Build, CI, tooling, or dependency changes | Config files, minimal code changes |
|
||||
|
||||
State the classification and rationale before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Codebase Exploration
|
||||
|
||||
Search the codebase to find reference implementations and integration points. Use the discovery commands from the `build-test-verify` skill as needed.
|
||||
|
||||
### Find Pattern Anchors
|
||||
|
||||
Identify 2-3 existing files that serve as templates for the planned work:
|
||||
|
||||
```
|
||||
**Pattern Anchors:**
|
||||
1. [file path] — [why this is a good reference]
|
||||
2. [file path] — [why this is a good reference]
|
||||
3. [file path] — [why this is a good reference]
|
||||
```
|
||||
|
||||
### Map Integration Points
|
||||
|
||||
Identify files that must be modified to integrate the new work:
|
||||
|
||||
- **Navigation**: Nav graph registrations, route definitions
|
||||
- **Dependency Injection**: Hilt modules, `@Provides` / `@Binds` functions
|
||||
- **Data Layer**: Repository interfaces, data source interfaces, Room DAOs
|
||||
- **API Layer**: Retrofit service interfaces, request/response models
|
||||
- **Feature Flags**: Feature flag definitions and checks
|
||||
- **Managers**: Single-responsibility data layer classes (see `docs/ARCHITECTURE.md` Managers section)
|
||||
- **Test Fixtures**: Shared test utilities in `src/testFixtures/` directories
|
||||
- **Product Flavor Source Sets**: Code in `src/standard/` vs `src/main/` for Play Services dependencies
|
||||
|
||||
### Document Existing Patterns
|
||||
|
||||
Note the specific patterns used by the pattern anchors:
|
||||
- State class structure (sealed class, data class fields)
|
||||
- Action/Event naming conventions
|
||||
- Repository method signatures and return types
|
||||
- Test structure and assertion patterns
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Architecture Design
|
||||
|
||||
Produce an ASCII diagram showing component relationships for the planned work:
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Screen │ ← Compose UI
|
||||
│ (Composable) │
|
||||
└────────┬────────┘
|
||||
│ State / Action / Event
|
||||
┌────────▼────────┐
|
||||
│ ViewModel │ ← Business logic orchestration
|
||||
└────────┬────────┘
|
||||
│ Repository calls
|
||||
┌────────▼────────┐
|
||||
│ Repository │ ← Data coordination (sealed class results)
|
||||
└───┬────┬────┬───┘
|
||||
│ │ │
|
||||
┌───▼───┐ │ ┌─▼──────┐
|
||||
│Manager│ │ │Manager │ ← Single-responsibility (optional)
|
||||
└───┬───┘ │ └─┬──────┘
|
||||
│ │ │
|
||||
┌───▼─────▼───▼────┐
|
||||
│ Data Sources │ ← Raw data (Result<T>, never throw)
|
||||
└─┬────┬────┬──────┘
|
||||
│ │ │
|
||||
Room Retrofit SDK
|
||||
```
|
||||
|
||||
Adapt the diagram to show the actual components planned. _Consult `docs/ARCHITECTURE.md` for full data layer patterns and conventions._
|
||||
|
||||
### Design Decisions
|
||||
|
||||
Document key architectural decisions in a table:
|
||||
|
||||
| Decision | Resolution | Rationale |
|
||||
|----------|-----------|-----------|
|
||||
| [What needed deciding] | [What was chosen] | [Why] |
|
||||
|
||||
---
|
||||
|
||||
## Step 4: File Inventory
|
||||
|
||||
### Files to Create
|
||||
|
||||
| File Path | Type | Pattern Reference |
|
||||
|-----------|------|-------------------|
|
||||
| [full path] | [ViewModel / Screen / Repository / etc.] | [pattern anchor file] |
|
||||
|
||||
**Include in file inventory:**
|
||||
- `...Navigation.kt` files for new screens
|
||||
- `...Module.kt` Hilt module files for new DI bindings
|
||||
- Paired test files (`...Test.kt`) for each new class
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File Path | Change Description | Risk Level |
|
||||
|-----------|-------------------|------------|
|
||||
| [full path] | [what changes] | Low / Medium / High |
|
||||
|
||||
**Risk levels:**
|
||||
- **Low**: Additive changes (new entries in nav graph, new bindings in Hilt module)
|
||||
- **Medium**: Modifying existing logic (adding parameters, new branches)
|
||||
- **High**: Changing interfaces, data models, or shared utilities
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Implementation Phases
|
||||
|
||||
Break the work into sequential phases. Each phase should be independently testable and committable.
|
||||
|
||||
**Phase ordering principle**: Foundation → SDK/Data → Network → UI (tests accompany each phase)
|
||||
|
||||
For each phase:
|
||||
|
||||
```markdown
|
||||
### Phase N: [Name]
|
||||
|
||||
**Goal**: [What this phase accomplishes]
|
||||
|
||||
**Files**:
|
||||
- Create: [list]
|
||||
- Modify: [list]
|
||||
|
||||
**Tasks**:
|
||||
1. [Specific implementation task]
|
||||
2. [Specific implementation task]
|
||||
3. ...
|
||||
|
||||
**Verification**:
|
||||
- [Test command or manual verification step]
|
||||
|
||||
**Skills**: [Which workflow skills apply — e.g., `implementing-android-code`, `testing-android-code`]
|
||||
```
|
||||
|
||||
### Phase Guidelines
|
||||
|
||||
- Each phase should be small enough to be independently testable and committable
|
||||
- Tests are written within the same phase as the code they verify (not deferred to a "testing phase")
|
||||
- UI phases come after their data dependencies are in place
|
||||
- If a phase has more than 5 tasks, consider splitting it
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Risk & Verification
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| [What could go wrong] | Low/Med/High | Low/Med/High | [How to prevent or handle] |
|
||||
|
||||
### Verification Plan
|
||||
|
||||
**Automated Verification:**
|
||||
- Unit test commands (from `build-test-verify` skill)
|
||||
- Lint/detekt commands
|
||||
- Build verification
|
||||
|
||||
**Manual Verification:**
|
||||
- [Specific manual test scenarios]
|
||||
- [Edge cases to manually verify]
|
||||
- Verify ViewModel state survives process death (test via `SavedStateHandle` persistence and `Don't keep activities` developer option)
|
||||
@@ -1,181 +0,0 @@
|
||||
---
|
||||
name: refining-android-requirements
|
||||
version: 0.1.0
|
||||
description: Requirements gap analysis and structured specification for Bitwarden Android. Use when refining requirements, analyzing specs, identifying gaps, or producing structured specifications from tickets or descriptions. Triggered by "refine requirements", "gap analysis", "spec review", "requirements analysis", "what's missing from this spec", "analyze this ticket".
|
||||
---
|
||||
|
||||
# Requirements Refinement
|
||||
|
||||
This skill takes raw requirements (from Jira tickets, Confluence pages, or free-text descriptions) and produces a structured, implementation-ready specification through systematic gap analysis.
|
||||
|
||||
**Key principle**: This skill identifies gaps and produces specifications. It does NOT propose solutions or architecture — that is the responsibility of the `planning-android-implementation` skill.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Source Consolidation
|
||||
|
||||
Combine all input sources into a single working document. For each requirement, note its source:
|
||||
|
||||
```
|
||||
- [Source: PM-12345] User must be able to configure timeout
|
||||
- [Source: Confluence] Timeout range is 1-60 minutes
|
||||
- [Source: User] Default timeout should be 15 minutes
|
||||
```
|
||||
|
||||
Flag any contradictions between sources for immediate resolution.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Gap Analysis
|
||||
|
||||
Evaluate the consolidated requirements against the following 5-category rubric. For each category, check every item and note whether it is **covered**, **partially covered**, or **missing**.
|
||||
|
||||
### A. Functional Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| User actions defined? | What specific user actions trigger this feature? |
|
||||
| All states covered? (empty, loading, error, success) | What should the user see in [empty/loading/error] state? |
|
||||
| Edge cases identified? | What happens when [boundary condition]? |
|
||||
| Cancellation/back navigation flows? | Can the user cancel mid-flow? What happens to partial data? |
|
||||
| Input validation rules? | What are the valid ranges/formats for [input]? |
|
||||
| Success/failure criteria? | How does the user know the operation succeeded or failed? |
|
||||
| Offline behavior? | What happens if this is attempted offline? |
|
||||
|
||||
### B. Technical Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Module scope identified? (`:app`, `:authenticator`, shared) | Which module(s) does this feature belong to? |
|
||||
| SDK dependencies? | Does this require Bitwarden SDK operations? Which ones? |
|
||||
| Data storage approach? (Room, DataStore, in-memory) | Where is the data for this feature persisted? |
|
||||
| Network API endpoints? | Which API endpoints are involved? Are they existing or new? |
|
||||
| Process death handling? | What state needs to survive process death? |
|
||||
| Migration requirements? | Does existing data need migration? |
|
||||
| Feature flag needed? | Should this be behind a feature flag for staged rollout? |
|
||||
| Product flavors (standard vs fdroid)? | Does this feature depend on Google Play Services? Available on F-Droid? |
|
||||
| Data layer tier? | Does this need a new Manager (single-responsibility) or only Repository/DataSource? Consult `docs/ARCHITECTURE.md` Data Layer section. |
|
||||
| Streaming vs discrete data? | Is data continuously observed (`DataState<T>` + `StateFlow`) or a one-shot operation (custom sealed class)? See `docs/ARCHITECTURE.md` Repositories section. |
|
||||
|
||||
### C. Security Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Data sensitivity classified? | What sensitivity level does this data have? (vault-level, account-level, non-sensitive) |
|
||||
| Storage encryption required? | Must this data be encrypted at rest? Via SDK or Android Keystore? |
|
||||
| Logout cleanup behavior? | What must be cleared when the user logs out? |
|
||||
| Auth-gating? | Does accessing this feature require active authentication? |
|
||||
| Input sanitization? | Are there URL or credential inputs that need validation? |
|
||||
| Sensitive data in ViewModel state? | Will passwords, tokens, or keys appear in state? Must use `@IgnoredOnParcel`. See `implementing-android-code` skill Section F. |
|
||||
| SDK crypto context isolation? | Does this use vault encryption? Must use `ScopedVaultSdkSource` for multi-account safety. See CLAUDE.md Security Rules. |
|
||||
|
||||
### D. UX/UI Requirements
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| UI copy/strings defined? | What text should appear for [label/button/message]? |
|
||||
| Error messages specified? | What should the error message say when [failure case]? |
|
||||
| Loading states designed? | Should loading show a spinner, skeleton, or shimmer? |
|
||||
| Navigation flow clear? | Where does the user go after [action]? Back stack behavior? |
|
||||
| Accessibility considerations? | Are there content descriptions or focus order requirements? |
|
||||
| Toast/snackbar/dialog for feedback? | What feedback mechanism for [action result]? |
|
||||
|
||||
### E. Cross-Cutting Concerns
|
||||
|
||||
| Check | Question to Ask If Missing |
|
||||
|-------|---------------------------|
|
||||
| Multi-account behavior? | How does this behave with multiple accounts? Per-account or global? |
|
||||
| Backwards compatibility? | Does this affect existing users? Migration path? |
|
||||
| Feature flag strategy? | Is this behind a server-side or local feature flag? |
|
||||
| Analytics/logging? | Are there analytics events to track? |
|
||||
| Bitwarden Authenticator impact? | Does this affect the `:authenticator` module? |
|
||||
| F-Droid compatibility? | Does this degrade gracefully without Google Play Services (no push notifications, no Play Integrity)? |
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Present Gaps
|
||||
|
||||
Organize all identified gaps into two categories:
|
||||
|
||||
### Blocking Questions
|
||||
|
||||
Questions that **must** be answered before implementation can begin because they change the architecture, data model, or core flow.
|
||||
|
||||
Format each question as:
|
||||
|
||||
```
|
||||
**G[N]** ([Category]) — [Question text]
|
||||
Context: [Why this matters / what depends on the answer]
|
||||
```
|
||||
|
||||
### Non-Blocking Questions
|
||||
|
||||
Questions that have **reasonable defaults** and can be resolved during implementation. Note the assumed default.
|
||||
|
||||
Format each question as:
|
||||
|
||||
```
|
||||
**G[N]** ([Category]) — [Question text]
|
||||
Default assumption: [What we'll assume if not answered]
|
||||
Context: [Why this matters]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Produce Specification
|
||||
|
||||
After the user answers blocking questions (and optionally non-blocking ones), produce a structured specification:
|
||||
|
||||
```markdown
|
||||
## Overview
|
||||
|
||||
[1-2 paragraph summary of the feature, its purpose, and scope]
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| FR1 | [requirement] | [source] | [any notes] |
|
||||
| FR2 | ... | ... | ... |
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| TR1 | [requirement] | [source] | [any notes] |
|
||||
| TR2 | ... | ... | ... |
|
||||
|
||||
## Security Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| SR1 | [requirement] | [source] | [any notes] |
|
||||
|
||||
## UX Requirements
|
||||
|
||||
| ID | Requirement | Source | Notes |
|
||||
|----|------------|--------|-------|
|
||||
| UX1 | [requirement] | [source] | [any notes] |
|
||||
|
||||
## Open Items
|
||||
|
||||
Non-blocking items with assumed defaults that may be revisited:
|
||||
|
||||
| ID | Question | Assumed Default | Category |
|
||||
|----|----------|----------------|----------|
|
||||
| G[N] | [question] | [default] | [category] |
|
||||
|
||||
## Source Documentation
|
||||
|
||||
| Source | Type | Link |
|
||||
|--------|------|------|
|
||||
| [name] | Jira / Confluence / User-provided | [link if available] |
|
||||
```
|
||||
|
||||
### Output Guidelines
|
||||
|
||||
- Requirements use numbered IDs (FR1, TR1, SR1, UX1) for traceability through implementation
|
||||
- Each requirement cites its source (ticket, page, or user-provided)
|
||||
- Technical requirements use table format for structured key/value data
|
||||
- Interface signatures are included as fenced code blocks when applicable
|
||||
- Open items preserve the gap ID (G[N]) for cross-referencing
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: reviewing-changes
|
||||
description: Android-specific code review checklist and MVVM/Compose pattern validation for Bitwarden Android — use this for any review task, even if the user doesn't explicitly ask for a "checklist". Detects change type automatically and loads the right review strategy (feature additions, bug fixes, UI refinements, refactoring, dependency updates, infrastructure). Triggered by "review PR", "review changes", "review this code", "check this code", "Android review", code review requests on Kotlin/ViewModel/Composable/Repository/Gradle files, or any time someone asks to look at a diff, PR, or code changes in bitwarden/android.
|
||||
version: 3.0.0
|
||||
description: Guides Android code reviews with type-specific checklists and MVVM/Compose pattern validation. Use when reviewing Android PRs, pull requests, diffs, or local changes involving Kotlin, ViewModel, Composable, Repository, or Gradle files. Triggered by "review PR", "review changes", "check this code", "Android review", or code review requests mentioning bitwarden/android. Loads specialized checklists for feature additions, bug fixes, UI refinements, refactoring, dependency updates, and infrastructure changes.
|
||||
---
|
||||
|
||||
# Reviewing Changes - Android Additions
|
||||
@@ -9,10 +10,16 @@ This skill provides Android-specific workflow additions that complement the base
|
||||
|
||||
## Instructions
|
||||
|
||||
**IMPORTANT**: Work systematically through each step before providing feedback. Each checklist file includes structured thinking guidance for its review passes.
|
||||
**IMPORTANT**: Use structured thinking throughout your review process. Plan your analysis in `<thinking>` tags before providing final feedback.
|
||||
|
||||
### Step 1: Retrieve Additional Details
|
||||
|
||||
<thinking>
|
||||
Determine if more context is available for the changes:
|
||||
1. Are there JIRA tickets or GitHub Issues mentioned in the PR title or body?
|
||||
2. Are there other GitHub pull requests mentioned in the PR title or body?
|
||||
</thinking>
|
||||
|
||||
Retrieve any additional information linked to the pull request using available tools (JIRA MCP, GitHub API).
|
||||
|
||||
If pull request title and message do not provide enough context, request additional details from the reviewer:
|
||||
@@ -21,12 +28,16 @@ If pull request title and message do not provide enough context, request additio
|
||||
- Link to another pull request
|
||||
- Add more detail to the PR title or body
|
||||
|
||||
**Android metadata checks** — flag as ❓ if any of these are missing:
|
||||
- PR includes `*Screen.kt` or Composable changes but has no screenshots
|
||||
- PR adds new `ViewModel` or `Repository` but has no test plan or test file changes
|
||||
|
||||
### Step 2: Detect Change Type with Android Refinements
|
||||
|
||||
<thinking>
|
||||
Analyze the changeset systematically:
|
||||
1. What files were modified? (code vs config vs docs)
|
||||
2. What is the PR/commit title indicating?
|
||||
3. Is there new functionality or just modifications?
|
||||
4. What's the risk level of these changes?
|
||||
</thinking>
|
||||
|
||||
Use the base change type detection from the agent, with Android-specific refinements:
|
||||
|
||||
**Android-specific patterns:**
|
||||
@@ -54,13 +65,21 @@ The checklist provides:
|
||||
|
||||
### Step 4: Execute Review Following Checklist
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. What are the highest-risk areas of this change?
|
||||
2. Which architectural patterns need verification?
|
||||
3. What security implications exist?
|
||||
4. How should I prioritize my findings?
|
||||
5. What tone is appropriate for this feedback?
|
||||
</thinking>
|
||||
|
||||
Follow the checklist's multi-pass strategy, thinking through each pass systematically.
|
||||
|
||||
### Step 5: Consult Android Reference Materials As Needed
|
||||
|
||||
Load reference files only when needed for specific questions:
|
||||
|
||||
- **Re-reviews** → invoke `reviewing-incremental-changes` agent skill; scope to changed lines only, do not flag new issues in unchanged code
|
||||
- **Issue prioritization** → `reference/priority-framework.md` (Critical vs Suggested vs Optional)
|
||||
- **Phrasing feedback** → `reference/review-psychology.md` (questions vs commands, I-statements)
|
||||
- **Architecture questions** → `reference/architectural-patterns.md` (MVVM, Hilt DI, module org, error handling)
|
||||
@@ -72,7 +91,6 @@ Load reference files only when needed for specific questions:
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Priority order**: Security → Correctness → Breaking Changes → Performance → Maintainability
|
||||
- **Appropriate depth**: Match review rigor to change complexity and risk
|
||||
- **Specific references**: Always use `file:line_number` format for precise location
|
||||
- **Actionable feedback**: Say what to do and why, not just what's wrong
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Understand the Bug
|
||||
|
||||
<thinking>
|
||||
Before evaluating the fix:
|
||||
1. What was the original bug/broken behavior?
|
||||
2. What is the expected correct behavior?
|
||||
3. What was the root cause?
|
||||
4. How was the bug discovered? (user report, test, production)
|
||||
5. What's the severity? (crash, data loss, UI glitch, minor annoyance)
|
||||
</thinking>
|
||||
|
||||
**1. Understand root cause:**
|
||||
- What was the broken behavior?
|
||||
- What caused it?
|
||||
@@ -20,6 +29,15 @@
|
||||
|
||||
### Second Pass: Verify the Fix
|
||||
|
||||
<thinking>
|
||||
Evaluate the fix systematically:
|
||||
1. Does this fix address the root cause or just symptoms?
|
||||
2. Are there edge cases not covered?
|
||||
3. Could this break other functionality?
|
||||
4. Is the fix localized or does it ripple through the codebase?
|
||||
5. How do we prevent this bug from returning?
|
||||
</thinking>
|
||||
|
||||
**4. Code changes:**
|
||||
- Does the fix make sense?
|
||||
- Is it the simplest solution?
|
||||
@@ -83,7 +101,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Identify and Assess
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. Which dependencies were updated?
|
||||
2. What are the version changes? (patch, minor, major)
|
||||
3. Are any security-sensitive libraries involved? (crypto, auth, networking)
|
||||
4. Any pre-release versions (alpha, beta, RC)?
|
||||
5. What's the blast radius if something breaks?
|
||||
</thinking>
|
||||
|
||||
**1. Identify the change:**
|
||||
- Which library? Old version → New version?
|
||||
- Major (X.0.0), Minor (0.X.0), or Patch (0.0.X) version change?
|
||||
@@ -16,6 +25,15 @@
|
||||
|
||||
### Second Pass: Deep Analysis
|
||||
|
||||
<thinking>
|
||||
For each dependency update:
|
||||
1. What changes are in this release?
|
||||
2. Are there breaking changes?
|
||||
3. Are there security fixes?
|
||||
4. Do we use the affected APIs?
|
||||
5. How does this affect our codebase?
|
||||
</thinking>
|
||||
|
||||
**3. Review release notes** (if available):
|
||||
- Breaking changes mentioned?
|
||||
- Security fixes included?
|
||||
@@ -74,7 +92,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Reviews
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: High-Level Assessment
|
||||
|
||||
<thinking>
|
||||
Before diving into details:
|
||||
1. What is this feature supposed to do?
|
||||
2. How does it fit into the existing architecture?
|
||||
3. What are the security implications?
|
||||
4. What's the scope? (files touched, modules affected)
|
||||
5. What are the highest-risk areas?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the feature:**
|
||||
- Read PR description - what problem does this solve?
|
||||
- Identify user-facing changes vs internal changes
|
||||
@@ -21,6 +30,15 @@
|
||||
|
||||
### Second Pass: Architecture Deep-Dive
|
||||
|
||||
<thinking>
|
||||
Verify architectural integrity:
|
||||
1. Does this follow MVVM + UDF pattern?
|
||||
2. Is Hilt DI used correctly?
|
||||
3. Is state management proper (StateFlow, immutability)?
|
||||
4. Are modules organized correctly?
|
||||
5. Is error handling robust (Result types)?
|
||||
</thinking>
|
||||
|
||||
**4. MVVM + UDF Pattern Compliance:**
|
||||
- ViewModels properly structured?
|
||||
- State management using StateFlow?
|
||||
@@ -42,6 +60,15 @@
|
||||
|
||||
### Third Pass: Details and Quality
|
||||
|
||||
<thinking>
|
||||
Check quality and completeness:
|
||||
1. Is code quality high? (null safety, documentation, naming)
|
||||
2. Are tests comprehensive? (unit + integration)
|
||||
3. Are there edge cases not covered?
|
||||
4. Is documentation clear?
|
||||
5. Are there any code smells or anti-patterns?
|
||||
</thinking>
|
||||
|
||||
**8. Testing:**
|
||||
- Unit tests for ViewModels and repositories?
|
||||
- Test coverage for edge cases and error scenarios?
|
||||
@@ -59,13 +86,144 @@
|
||||
|
||||
## Architecture Review
|
||||
|
||||
Read `reference/architectural-patterns.md` for full patterns and code examples.
|
||||
### MVVM Pattern Compliance
|
||||
|
||||
**Check these four areas:**
|
||||
- **MVVM/UDF**: ViewModel exposes `StateFlow` (not `MutableStateFlow`), business logic in Repository, UI is stateless
|
||||
- **Hilt DI**: `@HiltViewModel` + `@Inject constructor`, inject interfaces not implementations, no manual instantiation
|
||||
- **Module placement**: UI in `:ui`/`:app`, data in `:data`, network in `:network`, no circular dependencies
|
||||
- **Error handling**: `Result<T>` / `runCatching` throughout — no thrown exceptions from data layer
|
||||
Read `reference/architectural-patterns.md` for detailed patterns.
|
||||
|
||||
**ViewModels must:**
|
||||
- Use `@HiltViewModel` annotation
|
||||
- Use `@Inject constructor`
|
||||
- Expose `StateFlow<T>`, NOT `MutableStateFlow<T>` publicly
|
||||
- Delegate business logic to Repository/Manager
|
||||
- Avoid direct Android framework dependencies (except ViewModel, SavedStateHandle)
|
||||
|
||||
**Common Violations:**
|
||||
```kotlin
|
||||
// ❌ BAD - Exposes mutable state
|
||||
class FeatureViewModel @Inject constructor() : ViewModel() {
|
||||
val state: MutableStateFlow<State> = MutableStateFlow(State.Initial)
|
||||
}
|
||||
|
||||
// ✅ GOOD - Exposes immutable state
|
||||
class FeatureViewModel @Inject constructor() : ViewModel() {
|
||||
private val _state = MutableStateFlow<State>(State.Initial)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
}
|
||||
|
||||
// ❌ BAD - Business logic in ViewModel
|
||||
fun onSubmit() {
|
||||
val encrypted = encryptionManager.encrypt(password) // Should be in Repository
|
||||
_state.value = State.Success
|
||||
}
|
||||
|
||||
// ✅ GOOD - Business logic in Repository, state updated via internal event
|
||||
fun onSubmit() {
|
||||
viewModelScope.launch {
|
||||
// The result of the async operation is captured
|
||||
val result = repository.submitData(password)
|
||||
// A single event is sent with the result, not updating state directly
|
||||
sendAction(FeatureAction.Internal.SubmissionComplete(result))
|
||||
}
|
||||
}
|
||||
|
||||
// The ViewModel has a handler that processes the internal event
|
||||
private fun handleInternalAction(action: FeatureAction.Internal) {
|
||||
when (action) {
|
||||
is FeatureAction.Internal.SubmissionComplete -> {
|
||||
// The event handler evaluates the result and updates state
|
||||
action.result.fold(
|
||||
onSuccess = { _state.value = State.Success },
|
||||
onFailure = { _state.value = State.Error(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UI Layer must:**
|
||||
- Only observe state, never modify
|
||||
- Pass user actions as events to ViewModel
|
||||
- Contain no business logic
|
||||
- Use existing UI components from `:ui` module where possible
|
||||
|
||||
### Hilt Dependency Injection
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#dependency-injection`
|
||||
|
||||
**Required Patterns:**
|
||||
- ViewModels: `@HiltViewModel` + `@Inject constructor`
|
||||
- Repositories: `@Inject constructor` on implementation
|
||||
- Inject interfaces, not concrete implementations
|
||||
- Modules must provide proper scoping (`@Singleton`, `@ViewModelScoped`)
|
||||
|
||||
**Common Violations:**
|
||||
```kotlin
|
||||
// ❌ BAD - Manual instantiation
|
||||
class FeatureViewModel : ViewModel() {
|
||||
private val repository = FeatureRepositoryImpl()
|
||||
}
|
||||
|
||||
// ✅ GOOD - Injected interface
|
||||
@HiltViewModel
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository // Interface, not implementation
|
||||
) : ViewModel()
|
||||
|
||||
// ❌ BAD - Injecting implementation
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepositoryImpl // Should inject interface
|
||||
)
|
||||
|
||||
// ✅ GOOD - Interface injection
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository // Interface
|
||||
)
|
||||
```
|
||||
|
||||
### Module Organization
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#module-structure`
|
||||
|
||||
**Correct Placement:**
|
||||
- `:core` - Shared utilities (cryptography, analytics, logging)
|
||||
- `:data` - Repositories, database, domain models
|
||||
- `:network` - API clients, network utilities
|
||||
- `:ui` - Reusable Compose components, theme
|
||||
- `:app` - Feature screens, ViewModels, navigation
|
||||
- `:authenticator` - Authenticator app (separate from password manager)
|
||||
|
||||
**Check:**
|
||||
- UI code in `:ui` or `:app` modules
|
||||
- Data models in `:data`
|
||||
- Network clients in `:network`
|
||||
- No circular dependencies between modules
|
||||
|
||||
### Error Handling
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#error-handling`
|
||||
|
||||
**Required Pattern - Use Result types:**
|
||||
```kotlin
|
||||
// ✅ GOOD - Result type
|
||||
suspend fun fetchData(): Result<Data> = runCatching {
|
||||
apiService.getData()
|
||||
}
|
||||
|
||||
// ViewModel handles Result
|
||||
repository.fetchData().fold(
|
||||
onSuccess = { data -> _state.value = State.Success(data) },
|
||||
onFailure = { error -> _state.value = State.Error(error) }
|
||||
)
|
||||
|
||||
// ❌ BAD - Exception-based in business logic
|
||||
suspend fun fetchData(): Data {
|
||||
try {
|
||||
return apiService.getData()
|
||||
} catch (e: Exception) {
|
||||
throw FeatureException(e) // Don't throw in business logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Review
|
||||
|
||||
@@ -208,4 +366,15 @@ Use `reference/review-psychology.md` for phrasing guidance.
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
See `examples/review-outputs.md` for comprehensive feature review example.
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Understand the Change
|
||||
|
||||
<thinking>
|
||||
Assess infrastructure change:
|
||||
1. What problem does this solve?
|
||||
2. Does this affect production builds, CI/CD, or dev workflow?
|
||||
3. What's the risk if this breaks?
|
||||
4. Can this be tested before merge?
|
||||
5. What's the rollback plan?
|
||||
</thinking>
|
||||
|
||||
**1. Identify the goal:**
|
||||
- What problem does this solve?
|
||||
- Is this optimization, fix, or new capability?
|
||||
@@ -21,6 +30,15 @@
|
||||
|
||||
### Second Pass: Verify Implementation
|
||||
|
||||
<thinking>
|
||||
Verify configuration and impact:
|
||||
1. Is the configuration syntax valid?
|
||||
2. Are secrets/credentials handled securely?
|
||||
3. What's the impact on build times and CI performance?
|
||||
4. How will this affect the team's workflow?
|
||||
5. Is there adequate testing/validation?
|
||||
</thinking>
|
||||
|
||||
**4. Configuration correctness:**
|
||||
- Syntax valid?
|
||||
- References correct?
|
||||
@@ -171,7 +189,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Understand the Refactoring
|
||||
|
||||
<thinking>
|
||||
Analyze the refactoring scope:
|
||||
1. What pattern is being improved?
|
||||
2. Why is this refactoring needed?
|
||||
3. Does this change behavior or just structure?
|
||||
4. What's the scope? (files affected, migration completeness)
|
||||
5. What are the risks if something breaks?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the goal:**
|
||||
- What pattern is being improved?
|
||||
- Why is this refactoring needed?
|
||||
@@ -21,6 +30,15 @@
|
||||
|
||||
### Second Pass: Verify Consistency
|
||||
|
||||
<thinking>
|
||||
Verify refactoring quality:
|
||||
1. Is the new pattern applied consistently throughout?
|
||||
2. Are there missed instances of the old pattern?
|
||||
3. Do tests still pass with same behavior?
|
||||
4. Is the migration complete or partial?
|
||||
5. Does this introduce any new issues?
|
||||
</thinking>
|
||||
|
||||
**4. Pattern consistency:**
|
||||
- Is the new pattern applied consistently throughout?
|
||||
- Are there missed instances of the old pattern?
|
||||
@@ -151,7 +169,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Reviews
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
### First Pass: Visual Changes
|
||||
|
||||
<thinking>
|
||||
Analyze the UI changes:
|
||||
1. What visual/UX problem is being solved?
|
||||
2. Are there designs or screenshots to reference?
|
||||
3. Is this affecting existing screens or new ones?
|
||||
4. What's the scope of visual changes?
|
||||
5. Are design tokens (colors, spacing, typography) being used correctly?
|
||||
</thinking>
|
||||
|
||||
**1. Understand the changes:**
|
||||
- What visual/UX problem is being solved?
|
||||
- Are there designs or screenshots to reference?
|
||||
@@ -16,6 +25,15 @@
|
||||
|
||||
### Second Pass: Implementation Review
|
||||
|
||||
<thinking>
|
||||
Check implementation quality:
|
||||
1. Are Compose best practices followed?
|
||||
2. Is state hoisting applied correctly?
|
||||
3. Are existing components reused where possible?
|
||||
4. Is accessibility properly handled?
|
||||
5. Does this follow design system patterns?
|
||||
</thinking>
|
||||
|
||||
**3. Compose best practices:**
|
||||
- Composables properly structured?
|
||||
- State hoisted correctly?
|
||||
@@ -169,7 +187,16 @@ Use `reference/priority-framework.md` to classify findings as Critical/Important
|
||||
|
||||
## Output Format
|
||||
|
||||
See `examples/review-outputs.md` for the required output format and inline comment structure.
|
||||
Follow the format guidance from `SKILL.md` Step 5 (concise summary with critical issues only, detailed inline comments with `<details>` tags).
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [One-line summary of each critical blocking issue with file:line reference]
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
## Example Review
|
||||
|
||||
|
||||
@@ -50,34 +50,21 @@ Reference: [docs link if applicable]
|
||||
- ⚠️ **IMPORTANT** - Should fix (missing tests, quality issues)
|
||||
- ♻️ **DEBT** - Technical debt (duplication, convention violations, future rework needed)
|
||||
- 🎨 **SUGGESTED** - Nice to have (refactoring, improvements)
|
||||
- ❓ **QUESTION** - Seeking clarification (requirements, design decisions)
|
||||
- 💭 **QUESTION** - Seeking clarification (requirements, design decisions)
|
||||
|
||||
### Summary Comment Format
|
||||
|
||||
Uses the agent's `posting-review-summary` skill format. Surface ❌ CRITICAL issues at the top level for immediate visibility, wrap the full findings list in `<details>` for scannability.
|
||||
|
||||
**Required format for ALL PRs:**
|
||||
```
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
[1-2 neutral sentences describing what was reviewed]
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- ❌ [One-line summary with file:line]
|
||||
- [issue with file:line]
|
||||
|
||||
<details>
|
||||
<summary>All findings</summary>
|
||||
|
||||
- ❌ **CRITICAL**: [description] (`file:line`)
|
||||
- ⚠️ **IMPORTANT**: [description] (`file:line`)
|
||||
- ♻️ **DEBT**: [description] (`file:line`)
|
||||
- 🎨 **SUGGESTED**: [description] (`file:line`)
|
||||
- ❓ **QUESTION**: [description] (`file:line`)
|
||||
</details>
|
||||
See inline comments for details.
|
||||
```
|
||||
|
||||
For clean PRs with no findings, omit both sections entirely — verdict + 1-2 sentences is sufficient.
|
||||
|
||||
**GitHub pitfall**: Never use `#` followed by a number in comment text (e.g., `#42`, `#PR123`). GitHub autolinks these to issues/PRs. Use `Finding 1:` or `item 42` instead.
|
||||
All PRs use the same minimal format - no exceptions for size or complexity. Summary must be 5-10 lines maximum.
|
||||
|
||||
---
|
||||
|
||||
@@ -281,7 +268,7 @@ Would add security layer against brute force. Consider discussing threat model w
|
||||
|
||||
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
|
||||
```markdown
|
||||
❓ **QUESTION**: Can we use BitwardenTextField?
|
||||
💭 **QUESTION**: Can we use BitwardenTextField?
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
@@ -9,7 +9,7 @@ Use this framework to classify findings during code review. Clear prioritization
|
||||
- [⚠️ IMPORTANT (Should Fix)](#important-should-fix)
|
||||
- [♻️ DEBT (Technical Debt)](#debt-technical-debt)
|
||||
- [🎨 SUGGESTED (Nice to Have)](#suggested-nice-to-have)
|
||||
- [❓ QUESTION (Seeking Clarification)](#question-seeking-clarification)
|
||||
- [💭 QUESTION (Seeking Clarification)](#question-seeking-clarification)
|
||||
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
|
||||
|
||||
**Guidelines:**
|
||||
@@ -170,12 +170,13 @@ Will require rework when experimentation framework launches.
|
||||
|
||||
## 🎨 **SUGGESTED** (Nice to Have)
|
||||
|
||||
Improvements with measurable value only. A finding qualifies as SUGGESTED if it provides: security gain, cyclomatic complexity reduction, bug class prevention, or elimination of an O(n²) pattern. Subjective style preferences, vague simplifications, and naming nitpicks do not qualify — leave those out entirely or raise in conversation.
|
||||
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
|
||||
|
||||
### Code Quality
|
||||
- Extractable duplicated logic that reduces measurable complexity or improves testability
|
||||
- Patterns that would prevent a recurring bug class in this module
|
||||
- Architecture improvements that eliminate tight coupling with measurable impact
|
||||
- Minor style inconsistencies (if not caught by linter)
|
||||
- Opportunities for DRY improvements
|
||||
- Better variable naming for clarity
|
||||
- Simplification opportunities
|
||||
|
||||
**Example**:
|
||||
```
|
||||
@@ -207,7 +208,7 @@ Could be extracted to separate validator class for reusability and testing.
|
||||
|
||||
---
|
||||
|
||||
## ❓ **QUESTION** (Seeking Clarification)
|
||||
## 💭 **QUESTION** (Seeking Clarification)
|
||||
|
||||
Questions about requirements, unclear intent, or potential conflicts that require human knowledge to answer. Open inquiries that cannot be resolved through code inspection alone.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Effective code review feedback is clear, actionable, and constructive. This guid
|
||||
## Table of Contents
|
||||
|
||||
**Guidelines:**
|
||||
- [Core Directives](#core-directives)
|
||||
- [Phrasing Templates](#phrasing-templates)
|
||||
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
|
||||
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
|
||||
@@ -15,6 +16,17 @@ Effective code review feedback is clear, actionable, and constructive. This guid
|
||||
|
||||
---
|
||||
|
||||
## Core Directives
|
||||
|
||||
- **Keep positive feedback minimal**: For clean PRs with no issues, use 2-3 line approval only. When acknowledging good practices in PRs with issues, use single bullet list with no elaboration. Never create elaborate sections praising correct implementations.
|
||||
- Ask questions for design decisions, be prescriptive for clear violations
|
||||
- Focus on code, not people ("This code..." not "You...")
|
||||
- Use I-statements for subjective feedback ("Hard for me to understand...")
|
||||
- Explain rationale with every recommendation
|
||||
- Avoid: "just", "simply", "obviously", "easy"
|
||||
|
||||
---
|
||||
|
||||
## Phrasing Templates
|
||||
|
||||
### Critical Issues (Prescriptive)
|
||||
|
||||
44
.claude/skills/testing-android-code/README.md
Normal file
44
.claude/skills/testing-android-code/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Testing Android Code Skill
|
||||
|
||||
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
The skill automatically loads when you ask questions like:
|
||||
|
||||
- "How do I test this ViewModel?"
|
||||
- "Why is my Bitwarden test failing?"
|
||||
- "Write tests for this repository"
|
||||
|
||||
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
|
||||
|
||||
## What's Included
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Core testing patterns and base class locations |
|
||||
| `references/test-base-classes.md` | Detailed base class documentation |
|
||||
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
|
||||
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
|
||||
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
|
||||
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
|
||||
| `examples/repository-test-example.md` | Complete repository test with mocks |
|
||||
|
||||
## Patterns Covered
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
|
||||
|
||||
## Quick Start
|
||||
|
||||
For comprehensive architecture and testing philosophy, see:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
@@ -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`
|
||||
|
||||
23
.github/actions/setup-android-build/action.yml
vendored
23
.github/actions/setup-android-build/action.yml
vendored
@@ -8,8 +8,27 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@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
|
||||
|
||||
150
.github/scripts/gh_release_update_issues.py
vendored
150
.github/scripts/gh_release_update_issues.py
vendored
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Requires Python 3.9+
|
||||
"""
|
||||
Comment GitHub issues linked to Pull Requests mentioned in a given release.
|
||||
|
||||
Usage:
|
||||
python gh_release_update_issues.py <release_url> [--dry-run]
|
||||
|
||||
Arguments:
|
||||
release-url: The URL of the release to comment on
|
||||
--dry-run: Run without actually updating issues
|
||||
|
||||
Examples:
|
||||
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0
|
||||
python gh_release_update_issues.py https://github.com/owner/repo/releases/tag/v1.0.0 --dry-run
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import json
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
def parse_release_url(release_url: str) -> Tuple[str, str, str]:
|
||||
"""Extract owner, repo name, and tag from a GitHub release URL.
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo_name, release_tag)
|
||||
"""
|
||||
match = re.search(r'github\.com/([\w-]+)/([\w.-]+)/releases/tag/(.+)$', release_url)
|
||||
if not match:
|
||||
raise ValueError(f"Cannot parse release URL: {release_url}")
|
||||
return match.group(1), match.group(2), match.group(3)
|
||||
|
||||
def extract_pr_numbers(release_notes: str) -> List[int]:
|
||||
return [int(n) for n in re.findall(r'/pull/(\d+)', release_notes)]
|
||||
|
||||
def build_issue_comment(repo: str, release_name: str, release_link: str, pr_numbers: List[int]) -> str:
|
||||
if len(pr_numbers) == 0:
|
||||
return ""
|
||||
|
||||
pr_links = [f"* https://github.com/{repo}/pull/{pr_number}" for pr_number in pr_numbers]
|
||||
|
||||
return f":shipit: Pull Request(s) linked to this issue released in [{release_name}]({release_link}):\n\n"+ "\n".join(pr_links)
|
||||
|
||||
def gh_fetch_release(repo: str, release_tag: str) -> Tuple[str, str]:
|
||||
result = subprocess.run(
|
||||
['gh', 'release', 'view', release_tag, '--repo', repo, '--json', 'name,body'],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
return data['name'], data['body']
|
||||
|
||||
def gh_comment_issue(repo: str, issue_number: int, comment: str) -> None:
|
||||
"""Use GitHub CLI to comment on an issue.
|
||||
"""
|
||||
subprocess.run([
|
||||
'gh', 'issue', 'comment', str(issue_number), '--body', comment, '--repo', repo
|
||||
], check=True)
|
||||
|
||||
def gh_fetch_linked_issues_batched(owner: str, repo_name: str, pr_numbers: List[int]) -> Dict[int, List[int]]:
|
||||
"""Batch-fetch linked issues for all PRs in a single GraphQL call.
|
||||
|
||||
Returns:
|
||||
Dict mapping each PR number to its list of linked issue numbers.
|
||||
"""
|
||||
if not pr_numbers:
|
||||
return {}
|
||||
|
||||
tmpl = 'pr_%d: pullRequest(number: %d) { closingIssuesReferences(first: 100) { nodes { number } } }'
|
||||
pr_fragments = "\n".join(tmpl % (pr, pr) for pr in pr_numbers)
|
||||
query = """
|
||||
query ($owner: String!, $repo: String!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
%s
|
||||
}
|
||||
}
|
||||
""" % pr_fragments
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
'gh', 'api', 'graphql',
|
||||
'-F', f'owner={owner}',
|
||||
'-F', f'repo={repo_name}',
|
||||
'-f', f'query={query}',
|
||||
],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
repo_data = data['data']['repository']
|
||||
|
||||
pr_issues_map: Dict[int, List[int]] = {}
|
||||
for pr_number in pr_numbers:
|
||||
nodes = repo_data.get(f'pr_{pr_number}', {}).get('closingIssuesReferences', {}).get('nodes', [])
|
||||
pr_issues = [node['number'] for node in nodes]
|
||||
pr_issues_map[pr_number] = pr_issues
|
||||
return pr_issues_map
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"::error::Error batch-fetching linked issues: {e.stderr}")
|
||||
raise
|
||||
|
||||
def map_issues_to_prs(pr_issues_map: Dict[int, List[int]]) -> Dict[int, List[int]]:
|
||||
"""Invert a PR->issues map into an issue->PRs map."""
|
||||
issue_pr_map: Dict[int, List[int]] = defaultdict(list)
|
||||
for pr_number, issue_numbers in pr_issues_map.items():
|
||||
for issue_number in issue_numbers:
|
||||
issue_pr_map[issue_number].append(pr_number)
|
||||
return dict(issue_pr_map)
|
||||
|
||||
def comment_issues(repo: str, issue_pr_map: Dict[int, List[int]], release_name: str, release_url: str, dry_run: bool) -> None:
|
||||
for issue_number, linked_prs in issue_pr_map.items():
|
||||
comment = build_issue_comment(repo, release_name, release_url, linked_prs)
|
||||
print(f"{'Dry run - ' if dry_run else ''}Commenting on issue {issue_number}:\n{comment}\n")
|
||||
if not dry_run and comment:
|
||||
gh_comment_issue(repo, issue_number, comment)
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Comment GitHub issues linked to Pull Requests mentioned in a given release.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'release_url',
|
||||
help='Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Run without actually commenting issues'
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_args()
|
||||
|
||||
owner, repo_name, release_tag = parse_release_url(args.release_url)
|
||||
repo = f"{owner}/{repo_name}"
|
||||
print(f"📋 Release URL: {args.release_url}")
|
||||
|
||||
release_name, release_notes = gh_fetch_release(repo, release_tag)
|
||||
print(f"📋 Release Name: {release_name}")
|
||||
|
||||
pr_numbers = extract_pr_numbers(release_notes)
|
||||
print(f"📋 PR Numbers parsed from release notes: {pr_numbers}")
|
||||
pr_issues_map = gh_fetch_linked_issues_batched(owner, repo_name, pr_numbers)
|
||||
print(f"📋 PRs with linked issues: {[pr for pr, issues in pr_issues_map.items() if issues]}\n")
|
||||
issue_pr_map = map_issues_to_prs(pr_issues_map)
|
||||
comment_issues(repo, issue_pr_map, release_name, args.release_url, args.dry_run)
|
||||
23
.github/scripts/set-build-version.sh
vendored
23
.github/scripts/set-build-version.sh
vendored
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Runs fastlane setBuildVersionInfo and appends Version Name/Number to GITHUB_STEP_SUMMARY.
|
||||
# Usage: set-build-version.sh <version_code> [version_name] [toml_path]
|
||||
|
||||
VERSION_CODE="${1:?Usage: $0 <version_code> [version_name] [toml_path]}"
|
||||
VERSION_NAME="${2:-}"
|
||||
TOML_FILE="${3:-gradle/libs.versions.toml}"
|
||||
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME"
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
VERSION_NAME=""
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat "$TOML_FILE")" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
2
.github/workflows/_version.yml
vendored
2
.github/workflows/_version.yml
vendored
@@ -167,7 +167,7 @@ jobs:
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload version info artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: version-info
|
||||
path: version_info.json
|
||||
|
||||
129
.github/workflows/build-authenticator.yml
vendored
129
.github/workflows/build-authenticator.yml
vendored
@@ -31,6 +31,7 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
|
||||
@@ -49,10 +50,70 @@ jobs:
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build Authenticator
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
uses: bitwarden/android/.github/actions/log-inputs@main
|
||||
with:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Check Authenticator
|
||||
run: bundle exec fastlane check
|
||||
|
||||
- name: Build Authenticator
|
||||
run: bundle exec fastlane buildAuthenticatorDebug
|
||||
|
||||
publish_playstore:
|
||||
name: Publish Authenticator Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -67,6 +128,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -126,15 +197,40 @@ jobs:
|
||||
- name: AZ Logout
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key \
|
||||
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
./scripts/update_app_ci_build_info.sh \
|
||||
@@ -146,9 +242,22 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
@@ -176,7 +285,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.aab
|
||||
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
|
||||
@@ -184,7 +293,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.apk
|
||||
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
|
||||
@@ -204,7 +313,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: authenticator-android-apk-sha256.txt
|
||||
path: ./authenticator-android-apk-sha256.txt
|
||||
@@ -212,7 +321,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
|
||||
64
.github/workflows/build-testharness.yml
vendored
64
.github/workflows/build-testharness.yml
vendored
@@ -20,6 +20,7 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -52,20 +53,69 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "(.+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build Test Harness Debug APK
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
- name: Upload Test Harness APK
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev-debug.apk
|
||||
path: testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk
|
||||
@@ -77,7 +127,7 @@ jobs:
|
||||
> ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
- name: Upload Test Harness SHA file
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
path: ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
198
.github/workflows/build.yml
vendored
198
.github/workflows/build.yml
vendored
@@ -31,6 +31,7 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
DISTRIBUTE_TO_FIREBASE: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
PUBLISH_TO_PLAY_STORE: ${{ inputs.publish-to-play-store || github.event_name == 'push' }}
|
||||
@@ -51,10 +52,77 @@ jobs:
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
uses: bitwarden/android/.github/actions/log-inputs@main
|
||||
with:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Check
|
||||
run: bundle exec fastlane check
|
||||
|
||||
- name: Build
|
||||
run: bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload test reports on failure
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
path: app/build/reports/tests/
|
||||
|
||||
publish_playstore:
|
||||
name: Publish Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -69,6 +137,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -121,8 +199,33 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -135,9 +238,13 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
|
||||
@@ -192,7 +299,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab
|
||||
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
|
||||
@@ -200,7 +307,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab
|
||||
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
|
||||
@@ -208,7 +315,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk
|
||||
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
|
||||
@@ -216,7 +323,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk
|
||||
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
|
||||
@@ -225,7 +332,7 @@ jobs:
|
||||
# When building variants other than 'prod'
|
||||
- name: Upload to GitHub Artifacts - dev.apk
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
|
||||
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
|
||||
@@ -263,7 +370,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.apk-sha256.txt
|
||||
@@ -271,7 +378,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
@@ -279,7 +386,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.aab-sha256.txt
|
||||
@@ -287,7 +394,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
@@ -295,7 +402,7 @@ jobs:
|
||||
|
||||
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
@@ -338,6 +445,7 @@ jobs:
|
||||
name: Publish F-Droid artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -347,6 +455,16 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
@@ -385,8 +503,33 @@ jobs:
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
@@ -399,9 +542,20 @@ jobs:
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number || github.run_number }}
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: ./.github/scripts/set-build-version.sh "$VERSION_CODE" "$VERSION_NAME"
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Generate F-Droid artifacts
|
||||
env:
|
||||
FDROID_STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-KEYSTORE-PASSWORD }}
|
||||
@@ -424,7 +578,7 @@ jobs:
|
||||
keyPassword:$FDROID_BETA_KEY_PASSWORD
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
|
||||
@@ -436,14 +590,14 @@ jobs:
|
||||
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
|
||||
@@ -455,7 +609,7 @@ jobs:
|
||||
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
2
.github/workflows/crowdin-pull.yml
vendored
2
.github/workflows/crowdin-pull.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
name: SDLC / Update Linked Issues on Release
|
||||
run-name: ${{ inputs.dry-run && '(Dry Run) ' || '' }}Update Linked Issues on Release - ${{ github.event.release.name || inputs.release_url }}
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_url:
|
||||
description: 'Release URL (e.g. https://github.com/owner/repo/releases/tag/v1.0.0)'
|
||||
required: true
|
||||
dry-run:
|
||||
description: 'Dry run'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
update-linked-issues:
|
||||
name: Update Linked Issues
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Update Linked Issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_RELEASE_URL: ${{ github.event.release.html_url || inputs.release_url }}
|
||||
_DRY_RUN: ${{ inputs.dry-run && '--dry-run' || '' }}
|
||||
run: |
|
||||
python3 .github/scripts/gh_release_update_issues.py "$_RELEASE_URL" $_DRY_RUN
|
||||
2
.github/workflows/sdlc-sdk-update.yml
vendored
2
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
|
||||
174
.github/workflows/test.yml
vendored
174
.github/workflows/test.yml
vendored
@@ -3,8 +3,9 @@ name: Test
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/**/*
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
merge_group:
|
||||
@@ -12,45 +13,16 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
_JAVA_VERSION: 21
|
||||
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
|
||||
|
||||
jobs:
|
||||
test-sharded:
|
||||
name: "Test ${{ matrix.group }}"
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
packages: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- group: static-analysis
|
||||
fastlane_method: checkLint
|
||||
fastlane_options: ""
|
||||
# App shards
|
||||
- group: app-data
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.data.*"
|
||||
- group: app-ui-auth-tools
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.auth.* --tests com.x8bit.bitwarden.ui.tools.* --tests com.x8bit.bitwarden.ui.autofill.* --tests com.x8bit.bitwarden.ui.credentials.*"
|
||||
- group: app-ui-platform
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.platform.*"
|
||||
- group: app-ui-vault
|
||||
fastlane_method: testAppShard
|
||||
fastlane_options: "--tests com.x8bit.bitwarden.ui.vault.*"
|
||||
# Authenticator
|
||||
- group: authenticator
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":authenticator"
|
||||
# Library shards
|
||||
- group: lib-core-network-bridge
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":core :network :cxf :authenticatorbridge :testharness"
|
||||
- group: lib-data-ui
|
||||
fastlane_method: testLibraries
|
||||
fastlane_options: ":data :ui"
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
@@ -58,101 +30,87 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_GROUP: ${{ matrix.group }}
|
||||
_FASTLANE_METHOD: ${{ matrix.fastlane_method }}
|
||||
_FASTLANE_OPTIONS: ${{ matrix.fastlane_options }}
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env._JAVA_VERSION }}
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
if [ "$_GROUP" = "app-ui-auth-tools" ]; then
|
||||
_TOP_LEVEL_TESTS=$(basename -a -s .kt app/src/test/kotlin/com/x8bit/bitwarden/*Test.kt \
|
||||
| xargs -I{} printf ' --tests com.x8bit.bitwarden.{}')
|
||||
_FASTLANE_OPTIONS="${_FASTLANE_OPTIONS} ${_TOP_LEVEL_TESTS}"
|
||||
fi
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
if [ "$_GROUP" = "static-analysis" ]; then
|
||||
bundle exec fastlane "$_FASTLANE_METHOD"
|
||||
else
|
||||
bundle exec fastlane "$_FASTLANE_METHOD" target:"$_FASTLANE_OPTIONS"
|
||||
fi
|
||||
|
||||
- name: Generate coverage report
|
||||
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
- name: Build and test
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
|
||||
run: |
|
||||
bundle exec fastlane generateCoverageReport
|
||||
bundle exec fastlane check
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
path: |
|
||||
build/reports/kover/reportMergedCoverage.xml
|
||||
app/build/reports/tests/
|
||||
authenticator/build/reports/tests/
|
||||
authenticatorbridge/build/reports/tests/
|
||||
core/build/reports/tests/
|
||||
data/build/reports/tests/
|
||||
network/build/reports/tests/
|
||||
ui/build/reports/tests/
|
||||
|
||||
- name: Upload to codecov.io
|
||||
if: always() && matrix.group != 'static-analysis' && (github.event_name == 'push' || github.event_name == 'pull_request')
|
||||
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!"
|
||||
|
||||
28
Gemfile.lock
28
Gemfile.lock
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -124,16 +124,6 @@ interface AuthDiskSource : AppIdProvider {
|
||||
*/
|
||||
fun storeUserKey(userId: String, userKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves the local user data key for the given [userId].
|
||||
*/
|
||||
fun getLocalUserDataKey(userId: String): String?
|
||||
|
||||
/**
|
||||
* Stores the local user data key for a given [userId].
|
||||
*/
|
||||
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
|
||||
|
||||
/**
|
||||
* Retrieves a private key using a [userId].
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,6 @@ private const val REMEMBERED_ORG_IDENTIFIER_KEY = "rememberedOrgIdentifier"
|
||||
private const val STATE_KEY = "state"
|
||||
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
|
||||
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
|
||||
private const val LOCAL_USER_DATA_KEY = "localUserDataKey"
|
||||
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
|
||||
private const val PIN_PROTECTED_USER_KEY_KEY = "pinKeyEncryptedUserKey"
|
||||
private const val PIN_PROTECTED_USER_KEY_KEY_ENVELOPE = "pinKeyEncryptedUserKeyEnvelope"
|
||||
@@ -145,7 +144,6 @@ class AuthDiskSourceImpl(
|
||||
override fun clearData(userId: String) {
|
||||
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
|
||||
storeUserKey(userId = userId, userKey = null)
|
||||
storeLocalUserDataKey(userId = userId, wrappedKey = null)
|
||||
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
|
||||
storePrivateKey(userId = userId, privateKey = null)
|
||||
storeAccountKeys(userId = userId, accountKeys = null)
|
||||
@@ -239,13 +237,6 @@ class AuthDiskSourceImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override fun getLocalUserDataKey(userId: String): String? =
|
||||
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
|
||||
|
||||
override fun storeLocalUserDataKey(userId: String, wrappedKey: String?) {
|
||||
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
|
||||
}
|
||||
|
||||
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
|
||||
override fun getPrivateKey(userId: String): String? =
|
||||
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
|
||||
|
||||
@@ -8,7 +8,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Represents the current account information for a given user.
|
||||
@@ -37,12 +37,12 @@ data class AccountJson(
|
||||
*
|
||||
* @property userId The ID of the user.
|
||||
* @property email The user's email address.
|
||||
* @property isEmailVerified Whether the user's email is verified.
|
||||
* @property isTwoFactorEnabled If the profile has two-factor authentication enabled.
|
||||
* @property isEmailVerified Whether or not the user's email is verified.
|
||||
* @property isTwoFactorEnabled If the profile has two factor authentication enabled.
|
||||
* @property name The user's name (if applicable).
|
||||
* @property stamp The account's security stamp (if applicable).
|
||||
* @property organizationId The ID of the associated organization (if applicable).
|
||||
* @property hasPremium True if the user has a Premium account.
|
||||
* @property hasPremium True if the user has a premium account.
|
||||
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
|
||||
* @property forcePasswordResetReason Describes the reason for a forced password reset.
|
||||
* @property kdfType The KDF type.
|
||||
@@ -103,7 +103,7 @@ data class AccountJson(
|
||||
|
||||
@SerialName("creationDate")
|
||||
@Contextual
|
||||
val creationDate: Instant?,
|
||||
val creationDate: ZonedDateTime?,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,6 +128,7 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
clearPendingAuthRequest()
|
||||
@@ -198,6 +199,7 @@ class AuthRequestManagerImpl(
|
||||
|
||||
updateAuthRequest
|
||||
.creationDate
|
||||
.toInstant()
|
||||
.plusMillis(PASSWORDLESS_NOTIFICATION_TIMEOUT_MILLIS)
|
||||
.isBefore(clock.instant()) -> {
|
||||
isComplete = true
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* Represents a Login Approval request.
|
||||
@@ -27,8 +27,8 @@ data class AuthRequest(
|
||||
val ipAddress: String,
|
||||
val key: String?,
|
||||
val masterPasswordHash: String?,
|
||||
val creationDate: Instant,
|
||||
val responseDate: Instant?,
|
||||
val creationDate: ZonedDateTime,
|
||||
val responseDate: ZonedDateTime?,
|
||||
val requestApproved: Boolean,
|
||||
val originUrl: String,
|
||||
val fingerprint: String,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.x8bit.bitwarden.data.auth.repository.model
|
||||
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Represents the overall "user state" of the current active user as well as any users that may be
|
||||
@@ -42,10 +40,10 @@ data class UserState(
|
||||
* @property name The user's name (if applicable).
|
||||
* @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format.
|
||||
* @property environment The [Environment] associated with the user's account.
|
||||
* @property isPremium `true` if the account has a Premium membership.
|
||||
* @property isPremium `true` if the account has a premium membership.
|
||||
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
|
||||
* authentication to view their vault.
|
||||
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
|
||||
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
|
||||
* @property needsPasswordReset If the user needs to reset their password.
|
||||
* @property needsMasterPassword Indicates whether the user needs to create a password (e.g.
|
||||
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
|
||||
@@ -57,7 +55,6 @@ data class UserState(
|
||||
* user's vault is enabled.
|
||||
* @property vaultUnlockType The mechanism by which the user's vault may be unlocked.
|
||||
* @property isUsingKeyConnector Indicates if the account is currently using a key connector.
|
||||
* @property creationDate The date the account was created, if available.
|
||||
*/
|
||||
data class Account(
|
||||
val userId: String,
|
||||
@@ -79,7 +76,6 @@ data class UserState(
|
||||
val onboardingStatus: OnboardingStatus,
|
||||
val firstTimeState: FirstTimeState,
|
||||
val isExportable: Boolean,
|
||||
val creationDate: Instant?,
|
||||
) {
|
||||
/**
|
||||
* Indicates that the user does or does not have a means to manually unlock the vault.
|
||||
@@ -100,33 +96,4 @@ data class UserState(
|
||||
val hasLoginApprovingDevice: Boolean,
|
||||
val hasResetPasswordPermission: Boolean,
|
||||
)
|
||||
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
/**
|
||||
* A basic empty account model.
|
||||
*/
|
||||
val EMPTY_ACCOUNT: Account = Account(
|
||||
userId = "",
|
||||
name = null,
|
||||
email = "",
|
||||
avatarColorHex = "".toHexColorRepresentation(),
|
||||
environment = Environment.Us,
|
||||
isPremium = false,
|
||||
isLoggedIn = false,
|
||||
isVaultUnlocked = false,
|
||||
needsPasswordReset = false,
|
||||
organizations = emptyList(),
|
||||
isBiometricsEnabled = false,
|
||||
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||
needsMasterPassword = false,
|
||||
hasMasterPassword = true,
|
||||
trustedDevice = null,
|
||||
isUsingKeyConnector = false,
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
firstTimeState = FirstTimeState(),
|
||||
isExportable = false,
|
||||
creationDate = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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 {
|
||||
|
||||
@@ -248,7 +248,6 @@ fun UserStateJson.toUserState(
|
||||
firstTimeState = firstTimeState,
|
||||
isExportable = !hasPersonalOwnershipRestrictedOrg &&
|
||||
!hasPersonalVaultExportRestrictedOrg,
|
||||
creationDate = profile.creationDate,
|
||||
)
|
||||
},
|
||||
hasPendingAccountAddition = hasPendingAccountAddition,
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.manager
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A container for values specifying whether the accessibility service is enabled.
|
||||
* A container for values specifying whether or not the accessibility service is enabled.
|
||||
*/
|
||||
interface AccessibilityEnabledManager {
|
||||
/**
|
||||
|
||||
@@ -5,13 +5,13 @@ import android.view.autofill.AutofillManager
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bitwarden.data.manager.appstate.AppStateManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManagerImpl
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManagerImpl
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
||||
@@ -2,13 +2,12 @@ package com.x8bit.bitwarden.data.autofill.manager
|
||||
|
||||
import android.view.autofill.AutofillManager
|
||||
import androidx.lifecycle.LifecycleCoroutineScope
|
||||
import com.bitwarden.data.manager.appstate.AppStateManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillEnabledManager
|
||||
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserThirdPartyAutofillManager
|
||||
import com.x8bit.bitwarden.data.autofill.model.browser.BrowserThirdPartyAutofillStatus
|
||||
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Primary implementation of [AutofillActivityManager].
|
||||
@@ -21,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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?)
|
||||
|
||||
@@ -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?> =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -60,12 +60,6 @@ enum class NotificationType {
|
||||
|
||||
@SerialName("16")
|
||||
AUTH_REQUEST_RESPONSE,
|
||||
|
||||
@SerialName("25")
|
||||
POLICY_CHANGED,
|
||||
|
||||
@SerialName("27")
|
||||
PREMIUM_STATUS_CHANGED,
|
||||
}
|
||||
|
||||
@Keep
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user