mirror of
https://github.com/bitwarden/android.git
synced 2026-05-09 13:29:18 -05:00
Compare commits
1 Commits
pr-6572
...
languages/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cc4aa41b1 |
@@ -1,284 +0,0 @@
|
||||
# Bitwarden Android - Claude Code Configuration
|
||||
|
||||
Official Android application for Bitwarden Password Manager and Bitwarden Authenticator, providing secure password management, two-factor authentication, and credential autofill services with zero-knowledge encryption.
|
||||
|
||||
## Overview
|
||||
|
||||
### 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)
|
||||
- **Result Types**: Custom sealed classes for operation results (never throw exceptions from data layer)
|
||||
- **UDF (Unidirectional Data Flow)**: State flows down, actions flow up through ViewModels
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
### System Architecture
|
||||
|
||||
```
|
||||
User Request (UI Action)
|
||||
|
|
||||
Screen (Compose)
|
||||
|
|
||||
ViewModel (State/Action/Event)
|
||||
|
|
||||
Repository (Business Logic)
|
||||
|
|
||||
+----+----+----+
|
||||
| | | |
|
||||
Disk Network SDK
|
||||
| | |
|
||||
Room Retrofit Bitwarden
|
||||
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
|
||||
2. **State Hoisting to ViewModel**: All state that affects behavior must live in the ViewModel's state
|
||||
3. **Interface-Based DI**: All implementations use interface/`...Impl` pairs with Hilt injection
|
||||
4. **Encryption by Default**: All sensitive data encrypted via SDK before storage
|
||||
|
||||
### 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.
|
||||
- **Common Patterns**: Flow collection via `Internal` actions, error handling via `when` branches, `DataState` streaming with `.map { }` and `.stateIn()`.
|
||||
|
||||
> For complete architecture patterns and code templates, see `docs/ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Development Guide
|
||||
|
||||
### Adding New Feature Screen
|
||||
|
||||
Use the `implementing-android-code` skill for Bitwarden-specific patterns, gotchas, and copy-pasteable templates. Follow these steps:
|
||||
|
||||
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
|
||||
|
||||
### Code Reviews
|
||||
|
||||
Use the `reviewing-changes` skill for structured code review checklists covering MVVM/Compose patterns, security validation, and type-specific review guidance.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
Key types used throughout the codebase:
|
||||
|
||||
- **`UserState`** (`data/auth/`) - Active user ID, accounts list, pending account state
|
||||
- **`VaultUnlockData`** (`data/vault/repository/model/`) - User ID and vault unlock status
|
||||
- **`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
|
||||
|
||||
---
|
||||
|
||||
## Code Style & Standards
|
||||
|
||||
- **Formatter**: Android Studio with `bitwarden-style.xml` | **Line Limit**: 100 chars | **Detekt**: Enabled
|
||||
- **Naming**: `camelCase` (vars/fns), `PascalCase` (classes), `SCREAMING_SNAKE_CASE` (constants), `...Impl` (implementations)
|
||||
- **KDoc**: Required for all public APIs
|
||||
- **String Resources**: Add new strings to `:ui` module (`ui/src/main/res/values/strings.xml`). Use typographic quotes/apostrophes (`"` `"` `'`) not escaped ASCII (`\"` `\'`)
|
||||
|
||||
> For complete style rules (imports, formatting, documentation, Compose conventions), see `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
In addition to the Key Principles above, follow these rules:
|
||||
|
||||
### DO
|
||||
- Use `remember(viewModel)` for lambdas passed to composables
|
||||
- Map async results to internal actions before updating state
|
||||
- Inject `Clock` for time-dependent operations
|
||||
- Return early to reduce nesting
|
||||
|
||||
### DON'T
|
||||
- Update state directly inside coroutines (use internal actions)
|
||||
- Use `any` types or suppress null safety
|
||||
- Catch generic `Exception` (catch specific types)
|
||||
- Use `e.printStackTrace()` (use Timber logging)
|
||||
- Create new patterns when established ones exist
|
||||
- Skip KDoc for public APIs
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- **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/)
|
||||
@@ -1,3 +0,0 @@
|
||||
Use the `reviewing-changes` skill to review this pull request.
|
||||
|
||||
The PR branch is already checked out in the current working directory.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extraKnownMarketplaces": {
|
||||
"bitwarden-marketplace": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "bitwarden/ai-plugins"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the `implementing-android-code` skill will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.1] - 2026-02-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added missing `@EncryptedPreferences` and `@UnencryptedPreferences` annotations to `ExampleDiskSourceImpl` code example
|
||||
- Fixed typographic apostrophe example to use correct right single quotation mark (U+2019)
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed redundant "Summary" section that duplicated existing content
|
||||
|
||||
## [0.1.0] - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- Bitwarden Android implementation patterns covering:
|
||||
- ViewModel State-Action-Event (SAE) pattern with `BaseViewModel`
|
||||
- Type-safe navigation with `@Serializable` routes and `composableWithSlideTransitions`
|
||||
- Screen/Compose implementation with `EventsEffect` and stateless composables
|
||||
- Data layer patterns: repositories, data sources, `DataState<T>`, error handling
|
||||
- UI component library usage and string resource conventions
|
||||
- Security patterns: zero-knowledge architecture, encrypted storage, SDK isolation
|
||||
- Testing quick reference for ViewModels, repositories, compose, and data sources
|
||||
- Clock/time injection patterns for deterministic operations
|
||||
- Anti-patterns and common gotchas
|
||||
- Copy-pasteable code templates (templates.md) for all layer types
|
||||
- README.md, CHANGELOG.md, CONTRIBUTING.md for marketplace preparation
|
||||
@@ -1,44 +0,0 @@
|
||||
# Contributing to implementing-android-code
|
||||
|
||||
## Development
|
||||
|
||||
This skill provides Bitwarden Android implementation patterns, gotchas, and code templates for Claude Code. It consists of two content files:
|
||||
|
||||
- **SKILL.md** - Quick reference for patterns, anti-patterns, and gotchas
|
||||
- **templates.md** - Copy-pasteable code templates for all layer types
|
||||
|
||||
## Making Changes
|
||||
|
||||
This skill follows [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **Patch** (0.1.x): Typo fixes, minor clarifications, template corrections
|
||||
- **Minor** (0.x.0): New patterns, new templates, expanded coverage areas
|
||||
- **Major** (x.0.0): Structural changes, pattern overhauls, breaking reorganizations
|
||||
|
||||
When making changes:
|
||||
|
||||
1. Update the relevant content in `SKILL.md` and/or `templates.md`
|
||||
2. Bump the `version` field in the SKILL.md YAML frontmatter
|
||||
3. Add an entry to `CHANGELOG.md` under the appropriate version heading
|
||||
|
||||
## Testing Locally
|
||||
|
||||
To test the skill locally with Claude Code:
|
||||
|
||||
```bash
|
||||
# From the repository root, invoke Claude Code and trigger the skill
|
||||
claude "How do I implement a ViewModel?"
|
||||
```
|
||||
|
||||
Verify that:
|
||||
- The skill triggers on expected phrases
|
||||
- Templates render correctly
|
||||
- Pattern references are accurate against the current codebase
|
||||
|
||||
## Pull Requests
|
||||
|
||||
All pull requests that modify skill content must include:
|
||||
|
||||
1. A version bump in the SKILL.md frontmatter
|
||||
2. A corresponding CHANGELOG.md entry
|
||||
3. Verification that templates compile against the current codebase patterns
|
||||
@@ -1,77 +0,0 @@
|
||||
# implementing-android-code
|
||||
|
||||
Bitwarden Android implementation patterns skill for Claude Code. Provides critical patterns, gotchas, anti-patterns, and copy-pasteable templates unique to the Bitwarden Android codebase.
|
||||
|
||||
## Features
|
||||
|
||||
- **ViewModel SAE Pattern** - State-Action-Event with `BaseViewModel`, `SavedStateHandle` persistence, process death recovery
|
||||
- **Type-Safe Navigation** - `@Serializable` routes, `composableWithSlideTransitions`, `NavGraphBuilder`/`NavController` extensions
|
||||
- **Screen/Compose** - Stateless composables, `EventsEffect`, `remember(viewModel)` lambda patterns
|
||||
- **Data Layer** - Repository pattern, `DataState<T>` streaming, `Result` sealed classes, Flow collection via Internal actions
|
||||
- **UI Components** - Bitwarden component library usage, theming, string resources
|
||||
- **Security Patterns** - Zero-knowledge architecture, encrypted storage, SDK isolation
|
||||
- **Testing Patterns** - ViewModel, repository, compose, and data source test structure
|
||||
- **Clock/Time Handling** - `Clock` injection for deterministic time operations
|
||||
|
||||
## Skill Structure
|
||||
|
||||
```
|
||||
implementing-android-code/
|
||||
├── SKILL.md # Quick reference for patterns, gotchas, and anti-patterns
|
||||
├── templates.md # Copy-pasteable code templates for all layer types
|
||||
├── README.md # This file
|
||||
├── CHANGELOG.md # Version history
|
||||
└── CONTRIBUTING.md # Contribution guidelines
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Claude triggers this skill automatically when conversations involve implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
|
||||
**Example trigger phrases:**
|
||||
- "How do I implement a ViewModel?"
|
||||
- "Create a new screen"
|
||||
- "Add navigation"
|
||||
- "Write a repository"
|
||||
- "BaseViewModel pattern"
|
||||
- "State-Action-Event"
|
||||
- "type-safe navigation"
|
||||
- "Clock injection"
|
||||
|
||||
## Content Summary
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| A. ViewModel Implementation | SAE pattern, `handleAction`, `sendAction`, `SavedStateHandle` |
|
||||
| B. Type-Safe Navigation | `@Serializable` routes, transitions, `NavGraphBuilder` extensions |
|
||||
| C. Screen Implementation | Stateless composables, `EventsEffect`, action lambdas |
|
||||
| D. Data Layer | Repositories, data sources, `DataState`, error handling |
|
||||
| E. UI Components | Bitwarden component library, theming, string resources |
|
||||
| F. Security Patterns | Zero-knowledge, encrypted storage, SDK isolation |
|
||||
| G. Testing Quick Reference | ViewModel, repository, compose, data source tests |
|
||||
| H. Clock/Time Patterns | `Clock` injection, deterministic time testing |
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/ARCHITECTURE.md`](../../../docs/ARCHITECTURE.md) - Comprehensive architecture patterns and examples
|
||||
- [`docs/STYLE_AND_BEST_PRACTICES.md`](../../../docs/STYLE_AND_BEST_PRACTICES.md) - Code style, formatting, Compose conventions
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines, versioning, and pull request requirements.
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
|
||||
## License
|
||||
|
||||
This skill is part of the [Bitwarden Android](https://github.com/bitwarden/android) project and follows its licensing terms.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- Bitwarden Android team
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, open an issue in the [bitwarden/android](https://github.com/bitwarden/android) repository.
|
||||
@@ -1,480 +0,0 @@
|
||||
---
|
||||
name: implementing-android-code
|
||||
version: 0.1.1
|
||||
description: This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app.
|
||||
---
|
||||
|
||||
# Implementing Android Code - Bitwarden Quick Reference
|
||||
|
||||
**This skill provides tactical guidance for Bitwarden-specific patterns.** For comprehensive architecture decisions and complete code style rules, consult `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
---
|
||||
|
||||
## Critical Patterns Reference
|
||||
|
||||
### A. ViewModel Implementation (State-Action-Event Pattern)
|
||||
|
||||
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
|
||||
|
||||
**Key Requirements:**
|
||||
- Annotate with `@HiltViewModel`
|
||||
- State class MUST be `@Parcelize data class : Parcelable`
|
||||
- Implement `handleAction(action: A)` - MUST be synchronous
|
||||
- Post internal actions from coroutines using `sendAction()`
|
||||
- Save/restore state via `SavedStateHandle[KEY_STATE]`
|
||||
- Private action handlers: `private fun handle*` naming convention
|
||||
|
||||
**Template**: See [ViewModel template](templates.md#viewmodel-template-state-action-event-pattern)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val repository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: ExampleState()
|
||||
) {
|
||||
init {
|
||||
stateFlow.onEach { savedStateHandle[KEY_STATE] = it }.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
// Synchronous dispatch only
|
||||
when (action) {
|
||||
is Action.Click -> handleClick()
|
||||
is Action.Internal.DataReceived -> handleDataReceived(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClick() {
|
||||
viewModelScope.launch {
|
||||
val result = repository.fetchData()
|
||||
sendAction(Action.Internal.DataReceived(result)) // Post internal action
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDataReceived(action: Action.Internal.DataReceived) {
|
||||
mutableStateFlow.update { it.copy(data = action.result) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` (see `handleAction` method)
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt` (see class declaration)
|
||||
|
||||
**Critical Gotchas:**
|
||||
- ❌ **NEVER** update `mutableStateFlow` directly inside coroutines
|
||||
- ✅ **ALWAYS** post internal actions from coroutines, update state in `handleAction()`
|
||||
- ❌ **NEVER** forget `@IgnoredOnParcel` for sensitive data (causes security leak)
|
||||
- ✅ **ALWAYS** use `@Parcelize` on state classes for process death recovery
|
||||
- ✅ State restoration happens automatically if properly saved to `SavedStateHandle`
|
||||
|
||||
---
|
||||
|
||||
### B. Navigation Implementation (Type-Safe)
|
||||
|
||||
All navigation uses **type-safe routes** with kotlinx.serialization.
|
||||
|
||||
**Pattern Structure:**
|
||||
1. `@Serializable` route data class with parameters
|
||||
2. `...Args` helper class for extracting from `SavedStateHandle`
|
||||
3. `NavGraphBuilder.{screen}Destination()` extension for adding screen to graph
|
||||
4. `NavController.navigateTo{Screen}()` extension for navigation calls
|
||||
|
||||
**Template**: See [Navigation template](templates.md#navigation-template-type-safe-routes)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ExampleRoute(val userId: String, val isEditMode: Boolean = false)
|
||||
|
||||
data class ExampleArgs(val userId: String, val isEditMode: Boolean)
|
||||
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(userId = route.userId, isEditMode = route.isEditMode)
|
||||
}
|
||||
|
||||
fun NavController.navigateToExample(userId: String, isEditMode: Boolean = false, navOptions: NavOptions? = null) {
|
||||
this.navigate(route = ExampleRoute(userId, isEditMode), navOptions = navOptions)
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.exampleDestination(onNavigateBack: () -> Unit) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(onNavigateBack = onNavigateBack)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt` (see `LoginRoute` and extensions)
|
||||
|
||||
**Key Benefits:**
|
||||
- ✅ Type safety: Compile-time errors for missing parameters
|
||||
- ✅ No string literals in navigation code
|
||||
- ✅ Automatic serialization/deserialization
|
||||
- ✅ Clear contract for screen dependencies
|
||||
|
||||
---
|
||||
|
||||
### C. Screen/Compose Implementation
|
||||
|
||||
All screens follow consistent Compose patterns.
|
||||
|
||||
**Template**: See [Screen/Compose template](templates.md#screencompose-template)
|
||||
|
||||
**Key Patterns:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(R.string.title),
|
||||
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
// UI content
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:** `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt` (see `LoginScreen` composable)
|
||||
|
||||
**Essential Requirements:**
|
||||
- ✅ Use `hiltViewModel()` for dependency injection
|
||||
- ✅ Use `collectAsStateWithLifecycle()` for state (not `collectAsState()`)
|
||||
- ✅ Use `EventsEffect(viewModel)` for one-shot events
|
||||
- ✅ Use `remember(viewModel) { }` for stable callbacks to prevent recomposition
|
||||
- ✅ Use `Bitwarden*` prefixed components from `:ui` module
|
||||
|
||||
**State Hoisting Rules:**
|
||||
- **ViewModel state**: Data that needs to survive process death or affects business logic
|
||||
- **UI-only state**: Temporary UI state (scroll position, text field focus) using `remember` or `rememberSaveable`
|
||||
|
||||
---
|
||||
|
||||
### D. Data Layer Implementation
|
||||
|
||||
The data layer follows strict patterns for repositories, managers, and data sources.
|
||||
|
||||
**Interface + Implementation Separation (ALWAYS)**
|
||||
|
||||
**Template**: See [Data Layer template](templates.md#data-layer-template-repository--hilt-module)
|
||||
|
||||
**Pattern Summary:**
|
||||
```kotlin
|
||||
// Interface (injected via Hilt)
|
||||
interface ExampleRepository {
|
||||
suspend fun fetchData(id: String): ExampleResult
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
|
||||
// Implementation (NOT directly injected)
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
) : ExampleRepository {
|
||||
override suspend fun fetchData(id: String): ExampleResult {
|
||||
// NO exceptions thrown - return Result or sealed class
|
||||
return exampleService.getData(id).fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealed result class (domain-specific)
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: ExampleData) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
|
||||
// Hilt Module
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
): ExampleRepository = ExampleRepositoryImpl(exampleDiskSource, exampleService)
|
||||
}
|
||||
```
|
||||
|
||||
**Reference:**
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt`
|
||||
- `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
**Three-Layer Data Architecture:**
|
||||
1. **Data Sources** - Raw data access (network, disk, SDK). Return `Result<T>`, never throw.
|
||||
2. **Managers** - Single responsibility business logic. Wrap OS/external services.
|
||||
3. **Repositories** - Aggregate sources/managers. Return domain-specific sealed classes.
|
||||
|
||||
**Critical Rules:**
|
||||
- ❌ **NEVER** throw exceptions in data layer
|
||||
- ✅ **ALWAYS** use interface + `...Impl` pattern
|
||||
- ✅ **ALWAYS** inject interfaces, never implementations
|
||||
- ✅ Data sources return `Result<T>`, repositories return domain sealed classes
|
||||
- ✅ Use `StateFlow` for continuously observed data
|
||||
|
||||
---
|
||||
|
||||
### E. UI Components
|
||||
|
||||
**Use Existing Components First:**
|
||||
|
||||
The `:ui` module provides reusable `Bitwarden*` prefixed components. Search before creating new ones.
|
||||
|
||||
**Common Components:**
|
||||
- `BitwardenFilledButton` - Primary action buttons
|
||||
- `BitwardenOutlinedButton` - Secondary action buttons
|
||||
- `BitwardenTextField` - Text input fields
|
||||
- `BitwardenPasswordField` - Password input with show/hide
|
||||
- `BitwardenSwitch` - Toggle switches
|
||||
- `BitwardenTopAppBar` - Toolbar/app bar
|
||||
- `BitwardenScaffold` - Screen container with scaffold
|
||||
- `BitwardenBasicDialog` - Simple dialogs
|
||||
- `BitwardenLoadingDialog` - Loading indicators
|
||||
|
||||
**Component Discovery:**
|
||||
Search `ui/src/main/kotlin/com/bitwarden/ui/platform/components/` for existing `Bitwarden*` components. See **Codebase Discovery** in `CLAUDE.md` for search commands.
|
||||
|
||||
**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, see the **Codebase Discovery**, **Testing**, and **Deployment** sections in `CLAUDE.md`.
|
||||
|
||||
**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,644 +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 = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) }
|
||||
},
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.example),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
ExampleScreenContent(
|
||||
state = state,
|
||||
onInputChanged = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.InputChanged(it)) }
|
||||
},
|
||||
onSubmitClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Layer Template (Repository + Hilt Module)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
### Interface
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Provides data operations for the Example feature.
|
||||
*/
|
||||
interface ExampleRepository {
|
||||
/**
|
||||
* Submits data and returns a typed result.
|
||||
*/
|
||||
suspend fun submitData(input: String): ExampleResult
|
||||
|
||||
/**
|
||||
* Continuously observed data stream.
|
||||
*/
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
```
|
||||
|
||||
### Sealed Result Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Domain-specific result for Example operations.
|
||||
*/
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: String) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Default implementation of [ExampleRepository].
|
||||
*/
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : ExampleRepository {
|
||||
|
||||
override val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
get() = // ...
|
||||
|
||||
override suspend fun submitData(input: String): ExampleResult {
|
||||
return exampleService
|
||||
.postData(input)
|
||||
.fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hilt Module
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): ExampleRepository = ExampleRepositoryImpl(
|
||||
exampleDiskSource = exampleDiskSource,
|
||||
exampleService = exampleService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Templates
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
|
||||
|
||||
### Encrypted Disk Source (Module)
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleDiskSource(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): ExampleDiskSource = ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Encrypted Disk Source (Implementation)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Disk source for Example data using encrypted and unencrypted storage.
|
||||
*/
|
||||
class ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
|
||||
private companion object {
|
||||
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
|
||||
const val UNENCRYPTED_PREF_KEY = "examplePreference"
|
||||
}
|
||||
|
||||
override var authToken: String?
|
||||
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
|
||||
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
|
||||
|
||||
override var uiPreference: Boolean
|
||||
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
|
||||
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Templates
|
||||
|
||||
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
### ViewModel Test
|
||||
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository = mockk<ExampleRepository>()
|
||||
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockRepository.dataFlow } returns mutableDataFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is no saved state`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is a saved state`() {
|
||||
val savedState = DEFAULT_STATE.copy(data = "saved")
|
||||
val viewModel = createViewModel(state = savedState)
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should call repository and update state on success`() = runTest {
|
||||
val expected = ExampleResult.Success(data = "result")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
// Initial state
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Updated state after result
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "result", isLoading = false),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should show error dialog on failure`() = runTest {
|
||||
val expected = ExampleResult.Error(message = "Network error")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
val errorState = awaitItem()
|
||||
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExampleAction.BackClick)
|
||||
assertEquals(ExampleEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create ViewModel with optional saved state
|
||||
private fun createViewModel(
|
||||
state: ExampleState? = DEFAULT_STATE,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to state),
|
||||
),
|
||||
exampleRepository = mockRepository,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Testing with stateEventFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `SubmitClick should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Assert state change
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "data"),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
|
||||
// Assert event emission
|
||||
assertEquals(
|
||||
ExampleEvent.ShowToast("Success".asText()),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
name: reviewing-changes
|
||||
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
|
||||
|
||||
This skill provides Android-specific workflow additions that complement the base `bitwarden-code-reviewer` agent standards.
|
||||
|
||||
## Instructions
|
||||
|
||||
**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:
|
||||
- Link a JIRA ticket
|
||||
- Associate a GitHub issue
|
||||
- Link to another pull request
|
||||
- Add more detail to the PR title or body
|
||||
|
||||
### 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:**
|
||||
- **Feature Addition**: New `ViewModel`, new `Repository`, new `@Composable` functions, new `*Screen.kt` files
|
||||
- **UI Refinement**: Changes only in `*Screen.kt`, `*Composable.kt`, `ui/` package files
|
||||
- **Infrastructure**: Changes to `.github/workflows/`, `gradle/`, `build.gradle.kts`, `libs.versions.toml`
|
||||
- **Dependency Update**: Changes only to `libs.versions.toml` or `build.gradle.kts` with version bumps
|
||||
|
||||
### Step 3: Load Appropriate Checklist
|
||||
|
||||
Based on detected type, read the relevant checklist file:
|
||||
|
||||
- **Dependency Update** → `checklists/dependency-update.md` (expedited review)
|
||||
- **Bug Fix** → `checklists/bug-fix.md` (focused review)
|
||||
- **Feature Addition** → `checklists/feature-addition.md` (comprehensive review)
|
||||
- **UI Refinement** → `checklists/ui-refinement.md` (design-focused review)
|
||||
- **Refactoring** → `checklists/refactoring.md` (pattern-focused review)
|
||||
- **Infrastructure** → `checklists/infrastructure.md` (tooling-focused review)
|
||||
|
||||
The checklist provides:
|
||||
- Multi-pass review strategy
|
||||
- Type-specific focus areas
|
||||
- What to check and what to skip
|
||||
- Structured thinking guidance
|
||||
|
||||
### 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:
|
||||
|
||||
- **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)
|
||||
- **Security questions (quick reference)** → `reference/security-patterns.md` (common patterns and anti-patterns)
|
||||
- **Security questions (comprehensive)** → `docs/ARCHITECTURE.md#security` (full zero-knowledge architecture)
|
||||
- **Testing questions** → `reference/testing-patterns.md` (unit tests, mocking, null safety)
|
||||
- **UI questions** → `reference/ui-patterns.md` (Compose patterns, theming)
|
||||
- **Style questions** → `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **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
|
||||
- **Efficient reviews**: Use multi-pass strategy, skip what's not relevant
|
||||
- **Android patterns**: Validate MVVM, Hilt DI, Compose conventions, Kotlin idioms
|
||||
@@ -1,164 +0,0 @@
|
||||
# Bug Fix Review Checklist
|
||||
|
||||
## Multi-Pass Strategy
|
||||
|
||||
### 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?
|
||||
- How does this fix address the root cause?
|
||||
|
||||
**2. Assess scope:**
|
||||
- How many files changed?
|
||||
- Is this a targeted fix or broader refactoring?
|
||||
- Does this affect multiple features?
|
||||
|
||||
**3. Check for side effects:**
|
||||
- Could this break other features?
|
||||
- Are there edge cases not considered?
|
||||
|
||||
### 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?
|
||||
- Any unnecessary changes included?
|
||||
|
||||
**5. Testing:**
|
||||
- Is there a regression test?
|
||||
- Does test verify the bug is fixed?
|
||||
- Are edge cases covered?
|
||||
|
||||
**6. Related code:**
|
||||
- Same pattern in other places that might have same bug?
|
||||
- Should other similar code be fixed too?
|
||||
|
||||
## What to CHECK
|
||||
|
||||
✅ **Root Cause Analysis**
|
||||
- Does the fix address the root cause or just symptoms?
|
||||
- Is the explanation in PR/commit clear?
|
||||
|
||||
✅ **Regression Testing**
|
||||
- Is there a new test that would fail without this fix?
|
||||
- Does test cover the reported bug scenario?
|
||||
- Are related edge cases tested?
|
||||
|
||||
✅ **Side Effects**
|
||||
- Could this break existing functionality?
|
||||
- Are there similar code paths that need checking?
|
||||
- Does this change behavior in unexpected ways?
|
||||
|
||||
✅ **Fix Scope**
|
||||
- Is the fix appropriately scoped (not too broad, not too narrow)?
|
||||
- Are all instances of the bug fixed?
|
||||
- Any related bugs discovered during investigation?
|
||||
|
||||
## What to SKIP
|
||||
|
||||
❌ **Full Architecture Review** - Unless fix reveals architectural problems
|
||||
❌ **Comprehensive Testing Review** - Focus on regression tests, not entire test suite
|
||||
❌ **Major Refactoring Suggestions** - Unless directly related to preventing similar bugs
|
||||
|
||||
## Red Flags
|
||||
|
||||
🚩 **No test for the bug** - How will we prevent regression?
|
||||
🚩 **Fix doesn't match root cause** - Is this fixing symptoms?
|
||||
🚩 **Broad changes beyond the bug** - Should this be split into separate PRs?
|
||||
🚩 **Similar patterns elsewhere** - Should those be fixed too?
|
||||
|
||||
## Key Questions to Ask
|
||||
|
||||
Use `reference/review-psychology.md` for phrasing:
|
||||
|
||||
- "Can we add a test that would fail without this fix?"
|
||||
- "I see this pattern in [other file] - does it have the same issue?"
|
||||
- "Is this fixing the root cause or masking the symptom?"
|
||||
- "Could this change affect [related feature]?"
|
||||
|
||||
## Prioritizing Findings
|
||||
|
||||
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
|
||||
|
||||
## Output Format
|
||||
|
||||
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
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE
|
||||
|
||||
See inline comments for suggested improvements.
|
||||
```
|
||||
|
||||
**Inline comment examples:**
|
||||
|
||||
```
|
||||
**data/auth/BiometricRepository.kt:120** - SUGGESTED: Extract null handling
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
Root cause analysis: BiometricPrompt result was nullable but code assumed non-null, causing crash on cancellation (PM-12345).
|
||||
|
||||
Consider extracting null handling pattern:
|
||||
|
||||
\```kotlin
|
||||
private fun handleBiometricResult(result: BiometricPrompt.AuthenticationResult?): AuthResult {
|
||||
return result?.let { AuthResult.Success(it) } ?: AuthResult.Cancelled
|
||||
}
|
||||
\```
|
||||
|
||||
This pattern could be reused if we add other biometric auth points.
|
||||
</details>
|
||||
```
|
||||
|
||||
```
|
||||
**app/auth/BiometricViewModel.kt:89** - SUGGESTED: Add regression test
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
Add test for cancellation scenario to prevent regression:
|
||||
|
||||
\```kotlin
|
||||
@Test
|
||||
fun `when biometric cancelled then returns cancelled state`() = runTest {
|
||||
coEvery { repository.authenticate() } returns Result.failure(CancelledException())
|
||||
viewModel.onBiometricAuth()
|
||||
assertEquals(AuthState.Cancelled, viewModel.state.value)
|
||||
}
|
||||
\```
|
||||
|
||||
This prevents regression of the bug just fixed.
|
||||
</details>
|
||||
```
|
||||
@@ -1,166 +0,0 @@
|
||||
# Dependency Update Review Checklist
|
||||
|
||||
## Multi-Pass Strategy
|
||||
|
||||
### 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?
|
||||
- Single dependency or multiple?
|
||||
|
||||
**2. Check compilation safety:**
|
||||
- Any imports in codebase that might break?
|
||||
- Any deprecated APIs we're currently using?
|
||||
- Check if this is a breaking change version
|
||||
|
||||
### 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?
|
||||
- New features we should know about?
|
||||
- Deprecations that affect our usage?
|
||||
|
||||
**4. Verify consistency:**
|
||||
- If updating androidx library, are related libraries updated consistently?
|
||||
- BOM (Bill of Materials) consistency if applicable?
|
||||
- Test dependencies updated alongside main dependencies?
|
||||
|
||||
## What to CHECK
|
||||
|
||||
✅ **Compilation Safety**
|
||||
- Look for API deprecations in our codebase
|
||||
- Check if import statements still valid
|
||||
- Major version bumps require extra scrutiny
|
||||
- Beta/alpha versions need stability assessment
|
||||
|
||||
✅ **Security Implications** (if applicable)
|
||||
- Security-related libraries (crypto, auth, networking)?
|
||||
- Check for CVEs addressed in release notes
|
||||
- Review security advisories for this library
|
||||
|
||||
✅ **Testing Implications**
|
||||
- Does this affect test utilities?
|
||||
- Are there breaking changes in test APIs?
|
||||
- Do existing tests still cover the same scenarios?
|
||||
|
||||
✅ **Changelog Review**
|
||||
- Read release notes for breaking changes
|
||||
- Note any behavioral changes
|
||||
- Check migration guides if major version
|
||||
|
||||
## What to SKIP
|
||||
|
||||
❌ **Full Architecture Review** - No code changed, patterns unchanged
|
||||
❌ **Code Style Review** - No code to review
|
||||
❌ **New Test Requirements** - Unless API changed significantly
|
||||
❌ **Security Deep-Dive** - Unless crypto/auth/networking library
|
||||
❌ **Performance Analysis** - Unless release notes mention performance changes
|
||||
|
||||
## Red Flags (Escalate to Full Review)
|
||||
|
||||
🚩 **Major version bump** (e.g., 1.x → 2.0) - Read `checklists/feature-addition.md`
|
||||
🚩 **Security/crypto library** - Read `reference/architectural-patterns.md` and `docs/ARCHITECTURE.md#security`
|
||||
🚩 **Breaking changes in release notes** - Read relevant code sections carefully
|
||||
🚩 **Multiple dependency updates at once** - Check for interaction risks
|
||||
🚩 **Beta/Alpha versions** - Assess stability concerns and rollback plan
|
||||
|
||||
If any red flags present, escalate to more comprehensive review using appropriate checklist.
|
||||
|
||||
## Prioritizing Findings
|
||||
|
||||
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
|
||||
|
||||
## Output Format
|
||||
|
||||
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
|
||||
|
||||
### Example 1: Simple Patch Version (No Critical Issues)
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE
|
||||
|
||||
See inline comments for all issue details.
|
||||
```
|
||||
|
||||
**Inline comment example:**
|
||||
```
|
||||
**libs.versions.toml:45** - SUGGESTED: Beta version in production
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
androidx.credentials updated from 1.5.0 to 1.6.0-beta03
|
||||
|
||||
Monitor for stability issues - beta releases may have unexpected behavior in production.
|
||||
|
||||
Changelog: Adds support for additional credential types, internal bug fixes.
|
||||
</details>
|
||||
```
|
||||
|
||||
### Example 2: Major Version with Breaking Changes (With Critical Issues)
|
||||
|
||||
```markdown
|
||||
**Overall Assessment:** REQUEST CHANGES
|
||||
|
||||
**Critical Issues:**
|
||||
- Breaking API changes in Retrofit 3.0.0 (network/api/BitwardenApiService.kt)
|
||||
- Breaking API changes in Retrofit 3.0.0 (network/api/VaultApiService.kt)
|
||||
|
||||
See inline comments for migration details.
|
||||
```
|
||||
|
||||
**Inline comment example:**
|
||||
```
|
||||
**network/api/BitwardenApiService.kt:15** - CRITICAL: Breaking API changes
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
Retrofit 3.0.0 removes `Call<T>` return type. Migration required:
|
||||
|
||||
\```kotlin
|
||||
// Before
|
||||
fun getUser(): Call<UserResponse>
|
||||
|
||||
// After
|
||||
suspend fun getUser(): Response<UserResponse>
|
||||
\```
|
||||
|
||||
Update all API service interfaces to use suspend functions, update call sites to use coroutines instead of enqueue/execute, and update tests accordingly.
|
||||
|
||||
Consider creating a separate PR for this migration due to scope.
|
||||
|
||||
Reference: https://github.com/square/retrofit/blob/master/CHANGELOG.md#version-300
|
||||
</details>
|
||||
```
|
||||
@@ -1,380 +0,0 @@
|
||||
# Feature Addition Review Checklist
|
||||
|
||||
## Multi-Pass Strategy
|
||||
|
||||
### 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
|
||||
- Note any security implications (auth, encryption, data handling)
|
||||
|
||||
**2. Scan file structure:**
|
||||
- Which modules affected? (app, data, network, ui, core?)
|
||||
- Are files organized correctly per module structure?
|
||||
- Any new public APIs introduced?
|
||||
|
||||
**3. Initial risk assessment:**
|
||||
- Does this touch sensitive data or security-critical paths?
|
||||
- Does this affect existing features or only add new ones?
|
||||
- Are there obvious compilation or null safety issues?
|
||||
|
||||
### 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?
|
||||
- Business logic in correct layer?
|
||||
|
||||
**5. Dependency Injection:**
|
||||
- Hilt DI used correctly?
|
||||
- Dependencies injected, not manually instantiated?
|
||||
- Proper scoping applied?
|
||||
|
||||
**6. Module Organization:**
|
||||
- Code placed in correct modules?
|
||||
- No circular dependencies introduced?
|
||||
- Proper separation of concerns?
|
||||
|
||||
**7. Error Handling:**
|
||||
- Using Result types, not exception-based handling?
|
||||
- Errors propagated correctly through layers?
|
||||
|
||||
### 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?
|
||||
- Tests verify behavior, not implementation?
|
||||
|
||||
**9. Code Quality:**
|
||||
- Null safety handled properly?
|
||||
- Public APIs have KDoc documentation?
|
||||
- Naming follows project conventions?
|
||||
|
||||
**10. Security:**
|
||||
- Sensitive data encrypted properly?
|
||||
- Authentication/authorization handled correctly?
|
||||
- Zero-knowledge architecture preserved?
|
||||
|
||||
## Architecture Review
|
||||
|
||||
### MVVM Pattern Compliance
|
||||
|
||||
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
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#security`
|
||||
|
||||
**Critical Security Checks:**
|
||||
|
||||
- **Sensitive data encrypted**: Passwords, keys, tokens use Android Keystore or EncryptedSharedPreferences
|
||||
- **No plaintext secrets**: No passwords/keys in logs, memory dumps, or SharedPreferences
|
||||
- **Input validation**: All user-provided data validated and sanitized
|
||||
- **Authentication tokens**: Securely stored and transmitted
|
||||
- **Zero-knowledge architecture**: Encryption happens client-side, server never sees plaintext
|
||||
|
||||
**Red Flags:**
|
||||
```kotlin
|
||||
// ❌ CRITICAL - Plaintext storage
|
||||
sharedPreferences.edit {
|
||||
putString("pin", userPin) // Must use EncryptedSharedPreferences
|
||||
}
|
||||
|
||||
// ❌ CRITICAL - Logging sensitive data
|
||||
Log.d("Auth", "Password: $password") // Never log sensitive data
|
||||
|
||||
// ❌ CRITICAL - Weak encryption
|
||||
val cipher = Cipher.getInstance("DES") // Use AES-256-GCM
|
||||
|
||||
// ✅ GOOD - Keystore encryption
|
||||
val encryptedData = keystoreManager.encrypt(sensitiveData)
|
||||
secureStorage.store(encryptedData)
|
||||
```
|
||||
|
||||
**If security concerns found, classify as CRITICAL using `reference/priority-framework.md`**
|
||||
|
||||
## Testing Review
|
||||
|
||||
Reference: `reference/testing-patterns.md`
|
||||
|
||||
**Required Test Coverage:**
|
||||
|
||||
- **ViewModels**: Unit tests for state transitions, actions, error scenarios
|
||||
- **Repositories**: Unit tests for data transformations, error handling
|
||||
- **Business logic**: Unit tests for complex algorithms, calculations
|
||||
- **Edge cases**: Null inputs, empty states, network failures, concurrent operations
|
||||
|
||||
**Test Quality:**
|
||||
```kotlin
|
||||
// ✅ GOOD - Tests behavior
|
||||
@Test
|
||||
fun `when login succeeds then state updates to success`() = runTest {
|
||||
val viewModel = LoginViewModel(mockRepository)
|
||||
|
||||
coEvery { mockRepository.login(any(), any()) } returns Result.success(User())
|
||||
|
||||
viewModel.onLoginClicked("user", "pass")
|
||||
|
||||
viewModel.state.test {
|
||||
assertEquals(LoginState.Success, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ BAD - Tests implementation
|
||||
@Test
|
||||
fun `repository is called with correct parameters`() {
|
||||
// This is testing internal implementation, not behavior
|
||||
}
|
||||
```
|
||||
|
||||
**Testing Frameworks:**
|
||||
- JUnit 5 for test structure
|
||||
- MockK for mocking
|
||||
- Turbine for Flow testing
|
||||
- Kotlinx-coroutines-test for coroutine testing
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Null Safety
|
||||
|
||||
- No `!!` (non-null assertion) without clear safety guarantee
|
||||
- Platform types (from Java) handled with explicit nullability
|
||||
- Nullable types have proper null checks or use safe operators (`?.`, `?:`)
|
||||
|
||||
```kotlin
|
||||
// ❌ BAD - Unsafe assertion
|
||||
val result = apiService.getData()!! // Could crash
|
||||
|
||||
// ✅ GOOD - Safe handling
|
||||
val result = apiService.getData() ?: return State.Error("No data")
|
||||
|
||||
// ❌ BAD - Platform type unchecked
|
||||
val intent: Intent = getIntent() // Could be null from Java
|
||||
intent.getStringExtra("key") // Potential NPE
|
||||
|
||||
// ✅ GOOD - Explicit nullability
|
||||
val intent: Intent? = getIntent()
|
||||
intent?.getStringExtra("key")
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Public APIs**: Have KDoc comments explaining purpose, parameters, return values
|
||||
- **Complex algorithms**: Explained in comments
|
||||
- **Non-obvious behavior**: Documented with rationale
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Documented public API
|
||||
/**
|
||||
* Encrypts the given data using AES-256-GCM with a key from Android Keystore.
|
||||
*
|
||||
* @param plaintext The data to encrypt
|
||||
* @return Result containing encrypted data or encryption error
|
||||
*/
|
||||
suspend fun encrypt(plaintext: ByteArray): Result<EncryptedData>
|
||||
```
|
||||
|
||||
### Style Compliance
|
||||
|
||||
Reference: `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
|
||||
Only flag style issues if:
|
||||
- Not caught by linters (Detekt, ktlint)
|
||||
- Have architectural implications
|
||||
- Significantly impact readability
|
||||
|
||||
Skip minor formatting (spaces, line breaks, etc.) - linters handle this.
|
||||
|
||||
## Prioritizing Findings
|
||||
|
||||
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
|
||||
|
||||
## Providing Feedback
|
||||
|
||||
Use `reference/review-psychology.md` for phrasing guidance.
|
||||
|
||||
**Key principles:**
|
||||
- **Ask questions** for design decisions: "Can we use the existing BitwardenTextField component here?"
|
||||
- **Be prescriptive** for clear violations: "Change MutableStateFlow to StateFlow (MVVM pattern requirement)"
|
||||
- **Explain rationale**: "This exposes mutable state, violating unidirectional data flow"
|
||||
- **Use I-statements**: "It's hard for me to understand this logic without comments"
|
||||
- **Avoid condescension**: Don't use "just", "simply", "obviously"
|
||||
|
||||
## Output Format
|
||||
|
||||
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.
|
||||
```
|
||||
@@ -1,250 +0,0 @@
|
||||
# Infrastructure Review Checklist
|
||||
|
||||
## Multi-Pass Strategy
|
||||
|
||||
### 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?
|
||||
- What's the expected impact?
|
||||
|
||||
**2. Assess risk:**
|
||||
- Does this affect production builds?
|
||||
- Could this break CI/CD pipelines?
|
||||
- Impact on developer workflow?
|
||||
|
||||
**3. Performance implications:**
|
||||
- Will builds be faster or slower?
|
||||
- CI time impact?
|
||||
- Resource usage changes?
|
||||
|
||||
### 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?
|
||||
- Secrets/credentials handled securely?
|
||||
|
||||
**5. Impact analysis:**
|
||||
- What workflows/builds are affected?
|
||||
- Rollback plan if this breaks?
|
||||
- Documentation for team?
|
||||
|
||||
**6. Testing strategy:**
|
||||
- How can this be tested before merge?
|
||||
- Canary/gradual rollout possible?
|
||||
- Monitoring for issues post-merge?
|
||||
|
||||
## What to CHECK
|
||||
|
||||
✅ **Configuration Correctness**
|
||||
- YAML/Groovy syntax valid
|
||||
- File references correct
|
||||
- Version numbers/tags valid
|
||||
- Conditional logic sound
|
||||
|
||||
✅ **Security**
|
||||
- No hardcoded secrets or credentials
|
||||
- GitHub secrets used properly
|
||||
- Permissions appropriately scoped
|
||||
- No sensitive data in logs
|
||||
|
||||
✅ **Performance Impact**
|
||||
- Build time implications understood
|
||||
- CI queue time impact assessed
|
||||
- Resource usage reasonable
|
||||
|
||||
✅ **Rollback Plan**
|
||||
- Can this be reverted easily?
|
||||
- Dependencies on other changes?
|
||||
- Gradual rollout possible?
|
||||
|
||||
✅ **Documentation**
|
||||
- Changes documented for team?
|
||||
- README or CONTRIBUTING updated?
|
||||
- Breaking changes clearly noted?
|
||||
|
||||
## What to SKIP
|
||||
|
||||
❌ **Bikeshedding Configuration** - Unless clear performance/maintenance benefit
|
||||
❌ **Over-Optimization** - Unless current system has proven problems
|
||||
❌ **Suggesting Major Rewrites** - Unless current approach is fundamentally broken
|
||||
|
||||
## Red Flags
|
||||
|
||||
🚩 **Hardcoded secrets** - Use GitHub secrets or secure storage
|
||||
🚩 **No rollback plan** - Critical infrastructure should be revertible
|
||||
🚩 **Untested changes** - CI changes should be validated
|
||||
🚩 **Breaking changes without notice** - Team needs advance warning
|
||||
🚩 **Performance regression** - Builds shouldn't get significantly slower
|
||||
|
||||
## Key Questions to Ask
|
||||
|
||||
Use `reference/review-psychology.md` for phrasing:
|
||||
|
||||
- "What's the rollback plan if this breaks CI?"
|
||||
- "Can we test this on a feature branch before main?"
|
||||
- "Will this impact build times? By how much?"
|
||||
- "Should this be documented in CONTRIBUTING.md?"
|
||||
|
||||
## Common Infrastructure Patterns
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
# ✅ GOOD - Secure, clear, tested
|
||||
name: Build and Test
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30 # Prevent runaway builds
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run tests
|
||||
env:
|
||||
API_KEY: ${{ secrets.API_KEY }} # Secure secret usage
|
||||
run: ./gradlew test
|
||||
|
||||
# ❌ BAD - Insecure, unclear
|
||||
name: Build
|
||||
on: push # Too broad, runs on all branches
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# No timeout - could run forever
|
||||
steps:
|
||||
- run: |
|
||||
export API_KEY="hardcoded_key_here" # Hardcoded secret!
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
### Gradle Configuration
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Clear, maintainable
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx) // Version catalog
|
||||
implementation(libs.hilt.android)
|
||||
|
||||
testImplementation(libs.junit5)
|
||||
testImplementation(libs.mockk)
|
||||
}
|
||||
|
||||
// ❌ BAD - Hardcoded versions
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0") // Hardcoded version
|
||||
implementation("com.google.dagger:hilt-android:2.48")
|
||||
}
|
||||
```
|
||||
|
||||
### Build Optimization
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Parallel, cached
|
||||
tasks.register("checkAll") {
|
||||
dependsOn("detekt", "ktlintCheck", "testStandardDebug")
|
||||
group = "verification"
|
||||
description = "Run all checks in parallel"
|
||||
|
||||
// Enable caching for faster builds
|
||||
outputs.upToDateWhen { false }
|
||||
}
|
||||
|
||||
// ❌ BAD - Sequential, no caching
|
||||
tasks.register("checkAll") {
|
||||
doLast {
|
||||
exec { commandLine("./gradlew", "detekt") }
|
||||
exec { commandLine("./gradlew", "ktlintCheck") } // Sequential
|
||||
exec { commandLine("./gradlew", "test") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Prioritizing Findings
|
||||
|
||||
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
|
||||
|
||||
## Output Format
|
||||
|
||||
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
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Optimizes CI build by parallelizing test execution and caching dependencies
|
||||
|
||||
Impact: Estimated 40% reduction in CI time (12 min → 7 min per build)
|
||||
|
||||
## Critical Issues
|
||||
None
|
||||
|
||||
## Suggested Improvements
|
||||
|
||||
**.github/workflows/build.yml:23** - Add timeout for safety
|
||||
```yaml
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30 # Prevent builds from hanging
|
||||
steps:
|
||||
# ...
|
||||
```
|
||||
This prevents runaway builds if something goes wrong.
|
||||
|
||||
**.github/workflows/build.yml:45** - Consider matrix strategy for module tests
|
||||
Can we run module tests in parallel using a matrix strategy?
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
module: [app, data, network, ui]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: ./gradlew :${{ matrix.module }}:test
|
||||
```
|
||||
This could further reduce CI time.
|
||||
|
||||
**build.gradle.kts:12** - Document caching strategy
|
||||
Can we add a comment explaining the caching configuration?
|
||||
Future maintainers will appreciate understanding why these specific cache keys are used.
|
||||
|
||||
## Rollback Plan
|
||||
If CI breaks:
|
||||
- Revert commit: `git revert [commit-hash]`
|
||||
- Previous workflow available at: `.github/workflows/build.yml@main^`
|
||||
- Monitor CI times at: https://github.com/[org]/[repo]/actions
|
||||
```
|
||||
@@ -1,294 +0,0 @@
|
||||
# Refactoring Review Checklist
|
||||
|
||||
## Multi-Pass Strategy
|
||||
|
||||
### 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?
|
||||
- What's the scope of changes?
|
||||
|
||||
**2. Assess completeness:**
|
||||
- Are all instances refactored or just some?
|
||||
- Are there related areas that should also change?
|
||||
- Is the migration complete or partial?
|
||||
|
||||
**3. Risk assessment:**
|
||||
- Does this change behavior?
|
||||
- How many files affected?
|
||||
- Are tests updated to reflect changes?
|
||||
|
||||
### 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?
|
||||
- Does this match established project patterns?
|
||||
|
||||
**5. Migration completeness:**
|
||||
- Old pattern fully removed or deprecated?
|
||||
- All usages updated?
|
||||
- Documentation updated?
|
||||
|
||||
**6. Test coverage:**
|
||||
- Do tests still pass?
|
||||
- Are tests refactored to match?
|
||||
- Does behavior remain unchanged?
|
||||
|
||||
## What to CHECK
|
||||
|
||||
✅ **Pattern Consistency**
|
||||
- New pattern applied consistently across all touched code
|
||||
- Follows established project patterns (MVVM, DI, error handling)
|
||||
- No mix of old and new patterns
|
||||
|
||||
✅ **Migration Completeness**
|
||||
- All instances of old pattern updated?
|
||||
- Deprecated methods removed or marked @Deprecated?
|
||||
- Related code also updated (tests, docs)?
|
||||
|
||||
✅ **Behavior Preservation**
|
||||
- Refactoring doesn't change behavior
|
||||
- Tests still pass
|
||||
- Edge cases still handled
|
||||
|
||||
✅ **Deprecation Strategy** (if applicable)
|
||||
- Old APIs marked @Deprecated with migration guidance
|
||||
- Replacement clearly documented
|
||||
- Timeline for removal specified
|
||||
|
||||
## What to SKIP
|
||||
|
||||
❌ **Suggesting Additional Refactorings** - Unless directly related to current changes
|
||||
❌ **Scope Creep** - Don't request refactoring of untouched code
|
||||
❌ **Perfection** - Better code is better than perfect code
|
||||
|
||||
## Red Flags
|
||||
|
||||
🚩 **Incomplete migration** - Mix of old and new patterns
|
||||
🚩 **Behavior changes** - Refactoring shouldn't change behavior
|
||||
🚩 **Broken tests** - Tests should be updated to match refactoring
|
||||
🚩 **Undocumented pattern** - New pattern should be clear to team
|
||||
|
||||
## Key Questions to Ask
|
||||
|
||||
Use `reference/review-psychology.md` for phrasing:
|
||||
|
||||
- "I see the old pattern still used in [file:line] - should that be updated too?"
|
||||
- "Can we add @Deprecated to the old method with migration guidance?"
|
||||
- "How do we ensure this behavior remains the same?"
|
||||
- "Should this pattern be documented in ARCHITECTURE.md?"
|
||||
|
||||
## Common Refactoring Patterns
|
||||
|
||||
### Extract Interface/Repository
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Complete migration
|
||||
interface FeatureRepository {
|
||||
suspend fun getData(): Result<Data>
|
||||
}
|
||||
|
||||
class FeatureRepositoryImpl @Inject constructor(
|
||||
private val apiService: FeatureApiService
|
||||
) : FeatureRepository {
|
||||
override suspend fun getData(): Result<Data> = runCatching {
|
||||
apiService.fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
// All usages updated to inject interface
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository // Interface
|
||||
) : ViewModel()
|
||||
|
||||
// ❌ BAD - Incomplete migration
|
||||
// Some files still inject FeatureRepositoryImpl directly
|
||||
```
|
||||
|
||||
### Modernize Error Handling
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Complete migration
|
||||
// Old exception-based removed
|
||||
suspend fun fetchData(): Result<Data> = runCatching {
|
||||
apiService.getData()
|
||||
}
|
||||
|
||||
// All call sites updated
|
||||
repository.fetchData().fold(
|
||||
onSuccess = { /* handle */ },
|
||||
onFailure = { /* handle */ }
|
||||
)
|
||||
|
||||
// ❌ BAD - Mixed patterns
|
||||
// Some functions use Result, others still throw exceptions
|
||||
```
|
||||
|
||||
### Extract Reusable Component
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Complete extraction
|
||||
// Component moved to :ui module
|
||||
@Composable
|
||||
fun BitwardenButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
)
|
||||
|
||||
// All usages updated to use new component
|
||||
// Old inline button implementations removed
|
||||
|
||||
// ❌ BAD - Incomplete extraction
|
||||
// Some screens use new component, others still have inline implementation
|
||||
```
|
||||
|
||||
## Prioritizing Findings
|
||||
|
||||
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
|
||||
|
||||
## Output Format
|
||||
|
||||
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
|
||||
|
||||
### Example 1: Refactoring with Incomplete Migration
|
||||
|
||||
**Context**: Refactoring authentication to Repository pattern, but one ViewModel still uses old pattern
|
||||
|
||||
**Summary Comment:**
|
||||
```markdown
|
||||
**Overall Assessment:** REQUEST CHANGES
|
||||
|
||||
**Critical Issues:**
|
||||
- Incomplete migration (app/vault/VaultViewModel.kt:89)
|
||||
|
||||
See inline comments for details.
|
||||
```
|
||||
|
||||
**Inline Comment 1** (on `app/vault/VaultViewModel.kt:89`):
|
||||
```markdown
|
||||
**IMPORTANT**: Incomplete migration
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
This ViewModel still injects AuthManager directly. Should it use AuthRepository like the other 11 ViewModels?
|
||||
|
||||
\```kotlin
|
||||
// Current (old pattern)
|
||||
class VaultViewModel @Inject constructor(
|
||||
private val authManager: AuthManager
|
||||
)
|
||||
|
||||
// Should be (new pattern)
|
||||
class VaultViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository
|
||||
)
|
||||
\```
|
||||
|
||||
This is the only ViewModel still using the old pattern.
|
||||
</details>
|
||||
```
|
||||
|
||||
**Inline Comment 2** (on `data/auth/AuthManager.kt:1`):
|
||||
```markdown
|
||||
**SUGGESTED**: Add deprecation notice
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
Can we add @Deprecated to AuthManager to guide future development?
|
||||
|
||||
\```kotlin
|
||||
@Deprecated(
|
||||
message = "Use AuthRepository interface instead",
|
||||
replaceWith = ReplaceWith("AuthRepository"),
|
||||
level = DeprecationLevel.WARNING
|
||||
)
|
||||
class AuthManager @Inject constructor(...)
|
||||
\```
|
||||
|
||||
This helps prevent new code from using the old pattern.
|
||||
</details>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Example 2: Clean Refactoring (No Issues)
|
||||
|
||||
**Context**: Refactoring with complete migration, all patterns followed correctly, tests passing
|
||||
|
||||
**Review Comment:**
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE
|
||||
|
||||
Clean refactoring moving ExitManager to :ui module. Follows established patterns, eliminates duplication, tests updated correctly.
|
||||
```
|
||||
|
||||
**Token count:** ~30 tokens (vs ~800 for verbose format)
|
||||
|
||||
**Why this works:**
|
||||
- 3 lines total
|
||||
- Clear approval decision
|
||||
- Briefly notes what was done
|
||||
- No elaborate sections, checkmarks, or excessive praise
|
||||
- Author gets immediate green light to merge
|
||||
|
||||
**What NOT to do for clean refactorings:**
|
||||
```markdown
|
||||
❌ DO NOT create these sections:
|
||||
|
||||
## Summary
|
||||
This PR successfully refactors ExitManager into shared code...
|
||||
|
||||
## Key Strengths
|
||||
- ✅ Follows established module organization patterns
|
||||
- ✅ Removes code duplication between apps
|
||||
- ✅ Improves test coverage
|
||||
- ✅ Maintains consistent behavior
|
||||
[...20 more checkmarks...]
|
||||
|
||||
## Code Quality & Architecture
|
||||
**Architectural Compliance:** ✅
|
||||
- Correctly places manager in :ui module
|
||||
- Follows established pattern for UI-layer managers
|
||||
[...detailed analysis...]
|
||||
|
||||
## Changes
|
||||
- ✅ Moved ExitManager interface from app → ui module
|
||||
- ✅ Moved ExitManagerImpl from app → ui module
|
||||
[...listing every file...]
|
||||
```
|
||||
|
||||
This is excessive. **For clean PRs: 2-3 lines maximum.**
|
||||
@@ -1,233 +0,0 @@
|
||||
# UI Refinement Review Checklist
|
||||
|
||||
## Multi-Pass Strategy
|
||||
|
||||
### 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?
|
||||
- Is this a bug fix or enhancement?
|
||||
|
||||
**2. Component usage:**
|
||||
- Using existing components from `:ui` module?
|
||||
- Any new custom components created?
|
||||
- Could existing components be reused?
|
||||
|
||||
### 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?
|
||||
- Preview composables included?
|
||||
|
||||
**4. Accessibility:**
|
||||
- Content descriptions for images/icons?
|
||||
- Semantic properties for screen readers?
|
||||
- Touch targets meet minimum size (48dp)?
|
||||
|
||||
**5. Design consistency:**
|
||||
- Using theme colors, spacing, typography?
|
||||
- Consistent with other screens?
|
||||
- Responsive to different screen sizes?
|
||||
|
||||
## What to CHECK
|
||||
|
||||
✅ **Compose Best Practices**
|
||||
- Composables are stateless where possible
|
||||
- State hoisting follows patterns
|
||||
- Side effects (LaunchedEffect, DisposableEffect) used correctly
|
||||
- Preview composables provided for development
|
||||
|
||||
✅ **Component Reuse**
|
||||
- Using existing BitwardenButton, BitwardenTextField, etc.?
|
||||
- Could custom UI be replaced with existing components?
|
||||
- New reusable components placed in `:ui` module?
|
||||
|
||||
✅ **Accessibility**
|
||||
- `contentDescription` for icons and images
|
||||
- `semantics` for custom interactions
|
||||
- Sufficient contrast ratios
|
||||
- Touch targets ≥ 48dp minimum
|
||||
|
||||
✅ **Design Consistency**
|
||||
- Using `BitwardenTheme` colors (not hardcoded)
|
||||
- Using `BitwardenTheme` spacing (16.dp, 8.dp, etc.)
|
||||
- Using `BitwardenTheme` typography styles
|
||||
- Consistent with existing screen patterns
|
||||
|
||||
✅ **Responsive Design**
|
||||
- Handles different screen sizes?
|
||||
- Scrollable content where appropriate?
|
||||
- Landscape orientation considered?
|
||||
|
||||
## What to SKIP
|
||||
|
||||
❌ **Deep Architecture Review** - Unless ViewModel changes are substantial
|
||||
❌ **Business Logic Review** - Focus is on presentation, not logic
|
||||
❌ **Security Review** - Unless UI exposes sensitive data improperly
|
||||
|
||||
## Red Flags
|
||||
|
||||
🚩 **Duplicating existing components** - Should reuse from `:ui` module
|
||||
🚩 **Hardcoded colors/dimensions** - Should use theme
|
||||
🚩 **Missing accessibility properties** - Critical for screen readers
|
||||
🚩 **State management in UI** - Should be hoisted to ViewModel
|
||||
|
||||
## Key Questions to Ask
|
||||
|
||||
Use `reference/review-psychology.md` for phrasing:
|
||||
|
||||
- "Can we use BitwardenButton here instead of this custom button?"
|
||||
- "Should this color come from BitwardenTheme instead of being hardcoded?"
|
||||
- "How will this look on a small screen?"
|
||||
- "Is there a contentDescription for this icon?"
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Composable Structure
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Stateless, hoisted state
|
||||
@Composable
|
||||
fun FeatureScreen(
|
||||
state: FeatureState,
|
||||
onActionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// UI rendering only
|
||||
}
|
||||
|
||||
// ❌ BAD - Business state in composable
|
||||
@Composable
|
||||
fun FeatureScreen() {
|
||||
var userData by remember { mutableStateOf<User?>(null) } // Business state should be in ViewModel
|
||||
var isLoading by remember { mutableStateOf(false) } // App state should be in ViewModel
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ OK - UI-local state in composable
|
||||
@Composable
|
||||
fun LoginForm(onSubmit: (String, String) -> Unit) {
|
||||
var username by remember { mutableStateOf("") } // UI-local input state is fine
|
||||
var password by remember { mutableStateOf("") }
|
||||
// Hoist only as high as needed
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Usage
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Using theme
|
||||
Text(
|
||||
text = "Title",
|
||||
style = BitwardenTheme.typography.titleLarge,
|
||||
color = BitwardenTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
// Design system uses 4.dp increments (4, 8, 12, 16, 24, 32, etc.)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ❌ BAD - Hardcoded
|
||||
Text(
|
||||
text = "Title",
|
||||
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold), // Should use theme
|
||||
color = Color(0xFF0000FF) // Should use theme color
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(17.dp)) // Non-standard spacing
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
```kotlin
|
||||
// ✅ GOOD - Interactive element with description
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_password),
|
||||
contentDescription = "Password visibility toggle",
|
||||
modifier = Modifier.clickable { onToggle() }
|
||||
)
|
||||
|
||||
// ✅ GOOD - Decorative icon with explicit null
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_check),
|
||||
contentDescription = null, // Decorative icon next to descriptive text
|
||||
tint = BitwardenTheme.colorScheme.success
|
||||
)
|
||||
|
||||
// ❌ BAD - Interactive element missing description
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_delete),
|
||||
contentDescription = null, // Interactive elements need descriptions
|
||||
modifier = Modifier.clickable { onDelete() }
|
||||
)
|
||||
```
|
||||
|
||||
## Prioritizing Findings
|
||||
|
||||
Use `reference/priority-framework.md` to classify findings as Critical/Important/Suggested/Optional.
|
||||
|
||||
## Output Format
|
||||
|
||||
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
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Updates login screen layout for improved visual hierarchy and touch targets
|
||||
|
||||
## Critical Issues
|
||||
None
|
||||
|
||||
## Suggested Improvements
|
||||
|
||||
**app/auth/LoginScreen.kt:67** - Can we use BitwardenTextField?
|
||||
This custom text field looks very similar to `ui/components/BitwardenTextField.kt:89`.
|
||||
Would using the existing component maintain consistency?
|
||||
|
||||
**app/auth/LoginScreen.kt:123** - Add contentDescription
|
||||
```kotlin
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_visibility),
|
||||
contentDescription = "Show password", // Add for accessibility
|
||||
modifier = Modifier.clickable { onToggleVisibility() }
|
||||
)
|
||||
```
|
||||
|
||||
**app/auth/LoginScreen.kt:145** - Use design system spacing
|
||||
```kotlin
|
||||
// Current
|
||||
Spacer(modifier = Modifier.height(17.dp))
|
||||
|
||||
// Design system uses 4.dp increments (4, 8, 12, 16, 24, 32, etc.)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
```
|
||||
```
|
||||
@@ -1,446 +0,0 @@
|
||||
# Review Output Examples
|
||||
|
||||
Well-structured code reviews demonstrating appropriate depth, tone, and formatting for different change types.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Format Reference:**
|
||||
- [Quick Format Reference](#quick-format-reference)
|
||||
- [Inline Comment Format](#inline-comment-format-required)
|
||||
- [Summary Comment Format](#summary-comment-format)
|
||||
|
||||
**Examples:**
|
||||
- [Example 1: Clean PR (No Issues)](#example-1-clean-pr-no-issues)
|
||||
- [Example 2: Dependency Update with Breaking Changes](#example-2-dependency-update-with-breaking-changes)
|
||||
- [Example 3: Feature Addition with Critical Issues](#example-3-feature-addition-with-critical-issues)
|
||||
|
||||
**Anti-Patterns:**
|
||||
- [❌ Anti-Patterns to Avoid](#-anti-patterns-to-avoid)
|
||||
- [Problem: Verbose Summary with Multiple Sections](#problem-verbose-summary-with-multiple-sections)
|
||||
- [Problem: Praise-Only Inline Comments](#problem-praise-only-inline-comments)
|
||||
- [Problem: Missing `<details>` Tags](#problem-missing-details-tags)
|
||||
|
||||
**Summary:**
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## Quick Format Reference
|
||||
|
||||
### Inline Comment Format (REQUIRED)
|
||||
|
||||
**MUST use `<details>` tags.** Only severity + description visible; all other content collapsed.
|
||||
|
||||
```
|
||||
[emoji] **[SEVERITY]**: [One-line issue description]
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
[Code example or specific fix]
|
||||
|
||||
[Rationale explaining why]
|
||||
|
||||
Reference: [docs link if applicable]
|
||||
</details>
|
||||
```
|
||||
|
||||
**Severity Levels:**
|
||||
- ❌ **CRITICAL** - Blocking, must fix (security, crashes, architecture violations)
|
||||
- ⚠️ **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)
|
||||
|
||||
### Summary Comment Format
|
||||
|
||||
**Required format for ALL PRs:**
|
||||
```
|
||||
**Overall Assessment:** APPROVE / REQUEST CHANGES
|
||||
|
||||
**Critical Issues** (if any):
|
||||
- [issue with file:line]
|
||||
|
||||
See inline comments for details.
|
||||
```
|
||||
|
||||
All PRs use the same minimal format - no exceptions for size or complexity. Summary must be 5-10 lines maximum.
|
||||
|
||||
---
|
||||
|
||||
## Example 1: Clean PR (No Issues)
|
||||
|
||||
**Context**: Moving shared code to common module, complete migration, all patterns followed
|
||||
|
||||
**Review Comment:**
|
||||
```markdown
|
||||
**Overall Assessment:** APPROVE
|
||||
|
||||
Clean refactoring that moves ExitManager to :ui module, eliminating duplication between apps.
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- Immediate approval visible (2-3 lines)
|
||||
- One sentence acknowledging the work
|
||||
- No unnecessary sections or elaborate praise
|
||||
- Author gets quick feedback and can proceed
|
||||
|
||||
---
|
||||
|
||||
## Example 2: Dependency Update with Breaking Changes
|
||||
|
||||
**Context**: Major version update requiring code migration
|
||||
|
||||
**Summary Comment:**
|
||||
```markdown
|
||||
**Overall Assessment:** REQUEST CHANGES
|
||||
|
||||
**Critical Issues:**
|
||||
- API migration required for Retrofit 3.0 breaking changes (network/api/BitwardenApiService.kt:34)
|
||||
|
||||
See inline comments for migration details.
|
||||
```
|
||||
|
||||
**Inline Comment 1** (on `network/api/BitwardenApiService.kt:34`):
|
||||
```markdown
|
||||
❌ **CRITICAL**: API migration required for Retrofit 3.0
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
Retrofit 3.0 removes the `Call<T>` return type. All 12 API methods in this file need migration:
|
||||
|
||||
```kotlin
|
||||
// Current (deprecated in Retrofit 3.0)
|
||||
@GET("api/accounts/profile")
|
||||
fun getProfile(): Call<ProfileResponse>
|
||||
|
||||
// Must migrate to
|
||||
@GET("api/accounts/profile")
|
||||
suspend fun getProfile(): Response<ProfileResponse>
|
||||
```
|
||||
|
||||
Breaking API change affects:
|
||||
- 12 methods in BitwardenApiService
|
||||
- 8 methods in VaultApiService
|
||||
- All call sites using enqueue/execute
|
||||
- Test utilities
|
||||
|
||||
Consider creating separate PR for this migration given the scope.
|
||||
|
||||
Reference: [Retrofit 3.0 migration guide](https://square.github.io/retrofit/changelogs/changelog-3.x/)
|
||||
</details>
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Minimal summary (2-3 lines)
|
||||
- Full details in collapsed inline comment
|
||||
- Specific file:line references
|
||||
- Code examples in <details>
|
||||
- Migration guidance and scope assessment
|
||||
|
||||
---
|
||||
|
||||
## Example 3: Feature Addition with Critical Issues
|
||||
|
||||
**Context**: Implements PIN unlock for vault access
|
||||
|
||||
**Summary Comment:**
|
||||
```markdown
|
||||
**Overall Assessment:** REQUEST CHANGES
|
||||
|
||||
**Critical Issues:**
|
||||
- Exposes mutable state violating MVVM (UnlockViewModel.kt:78)
|
||||
- PIN stored without encryption - SECURITY ISSUE (UnlockRepository.kt:145)
|
||||
|
||||
See inline comments for all issues and suggestions.
|
||||
```
|
||||
|
||||
**Inline Comment 1** (on `app/vault/unlock/UnlockViewModel.kt:78`):
|
||||
```markdown
|
||||
❌ **CRITICAL**: Exposes mutable state
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
Change `MutableStateFlow<State>` to `StateFlow<State>`:
|
||||
|
||||
```kotlin
|
||||
// Current (problematic)
|
||||
val unlockState: MutableStateFlow<UnlockState>
|
||||
|
||||
// Should be
|
||||
private val _unlockState = MutableStateFlow<UnlockState>()
|
||||
val unlockState: StateFlow<UnlockState> = _unlockState.asStateFlow()
|
||||
```
|
||||
|
||||
Exposing MutableStateFlow allows external mutation, violating MVVM unidirectional data flow.
|
||||
|
||||
Reference: docs/ARCHITECTURE.md#mvvm-pattern
|
||||
</details>
|
||||
```
|
||||
|
||||
**Inline Comment 2** (on `data/vault/UnlockRepository.kt:145`):
|
||||
```markdown
|
||||
❌ **CRITICAL**: PIN stored without encryption - SECURITY ISSUE
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
Storing PIN in plaintext SharedPreferences exposes it to backup systems and rooted devices.
|
||||
|
||||
```kotlin
|
||||
// Current (CRITICAL SECURITY ISSUE)
|
||||
sharedPreferences.edit {
|
||||
putString(KEY_PIN, pin)
|
||||
}
|
||||
|
||||
// Must use Android Keystore encryption
|
||||
suspend fun storePin(pin: String): Result<Unit> = runCatching {
|
||||
val encrypted = keystoreManager.encrypt(pin.toByteArray())
|
||||
encryptedPrefs.putBytes(KEY_PIN, encrypted)
|
||||
}
|
||||
```
|
||||
|
||||
Use Android Keystore encryption or EncryptedSharedPreferences per security architecture.
|
||||
|
||||
Reference: docs/ARCHITECTURE.md#security
|
||||
</details>
|
||||
```
|
||||
|
||||
**Inline Comment 3** (on `app/vault/unlock/UnlockViewModel.kt:92`):
|
||||
```markdown
|
||||
⚠️ **IMPORTANT**: Missing error handling test
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
Add test to prevent regression if error handling changes:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `when incorrect PIN entered then returns error state`() = runTest {
|
||||
val viewModel = UnlockViewModel(mockRepository)
|
||||
coEvery { mockRepository.validatePin("1234") }
|
||||
returns Result.failure(InvalidPinException())
|
||||
|
||||
viewModel.onPinEntered("1234")
|
||||
|
||||
assertEquals(UnlockState.Error("Invalid PIN"), viewModel.state.value)
|
||||
}
|
||||
```
|
||||
|
||||
Ensures error flow remains robust across refactorings.
|
||||
</details>
|
||||
```
|
||||
|
||||
**Inline Comment 4** (on `app/vault/unlock/UnlockViewModel.kt:105`):
|
||||
```markdown
|
||||
🎨 **SUGGESTED**: Consider rate limiting for PIN attempts
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
Currently allows unlimited attempts, which could enable brute force attacks.
|
||||
|
||||
```kotlin
|
||||
private var attemptCount = 0
|
||||
private var lockoutUntil: Instant? = null
|
||||
|
||||
fun onPinEntered(pin: String) {
|
||||
if (isLockedOut()) {
|
||||
_state.value = UnlockState.LockedOut(lockoutUntil!!)
|
||||
return
|
||||
}
|
||||
// ... validate PIN ...
|
||||
if (invalid) {
|
||||
attemptCount++
|
||||
if (attemptCount >= MAX_ATTEMPTS) {
|
||||
lockoutUntil = clock.millis() + 15.minutes
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Would add security layer against brute force. Consider discussing threat model with security team.
|
||||
</details>
|
||||
```
|
||||
|
||||
**Inline Comment 5** (on `app/vault/unlock/UnlockScreen.kt:134`):
|
||||
```markdown
|
||||
💭 **QUESTION**: Can we use BitwardenTextField?
|
||||
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
|
||||
This custom PIN input field looks similar to `ui/components/BitwardenTextField.kt:67`.
|
||||
|
||||
Would using the existing component maintain consistency and reduce custom UI code?
|
||||
</details>
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Minimal summary (3-4 lines) with critical issues only
|
||||
- Each issue gets separate inline comment with `<details>` tag
|
||||
- Multiple severity levels demonstrated (CRITICAL, IMPORTANT, SUGGESTED, QUESTION)
|
||||
- Mix of prescriptive fixes and collaborative questions
|
||||
- Code examples collapsed in <details>
|
||||
- No "Good Practices" or "Action Items" sections
|
||||
|
||||
---
|
||||
|
||||
## ❌ Anti-Patterns to Avoid
|
||||
|
||||
### Problem: Verbose Summary with Multiple Sections
|
||||
|
||||
**What NOT to do:**
|
||||
```markdown
|
||||
### Review Complete ✅
|
||||
|
||||
## Summary
|
||||
[Lengthy description of what the PR does]
|
||||
|
||||
### Strengths 👍
|
||||
1. **Excellent documentation** - KDoc comments are comprehensive
|
||||
2. **Proper fail-closed design** - Security defaults to rejection
|
||||
3. **Defense in depth** - Multiple validation layers
|
||||
[7 total items with elaboration]
|
||||
|
||||
### Critical Issues ⚠️
|
||||
- Missing test coverage for security-critical code (with full details)
|
||||
- [More issues with full explanations]
|
||||
|
||||
### Recommendations 🎨
|
||||
- [Multiple recommendations]
|
||||
|
||||
### Test Coverage Status 📊
|
||||
- [Analysis]
|
||||
|
||||
### Architecture Compliance ✅
|
||||
- [Analysis]
|
||||
|
||||
## Recommendation
|
||||
**Conditional approval** with follow-up...
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- 800+ tokens for a summary comment
|
||||
- Multiple sections (Strengths, Recommendations, Test Coverage, Architecture)
|
||||
- Elaborates on positive aspects ("Excellent documentation...")
|
||||
- Duplicates critical issues (summary has details + inline comments have same details)
|
||||
- Creates visual clutter in PR conversation
|
||||
|
||||
**Correct approach:**
|
||||
```markdown
|
||||
**Overall Assessment:** REQUEST CHANGES
|
||||
|
||||
**Critical Issues:**
|
||||
- Missing test coverage for security-critical code (PasswordManagerSignatureVerifierImpl.kt:47)
|
||||
|
||||
See inline comments for details.
|
||||
```
|
||||
|
||||
**Key differences:**
|
||||
- 3-5 lines vs 800+ tokens
|
||||
- Verdict + critical issues only
|
||||
- All details belong in inline comments
|
||||
- No positive commentary sections
|
||||
- Scales with PR complexity, not analysis thoroughness
|
||||
|
||||
### Problem: Praise-Only Inline Comments
|
||||
|
||||
**What NOT to do:**
|
||||
|
||||
Creating inline comment on `AuthenticatorBridgeManagerImpl.kt:73`:
|
||||
```markdown
|
||||
👍 **Excellent integration of signature verification**
|
||||
|
||||
The signature verification is properly integrated into the connection flow:
|
||||
- Checked during initialization (line 73)
|
||||
- Checked before binding (line 134)
|
||||
- Ensures only validated apps can connect
|
||||
|
||||
This is exactly the right approach for fail-safe security.
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- Entire comment is positive feedback with no actionable issue
|
||||
- Takes up space in PR conversation
|
||||
- Distracts from actual issues
|
||||
- Violates "focus on actionable feedback" principle
|
||||
|
||||
**Correct approach:**
|
||||
- Do not create this comment at all
|
||||
- Reserve inline comments exclusively for issues requiring attention
|
||||
|
||||
### Problem: Missing `<details>` Tags
|
||||
|
||||
**What NOT to do:**
|
||||
|
||||
```markdown
|
||||
❌ **CRITICAL**: Missing test coverage for security-critical code
|
||||
|
||||
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
|
||||
|
||||
**Problems:**
|
||||
1. No validation that certificate hashes match actual Bitwarden certificates
|
||||
2. No verification of fail-closed behavior on edge cases
|
||||
3. No tests for multiple signer rejection logic
|
||||
4. Certificate hash typos would go undetected until production
|
||||
|
||||
**Recommendation:**
|
||||
Replace `@OmitFromCoverage` with proper unit tests.
|
||||
|
||||
Example test structure:
|
||||
[long code block]
|
||||
|
||||
Security-critical code should have the highest test coverage, not be omitted.
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- All content visible immediately (code examples, problems list, rationale)
|
||||
- Creates visual clutter in PR conversation
|
||||
- Makes it hard to scan multiple issues quickly
|
||||
|
||||
**Correct approach:**
|
||||
```markdown
|
||||
❌ **CRITICAL**: Missing test coverage for security-critical code
|
||||
|
||||
<details>
|
||||
<summary>Details and fix</summary>
|
||||
|
||||
The `@OmitFromCoverage` annotation excludes this entire class from test coverage.
|
||||
|
||||
**Problems:**
|
||||
1. No validation that certificate hashes match actual Bitwarden certificates
|
||||
2. No verification of fail-closed behavior on edge cases
|
||||
3. No tests for multiple signer rejection logic
|
||||
4. Certificate hash typos would go undetected until production
|
||||
|
||||
**Recommendation:**
|
||||
Replace `@OmitFromCoverage` with proper unit tests.
|
||||
|
||||
Example test structure:
|
||||
[code block]
|
||||
|
||||
Security-critical code should have the highest test coverage, not be omitted.
|
||||
</details>
|
||||
```
|
||||
|
||||
**Key difference:** Only severity + one-line description visible. All details collapsed.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Always use:**
|
||||
- Minimal summary (verdict + critical issues)
|
||||
- Separate inline comments with `<details>` tags
|
||||
- Hybrid emoji + text severity prefixes
|
||||
- Focus exclusively on actionable feedback
|
||||
|
||||
**Never use:**
|
||||
- Multiple summary sections (Strengths, Recommendations, etc.)
|
||||
- Praise-only inline comments
|
||||
- Duplication between summary and inline comments
|
||||
- Verbose analysis in summary (belongs in inline comments)
|
||||
@@ -1,351 +0,0 @@
|
||||
# Architectural Patterns Quick Reference
|
||||
|
||||
Quick reference for Bitwarden Android architectural patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Core Patterns:**
|
||||
- [MVVM + UDF Pattern](#mvvm--udf-pattern)
|
||||
- [ViewModel Structure](#viewmodel-structure)
|
||||
- [UI Layer (Compose)](#ui-layer-compose)
|
||||
- [Hilt Dependency Injection](#hilt-dependency-injection)
|
||||
- [ViewModels](#viewmodels)
|
||||
- [Repositories and Managers](#repositories-and-managers)
|
||||
- [Clock/Time Handling](#clocktime-handling)
|
||||
- [Module Organization](#module-organization)
|
||||
- [Error Handling](#error-handling)
|
||||
- [Use Result Types, Not Exceptions](#use-result-types-not-exceptions)
|
||||
- [Quick Checklist](#quick-checklist)
|
||||
|
||||
---
|
||||
|
||||
## MVVM + UDF Pattern
|
||||
|
||||
### ViewModel Structure
|
||||
|
||||
**✅ GOOD - Proper state encapsulation**:
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository
|
||||
) : ViewModel() {
|
||||
// Private mutable state
|
||||
private val _state = MutableStateFlow<FeatureState>(FeatureState.Initial)
|
||||
|
||||
// Public immutable state
|
||||
val state: StateFlow<FeatureState> = _state.asStateFlow()
|
||||
|
||||
// Actions as functions, state updated via internal action
|
||||
fun onActionClicked() {
|
||||
viewModelScope.launch {
|
||||
val result = repository.performAction()
|
||||
sendAction(FeatureAction.Internal.ActionComplete(result))
|
||||
}
|
||||
}
|
||||
|
||||
// The ViewModel has a handler that processes the internal action
|
||||
private fun handleInternalAction(action: FeatureAction.Internal) {
|
||||
when (action) {
|
||||
is FeatureAction.Internal.ActionComplete -> {
|
||||
// The action handler evaluates the result and updates state
|
||||
action.result.fold(
|
||||
onSuccess = { _state.value = State.Success },
|
||||
onFailure = { _state.value = State.Error(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**❌ BAD - Common violations**:
|
||||
```kotlin
|
||||
class FeatureViewModel : ViewModel() {
|
||||
// ❌ Exposes mutable state
|
||||
val state: MutableStateFlow<FeatureState>
|
||||
|
||||
// ❌ Business logic in ViewModel
|
||||
fun onSubmit() {
|
||||
val encrypted = encryptionManager.encrypt(data) // Should be in Repository
|
||||
_state.value = FeatureState.Success
|
||||
}
|
||||
|
||||
// ❌ Direct Android framework dependency
|
||||
fun onCreate(context: Context) { // ViewModels shouldn't depend on Context
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Expose `StateFlow<T>`, never `MutableStateFlow<T>`
|
||||
- Delegate business logic to Repository/Manager
|
||||
- No direct Android framework dependencies (except ViewModel, SavedStateHandle)
|
||||
- Use `viewModelScope` for coroutines
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#mvvm-pattern`
|
||||
|
||||
---
|
||||
|
||||
### UI Layer (Compose)
|
||||
|
||||
**✅ GOOD - Stateless, observes only**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeatureScreen(
|
||||
state: FeatureState,
|
||||
onActionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
when (state) {
|
||||
is FeatureState.Loading -> LoadingIndicator()
|
||||
is FeatureState.Success -> SuccessContent(state.data)
|
||||
is FeatureState.Error -> ErrorMessage(state.error)
|
||||
}
|
||||
|
||||
BitwardenButton(
|
||||
text = "Action",
|
||||
onClick = onActionClick // Sends event to ViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**❌ BAD - Stateful, modifies state**:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeatureScreen(viewModel: FeatureViewModel) {
|
||||
var localState by remember { mutableStateOf(...) } // ❌ State in UI
|
||||
|
||||
Button(onClick = {
|
||||
viewModel._state.value = FeatureState.Loading // ❌ Directly modifying ViewModel state
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Compose screens observe state, never modify
|
||||
- User actions passed as events/callbacks to ViewModel
|
||||
- No business logic in UI layer
|
||||
- Use existing components from `:ui` module
|
||||
|
||||
---
|
||||
|
||||
## Hilt Dependency Injection
|
||||
|
||||
### ViewModels
|
||||
|
||||
**✅ GOOD - Interface injection**:
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepository, // Interface, not implementation
|
||||
private val authManager: AuthManager,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel()
|
||||
```
|
||||
|
||||
**❌ BAD - Common violations**:
|
||||
```kotlin
|
||||
// ❌ No @HiltViewModel annotation
|
||||
class FeatureViewModel @Inject constructor(...)
|
||||
|
||||
// ❌ Injecting implementation instead of interface
|
||||
class FeatureViewModel @Inject constructor(
|
||||
private val repository: FeatureRepositoryImpl // Should inject interface
|
||||
)
|
||||
|
||||
// ❌ Manual instantiation
|
||||
class FeatureViewModel : ViewModel() {
|
||||
private val repository = FeatureRepositoryImpl() // Should use @Inject
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Annotate with `@HiltViewModel`
|
||||
- Use `@Inject constructor`
|
||||
- Inject interfaces, not implementations
|
||||
- Use `SavedStateHandle` for process death survival
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#dependency-injection`
|
||||
|
||||
---
|
||||
|
||||
### Repositories and Managers
|
||||
|
||||
**✅ GOOD - Implementation with @Inject**:
|
||||
```kotlin
|
||||
interface FeatureRepository {
|
||||
suspend fun fetchData(): Result<Data>
|
||||
}
|
||||
|
||||
class FeatureRepositoryImpl @Inject constructor(
|
||||
private val apiService: FeatureApiService,
|
||||
private val database: FeatureDao
|
||||
) : FeatureRepository {
|
||||
override suspend fun fetchData(): Result<Data> = runCatching {
|
||||
apiService.getData()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Module provides interface**:
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DataModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindFeatureRepository(
|
||||
impl: FeatureRepositoryImpl
|
||||
): FeatureRepository
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Define interface for abstraction
|
||||
- Implementation uses `@Inject constructor`
|
||||
- Module binds implementation to interface
|
||||
- Appropriate scoping (`@Singleton`, `@ViewModelScoped`)
|
||||
|
||||
---
|
||||
|
||||
### Clock/Time Handling
|
||||
|
||||
Time-dependent code must use injected `Clock` rather than direct `Instant.now()` or `DateTime.now()` calls. This follows the same DI principle as other dependencies.
|
||||
|
||||
**✅ GOOD - Injected Clock**:
|
||||
```kotlin
|
||||
// ViewModel with Clock injection
|
||||
class MyViewModel @Inject constructor(
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun save() {
|
||||
val timestamp = clock.instant()
|
||||
}
|
||||
}
|
||||
|
||||
// Extension function with Clock parameter
|
||||
fun State.getTimestamp(clock: Clock): Instant =
|
||||
existingTime ?: clock.instant()
|
||||
```
|
||||
|
||||
**❌ BAD - Static/direct calls**:
|
||||
```kotlin
|
||||
// Hidden dependency, non-testable
|
||||
val timestamp = Instant.now()
|
||||
val dateTime = DateTime.now()
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Inject `Clock` via Hilt constructor (like other dependencies)
|
||||
- Pass `Clock` as parameter to extension functions
|
||||
- `Clock` is provided via `CoreModule` as singleton
|
||||
- Enables deterministic testing with `Clock.fixed(...)`
|
||||
|
||||
Reference: `docs/STYLE_AND_BEST_PRACTICES.md#best-practices--time-and-clock-handling`
|
||||
|
||||
---
|
||||
|
||||
## Module Organization
|
||||
|
||||
```
|
||||
android/
|
||||
├── core/ # Shared utilities (cryptography, analytics, logging)
|
||||
├── data/ # Repositories, database, domain models
|
||||
├── network/ # API clients, network utilities
|
||||
├── ui/ # Reusable Compose components, theme
|
||||
├── app/ # Application, feature screens, ViewModels
|
||||
└── authenticator/ # Authenticator app (separate from password manager)
|
||||
```
|
||||
|
||||
**Correct Placement**:
|
||||
- UI screens and ViewModels → `:app`
|
||||
- Reusable Compose components → `:ui`
|
||||
- Data models and Repositories → `:data`
|
||||
- API services → `:network`
|
||||
- Cryptography, logging → `:core`
|
||||
|
||||
**Check for**:
|
||||
- No circular dependencies
|
||||
- Correct module placement
|
||||
- Proper visibility (internal vs public)
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#module-structure`
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Use Result Types, Not Exceptions
|
||||
|
||||
**✅ GOOD - Result-based**:
|
||||
```kotlin
|
||||
// Repository
|
||||
suspend fun fetchData(): Result<Data> = runCatching {
|
||||
apiService.getData()
|
||||
}
|
||||
|
||||
// ViewModel
|
||||
fun onFetch() {
|
||||
viewModelScope.launch {
|
||||
val result = repository.fetchData()
|
||||
sendAction(FeatureAction.Internal.FetchComplete(result))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**❌ BAD - Exception-based in business logic**:
|
||||
```kotlin
|
||||
// ❌ Don't throw in business logic
|
||||
suspend fun fetchData(): Data {
|
||||
try {
|
||||
return apiService.getData()
|
||||
} catch (e: Exception) {
|
||||
throw FeatureException(e) // Don't throw in repositories
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Try-catch in ViewModel
|
||||
fun onFetch() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val data = repository.fetchData()
|
||||
sendAction(FeatureAction.Internal.FetchComplete(data))
|
||||
} catch (e: Exception) {
|
||||
sendAction(FeatureAction.Internal.FetchComplete(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Use `Result<T>` return types in repositories
|
||||
- Use `runCatching { }` to wrap API calls
|
||||
- Handle results with `.fold()` in ViewModels
|
||||
- Don't throw exceptions in business logic
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#error-handling`
|
||||
|
||||
---
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
### Architecture
|
||||
- [ ] ViewModels expose StateFlow, not MutableStateFlow?
|
||||
- [ ] Business logic in Repository, not ViewModel?
|
||||
- [ ] Using Hilt DI (@HiltViewModel, @Inject constructor)?
|
||||
- [ ] Injecting interfaces, not implementations?
|
||||
- [ ] Time-dependent code uses injected `Clock` (not `Instant.now()`)?
|
||||
- [ ] Correct module placement?
|
||||
|
||||
### Error Handling
|
||||
- [ ] Using Result types, not exceptions in business logic?
|
||||
- [ ] Errors handled with .fold() in ViewModels?
|
||||
|
||||
---
|
||||
|
||||
For comprehensive details, always refer to:
|
||||
- `docs/ARCHITECTURE.md` - Full architecture patterns
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide
|
||||
@@ -1,431 +0,0 @@
|
||||
# Finding Priority Framework
|
||||
|
||||
Use this framework to classify findings during code review. Clear prioritization helps authors triage and address issues effectively.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Severity Categories:**
|
||||
- [❌ CRITICAL (Blocker - Must Fix Before Merge)](#critical-blocker---must-fix-before-merge)
|
||||
- [⚠️ 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)
|
||||
- [Optional (Acknowledge But Don't Require)](#optional-acknowledge-but-dont-require)
|
||||
|
||||
**Guidelines:**
|
||||
- [Classification Guidelines](#classification-guidelines)
|
||||
- [When Something is Between Categories](#when-something-is-between-categories)
|
||||
- [Context Matters](#context-matters)
|
||||
- [Examples by Change Type](#examples-by-change-type)
|
||||
- [Special Cases](#special-cases)
|
||||
- [Summary](#summary)
|
||||
|
||||
---
|
||||
|
||||
## ❌ **CRITICAL** (Blocker - Must Fix Before Merge)
|
||||
|
||||
These issues **must** be addressed before the PR can be merged. They pose immediate risks to security, stability, or architecture integrity.
|
||||
|
||||
### Security
|
||||
- Data leaks or plaintext sensitive data (passwords, keys, tokens)
|
||||
- Weak encryption or insecure key storage
|
||||
- Missing authentication or authorization checks
|
||||
- Input injection vulnerabilities (SQL, XSS, command injection)
|
||||
- Sensitive data in logs or error messages
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/vault/VaultRepository.kt:145** - CRITICAL: PIN stored without encryption
|
||||
PIN must be encrypted using Android Keystore, not stored in plaintext SharedPreferences.
|
||||
Reference: docs/ARCHITECTURE.md#security
|
||||
```
|
||||
|
||||
### Stability
|
||||
- Compilation errors or warnings
|
||||
- Null pointer exceptions in production paths
|
||||
- Resource leaks (file handles, network connections, memory)
|
||||
- Crashes or unhandled exceptions in critical paths
|
||||
- Thread safety violations
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/auth/BiometricRepository.kt:120** - CRITICAL: Missing null safety check
|
||||
biometricPrompt result can be null. Add explicit null check to prevent crash.
|
||||
```
|
||||
|
||||
### Architecture
|
||||
- Mutable state exposure in ViewModels (violates MVVM)
|
||||
- Exception-based error handling in business logic (should use Result)
|
||||
- Circular dependencies between modules
|
||||
- Violation of zero-knowledge principles
|
||||
- Direct dependency instantiation (should use DI)
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/login/LoginViewModel.kt:45** - CRITICAL: Exposes mutable state
|
||||
Change MutableStateFlow to StateFlow in public API to prevent external state mutation.
|
||||
This violates MVVM encapsulation pattern.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ **IMPORTANT** (Should Fix)
|
||||
|
||||
These issues should be addressed but don't block merge if there's a compelling reason. They improve code quality, maintainability, or robustness.
|
||||
|
||||
### Testing
|
||||
- Missing tests for critical paths (authentication, encryption, data sync)
|
||||
- Missing tests for new public APIs
|
||||
- Tests that don't verify actual behavior (test implementation, not behavior)
|
||||
- Missing test coverage for error scenarios
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/auth/BiometricRepository.kt** - IMPORTANT: Missing test for cancellation
|
||||
Add test for user cancellation scenario to prevent regression.
|
||||
```
|
||||
|
||||
### Architecture
|
||||
- Inconsistent patterns within PR (mixing error handling approaches)
|
||||
- Poor separation of concerns
|
||||
- Tight coupling between components
|
||||
- Not following established project patterns
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/vault/VaultViewModel.kt:89** - IMPORTANT: Business logic in ViewModel
|
||||
Encryption logic should be in Repository, not ViewModel.
|
||||
Reference: docs/ARCHITECTURE.md#mvvm-pattern
|
||||
```
|
||||
|
||||
### Documentation
|
||||
- Undocumented public APIs (missing KDoc)
|
||||
- Missing documentation for complex algorithms
|
||||
- Unclear naming or confusing interfaces
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**core/crypto/EncryptionManager.kt:34** - IMPORTANT: Missing KDoc
|
||||
Public encryption method should document parameters, return value, and exceptions.
|
||||
```
|
||||
|
||||
### Performance
|
||||
- Inefficient algorithms in hot paths (with evidence from profiling)
|
||||
- Blocking main thread with I/O operations
|
||||
- Memory inefficient data structures (with evidence)
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/vault/VaultListViewModel.kt:78** - IMPORTANT: N+1 query pattern
|
||||
Fetching items one-by-one in loop. Consider batch fetch to reduce database queries.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♻️ **DEBT** (Technical Debt)
|
||||
|
||||
Code that duplicates existing patterns, violates established conventions, or will require rework within 6 months. Introduces technical debt that should be tracked for future cleanup.
|
||||
|
||||
### Duplication
|
||||
- Copy-pasted code blocks across files
|
||||
- Repeated validation or business logic
|
||||
- Multiple implementations of same pattern
|
||||
- Data transformation duplicated in multiple places
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/vault/VaultListViewModel.kt:156** - DEBT: Duplicates encryption logic
|
||||
Same encryption pattern exists in VaultRepository.kt:234 and SyncManager.kt:89.
|
||||
Extract to shared EncryptionUtil to reduce maintenance burden.
|
||||
```
|
||||
|
||||
### Convention Violations
|
||||
- Inconsistent error handling approaches within same module
|
||||
- Mixing architectural patterns (MVVM + MVC)
|
||||
- Not following established DI patterns
|
||||
- Deviating from project code style significantly
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/auth/AuthRepository.kt:78** - DEBT: Exception-based error handling
|
||||
Project standard is Result<T> for error handling. This uses try-catch with throws.
|
||||
Creates inconsistency and makes testing harder.
|
||||
Reference: docs/ARCHITECTURE.md#error-handling
|
||||
```
|
||||
|
||||
### Future Rework Required
|
||||
- Hardcoded values that should be configurable
|
||||
- Temporary workarounds without TODO/FIXME
|
||||
- Code that will need changes when planned features arrive
|
||||
- Tight coupling that prevents future extensibility
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/settings/SettingsViewModel.kt:45** - DEBT: Hardcoded feature flags
|
||||
Feature flags should come from remote config for A/B testing.
|
||||
Will require rework when experimentation framework launches.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **SUGGESTED** (Nice to Have)
|
||||
|
||||
These are improvement opportunities but not required. Consider the effort vs. benefit before requesting changes.
|
||||
|
||||
### Code Quality
|
||||
- Minor style inconsistencies (if not caught by linter)
|
||||
- Opportunities for DRY improvements
|
||||
- Better variable naming for clarity
|
||||
- Simplification opportunities
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/vault/VaultScreen.kt:145** - SUGGESTED: Consider extracting helper function
|
||||
This 20-line block appears in 3 places. Consider extracting to reduce duplication.
|
||||
```
|
||||
|
||||
### Testing
|
||||
- Additional test coverage for edge cases (beyond critical paths)
|
||||
- More comprehensive integration tests
|
||||
- Performance tests for non-critical paths
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/login/LoginViewModelTest.kt** - SUGGESTED: Add test for concurrent login attempts
|
||||
Not critical, but would increase confidence in edge case handling.
|
||||
```
|
||||
|
||||
### Refactoring
|
||||
- Extracting reusable patterns
|
||||
- Modernizing old patterns (if touching related code)
|
||||
- Improving testability
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/vault/VaultRepository.kt:200** - SUGGESTED: Consider extracting validation logic
|
||||
Could be extracted to separate validator class for reusability and testing.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💭 **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.
|
||||
|
||||
### Requirements Clarification
|
||||
- Ambiguous acceptance criteria
|
||||
- Multiple valid implementation approaches
|
||||
- Unclear business rules or edge case handling
|
||||
- Conflicting requirements between specs and implementation
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/vault/ItemListViewModel.kt:67** - QUESTION: Expected sort behavior for equal timestamps?
|
||||
When items have identical timestamps, should secondary sort be by:
|
||||
- Name (alphabetical)
|
||||
- Creation order
|
||||
- Item type priority
|
||||
|
||||
Spec doesn't specify tie-breaking logic.
|
||||
```
|
||||
|
||||
### Design Decisions
|
||||
- Architecture choices that could go multiple ways
|
||||
- Trade-offs between approaches without clear winner
|
||||
- Feature flag strategy or rollout approach
|
||||
- API design with multiple valid patterns
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/sync/SyncManager.kt:134** - QUESTION: Should sync failures retry automatically?
|
||||
Current implementation fails immediately. Options:
|
||||
- Exponential backoff (3 retries)
|
||||
- User-triggered retry only
|
||||
- Background retry on network restore
|
||||
|
||||
What's the expected UX?
|
||||
```
|
||||
|
||||
### System Integration
|
||||
- Unclear contracts with external systems
|
||||
- Potential conflicts with other features/modules
|
||||
- Assumptions about third-party API behavior
|
||||
- Cross-team coordination needs
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/auth/BiometricPrompt.kt:89** - QUESTION: Compatibility with pending device credentials PR?
|
||||
PR #1234 is refactoring device credentials. Should this:
|
||||
- Merge first and adapt later
|
||||
- Wait for #1234 to land
|
||||
- Coordinate with that author
|
||||
|
||||
Timing unclear.
|
||||
```
|
||||
|
||||
### Testing Strategy
|
||||
- Uncertainty about test scope or approach
|
||||
- Questions about mocking external dependencies
|
||||
- Edge cases that need product input
|
||||
- Performance testing requirements
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/vault/EncryptionTest.kt:45** - QUESTION: Should we test against real Keystore?
|
||||
Currently using mocked Keystore. Real Keystore testing would:
|
||||
+ Catch hardware-specific issues
|
||||
- Slow down CI significantly
|
||||
- Require API 23+ emulators
|
||||
|
||||
What's the priority?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optional (Acknowledge But Don't Require)
|
||||
|
||||
Note good practices to reinforce positive patterns. Keep these **brief** - list only, no elaboration.
|
||||
|
||||
### Good Practices
|
||||
|
||||
**Format**: Simple bullet list, no explanation
|
||||
|
||||
```markdown
|
||||
## Good Practices
|
||||
- Proper Hilt DI usage throughout
|
||||
- Comprehensive unit test coverage
|
||||
- Clear separation of concerns
|
||||
- Well-documented public APIs
|
||||
```
|
||||
|
||||
**Don't do this** (too verbose):
|
||||
```markdown
|
||||
## Good Practices
|
||||
- Proper Hilt DI usage throughout: Great job using @Inject constructor and injecting interfaces! This follows our established patterns perfectly and makes the code very testable. Really excellent work here! 👍
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Classification Guidelines
|
||||
|
||||
### When Something is Between Categories
|
||||
|
||||
**If unsure between Critical and Important**:
|
||||
- Ask: "Could this cause production incidents, data loss, or security breaches?"
|
||||
- If yes → Critical
|
||||
- If no → Important
|
||||
|
||||
**If unsure between Important and Debt**:
|
||||
- Ask: "Is this a bug/defect or just duplication/inconsistency?"
|
||||
- If bug/defect → Important
|
||||
- If duplication/inconsistency → Debt
|
||||
|
||||
**If unsure between Important and Suggested**:
|
||||
- Ask: "Would I block merge over this?"
|
||||
- If yes → Important
|
||||
- If no → Suggested
|
||||
|
||||
**If unsure between Debt and Suggested**:
|
||||
- Ask: "Will this require rework within 6 months?"
|
||||
- If yes → Debt
|
||||
- If no → Suggested
|
||||
|
||||
**If unsure between Suggested and Question**:
|
||||
- Ask: "Am I requesting a change or asking for clarification?"
|
||||
- If requesting change → Suggested
|
||||
- If seeking clarification → Question
|
||||
|
||||
**If unsure between Suggested and Optional**:
|
||||
- Ask: "Is this actionable feedback or just acknowledgment?"
|
||||
- If actionable → Suggested
|
||||
- If acknowledgment → Optional
|
||||
|
||||
### Context Matters
|
||||
|
||||
**Same issue, different contexts**:
|
||||
|
||||
```
|
||||
// Critical for production code
|
||||
Missing null safety check in auth flow → CRITICAL
|
||||
|
||||
// Suggested for internal test utility
|
||||
Missing null safety check in test helper → SUGGESTED
|
||||
```
|
||||
|
||||
**Same pattern, different risk levels**:
|
||||
|
||||
```
|
||||
// Critical for new feature
|
||||
Missing tests for new auth method → CRITICAL
|
||||
|
||||
// Important for bug fix
|
||||
Missing regression test → IMPORTANT
|
||||
|
||||
// Suggested for refactoring
|
||||
Missing tests for refactored helper → SUGGESTED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples by Change Type
|
||||
|
||||
### Dependency Update
|
||||
- **Critical**: Known CVEs in old version not addressed
|
||||
- **Important**: Breaking changes that need migration
|
||||
- **Suggested**: Beta/alpha version stability concerns
|
||||
|
||||
### Bug Fix
|
||||
- **Critical**: Fix doesn't address root cause
|
||||
- **Important**: Missing regression test
|
||||
- **Suggested**: Similar bugs in related code
|
||||
|
||||
### Feature Addition
|
||||
- **Critical**: Security vulnerabilities, architecture violations
|
||||
- **Important**: Missing tests for critical paths
|
||||
- **Suggested**: Additional test coverage, minor refactoring
|
||||
|
||||
### UI Refinement
|
||||
- **Critical**: Missing accessibility for key actions
|
||||
- **Important**: Not using theme (hardcoded colors)
|
||||
- **Suggested**: Minor spacing/alignment improvements
|
||||
|
||||
### Refactoring
|
||||
- **Critical**: Changes behavior (should be behavior-preserving)
|
||||
- **Important**: Incomplete migration (mix of old/new patterns)
|
||||
- **Suggested**: Additional instances that could be refactored
|
||||
|
||||
### Infrastructure
|
||||
- **Critical**: Hardcoded secrets, no rollback plan
|
||||
- **Important**: Performance regression in build times
|
||||
- **Suggested**: Further optimization opportunities
|
||||
|
||||
---
|
||||
|
||||
## Special Cases
|
||||
|
||||
### Technical Debt
|
||||
- Acknowledge existing tech debt but don't require fixing in unrelated PR
|
||||
- Exception: If change makes tech debt worse, it's Important to address
|
||||
|
||||
### Scope Creep
|
||||
- Don't request changes outside PR scope
|
||||
- Can note as "Future consideration" but not required for this PR
|
||||
|
||||
### Linter-Catchable Issues
|
||||
- Don't flag issues that automated tools handle
|
||||
- Exception: If linter is misconfigured and missing real issues
|
||||
|
||||
### Personal Preferences
|
||||
- Don't flag unless grounded in project standards or architectural principles
|
||||
- Use "I-statements" if suggesting alternative approaches
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Critical**: Block merge, must fix (security, stability, architecture)
|
||||
**Important**: Should fix before merge (testing, quality, performance)
|
||||
**Debt**: Technical debt introduced, track for future cleanup
|
||||
**Suggested**: Nice to have, consider effort vs benefit
|
||||
**Question**: Seeking clarification on requirements or design
|
||||
**Optional**: Acknowledge good practices, keep brief
|
||||
@@ -1,175 +0,0 @@
|
||||
# Review Psychology: Constructive Feedback Phrasing
|
||||
|
||||
Effective code review feedback is clear, actionable, and constructive. This guide provides phrasing patterns for inline comments.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
**Guidelines:**
|
||||
- [Core Directives](#core-directives)
|
||||
- [Phrasing Templates](#phrasing-templates)
|
||||
- [Critical Issues (Prescriptive)](#critical-issues-prescriptive)
|
||||
- [Suggested Improvements (Exploratory)](#suggested-improvements-exploratory)
|
||||
- [Questions (Collaborative)](#questions-collaborative)
|
||||
- [Test Suggestions](#test-suggestions)
|
||||
- [When to Be Prescriptive vs Ask Questions](#when-to-be-prescriptive-vs-ask-questions)
|
||||
- [Special Cases](#special-cases)
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
**Pattern**: State problem + Provide solution + Explain why
|
||||
|
||||
```
|
||||
**[file:line]** - CRITICAL: [Issue description]
|
||||
|
||||
[Specific fix with code example if applicable]
|
||||
|
||||
[Rationale explaining why this is critical]
|
||||
|
||||
Reference: [docs link if applicable]
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/vault/VaultRepository.kt:145** - CRITICAL: PIN stored without encryption
|
||||
|
||||
PIN must be encrypted using Android Keystore, not stored in plaintext SharedPreferences.
|
||||
Plaintext storage exposes the PIN to backup systems and rooted devices.
|
||||
|
||||
Reference: docs/ARCHITECTURE.md#security
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Suggested Improvements (Exploratory)
|
||||
|
||||
**Pattern**: Observe + Suggest + Explain benefit
|
||||
|
||||
```
|
||||
**[file:line]** - Consider [alternative approach]
|
||||
|
||||
[Current observation]
|
||||
Can we [specific suggestion]?
|
||||
|
||||
[Benefit or rationale]
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**app/login/LoginScreen.kt:89** - Consider using existing BitwardenButton
|
||||
|
||||
This custom button implementation looks similar to `ui/components/BitwardenButton.kt:45`.
|
||||
Can we use the existing component to maintain consistency across the app?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Questions (Collaborative)
|
||||
|
||||
**Pattern**: Ask + Provide context (optional)
|
||||
|
||||
```
|
||||
**[file:line]** - [Question about intent or approach]?
|
||||
|
||||
[Optional context or observation]
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/sync/SyncManager.kt:234** - How does this handle concurrent sync attempts?
|
||||
|
||||
It looks like multiple coroutines could call `startSync()` simultaneously.
|
||||
Is there a mechanism to prevent race conditions, or is that handled elsewhere?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test Suggestions
|
||||
|
||||
**Pattern**: Observe gap + Suggest specific test + Provide skeleton
|
||||
|
||||
```
|
||||
**[file:line]** - Consider adding test for [scenario]
|
||||
|
||||
[Rationale]
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test description`() = runTest {
|
||||
// Test skeleton
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
**data/auth/BiometricRepository.kt** - Consider adding test for cancellation scenario
|
||||
|
||||
This would prevent regression of the bug you just fixed:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `when biometric cancelled then returns cancelled state`() = runTest {
|
||||
coEvery { biometricPrompt.authenticate() } returns null
|
||||
|
||||
val result = repository.authenticate()
|
||||
|
||||
assertEquals(AuthResult.Cancelled, result)
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When to Be Prescriptive vs Ask Questions
|
||||
|
||||
**Be Prescriptive** (Tell them what to do):
|
||||
- Security issues
|
||||
- Architecture pattern violations
|
||||
- Null safety problems
|
||||
- Compilation errors
|
||||
- Documented project standards
|
||||
|
||||
**Ask Questions** (Seek explanation):
|
||||
- Design decisions with multiple valid approaches
|
||||
- Performance trade-offs without data
|
||||
- Unclear intent or reasoning
|
||||
- Scope decisions (this PR vs future work)
|
||||
- Patterns not documented in project guidelines
|
||||
|
||||
---
|
||||
|
||||
## Special Cases
|
||||
|
||||
**Nitpicks** - For truly minor suggestions, use "Nit:" prefix:
|
||||
```
|
||||
**Nit**: Extra blank line at line 145
|
||||
```
|
||||
|
||||
**Uncertainty** - If unsure, acknowledge it:
|
||||
```
|
||||
I'm not certain, but this might be called frequently.
|
||||
Has this been profiled?
|
||||
```
|
||||
|
||||
**Positive Feedback** - Brief list only, no elaboration:
|
||||
```
|
||||
## Good Practices
|
||||
- Proper Hilt DI usage throughout
|
||||
- Comprehensive unit test coverage
|
||||
- Clear separation of concerns
|
||||
```
|
||||
@@ -1,90 +0,0 @@
|
||||
# Security Patterns Quick Reference
|
||||
|
||||
Quick reference for Bitwarden Android security patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md#security`.
|
||||
|
||||
## Encryption and Key Storage
|
||||
|
||||
**✅ GOOD - Android Keystore**:
|
||||
```kotlin
|
||||
// Sensitive data encrypted with Keystore
|
||||
class SecureStorage @Inject constructor(
|
||||
private val keystoreManager: KeystoreManager
|
||||
) {
|
||||
suspend fun storePin(pin: String): Result<Unit> = runCatching {
|
||||
val encrypted = keystoreManager.encrypt(pin.toByteArray())
|
||||
securePreferences.putBytes(KEY_PIN, encrypted)
|
||||
}
|
||||
}
|
||||
|
||||
// Or use EncryptedSharedPreferences
|
||||
val encryptedPrefs = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"secure_prefs",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
```
|
||||
|
||||
**❌ BAD - Plaintext or weak encryption**:
|
||||
```kotlin
|
||||
// ❌ CRITICAL - Plaintext storage
|
||||
sharedPreferences.edit {
|
||||
putString("pin", userPin) // Never store sensitive data in plaintext
|
||||
}
|
||||
|
||||
// ❌ CRITICAL - Weak encryption
|
||||
val cipher = Cipher.getInstance("DES") // Use AES-256-GCM
|
||||
|
||||
// ❌ CRITICAL - Hardcoded keys
|
||||
val key = "my_secret_key_123" // Use Android Keystore
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Use Android Keystore for encryption keys
|
||||
- Use EncryptedSharedPreferences for simple key-value storage
|
||||
- Use AES-256-GCM for encryption
|
||||
- Never store sensitive data in plaintext
|
||||
- Never hardcode encryption keys
|
||||
|
||||
Reference: `docs/ARCHITECTURE.md#security`
|
||||
|
||||
---
|
||||
|
||||
## Logging Sensitive Data
|
||||
|
||||
**✅ GOOD - No sensitive data**:
|
||||
```kotlin
|
||||
Log.d(TAG, "Authentication attempt for user")
|
||||
Log.d(TAG, "Vault sync completed with ${items.size} items")
|
||||
```
|
||||
|
||||
**❌ BAD - Logs sensitive data**:
|
||||
```kotlin
|
||||
// ❌ CRITICAL
|
||||
Log.d(TAG, "Password: $password")
|
||||
Log.d(TAG, "Auth token: $token")
|
||||
Log.d(TAG, "PIN: $pin")
|
||||
Log.d(TAG, "Encryption key: ${key.encoded}")
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Never log passwords, PINs, tokens, keys
|
||||
- Never log encryption keys or sensitive data
|
||||
- Be careful with error messages (don't include sensitive context)
|
||||
|
||||
---
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
### Security
|
||||
- [ ] Sensitive data encrypted with Keystore?
|
||||
- [ ] No plaintext passwords/keys?
|
||||
- [ ] No sensitive data in logs?
|
||||
- [ ] Using AES-256-GCM for encryption?
|
||||
- [ ] No hardcoded encryption keys?
|
||||
|
||||
---
|
||||
|
||||
For comprehensive security details, always refer to:
|
||||
- `docs/ARCHITECTURE.md#security` - Complete security architecture and zero-knowledge principles
|
||||
@@ -1,127 +0,0 @@
|
||||
# Testing Patterns Quick Reference
|
||||
|
||||
Quick reference for Bitwarden Android testing patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
## ViewModel Tests
|
||||
|
||||
**✅ GOOD - Tests behavior**:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `when login succeeds then state updates to success`() = runTest {
|
||||
// Arrange
|
||||
val viewModel = LoginViewModel(mockRepository)
|
||||
coEvery { mockRepository.login(any(), any()) } returns Result.success(User())
|
||||
|
||||
// Act
|
||||
viewModel.onLoginClicked("user@example.com", "password")
|
||||
|
||||
// Assert
|
||||
viewModel.state.test {
|
||||
assertEquals(LoginState.Loading, awaitItem())
|
||||
assertEquals(LoginState.Success, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**❌ BAD - Tests implementation**:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `repository is called with correct parameters`() {
|
||||
// ❌ This tests implementation details, not behavior
|
||||
viewModel.onLoginClicked("user", "pass")
|
||||
coVerify { mockRepository.login("user", "pass") }
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Test behavior, not implementation
|
||||
- Use `runTest` for coroutine tests
|
||||
- Use Turbine for Flow testing
|
||||
- Use MockK for mocking
|
||||
|
||||
---
|
||||
|
||||
## Repository Tests
|
||||
|
||||
**✅ GOOD - Tests data transformations**:
|
||||
```kotlin
|
||||
@Test
|
||||
fun `fetchItems maps API response to domain model`() = runTest {
|
||||
// Arrange
|
||||
val apiResponse = listOf(ApiItem(id = "1", name = "Test"))
|
||||
coEvery { apiService.getItems() } returns apiResponse
|
||||
|
||||
// Act
|
||||
val result = repository.fetchItems()
|
||||
|
||||
// Assert
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(
|
||||
listOf(DomainItem(id = "1", name = "Test")),
|
||||
result.getOrThrow()
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Test data transformations
|
||||
- Test error handling (network failures, API errors)
|
||||
- Test caching behavior if applicable
|
||||
- Mock API services and databases
|
||||
|
||||
Reference: Project uses JUnit 5, MockK, Turbine, kotlinx-coroutines-test
|
||||
|
||||
---
|
||||
|
||||
## Null Safety
|
||||
|
||||
**✅ GOOD - Safe handling**:
|
||||
```kotlin
|
||||
// Safe call with elvis operator
|
||||
val result = apiService.getData() ?: return State.Error("No data")
|
||||
|
||||
// Let with safe call
|
||||
intent?.getStringExtra("key")?.let { value ->
|
||||
processValue(value)
|
||||
}
|
||||
|
||||
// Require with message
|
||||
val data = requireNotNull(response.data) { "Response data must not be null" }
|
||||
```
|
||||
|
||||
**❌ BAD - Unsafe assertions**:
|
||||
```kotlin
|
||||
// ❌ Unsafe - can crash
|
||||
val result = apiService.getData()!!
|
||||
|
||||
// ❌ Platform type unchecked
|
||||
val intent: Intent = getIntent() // Could be null from Java
|
||||
val value = intent.getStringExtra("key") // Potential NPE
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Avoid `!!` unless safety is guaranteed (rare)
|
||||
- Handle platform types with explicit nullability
|
||||
- Use safe calls (`?.`), elvis operator (`?:`), or explicit checks
|
||||
- Use `requireNotNull` with descriptive message if crash is acceptable
|
||||
|
||||
---
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
### Testing
|
||||
- [ ] ViewModels have unit tests?
|
||||
- [ ] Tests verify behavior, not implementation?
|
||||
- [ ] Edge cases covered?
|
||||
- [ ] Error scenarios tested?
|
||||
|
||||
### Code Quality
|
||||
- [ ] Null safety handled properly (no `!!` without guarantee)?
|
||||
- [ ] Public APIs have KDoc?
|
||||
- [ ] Following naming conventions?
|
||||
|
||||
---
|
||||
|
||||
For comprehensive details, always refer to:
|
||||
- `docs/ARCHITECTURE.md` - Full architecture patterns
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide
|
||||
@@ -1,85 +0,0 @@
|
||||
# Compose UI Patterns Quick Reference
|
||||
|
||||
Quick reference for Bitwarden Android Compose UI patterns during code reviews. For comprehensive details, read `docs/ARCHITECTURE.md` and `docs/STYLE_AND_BEST_PRACTICES.md`.
|
||||
|
||||
## Component Reuse
|
||||
|
||||
**✅ GOOD - Uses existing components**:
|
||||
```kotlin
|
||||
BitwardenButton(
|
||||
text = "Submit",
|
||||
onClick = onSubmit
|
||||
)
|
||||
|
||||
BitwardenTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
label = "Email"
|
||||
)
|
||||
```
|
||||
|
||||
**❌ BAD - Duplicates existing components**:
|
||||
```kotlin
|
||||
// ❌ Recreating BitwardenButton
|
||||
Button(
|
||||
onClick = onSubmit,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = BitwardenTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("Submit")
|
||||
}
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Check `:ui` module for existing components before creating custom ones
|
||||
- Use BitwardenButton, BitwardenTextField, etc. for consistency
|
||||
- Place new reusable components in `:ui` module
|
||||
|
||||
---
|
||||
|
||||
## Theme Usage
|
||||
|
||||
**✅ GOOD - Uses theme**:
|
||||
```kotlin
|
||||
Text(
|
||||
text = "Title",
|
||||
style = BitwardenTheme.typography.titleLarge,
|
||||
color = BitwardenTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp)) // Standard spacing
|
||||
```
|
||||
|
||||
**❌ BAD - Hardcoded values**:
|
||||
```kotlin
|
||||
Text(
|
||||
text = "Title",
|
||||
style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold), // Use theme
|
||||
color = Color(0xFF0066FF) // Use theme color
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(17.dp)) // Non-standard spacing
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Use `BitwardenTheme.colorScheme` for colors
|
||||
- Use `BitwardenTheme.typography` for text styles
|
||||
- Use standard spacing (4.dp, 8.dp, 16.dp, 24.dp)
|
||||
|
||||
---
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
### UI Patterns
|
||||
- [ ] Using existing Bitwarden components from `:ui` module?
|
||||
- [ ] Using BitwardenTheme for colors and typography?
|
||||
- [ ] Using standard spacing values (4, 8, 16, 24 dp)?
|
||||
- [ ] No hardcoded colors or text styles?
|
||||
- [ ] UI is stateless (observes state, doesn't modify)?
|
||||
|
||||
---
|
||||
|
||||
For comprehensive details, always refer to:
|
||||
- `docs/ARCHITECTURE.md` - Full architecture patterns
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Complete style guide
|
||||
@@ -1,44 +0,0 @@
|
||||
# Testing Android Code Skill
|
||||
|
||||
Quick-reference guide for writing and reviewing tests in the Bitwarden Android codebase.
|
||||
|
||||
## Purpose
|
||||
|
||||
This skill provides tactical testing guidance for Bitwarden-specific patterns. It focuses on base test classes, test utilities, and common gotchas unique to this codebase rather than general testing concepts.
|
||||
|
||||
## When This Skill Activates
|
||||
|
||||
The skill automatically loads when you ask questions like:
|
||||
|
||||
- "How do I test this ViewModel?"
|
||||
- "Why is my Bitwarden test failing?"
|
||||
- "Write tests for this repository"
|
||||
|
||||
Or when you mention terms like: `BaseViewModelTest`, `BitwardenComposeTest`, `stateEventFlow`, `bufferedMutableSharedFlow`, `FakeDispatcherManager`, `createMockCipher`, `asSuccess`
|
||||
|
||||
## What's Included
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SKILL.md` | Core testing patterns and base class locations |
|
||||
| `references/test-base-classes.md` | Detailed base class documentation |
|
||||
| `references/flow-testing-patterns.md` | Turbine patterns for StateFlow/EventFlow |
|
||||
| `references/critical-gotchas.md` | Anti-patterns and debugging tips |
|
||||
| `examples/viewmodel-test-example.md` | Complete ViewModel test example |
|
||||
| `examples/compose-screen-test-example.md` | Complete Compose screen test |
|
||||
| `examples/repository-test-example.md` | Complete repository test with mocks |
|
||||
|
||||
## Patterns Covered
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - 35+ `createMock*` functions with `number: Int` pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`, `assertCoroutineThrows`
|
||||
|
||||
## Quick Start
|
||||
|
||||
For comprehensive architecture and testing philosophy, see:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
@@ -1,319 +0,0 @@
|
||||
---
|
||||
name: testing-android-code
|
||||
description: This skill should be used when writing or reviewing tests for Android code in Bitwarden. Triggered by "BaseViewModelTest", "BitwardenComposeTest", "BaseServiceTest", "stateEventFlow", "bufferedMutableSharedFlow", "FakeDispatcherManager", "expectNoEvents", "assertCoroutineThrows", "createMockCipher", "createMockSend", "asSuccess", "Why is my Bitwarden test failing?", or testing questions about ViewModels, repositories, Compose screens, or data sources in Bitwarden.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
# Testing Android Code - Bitwarden Testing Patterns
|
||||
|
||||
**This skill provides tactical testing guidance for Bitwarden-specific patterns.** For comprehensive architecture and testing philosophy, consult `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Test Framework Configuration
|
||||
|
||||
**Required Dependencies:**
|
||||
- **JUnit 5** (jupiter), **MockK**, **Turbine** (app.cash.turbine)
|
||||
- **kotlinx.coroutines.test**, **Robolectric**, **Compose Test**
|
||||
|
||||
**Critical Note:** Tests run with en-US locale for consistency. Don't assume other locales.
|
||||
|
||||
---
|
||||
|
||||
## A. ViewModel Testing Patterns
|
||||
|
||||
### Base Class: BaseViewModelTest
|
||||
|
||||
**Always extend `BaseViewModelTest` for ViewModel tests.**
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Automatically registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Provides `stateEventFlow()` helper for simultaneous StateFlow/EventFlow testing
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(mapOf(KEY_STATE to INITIAL_STATE))
|
||||
|
||||
@Test
|
||||
fun `ButtonClick should fetch data and update state`() = runTest {
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success("data")
|
||||
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
viewModel.trySendAction(ExampleAction.ButtonClick)
|
||||
assertEquals(INITIAL_STATE.copy(data = "data"), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**For complete examples:** See `references/test-base-classes.md`
|
||||
|
||||
### StateFlow vs EventFlow (Critical Distinction)
|
||||
|
||||
| Flow Type | Replay | First Action | Pattern |
|
||||
|-----------|--------|--------------|---------|
|
||||
| StateFlow | Yes (1) | `awaitItem()` gets current state | Expect initial → trigger → expect new |
|
||||
| EventFlow | No | `expectNoEvents()` first | expectNoEvents → trigger → expect event |
|
||||
|
||||
**For detailed patterns:** See `references/flow-testing-patterns.md`
|
||||
|
||||
---
|
||||
|
||||
## B. Compose UI Testing Patterns
|
||||
|
||||
### Base Class: BitwardenComposeTest
|
||||
|
||||
**Always extend `BitwardenComposeTest` for Compose screen tests.**
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
**Benefits:**
|
||||
- Pre-configures all Bitwarden managers (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed Clock for deterministic time-based tests
|
||||
|
||||
**Pattern:**
|
||||
```kotlin
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Use `bufferedMutableSharedFlow` for event testing in Compose tests. Default replay is 0; pass `replay = 1` if needed.
|
||||
|
||||
**For complete base class details:** See `references/test-base-classes.md`
|
||||
|
||||
---
|
||||
|
||||
## C. Repository and Service Testing
|
||||
|
||||
### Service Testing with MockWebServer
|
||||
|
||||
**Base Class:** `BaseServiceTest` (`network/src/testFixtures/`)
|
||||
|
||||
```kotlin
|
||||
class ExampleServiceTest : BaseServiceTest() {
|
||||
private val api: ExampleApi = retrofit.create()
|
||||
private val service = ExampleServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
val result = service.getConfig()
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Testing Pattern
|
||||
|
||||
```kotlin
|
||||
class ExampleRepositoryTest {
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
private val mockDiskSource: ExampleDiskSource = mockk()
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
exampleDiskSource = mockDiskSource,
|
||||
exampleService = mockService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `fetchData should return success when service succeeds`() = runTest {
|
||||
coEvery { mockService.getData(any()) } returns expectedData.asSuccess()
|
||||
val result = repository.fetchData(userId)
|
||||
assertTrue(result.isSuccess)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key patterns:** Use `FakeDispatcherManager`, fixed Clock, and `.asSuccess()` helpers.
|
||||
|
||||
---
|
||||
|
||||
## D. Test Data Builders
|
||||
|
||||
### Builder Pattern with Number Parameter
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/model/`
|
||||
|
||||
```kotlin
|
||||
fun createMockCipher(
|
||||
number: Int,
|
||||
id: String = "mockId-$number",
|
||||
name: String? = "mockName-$number",
|
||||
// ... more parameters with defaults
|
||||
): SyncResponseJson.Cipher
|
||||
|
||||
// Usage:
|
||||
val cipher1 = createMockCipher(number = 1) // mockId-1, mockName-1
|
||||
val cipher2 = createMockCipher(number = 2) // mockId-2, mockName-2
|
||||
val custom = createMockCipher(number = 3, name = "Custom")
|
||||
```
|
||||
|
||||
**Available Builders (35+):**
|
||||
- **Cipher:** `createMockCipher()`, `createMockLogin()`, `createMockCard()`, `createMockIdentity()`, `createMockSecureNote()`, `createMockSshKey()`, `createMockField()`, `createMockUri()`, `createMockFido2Credential()`, `createMockPasswordHistory()`, `createMockCipherPermissions()`
|
||||
- **Sync:** `createMockSyncResponse()`, `createMockFolder()`, `createMockCollection()`, `createMockPolicy()`, `createMockDomains()`
|
||||
- **Send:** `createMockSend()`, `createMockFile()`, `createMockText()`, `createMockSendJsonRequest()`
|
||||
- **Profile:** `createMockProfile()`, `createMockOrganization()`, `createMockProvider()`, `createMockPermissions()`
|
||||
- **Attachments:** `createMockAttachment()`, `createMockAttachmentJsonRequest()`, `createMockAttachmentResponse()`
|
||||
|
||||
See `network/src/testFixtures/kotlin/com/bitwarden/network/model/` for full list.
|
||||
|
||||
---
|
||||
|
||||
## E. Result Type Testing
|
||||
|
||||
**Locations:**
|
||||
- `.asSuccess()`, `.asFailure()`: `core/src/main/kotlin/com/bitwarden/core/data/util/ResultExtensions.kt`
|
||||
- `assertCoroutineThrows`: `core/src/testFixtures/kotlin/com/bitwarden/core/data/util/TestHelpers.kt`
|
||||
|
||||
```kotlin
|
||||
// Create results
|
||||
"data".asSuccess() // Result.success("data")
|
||||
throwable.asFailure() // Result.failure<T>(throwable)
|
||||
|
||||
// Assertions
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(expectedValue, result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## F. Test Utilities and Helpers
|
||||
|
||||
### Fake Implementations
|
||||
|
||||
| Fake | Location | Purpose |
|
||||
|------|----------|---------|
|
||||
| `FakeDispatcherManager` | `core/src/testFixtures/` | Deterministic coroutine execution |
|
||||
| `FakeConfigDiskSource` | `data/src/testFixtures/` | In-memory config storage |
|
||||
| `FakeSharedPreferences` | `data/src/testFixtures/` | Memory-backed SharedPreferences |
|
||||
|
||||
### Exception Testing (CRITICAL)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - Call directly, NOT inside runTest
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<IllegalStateException> {
|
||||
repository.throwingFunction()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `runTest` catches exceptions and rethrows them, breaking the assertion pattern.
|
||||
|
||||
---
|
||||
|
||||
## G. Critical Gotchas
|
||||
|
||||
Common testing mistakes in Bitwarden. **For complete details and examples:** See `references/critical-gotchas.md`
|
||||
|
||||
**Core Patterns:**
|
||||
- **assertCoroutineThrows + runTest** - Never wrap in `runTest`; call directly
|
||||
- **Static mock cleanup** - Always `unmockkStatic()` in `@After`
|
||||
- **StateFlow vs EventFlow** - StateFlow: `awaitItem()` first; EventFlow: `expectNoEvents()` first
|
||||
- **FakeDispatcherManager** - Always use instead of real `DispatcherManagerImpl`
|
||||
- **Coroutine test wrapper** - Use `runTest { }` for all Flow/coroutine tests
|
||||
|
||||
**Assertion Patterns:**
|
||||
- **Complete state assertions** - Assert entire state objects, not individual fields
|
||||
- **JUnit over Kotlin** - Use `assertTrue()`, not Kotlin's `assert()`
|
||||
- **Use Result extensions** - Use `asSuccess()` and `asFailure()` for Result type assertions
|
||||
|
||||
**Test Design:**
|
||||
- **Fake vs Mock strategy** - Use Fakes for happy paths, Mocks for error paths
|
||||
- **DI over static mocking** - Extract interfaces (like UuidManager) instead of mockkStatic
|
||||
- **Null stream testing** - Test null returns from ContentResolver operations
|
||||
- **bufferedMutableSharedFlow** - Use with `.onSubscription { emit(state) }` in Fakes
|
||||
- **Test factory methods** - Accept domain state types, not SavedStateHandle
|
||||
|
||||
---
|
||||
|
||||
## H. Test File Organization
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
module/src/test/kotlin/com/bitwarden/.../
|
||||
├── ui/*ScreenTest.kt, *ViewModelTest.kt
|
||||
├── data/repository/*RepositoryTest.kt
|
||||
└── network/service/*ServiceTest.kt
|
||||
|
||||
module/src/testFixtures/kotlin/com/bitwarden/.../
|
||||
├── util/TestHelpers.kt
|
||||
├── base/Base*Test.kt
|
||||
└── model/*Util.kt
|
||||
```
|
||||
|
||||
### Test Naming
|
||||
|
||||
- Classes: `*Test.kt`, `*ScreenTest.kt`, `*ViewModelTest.kt`
|
||||
- Functions: `` `given state when action should result` ``
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Key Bitwarden-specific testing patterns:
|
||||
|
||||
1. **BaseViewModelTest** - Automatic dispatcher setup with `stateEventFlow()` helper
|
||||
2. **BitwardenComposeTest** - Pre-configured with all managers and theme
|
||||
3. **BaseServiceTest** - MockWebServer setup for network testing
|
||||
4. **Turbine Flow Testing** - StateFlow (replay) vs EventFlow (no replay)
|
||||
5. **Test Data Builders** - Consistent `number: Int` parameter pattern
|
||||
6. **Fake Implementations** - FakeDispatcherManager, FakeConfigDiskSource
|
||||
7. **Result Type Testing** - `.asSuccess()`, `.asFailure()`
|
||||
|
||||
**Always consult:** `docs/ARCHITECTURE.md` and existing test files for reference implementations.
|
||||
|
||||
---
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
For detailed information, see:
|
||||
|
||||
- `references/test-base-classes.md` - Detailed base class documentation and usage patterns
|
||||
- `references/flow-testing-patterns.md` - Complete Turbine patterns for StateFlow/EventFlow
|
||||
- `references/critical-gotchas.md` - Full anti-pattern reference and debugging tips
|
||||
|
||||
**Complete Examples:**
|
||||
- `examples/viewmodel-test-example.md` - Full ViewModel test with StateFlow/EventFlow
|
||||
- `examples/compose-screen-test-example.md` - Full Compose screen test
|
||||
- `examples/repository-test-example.md` - Full repository test with mocks and fakes
|
||||
@@ -1,337 +0,0 @@
|
||||
/**
|
||||
* Complete Compose Screen Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BitwardenComposeTest
|
||||
* - Mocking ViewModel with flows
|
||||
* - Testing UI interactions
|
||||
* - Testing navigation callbacks
|
||||
* - Using bufferedMutableSharedFlow for events
|
||||
* - Testing dialogs with isDialog() and hasAnyAncestor()
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.ui.util.assertNoDialogExists
|
||||
import com.bitwarden.ui.util.isProgressBar
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ExampleScreenTest : BitwardenComposeTest() {
|
||||
|
||||
// Track navigation callbacks
|
||||
private var haveCalledNavigateBack = false
|
||||
private var haveCalledNavigateToNext = false
|
||||
|
||||
// Use bufferedMutableSharedFlow for events (default replay = 0)
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<ExampleEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
|
||||
// Mock ViewModel with relaxed = true
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
haveCalledNavigateBack = false
|
||||
haveCalledNavigateToNext = false
|
||||
|
||||
setContent {
|
||||
ExampleScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
onNavigateToNext = { haveCalledNavigateToNext = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Back button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on back click should send BackClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Back")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Submit button sends action to ViewModel
|
||||
*/
|
||||
@Test
|
||||
fun `on submit click should send SubmitClick action`() {
|
||||
composeTestRule
|
||||
.onNodeWithText("Submit")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading state shows progress indicator
|
||||
*/
|
||||
@Test
|
||||
fun `loading state should display progress indicator`() {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
|
||||
composeTestRule
|
||||
.onNode(isProgressBar)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Data state shows content
|
||||
*/
|
||||
@Test
|
||||
fun `data state should display content`() {
|
||||
mutableStateFlow.update { it.copy(data = "Test Data") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Data")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error state shows error message
|
||||
*/
|
||||
@Test
|
||||
fun `error state should display error message`() {
|
||||
mutableStateFlow.update { it.copy(errorMessage = "Something went wrong") }
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Something went wrong")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateBack event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateBack event should call onNavigateBack`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateBack)
|
||||
|
||||
assertTrue(haveCalledNavigateBack)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: NavigateToNext event triggers navigation callback
|
||||
*/
|
||||
@Test
|
||||
fun `NavigateToNext event should call onNavigateToNext`() {
|
||||
mutableEventFlow.tryEmit(ExampleEvent.NavigateToNext)
|
||||
|
||||
assertTrue(haveCalledNavigateToNext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Item in list can be clicked
|
||||
*/
|
||||
@Test
|
||||
fun `on item click should send ItemClick action`() {
|
||||
val itemId = "item-123"
|
||||
mutableStateFlow.update {
|
||||
it.copy(items = listOf(ExampleItem(id = itemId, name = "Test Item")))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Test Item")
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ItemClick(itemId)) }
|
||||
}
|
||||
|
||||
// ==================== DIALOG TESTS ====================
|
||||
|
||||
/**
|
||||
* Test: No dialog exists when dialogState is null
|
||||
*/
|
||||
@Test
|
||||
fun `no dialog should exist when dialogState is null`() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
|
||||
composeTestRule.assertNoDialogExists()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Loading dialog displays when state updates
|
||||
* PATTERN: Use isDialog() to check dialog exists
|
||||
*/
|
||||
@Test
|
||||
fun `loading dialog should display when dialogState is Loading`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ExampleState.DialogState.Loading("Please wait..."))
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify loading text within dialog using hasAnyAncestor(isDialog())
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Please wait...")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Error dialog displays title and message
|
||||
* PATTERN: Use filterToOne(hasAnyAncestor(isDialog())) to find text within dialogs
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog should display title and message`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "An error has occurred",
|
||||
message = "Something went wrong. Please try again.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify dialog exists
|
||||
composeTestRule
|
||||
.onNode(isDialog())
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify title within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
// Verify message within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Something went wrong. Please try again.")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Dialog button click sends action
|
||||
* PATTERN: Find button with hasAnyAncestor(isDialog()) then performClick()
|
||||
*/
|
||||
@Test
|
||||
fun `error dialog dismiss button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
title = "Error",
|
||||
message = "An error occurred",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click dismiss button within dialog
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Ok")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Confirmation dialog with multiple buttons
|
||||
* PATTERN: Test both confirm and cancel actions
|
||||
*/
|
||||
@Test
|
||||
fun `confirmation dialog confirm button should send ConfirmAction`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure you want to proceed?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click confirm button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Confirm")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.ConfirmAction) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirmation dialog cancel button should send DismissDialog action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExampleState.DialogState.Confirmation(
|
||||
title = "Confirm Action",
|
||||
message = "Are you sure?",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Click cancel button
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(ExampleAction.DismissDialog) }
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
items = emptyList(),
|
||||
dialogState = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
val items: List<ExampleItem> = emptyList(),
|
||||
val dialogState: DialogState? = null,
|
||||
) {
|
||||
/**
|
||||
* PATTERN: Nested sealed class for dialog states.
|
||||
* Common dialog types: Loading, Error, Confirmation
|
||||
*/
|
||||
sealed class DialogState {
|
||||
data class Loading(val message: String) : DialogState()
|
||||
data class Error(val title: String, val message: String) : DialogState()
|
||||
data class Confirmation(val title: String, val message: String) : DialogState()
|
||||
}
|
||||
}
|
||||
|
||||
data class ExampleItem(val id: String, val name: String)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data class ItemClick(val itemId: String) : ExampleAction()
|
||||
data object DismissDialog : ExampleAction()
|
||||
data object ConfirmAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Complete Repository Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Fake for disk sources, Mock for network services
|
||||
* - Using FakeDispatcherManager for deterministic coroutines
|
||||
* - Using fixed Clock for deterministic time
|
||||
* - Testing Result types with .asSuccess() / .asFailure()
|
||||
* - Asserting actual objects (not isSuccess/isFailure) for better diagnostics
|
||||
* - Testing Flow emissions with Turbine
|
||||
*/
|
||||
package com.bitwarden.example.data.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.core.data.util.asFailure
|
||||
import com.bitwarden.core.data.util.asSuccess
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class ExampleRepositoryTest {
|
||||
|
||||
// Fixed clock for deterministic time-based tests
|
||||
private val fixedClock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
// Use FakeDispatcherManager for deterministic coroutine execution
|
||||
private val dispatcherManager = FakeDispatcherManager()
|
||||
|
||||
// Mock service (network layer is always mocked)
|
||||
private val mockService: ExampleService = mockk()
|
||||
|
||||
/**
|
||||
* PATTERN: Use Fake for disk source in happy path tests.
|
||||
* This is the Bitwarden convention for repository testing.
|
||||
*/
|
||||
private val fakeDiskSource = FakeExampleDiskSource()
|
||||
|
||||
private lateinit var repository: ExampleRepositoryImpl
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
repository = ExampleRepositoryImpl(
|
||||
clock = fixedClock,
|
||||
service = mockService,
|
||||
diskSource = fakeDiskSource,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== HAPPY PATH TESTS (use Fake) ====================
|
||||
|
||||
/**
|
||||
* Test: Successful fetch returns data and saves to disk
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return success and save to disk when service succeeds`() = runTest {
|
||||
val expectedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns expectedData.asSuccess()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(expectedData, result.getOrThrow())
|
||||
// Fake automatically stores the data - verify it's there
|
||||
assertEquals(expectedData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Service failure returns failure without saving
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service fails`() = runTest {
|
||||
val exception = Exception("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake was not updated
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Repository flow emits when disk source updates
|
||||
*/
|
||||
@Test
|
||||
fun `dataFlow should emit when disk source updates`() = runTest {
|
||||
val data1 = ExampleData(id = "1", name = "First", updatedAt = fixedClock.instant())
|
||||
val data2 = ExampleData(id = "2", name = "Second", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial null value from Fake
|
||||
assertNull(awaitItem())
|
||||
|
||||
// Update via Fake property setter (triggers emission)
|
||||
fakeDiskSource.storedData = data1
|
||||
assertEquals(data1, awaitItem())
|
||||
|
||||
// Another update
|
||||
fakeDiskSource.storedData = data2
|
||||
assertEquals(data2, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Refresh fetches and saves new data
|
||||
*/
|
||||
@Test
|
||||
fun `refresh should fetch new data and update disk source`() = runTest {
|
||||
val newData = ExampleData(id = "new", name = "Fresh", updatedAt = fixedClock.instant())
|
||||
coEvery { mockService.getData() } returns newData.asSuccess()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertEquals(Unit, result.getOrThrow())
|
||||
coVerify { mockService.getData() }
|
||||
assertEquals(newData, fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Delete clears data from disk
|
||||
*/
|
||||
@Test
|
||||
fun `deleteData should clear disk source`() = runTest {
|
||||
// Pre-populate the fake
|
||||
fakeDiskSource.storedData = ExampleData(id = "1", name = "Test", updatedAt = fixedClock.instant())
|
||||
|
||||
repository.deleteData()
|
||||
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Cached data returns from disk when available
|
||||
*/
|
||||
@Test
|
||||
fun `getCachedData should return disk data without network call`() = runTest {
|
||||
val cachedData = ExampleData(
|
||||
id = "cached",
|
||||
name = "Cached",
|
||||
updatedAt = fixedClock.instant(),
|
||||
)
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
val result = repository.getCachedData()
|
||||
|
||||
assertEquals(cachedData, result)
|
||||
coVerify(exactly = 0) { mockService.getData() }
|
||||
}
|
||||
|
||||
// ==================== ERROR PATH TESTS ====================
|
||||
|
||||
/**
|
||||
* PATTERN: For error paths, reconfigure the class-level mock per-test.
|
||||
* Use coEvery to change mock behavior for each specific test case.
|
||||
*/
|
||||
@Test
|
||||
fun `fetchData should return failure when service returns error`() = runTest {
|
||||
val exception = Exception("Server unavailable")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.fetchData()
|
||||
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
// Fake state unchanged on failure
|
||||
assertNull(fakeDiskSource.storedData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh should return failure and preserve cached data when service fails`() = runTest {
|
||||
// Pre-populate cache via Fake
|
||||
val cachedData = ExampleData(id = "cached", name = "Old", updatedAt = fixedClock.instant())
|
||||
fakeDiskSource.storedData = cachedData
|
||||
|
||||
// Reconfigure mock to return failure
|
||||
coEvery { mockService.getData() } returns Exception("Network error").asFailure()
|
||||
|
||||
val result = repository.refresh()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
// Cached data preserved on failure
|
||||
assertEquals(cachedData, fakeDiskSource.storedData)
|
||||
}
|
||||
}
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleData(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
interface ExampleService {
|
||||
suspend fun getData(): Result<ExampleData>
|
||||
}
|
||||
|
||||
interface ExampleDiskSource {
|
||||
val dataFlow: kotlinx.coroutines.flow.Flow<ExampleData?>
|
||||
fun getData(): ExampleData?
|
||||
fun saveData(data: ExampleData)
|
||||
fun clearData()
|
||||
}
|
||||
|
||||
/**
|
||||
* PATTERN: Fake implementation for happy path testing.
|
||||
*
|
||||
* Key characteristics:
|
||||
* - Uses bufferedMutableSharedFlow(replay = 1) for proper replay behavior
|
||||
* - Uses .onSubscription { emit(state) } for immediate state emission
|
||||
* - Private storage with override property setter that emits to flow
|
||||
* - Test assertions done via the override property getter
|
||||
*/
|
||||
class FakeExampleDiskSource : ExampleDiskSource {
|
||||
private var storedDataValue: ExampleData? = null
|
||||
private val mutableDataFlow = bufferedMutableSharedFlow<ExampleData?>(replay = 1)
|
||||
|
||||
/**
|
||||
* Override property with getter/setter. Setter emits to flow automatically.
|
||||
* Tests can read this property for assertions and write to trigger emissions.
|
||||
*/
|
||||
var storedData: ExampleData?
|
||||
get() = storedDataValue
|
||||
set(value) {
|
||||
storedDataValue = value
|
||||
mutableDataFlow.tryEmit(value)
|
||||
}
|
||||
|
||||
override val dataFlow: Flow<ExampleData?>
|
||||
get() = mutableDataFlow.onSubscription { emit(storedData) }
|
||||
|
||||
override fun getData(): ExampleData? = storedData
|
||||
|
||||
override fun saveData(data: ExampleData) {
|
||||
storedData = data
|
||||
}
|
||||
|
||||
override fun clearData() {
|
||||
storedData = null
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Complete ViewModel Test Example
|
||||
*
|
||||
* Key patterns demonstrated:
|
||||
* - Extending BaseViewModelTest
|
||||
* - Testing StateFlow with Turbine
|
||||
* - Testing EventFlow with Turbine
|
||||
* - Using stateEventFlow() for simultaneous testing
|
||||
* - MockK mocking patterns
|
||||
* - Test factory method design (accepts domain state, not SavedStateHandle)
|
||||
* - Complete state assertions (assert entire state objects)
|
||||
*/
|
||||
package com.bitwarden.example.feature
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository: ExampleRepository = mockk()
|
||||
private val mockAuthDiskSource: AuthDiskSource = mockk {
|
||||
every { userStateFlow } returns MutableStateFlow(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* StateFlow has replay=1, so first awaitItem() returns current state
|
||||
*/
|
||||
@Test
|
||||
fun `initial state should be default state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state transitions: initial -> loading -> success
|
||||
*/
|
||||
@Test
|
||||
fun `LoadData action should update state from idle to loading to success`() = runTest {
|
||||
val expectedData = "loaded data"
|
||||
coEvery { mockRepository.fetchData(any()) } returns Result.success(expectedData)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.LoadData)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = false, data = expectedData), awaitItem())
|
||||
}
|
||||
|
||||
coVerify { mockRepository.fetchData(any()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* EventFlow has no replay - MUST call expectNoEvents() first
|
||||
*/
|
||||
@Test
|
||||
fun `SubmitClick action should emit NavigateToNext event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns Result.success(Unit)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // CRITICAL for EventFlow
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
assertEquals(ExampleEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use stateEventFlow() helper for simultaneous testing
|
||||
*/
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.complexOperation(any()) } returns Result.success("result")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(isLoading = true), stateFlow.awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(data = "result"), stateFlow.awaitItem())
|
||||
assertEquals(ExampleEvent.ShowToast("Success!"), eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test state restoration from saved state.
|
||||
* Note: Use initialState parameter, NOT SavedStateHandle directly.
|
||||
*/
|
||||
@Test
|
||||
fun `initial state from saved state should be preserved`() = runTest {
|
||||
// Build complete expected state - always assert full objects
|
||||
val savedState = ExampleState(
|
||||
isLoading = false,
|
||||
data = "restored data",
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(savedState, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method accepts domain state, NOT SavedStateHandle.
|
||||
* This hides Android framework details from test logic.
|
||||
*/
|
||||
private fun createViewModel(
|
||||
initialState: ExampleState? = null,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
authDiskSource = mockAuthDiskSource,
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
|
||||
// Example types (normally in separate files)
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
sealed class ExampleAction {
|
||||
data object LoadData : ExampleAction()
|
||||
data object SubmitClick : ExampleAction()
|
||||
data object ComplexAction : ExampleAction()
|
||||
}
|
||||
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateToNext : ExampleEvent()
|
||||
data class ShowToast(val message: String) : ExampleEvent()
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
# Critical Gotchas and Anti-Patterns
|
||||
|
||||
Common mistakes and pitfalls when writing tests in the Bitwarden Android codebase.
|
||||
|
||||
## ❌ NEVER wrap assertCoroutineThrows in runTest
|
||||
|
||||
### The Problem
|
||||
|
||||
`runTest` catches exceptions and rethrows them, which breaks the `assertCoroutineThrows` assertion pattern.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() = runTest {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Won't work - exception is caught by runTest!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test exception`() {
|
||||
assertCoroutineThrows<Exception> {
|
||||
repository.throwingFunction()
|
||||
} // Works correctly
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Happens
|
||||
|
||||
`runTest` provides a coroutine scope and catches exceptions to provide better error messages. However, `assertCoroutineThrows` needs to catch the exception itself to verify it was thrown. When wrapped in `runTest`, the exception is caught twice, breaking the assertion.
|
||||
|
||||
## ❌ ALWAYS unmock static functions
|
||||
|
||||
### The Problem
|
||||
|
||||
MockK's static mocking persists across tests. Forgetting to clean up causes mysterious failures in subsequent tests.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
// Forgot @After - subsequent tests will fail mysteriously!
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Before
|
||||
fun setup() {
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
every { isBuildVersionAtLeast(any()) } returns true
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkStatic(::isBuildVersionAtLeast) // CRITICAL
|
||||
}
|
||||
```
|
||||
|
||||
### Common Static Functions to Watch
|
||||
|
||||
```kotlin
|
||||
// Platform version checks
|
||||
mockkStatic(::isBuildVersionAtLeast)
|
||||
unmockkStatic(::isBuildVersionAtLeast)
|
||||
|
||||
// URI parsing
|
||||
mockkStatic(Uri::class)
|
||||
unmockkStatic(Uri::class)
|
||||
|
||||
// Static utility functions
|
||||
mockkStatic(MyUtilClass::class)
|
||||
unmockkStatic(MyUtilClass::class)
|
||||
```
|
||||
|
||||
### Debugging Tip
|
||||
|
||||
If tests pass individually but fail when run together, suspect static mocking cleanup issues.
|
||||
|
||||
## ❌ Don't confuse StateFlow and EventFlow testing
|
||||
|
||||
### StateFlow (replay = 1)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - StateFlow always has current value
|
||||
viewModel.stateFlow.test {
|
||||
val initial = awaitItem() // Gets current state immediately
|
||||
viewModel.trySendAction(action)
|
||||
val updated = awaitItem() // Gets new state
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (no replay)
|
||||
|
||||
```kotlin
|
||||
// CORRECT - EventFlow has no initial value
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // MUST do this first
|
||||
viewModel.trySendAction(action)
|
||||
val event = awaitItem() // Gets emitted event
|
||||
}
|
||||
```
|
||||
|
||||
### Common Mistake
|
||||
|
||||
```kotlin
|
||||
// WRONG - Forgetting expectNoEvents() on EventFlow
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May cause flaky tests
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
## ❌ Don't mix real and test dispatchers
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = DispatcherManagerImpl(), // Real dispatcher!
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test will have timing issues - real dispatcher != test dispatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private val repository = ExampleRepositoryImpl(
|
||||
dispatcherManager = FakeDispatcherManager(), // Test dispatcher
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test repository`() = runTest {
|
||||
// Test runs deterministically
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
Real dispatchers use actual thread pools and delays. Test dispatchers (UnconfinedTestDispatcher) execute immediately and deterministically. Mixing them causes:
|
||||
- Non-deterministic test failures
|
||||
- Real delays in tests (slow test suite)
|
||||
- Race conditions
|
||||
|
||||
### Always Use
|
||||
|
||||
- `FakeDispatcherManager()` for repositories
|
||||
- `UnconfinedTestDispatcher()` when manually creating dispatchers
|
||||
- `runTest` for coroutine tests (provides TestDispatcher automatically)
|
||||
|
||||
## ❌ Don't forget to use runTest for coroutine tests
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() {
|
||||
viewModel.stateFlow.test { /* ... */ } // Missing runTest!
|
||||
}
|
||||
```
|
||||
|
||||
This causes:
|
||||
- Test completes before coroutines finish
|
||||
- False positives (test passes but assertions never run)
|
||||
- Mysterious failures
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `test coroutine`() = runTest {
|
||||
viewModel.stateFlow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### When runTest is Required
|
||||
|
||||
- Testing ViewModels (they use `viewModelScope`)
|
||||
- Testing Flows with Turbine `.test {}`
|
||||
- Testing repositories with suspend functions
|
||||
- Any test calling suspend functions
|
||||
|
||||
### Exception: assertCoroutineThrows
|
||||
|
||||
As noted above, `assertCoroutineThrows` should NOT be wrapped in `runTest`.
|
||||
|
||||
## ❌ Don't forget relaxed = true for complex mocks
|
||||
|
||||
### Without relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>() // Must mock every method!
|
||||
|
||||
// Error: "no answer found for: stateFlow"
|
||||
```
|
||||
|
||||
### With relaxed
|
||||
|
||||
```kotlin
|
||||
private val viewModel = mockk<ExampleViewModel>(relaxed = true) {
|
||||
// Only mock what you care about
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use relaxed
|
||||
|
||||
- Mocking ViewModels in Compose tests
|
||||
- Mocking complex objects with many methods
|
||||
- When you only care about specific method calls
|
||||
|
||||
### When NOT to Use relaxed
|
||||
|
||||
- Mocking repository interfaces (be explicit about behavior)
|
||||
- When you want to verify NO unexpected calls
|
||||
- Testing error paths (want test to fail if unexpected method called)
|
||||
|
||||
## ❌ Don't assert individual fields when complete state is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Asserting individual state fields can miss unintended side effects on other fields.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val state = viewModel.stateFlow.value
|
||||
assertEquals(null, state.dialog) // Only checks one field!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
viewModel.trySendAction(SomeAction.DoThing)
|
||||
|
||||
val expected = SomeState(
|
||||
isLoading = false,
|
||||
data = "result",
|
||||
dialog = null,
|
||||
)
|
||||
assertEquals(expected, viewModel.stateFlow.value) // Checks all fields
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Catches unintended mutations to other state fields
|
||||
- Makes expected state explicit and readable
|
||||
- Prevents silent regressions when state structure changes
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use Kotlin assert() for boolean checks
|
||||
|
||||
### The Problem
|
||||
|
||||
Kotlin's `assert()` doesn't follow JUnit conventions and provides poor failure messages.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assert(onNavigateCalled) // Kotlin assert - bad failure messages
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `event should trigger callback`() {
|
||||
mutableEventFlow.tryEmit(SomeEvent.Navigate)
|
||||
|
||||
assertTrue(onNavigateCalled) // JUnit assertTrue - proper assertion
|
||||
}
|
||||
```
|
||||
|
||||
### Always Use JUnit Assertions
|
||||
|
||||
- `assertTrue()` / `assertFalse()` for booleans
|
||||
- `assertEquals()` for value comparisons
|
||||
- `assertNotNull()` / `assertNull()` for nullability
|
||||
- `assertThrows<T>()` for exceptions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't pass SavedStateHandle to test factory methods
|
||||
|
||||
### The Problem
|
||||
|
||||
Exposing `SavedStateHandle` in test factory methods leaks Android framework details into test logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
savedStateHandle: SavedStateHandle = SavedStateHandle(), // Framework type exposed
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
val savedStateHandle = SavedStateHandle(mapOf("state" to savedState))
|
||||
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
private fun createViewModel(
|
||||
initialState: MyState? = null, // Domain type only
|
||||
): MyViewModel = MyViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", initialState) },
|
||||
repository = mockRepository,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state from saved state`() = runTest {
|
||||
val savedState = MyState(isLoading = true)
|
||||
|
||||
val viewModel = createViewModel(initialState = savedState)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Why This Matters
|
||||
|
||||
- Cleaner, more intuitive test code
|
||||
- Hides SavedStateHandle implementation details
|
||||
- Follows Bitwarden conventions
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't test SavedStateHandle persistence in unit tests
|
||||
|
||||
### The Problem
|
||||
|
||||
Testing whether state persists to SavedStateHandle is testing Android framework behavior, not your business logic.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `state should persist to SavedStateHandle`() = runTest {
|
||||
val savedStateHandle = SavedStateHandle()
|
||||
val viewModel = createViewModel(savedStateHandle = savedStateHandle)
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
val savedState = savedStateHandle.get<MyState>("state")
|
||||
assertEquals(expectedState, savedState) // Testing framework, not logic!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Focus on testing business logic and state transformations:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state correctly`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(SomeAction)
|
||||
|
||||
assertEquals(expectedState, viewModel.stateFlow.value) // Test observable state
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't use static mocking when DI pattern is available
|
||||
|
||||
### The Problem
|
||||
|
||||
Static mocking (`mockkStatic`) is harder to maintain and less testable than dependency injection.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class ParserTest {
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
mockkStatic(UUID::class)
|
||||
every { UUID.randomUUID() } returns mockk {
|
||||
every { toString() } returns "fixed-uuid"
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
unmockkStatic(UUID::class)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
Extract an interface and inject it:
|
||||
|
||||
```kotlin
|
||||
// Production code
|
||||
interface UuidManager {
|
||||
fun generateUuid(): String
|
||||
}
|
||||
|
||||
class UuidManagerImpl : UuidManager {
|
||||
override fun generateUuid(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
class Parser(private val uuidManager: UuidManager) { ... }
|
||||
|
||||
// Test code
|
||||
class ParserTest {
|
||||
private val mockUuidManager = mockk<UuidManager>()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockUuidManager.generateUuid() } returns "fixed-uuid"
|
||||
}
|
||||
|
||||
// No tearDown needed - no static mocking!
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use This Pattern
|
||||
|
||||
- UUID generation
|
||||
- Timestamp/Clock operations
|
||||
- System property access
|
||||
- Any static function that needs deterministic testing
|
||||
|
||||
---
|
||||
|
||||
## ❌ Don't forget to test null stream returns from Android APIs
|
||||
|
||||
### The Problem
|
||||
|
||||
Android's `ContentResolver.openOutputStream()` and `openInputStream()` can return null, not just throw exceptions.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
|
||||
assertFalse(result)
|
||||
}
|
||||
// Missing: test for null return!
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FileManagerTest {
|
||||
@Test
|
||||
fun `stringToUri with exception should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } throws IOException()
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stringToUri with null stream should return false`() = runTest {
|
||||
every { mockContentResolver.openOutputStream(any()) } returns null
|
||||
|
||||
val result = fileManager.stringToUri(mockUri, "data")
|
||||
assertFalse(result) // CRITICAL: must handle null!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Android APIs That Return Null
|
||||
|
||||
- `ContentResolver.openOutputStream()` / `openInputStream()`
|
||||
- `Context.getExternalFilesDir()`
|
||||
- `PackageManager.getApplicationInfo()` (can throw)
|
||||
|
||||
---
|
||||
|
||||
## Bitwarden Mocking Guidelines
|
||||
|
||||
**Mock at architectural boundaries:**
|
||||
- Repository → ViewModel (mock repository)
|
||||
- Service → Repository (mock service)
|
||||
- API → Service (use MockWebServer, not mocks)
|
||||
- DiskSource → Repository (mock disk source)
|
||||
|
||||
**Fake vs Mock Strategy (IMPORTANT):**
|
||||
- **Happy paths**: Use Fake implementations (`FakeAuthenticatorDiskSource`, `FakeVaultDiskSource`)
|
||||
- **Error paths**: Use MockK with isolated repository instances
|
||||
|
||||
```kotlin
|
||||
// Happy path - use Fake
|
||||
private val fakeDiskSource = FakeAuthenticatorDiskSource()
|
||||
|
||||
@Test
|
||||
fun `createItem should return Success`() = runTest {
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Success, result)
|
||||
}
|
||||
|
||||
// Error path - use isolated Mock
|
||||
@Test
|
||||
fun `createItem with exception should return Error`() = runTest {
|
||||
val mockDiskSource = mockk<AuthenticatorDiskSource> {
|
||||
coEvery { saveItem(any()) } throws RuntimeException()
|
||||
}
|
||||
val repository = RepositoryImpl(diskSource = mockDiskSource)
|
||||
|
||||
val result = repository.createItem(mockItem)
|
||||
assertEquals(CreateItemResult.Error, result)
|
||||
}
|
||||
```
|
||||
|
||||
**Use Fakes for:**
|
||||
- `FakeDispatcherManager` - deterministic coroutines
|
||||
- `FakeConfigDiskSource` - in-memory config storage
|
||||
- `FakeSharedPreferences` - memory-backed preferences
|
||||
- `FakeAuthenticatorDiskSource` - in-memory authenticator storage
|
||||
|
||||
**Create real instances for:**
|
||||
- Data classes, value objects (User, Config, CipherView)
|
||||
- Test data builders (`createMockCipher(number = 1)`)
|
||||
|
||||
## ❌ Don't forget bufferedMutableSharedFlow with onSubscription for Fakes
|
||||
|
||||
### The Problem
|
||||
|
||||
Fake data sources using `MutableSharedFlow` won't emit cached state to new subscribers without explicit handling.
|
||||
|
||||
### Wrong
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = MutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection gets nothing!
|
||||
repository.dataFlow.test {
|
||||
// Hangs or fails - no initial emission
|
||||
}
|
||||
```
|
||||
|
||||
### Correct
|
||||
|
||||
```kotlin
|
||||
class FakeDataSource : DataSource {
|
||||
private val mutableFlow = bufferedMutableSharedFlow<List<Item>>()
|
||||
private val storedItems = mutableListOf<Item>()
|
||||
|
||||
override fun getItems(): Flow<List<Item>> = mutableFlow
|
||||
.onSubscription { emit(storedItems.toList()) }
|
||||
|
||||
override suspend fun saveItem(item: Item) {
|
||||
storedItems.add(item)
|
||||
mutableFlow.emit(storedItems.toList())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initial collection receives current state
|
||||
repository.dataFlow.test {
|
||||
assertEquals(emptyList(), awaitItem()) // Works!
|
||||
}
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- Use `bufferedMutableSharedFlow()` from `core/data/repository/util/`
|
||||
- Add `.onSubscription { emit(currentState) }` for immediate state emission
|
||||
- This ensures new collectors receive the current cached state
|
||||
|
||||
---
|
||||
|
||||
## ✅ Use Result extension functions for assertions
|
||||
|
||||
### The Pattern
|
||||
|
||||
Use `asSuccess()` and `asFailure()` extensions from `com.bitwarden.core.data.util` for cleaner Result assertions.
|
||||
|
||||
### Success Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData should return success`() = runTest {
|
||||
val result = repository.getData()
|
||||
val expected = expectedData.asSuccess()
|
||||
|
||||
assertEquals(expected.getOrNull(), result.getOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Failure Path
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getData with error should return failure`() = runTest {
|
||||
val exception = IOException("Network error")
|
||||
coEvery { mockService.getData() } returns exception.asFailure()
|
||||
|
||||
val result = repository.getData()
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
assertEquals(exception, result.exceptionOrNull())
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid Redundant Assertions
|
||||
|
||||
```kotlin
|
||||
// WRONG - redundant success checks
|
||||
assertTrue(result.isSuccess)
|
||||
assertTrue(expected.isSuccess)
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
|
||||
// CORRECT - final assertion is sufficient
|
||||
assertArrayEquals(expected.getOrNull(), result.getOrNull())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
Before submitting tests, verify:
|
||||
|
||||
**Core Patterns:**
|
||||
- [ ] No `assertCoroutineThrows` inside `runTest`
|
||||
- [ ] All static mocks have `unmockk` in `@After`
|
||||
- [ ] EventFlow tests start with `expectNoEvents()`
|
||||
- [ ] Using FakeDispatcherManager, not real dispatchers
|
||||
- [ ] All coroutine tests use `runTest`
|
||||
|
||||
**Assertion Patterns:**
|
||||
- [ ] Assert complete state objects, not individual fields
|
||||
- [ ] Use JUnit `assertTrue()`, not Kotlin `assert()`
|
||||
- [ ] Use `asSuccess()` for Result type assertions
|
||||
- [ ] Avoid redundant assertion patterns
|
||||
|
||||
**Test Design:**
|
||||
- [ ] Test factory methods accept domain types, not SavedStateHandle
|
||||
- [ ] Use Fakes for happy paths, Mocks for error paths
|
||||
- [ ] Prefer DI patterns over static mocking
|
||||
- [ ] Test null returns from Android APIs (streams, files)
|
||||
- [ ] Fakes use `bufferedMutableSharedFlow()` with `.onSubscription`
|
||||
|
||||
**General:**
|
||||
- [ ] Tests don't depend on execution order
|
||||
- [ ] Complex mocks use `relaxed = true`
|
||||
- [ ] Test data is created fresh for each test
|
||||
- [ ] Mocking behavior, not value objects
|
||||
- [ ] Testing observable behavior, not implementation
|
||||
|
||||
When tests fail mysteriously, check these gotchas first.
|
||||
@@ -1,274 +0,0 @@
|
||||
# Flow Testing with Turbine
|
||||
|
||||
Bitwarden Android uses Turbine for testing Kotlin Flows, including the critical distinction between StateFlow and EventFlow patterns.
|
||||
|
||||
## StateFlow vs EventFlow
|
||||
|
||||
### StateFlow (Replayed)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 1` - Always emits current value to new collectors
|
||||
- First `awaitItem()` returns the current/initial state
|
||||
- Survives configuration changes
|
||||
- Used for UI state that needs to be immediately available
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should update state`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
// First awaitItem() gets CURRENT state
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.LoadData)
|
||||
|
||||
// Next awaitItem() gets UPDATED state
|
||||
assertEquals(LOADING_STATE, awaitItem())
|
||||
assertEquals(SUCCESS_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### EventFlow (No Replay)
|
||||
|
||||
**Characteristics:**
|
||||
- `replay = 0` - Only emits new events after subscription
|
||||
- No initial value emission
|
||||
- One-time events (navigation, toasts, dialogs)
|
||||
- Does not survive configuration changes
|
||||
|
||||
**Test Pattern:**
|
||||
```kotlin
|
||||
@Test
|
||||
fun `action should emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
// MUST call expectNoEvents() first - nothing emitted yet
|
||||
expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
// Now expect the event
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Always call `expectNoEvents()` before triggering actions on EventFlow. Forgetting this causes flaky tests.
|
||||
|
||||
## Testing State and Events Simultaneously
|
||||
|
||||
Use the `stateEventFlow()` helper from `BaseViewModelTest`:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = MyViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(MyAction.ComplexAction)
|
||||
|
||||
// Verify state progression
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
assertEquals(SUCCESS_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emission
|
||||
assertEquals(MyEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Repository Flow Testing
|
||||
|
||||
### Testing Database Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `dataFlow should emit when database updates`() = runTest {
|
||||
val dataFlow = MutableStateFlow(initialData)
|
||||
every { mockDiskSource.dataFlow } returns dataFlow
|
||||
|
||||
repository.dataFlow.test {
|
||||
// Initial value
|
||||
assertEquals(initialData, awaitItem())
|
||||
|
||||
// Update disk source
|
||||
dataFlow.value = updatedData
|
||||
|
||||
// Verify emission
|
||||
assertEquals(updatedData, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Transformed Flows
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `flow transformation should map correctly`() = runTest {
|
||||
val sourceFlow = MutableStateFlow(UserEntity(id = "1", name = "John"))
|
||||
every { mockDao.observeUser() } returns sourceFlow
|
||||
|
||||
// Repository transforms entity to domain model
|
||||
repository.userFlow.test {
|
||||
val expectedUser = User(id = "1", name = "John")
|
||||
assertEquals(expectedUser, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Testing Initial State + Action
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data should update from idle to loading to success`() = runTest {
|
||||
coEvery { repository.getData() } returns "data".asSuccess()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Success), awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Testing Error States
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load data with error should emit failure state`() = runTest {
|
||||
val error = Exception("Network error")
|
||||
coEvery { repository.getData() } returns error.asFailure()
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.loadData()
|
||||
|
||||
assertEquals(DEFAULT_STATE.copy(loadingState = LoadingState.Loading), awaitItem())
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(loadingState = LoadingState.Error("Network error")),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Testing Event Sequences
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `submit should emit validation then navigation events`() = runTest {
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents()
|
||||
|
||||
viewModel.trySendAction(MyAction.Submit)
|
||||
|
||||
assertEquals(MyEvent.ShowValidation, awaitItem())
|
||||
assertEquals(MyEvent.NavigateToNext, awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Testing Cancellation
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `cancelling collection should stop emissions`() = runTest {
|
||||
val flow = flow {
|
||||
repeat(100) {
|
||||
emit(it)
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
flow.test {
|
||||
assertEquals(0, awaitItem())
|
||||
assertEquals(1, awaitItem())
|
||||
|
||||
// Cancel after 2 items
|
||||
cancel()
|
||||
|
||||
// No more items received
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### ❌ Forgetting expectNoEvents() on EventFlow
|
||||
|
||||
```kotlin
|
||||
// WRONG
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(action) // May fail - no initial expectNoEvents
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
viewModel.eventFlow.test {
|
||||
expectNoEvents() // ALWAYS do this first
|
||||
viewModel.trySendAction(action)
|
||||
assertEquals(event, awaitItem())
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Not Using runTest
|
||||
|
||||
```kotlin
|
||||
// WRONG - Missing runTest
|
||||
@Test
|
||||
fun `test flow`() {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
|
||||
// CORRECT
|
||||
@Test
|
||||
fun `test flow`() = runTest {
|
||||
flow.test { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Mixing StateFlow and EventFlow Patterns
|
||||
|
||||
```kotlin
|
||||
// WRONG - Treating StateFlow like EventFlow
|
||||
stateFlow.test {
|
||||
expectNoEvents() // Unnecessary - StateFlow always has value
|
||||
/* ... */
|
||||
}
|
||||
|
||||
// WRONG - Treating EventFlow like StateFlow
|
||||
eventFlow.test {
|
||||
val item = awaitItem() // Will hang - no initial value!
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
**ViewModel with StateFlow and EventFlow:**
|
||||
`app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
**Repository Flow Testing:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/tools/generator/repository/GeneratorRepositoryTest.kt`
|
||||
|
||||
**Complex Flow Transformations:**
|
||||
`data/src/test/kotlin/com/bitwarden/data/vault/repository/VaultRepositoryTest.kt`
|
||||
@@ -1,259 +0,0 @@
|
||||
# Test Base Classes Reference
|
||||
|
||||
Bitwarden Android provides specialized base classes that configure test environments and provide helper utilities.
|
||||
|
||||
## BaseViewModelTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseViewModelTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides essential setup for testing ViewModels with proper coroutine dispatcher configuration and Flow testing helpers.
|
||||
|
||||
### Automatic Configuration
|
||||
- Registers `MainDispatcherExtension` for `UnconfinedTestDispatcher`
|
||||
- Ensures deterministic coroutine execution in tests
|
||||
- All coroutines complete immediately without real delays
|
||||
|
||||
### Key Feature: stateEventFlow() Helper
|
||||
|
||||
**Use Case:** When you need to test both StateFlow and EventFlow simultaneously.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `complex action should update state and emit event`() = runTest {
|
||||
val viewModel = ExampleViewModel(savedStateHandle, mockRepository)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
// Verify initial state
|
||||
assertEquals(INITIAL_STATE, stateFlow.awaitItem())
|
||||
|
||||
// No events yet
|
||||
eventFlow.expectNoEvents()
|
||||
|
||||
// Trigger action
|
||||
viewModel.trySendAction(ExampleAction.ComplexAction)
|
||||
|
||||
// Verify state updated
|
||||
assertEquals(LOADING_STATE, stateFlow.awaitItem())
|
||||
|
||||
// Verify event emitted
|
||||
assertEquals(ExampleEvent.ShowToast, eventFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyViewModelTest : BaseViewModelTest() {
|
||||
private val mockRepository: MyRepository = mockk()
|
||||
private val savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to INITIAL_STATE)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `test action`() = runTest {
|
||||
val viewModel = MyViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = mockRepository
|
||||
)
|
||||
|
||||
// Test with automatic dispatcher setup
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(INITIAL_STATE, awaitItem())
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## BitwardenComposeTest
|
||||
|
||||
**Location:** `app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Pre-configured test class for Compose UI tests with all Bitwarden managers and theme setup.
|
||||
|
||||
### Automatic Configuration
|
||||
- All Bitwarden managers pre-configured (FeatureFlags, AuthTab, Biometrics, etc.)
|
||||
- Wraps content in `BitwardenTheme` and `LocalManagerProvider`
|
||||
- Provides fixed `Clock` for deterministic time-based tests
|
||||
- Extends `BaseComposeTest` for Robolectric and dispatcher setup
|
||||
|
||||
### Key Features
|
||||
|
||||
**Pre-configured Managers:**
|
||||
- `FeatureFlagManager` - Controls feature flag behavior
|
||||
- `AuthTabManager` - Manages auth tab state
|
||||
- `BiometricsManager` - Handles biometric authentication
|
||||
- `ClipboardManager` - Clipboard operations
|
||||
- `NotificationManager` - Notification display
|
||||
|
||||
**Fixed Clock:**
|
||||
All tests use a fixed clock for deterministic time-based testing:
|
||||
```kotlin
|
||||
// Tests use consistent time: 2023-10-27T12:00:00Z
|
||||
val fixedClock: Clock
|
||||
```
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyScreenTest : BitwardenComposeTest() {
|
||||
private var haveCalledNavigateBack = false
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<MyViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
setContent {
|
||||
MyScreen(
|
||||
onNavigateBack = { haveCalledNavigateBack = true },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on back click should send action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Back").performClick()
|
||||
verify { viewModel.trySendAction(MyAction.BackClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading state should show progress`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(isLoading = true)
|
||||
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Important: bufferedMutableSharedFlow for Events
|
||||
|
||||
In Compose tests, use `bufferedMutableSharedFlow` instead of regular `MutableSharedFlow` (default replay is 0):
|
||||
|
||||
```kotlin
|
||||
// Correct for Compose tests
|
||||
private val mutableEventFlow = bufferedMutableSharedFlow<MyEvent>()
|
||||
|
||||
// This allows triggering events and having the UI react
|
||||
mutableEventFlow.tryEmit(MyEvent.NavigateBack)
|
||||
```
|
||||
|
||||
## BaseServiceTest
|
||||
|
||||
**Location:** `network/src/testFixtures/kotlin/com/bitwarden/network/base/BaseServiceTest.kt`
|
||||
|
||||
### Purpose
|
||||
Provides MockWebServer setup for testing API service implementations.
|
||||
|
||||
### Automatic Configuration
|
||||
- `server: MockWebServer` - Auto-started before each test, stopped after
|
||||
- `retrofit: Retrofit` - Pre-configured with:
|
||||
- JSON converter (kotlinx.serialization)
|
||||
- NetworkResultCallAdapter for Result<T> responses
|
||||
- Base URL pointing to MockWebServer
|
||||
- `json: Json` - kotlinx.serialization JSON instance
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
```kotlin
|
||||
class MyServiceTest : BaseServiceTest() {
|
||||
private val api: MyApi = retrofit.create()
|
||||
private val service = MyServiceImpl(api)
|
||||
|
||||
@Test
|
||||
fun `getConfig should return success when API succeeds`() = runTest {
|
||||
// Enqueue mock response
|
||||
server.enqueue(MockResponse().setBody(EXPECTED_JSON))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify result
|
||||
assertEquals(EXPECTED_RESPONSE.asSuccess(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConfig should return failure when API fails`() = runTest {
|
||||
// Enqueue error response
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
// Call service
|
||||
val result = service.getConfig()
|
||||
|
||||
// Verify failure
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MockWebServer Patterns
|
||||
|
||||
**Enqueue successful response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setBody("""{"key": "value"}"""))
|
||||
```
|
||||
|
||||
**Enqueue error response:**
|
||||
```kotlin
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
```
|
||||
|
||||
**Enqueue delayed response:**
|
||||
```kotlin
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody("""{"key": "value"}""")
|
||||
.setBodyDelay(1000, TimeUnit.MILLISECONDS)
|
||||
)
|
||||
```
|
||||
|
||||
**Verify request details:**
|
||||
```kotlin
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/config", request.path)
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("Bearer token", request.getHeader("Authorization"))
|
||||
```
|
||||
|
||||
## BaseComposeTest
|
||||
|
||||
**Location:** `ui/src/testFixtures/kotlin/com/bitwarden/ui/platform/base/BaseComposeTest.kt`
|
||||
|
||||
### Purpose
|
||||
Base class for Compose tests that extends `BaseRobolectricTest` and provides `setTestContent()` helper.
|
||||
|
||||
### Features
|
||||
- Robolectric configuration for Compose
|
||||
- Proper dispatcher setup
|
||||
- `composeTestRule` for UI testing
|
||||
- `setTestContent()` helper wraps content in theme
|
||||
|
||||
### Usage
|
||||
Typically you'll extend `BitwardenComposeTest` which extends this class. Use `BaseComposeTest` directly only for tests that don't need Bitwarden-specific manager configuration.
|
||||
|
||||
## When to Use Each Base Class
|
||||
|
||||
| Test Type | Base Class | Use When |
|
||||
|-----------|------------|----------|
|
||||
| ViewModel tests | `BaseViewModelTest` | Testing ViewModel state and events |
|
||||
| Compose screen tests | `BitwardenComposeTest` | Testing Compose UI with Bitwarden components |
|
||||
| API service tests | `BaseServiceTest` | Testing network layer with MockWebServer |
|
||||
| Repository tests | None (manual setup) | Testing repository logic with mocked dependencies |
|
||||
| Utility/helper tests | None (manual setup) | Testing pure functions or utilities |
|
||||
|
||||
## Complete Examples
|
||||
|
||||
**ViewModel Test:**
|
||||
`../examples/viewmodel-test-example.md`
|
||||
|
||||
**Compose Screen Test:**
|
||||
`../examples/compose-screen-test-example.md`
|
||||
|
||||
**Repository Test:**
|
||||
`../examples/repository-test-example.md`
|
||||
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -10,11 +10,6 @@
|
||||
# Actions and workflow changes.
|
||||
.github/ @bitwarden/dept-development-mobile
|
||||
|
||||
# Claude related files
|
||||
.claude/ @bitwarden/team-ai-sme
|
||||
.github/workflows/respond.yml @bitwarden/team-ai-sme
|
||||
.github/workflows/review-code.yml @bitwarden/team-ai-sme
|
||||
|
||||
# Auth
|
||||
# app/src/main/java/com/x8bit/bitwarden/data/auth @bitwarden/team-auth-dev
|
||||
# app/src/main/java/com/x8bit/bitwarden/ui/auth @bitwarden/team-auth-dev
|
||||
@@ -53,9 +48,3 @@
|
||||
# app/src/main/java/com/x8bit/bitwarden/ui/vault @bitwarden/team-vault-dev
|
||||
# app/src/test/java/com/x8bit/bitwarden/data/vault @bitwarden/team-vault-dev
|
||||
# app/src/test/java/com/x8bit/bitwarden/ui/vault @bitwarden/team-vault-dev
|
||||
|
||||
# Docker-related files
|
||||
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,3 +9,27 @@
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## ⏰ Reminders before review
|
||||
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Protected functional changes with optionality (feature flags)
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
||||
24
.github/actions/log-inputs/action.yml
vendored
24
.github/actions/log-inputs/action.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: 'Log Inputs to Job Summary'
|
||||
description: 'Log workflow inputs to the GitHub Actions job summary'
|
||||
|
||||
inputs:
|
||||
inputs:
|
||||
description: 'Workflow inputs as JSON'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
shell: bash
|
||||
env:
|
||||
INPUTS: ${{ inputs.inputs }}
|
||||
run: |
|
||||
{
|
||||
echo "<details><summary>Job Inputs</summary>"
|
||||
echo ""
|
||||
echo '```json'
|
||||
echo "$INPUTS"
|
||||
echo '```'
|
||||
echo "</details>"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
48
.github/actions/setup-android-build/action.yml
vendored
48
.github/actions/setup-android-build/action.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: 'Setup Android Build'
|
||||
description: 'Setup Android build environment with Gradle, Ruby, and Fastlane'
|
||||
inputs:
|
||||
java-version:
|
||||
description: 'Java version to use'
|
||||
required: false
|
||||
default: '21'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: 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: ${{ inputs.java-version }}
|
||||
|
||||
- name: Install Fastlane
|
||||
shell: bash
|
||||
run: |
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
58
.github/label-pr.json
vendored
58
.github/label-pr.json
vendored
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"title_patterns": {
|
||||
"t:feature": ["feat", "feature", "tool"],
|
||||
"t:bug": ["fix", "bug", "bugfix"],
|
||||
"t:tech-debt": ["refactor", "chore", "cleanup", "revert", "debt", "test", "perf"],
|
||||
"t:docs": ["docs"],
|
||||
"t:ci": ["ci", "build", "chore(ci)"],
|
||||
"t:deps": ["deps"],
|
||||
"t:breaking-change": ["breaking", "breaking-change"],
|
||||
"t:misc": ["misc"],
|
||||
"t:llm": ["llm"]
|
||||
},
|
||||
"path_patterns": {
|
||||
"app:shared": [
|
||||
"annotation/",
|
||||
"core/",
|
||||
"data/",
|
||||
"network/",
|
||||
"ui/",
|
||||
"authenticatorbridge/",
|
||||
"gradle/"
|
||||
],
|
||||
"app:password-manager": [
|
||||
"app/",
|
||||
"cxf/",
|
||||
"testharness/"
|
||||
],
|
||||
"app:authenticator": [
|
||||
"authenticator/"
|
||||
],
|
||||
"t:feature": [
|
||||
"app/src/main/assets/fido2_privileged_community.json",
|
||||
"app/src/main/assets/fido2_privileged_google.json",
|
||||
"testharness/"
|
||||
],
|
||||
"t:tech-debt": [
|
||||
"gradle.properties",
|
||||
"keystore/"
|
||||
],
|
||||
"t:ci": [
|
||||
".checkmarx/",
|
||||
".github/",
|
||||
"scripts/",
|
||||
"fastlane/",
|
||||
".gradle/",
|
||||
"detekt-config.yml"
|
||||
],
|
||||
"t:docs": [
|
||||
"docs/"
|
||||
],
|
||||
"t:deps": [
|
||||
"gradle/"
|
||||
],
|
||||
"t:llm": [
|
||||
".claude/"
|
||||
]
|
||||
}
|
||||
}
|
||||
34
.github/release.yml
vendored
34
.github/release.yml
vendored
@@ -1,34 +0,0 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- ignore-for-release
|
||||
categories:
|
||||
- title: '✨ Community Highlight'
|
||||
labels:
|
||||
- community-pr
|
||||
- title: ':shipit: Feature Development'
|
||||
labels:
|
||||
- t:feature
|
||||
- t:feature-app
|
||||
- t:feature-tool
|
||||
- t:new-feature
|
||||
- t:enhancement
|
||||
- title: '❗ Breaking Changes'
|
||||
labels:
|
||||
- t:breaking-change
|
||||
- title: '🐛 Bug fixes'
|
||||
labels:
|
||||
- t:bug
|
||||
- title: '⚙️ Maintenance'
|
||||
labels:
|
||||
- t:tech-debt
|
||||
- t:ci
|
||||
- t:docs
|
||||
- t:misc
|
||||
- title: '📦 Dependency Updates'
|
||||
labels:
|
||||
- dependencies
|
||||
- t:deps
|
||||
- title: '🎨 Other'
|
||||
labels:
|
||||
- '*'
|
||||
11
.github/renovate.json
vendored
11
.github/renovate.json
vendored
@@ -3,7 +3,6 @@
|
||||
"extends": [
|
||||
"github>bitwarden/renovate-config"
|
||||
],
|
||||
"ignoreDeps": ["com.bitwarden:sdk-android"],
|
||||
"enabledManagers": [
|
||||
"github-actions",
|
||||
"gradle",
|
||||
@@ -20,6 +19,16 @@
|
||||
"patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "gradle minor",
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch"
|
||||
],
|
||||
"matchManagers": [
|
||||
"gradle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"groupName": "kotlin",
|
||||
"description": "Kotlin and Compose dependencies that must be updated together to maintain compatibility.",
|
||||
|
||||
133
.github/scripts/jira-get-release-notes/README.md
vendored
133
.github/scripts/jira-get-release-notes/README.md
vendored
@@ -1,133 +0,0 @@
|
||||
# Get Release Notes from Jira script
|
||||
|
||||
Fetches release notes from Jira issues.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python dev environment - use [uv](https://github.com/astral-sh/uv)
|
||||
- Jira API token. Generate one at: https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
- Install dependencies:
|
||||
|
||||
```bash
|
||||
uv pip install -r pyproject.toml
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
./jira_release_notes.py RELEASE-1762 example@example.com T0k3n123
|
||||
```
|
||||
|
||||
# Output Format
|
||||
|
||||
The script retrieves the content from a custom field and handles two types of Jira release notes formats:
|
||||
|
||||
1. Bullet Points:
|
||||
```
|
||||
• Point 1
|
||||
• Point 2
|
||||
• Point 3
|
||||
```
|
||||
|
||||
2. Single Line:
|
||||
```
|
||||
Single line of release notes text
|
||||
```
|
||||
|
||||
## Jira JSON format example
|
||||
|
||||
### Single line
|
||||
|
||||
```json
|
||||
...
|
||||
"customfield_9999": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Single line release notes"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
...
|
||||
```
|
||||
|
||||
### Bullet points
|
||||
|
||||
```json
|
||||
...
|
||||
"customfield_9999": {
|
||||
"type": "doc",
|
||||
"version": 1,
|
||||
"content": [
|
||||
{
|
||||
"type": "bulletList",
|
||||
"content": [
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Release notes list item 1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Release notes list item 2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Release notes list item 3"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "listItem",
|
||||
"content": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Release notes list item 4"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
...
|
||||
```
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
import base64
|
||||
import json
|
||||
import requests
|
||||
|
||||
SCRIPT_NAME = "jira_release_notes.py"
|
||||
|
||||
def extract_text_from_content(content):
|
||||
if isinstance(content, list):
|
||||
texts = [extract_text_from_content(item) for item in content]
|
||||
return '\n'.join(text for text in texts if text.strip())
|
||||
|
||||
if isinstance(content, dict):
|
||||
if content.get('type') == 'text':
|
||||
return content.get('text', '')
|
||||
elif content.get('type') == 'paragraph':
|
||||
return extract_text_from_content(content.get('content', []))
|
||||
elif content.get('type') == 'bulletList':
|
||||
return extract_text_from_content(content.get('content', []))
|
||||
elif content.get('type') == 'listItem':
|
||||
item_text = extract_text_from_content(content.get('content', []))
|
||||
return f"* {item_text.strip()}"
|
||||
|
||||
return ''
|
||||
|
||||
def log_customfields_with_content(fields):
|
||||
"""Log all customfield_* fields that have a 'content' key to help troubleshoot structure changes."""
|
||||
print(f"[{SCRIPT_NAME}] Available customfield_* fields with 'content':", file=sys.stderr)
|
||||
found = False
|
||||
for key, value in fields.items():
|
||||
if key.startswith('customfield_') and isinstance(value, dict) and 'content' in value:
|
||||
found = True
|
||||
print(f"[{SCRIPT_NAME}] {key}: {json.dumps(value, indent=2)}", file=sys.stderr)
|
||||
if not found:
|
||||
print(f"[{SCRIPT_NAME}] None found", file=sys.stderr)
|
||||
|
||||
def parse_release_notes(response_json):
|
||||
release_notes_field_name = 'customfield_10309'
|
||||
try:
|
||||
fields = response_json.get('fields')
|
||||
if not fields:
|
||||
print(f"[{SCRIPT_NAME}] 'fields' is empty or missing in response", file=sys.stderr)
|
||||
return ''
|
||||
|
||||
release_notes_field = fields.get(release_notes_field_name)
|
||||
if not release_notes_field:
|
||||
print(f"[{SCRIPT_NAME}] Release notes field is empty or missing. Field name: {release_notes_field_name}", file=sys.stderr)
|
||||
log_customfields_with_content(fields)
|
||||
return ''
|
||||
|
||||
content = release_notes_field.get('content', [])
|
||||
if not content:
|
||||
print(f"[{SCRIPT_NAME}] Release notes field was found but 'content' is empty or missing in {release_notes_field_name}", file=sys.stderr)
|
||||
log_customfields_with_content(fields)
|
||||
return ''
|
||||
|
||||
release_notes = extract_text_from_content(content)
|
||||
return release_notes
|
||||
|
||||
except Exception as e:
|
||||
print(f"[{SCRIPT_NAME}] Error parsing release notes: {str(e)}", file=sys.stderr)
|
||||
return ''
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
print(f"Usage: {sys.argv[0]} <issue_id> <jira_email> <jira_api_token>")
|
||||
sys.exit(1)
|
||||
|
||||
jira_issue_id = sys.argv[1]
|
||||
jira_email = sys.argv[2]
|
||||
jira_api_token = sys.argv[3]
|
||||
jira_base_url = "https://bitwarden.atlassian.net"
|
||||
|
||||
auth = base64.b64encode(f"{jira_email}:{jira_api_token}".encode()).decode()
|
||||
headers = {
|
||||
"Authorization": f"Basic {auth}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
f"{jira_base_url}/rest/api/3/issue/{jira_issue_id}",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"[{SCRIPT_NAME}] Error fetching Jira issue ({jira_issue_id}). Status code: {response.status_code}. Msg: {response.text}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
release_notes = parse_release_notes(response.json())
|
||||
print(release_notes)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,9 +0,0 @@
|
||||
[project]
|
||||
name = "jira-get-release-notes"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"requests>=2.32.3",
|
||||
]
|
||||
91
.github/scripts/jira-get-release-notes/uv.lock
generated
vendored
91
.github/scripts/jira-get-release-notes/uv.lock
generated
vendored
@@ -1,91 +0,0 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.4.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jira-get-release-notes"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "requests", specifier = ">=2.32.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
|
||||
]
|
||||
263
.github/scripts/label-pr.py
vendored
263
.github/scripts/label-pr.py
vendored
@@ -1,263 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Requires Python 3.9+
|
||||
"""
|
||||
Label pull requests based on changed file paths and PR title patterns (conventional commit format).
|
||||
|
||||
Usage:
|
||||
python label-pr.py <pr-number> <pr-labels> [-a|--add|-r|--replace] [-d|--dry-run] [-c|--config CONFIG]
|
||||
|
||||
Arguments:
|
||||
pr-number: The pull request number
|
||||
pr-labels: Current PR labels as JSON array string
|
||||
-a, --add: Add labels without removing existing ones (default)
|
||||
-r, --replace: Replace all existing labels
|
||||
-d, --dry-run: Run without actually applying labels
|
||||
-c, --config: Path to JSON config file (default: .github/label-pr.json)
|
||||
|
||||
Examples:
|
||||
python label-pr.py 1234 '[]'
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' -a
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' --replace
|
||||
python label-pr.py 1234 '[{"name":"label1"}]' -r -d
|
||||
python label-pr.py 1234 '[]' --config custom-config.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
DEFAULT_MODE = "add"
|
||||
DEFAULT_CONFIG_PATH = ".github/label-pr.json"
|
||||
|
||||
def load_config_json(config_file: str) -> dict:
|
||||
"""Load configuration from JSON file."""
|
||||
if not os.path.exists(config_file):
|
||||
print(f"❌ Config file not found: {config_file}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
print(f"✅ Loaded config from: {config_file}")
|
||||
|
||||
valid_config = True
|
||||
if not config.get("title_patterns"):
|
||||
print("❌ Missing 'title_patterns' in config file")
|
||||
valid_config = False
|
||||
if not config.get("path_patterns"):
|
||||
print("❌ Missing 'path_patterns' in config file")
|
||||
valid_config = False
|
||||
|
||||
if not valid_config:
|
||||
print("::error::Invalid label-pr.json config file, exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
return config
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"❌ JSON deserialization error in label-pr.json config: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error loading label-pr.json config: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
def gh_get_changed_files(pr_number: str) -> list[str]:
|
||||
"""Get list of changed files in a pull request."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "pr", "diff", pr_number, "--name-only"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
changed_files = result.stdout.strip().split("\n")
|
||||
return list(filter(None, changed_files))
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"::error::Error getting changed files: {e}")
|
||||
return []
|
||||
|
||||
def gh_get_pr_title(pr_number: str) -> str:
|
||||
"""Get the title of a pull request."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "pr", "view", pr_number, "--json", "title", "--jq", ".title"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"::error::Error getting PR title: {e}")
|
||||
return ""
|
||||
|
||||
def gh_add_labels(pr_number: str, labels: list[str]) -> None:
|
||||
"""Add labels to a pull request (doesn't remove existing labels)."""
|
||||
gh_labels = ','.join(labels)
|
||||
subprocess.run(
|
||||
["gh", "pr", "edit", pr_number, "--add-label", gh_labels],
|
||||
check=True
|
||||
)
|
||||
|
||||
def gh_replace_labels(pr_number: str, labels: list[str]) -> None:
|
||||
"""Replace all labels on a pull request with the specified labels."""
|
||||
payload = json.dumps({"labels": labels})
|
||||
subprocess.run(
|
||||
["gh", "api", "repos/{owner}/{repo}/issues/" + pr_number, "-X", "PATCH", "--silent", "--input", "-"],
|
||||
input=payload,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
def label_filepaths(changed_files: list[str], path_patterns: dict) -> list[str]:
|
||||
"""Check changed files against path patterns and return labels to apply."""
|
||||
if not changed_files:
|
||||
return []
|
||||
|
||||
labels_to_apply = set() # Use set to avoid duplicates
|
||||
|
||||
for label, patterns in path_patterns.items():
|
||||
for file in changed_files:
|
||||
if any(file.startswith(pattern) for pattern in patterns):
|
||||
print(f"👀 File '{file}' matches pattern for label '{label}'")
|
||||
labels_to_apply.add(label)
|
||||
break
|
||||
|
||||
if "app:shared" in labels_to_apply:
|
||||
labels_to_apply.add("app:password-manager")
|
||||
labels_to_apply.add("app:authenticator")
|
||||
labels_to_apply.remove("app:shared")
|
||||
|
||||
if not labels_to_apply:
|
||||
print("::notice::No matching file paths found.")
|
||||
|
||||
return list(labels_to_apply)
|
||||
|
||||
def label_title(pr_title: str, title_patterns: dict) -> list[str]:
|
||||
"""Check PR title against patterns and return labels to apply."""
|
||||
if not pr_title:
|
||||
return []
|
||||
|
||||
labels_to_apply = set()
|
||||
title_lower = pr_title.lower()
|
||||
for label, patterns in title_patterns.items():
|
||||
for pattern in patterns:
|
||||
# Check for pattern with : or ( suffix (conventional commits format)
|
||||
if f"{pattern}:" in title_lower or f"{pattern}(" in title_lower:
|
||||
print(f"📝 Title matches pattern '{pattern}' for label '{label}'")
|
||||
labels_to_apply.add(label)
|
||||
break
|
||||
|
||||
if not labels_to_apply:
|
||||
print("::notice::No matching title patterns found.")
|
||||
|
||||
return list(labels_to_apply)
|
||||
|
||||
def parse_pr_labels(pr_labels_str: str) -> list[str]:
|
||||
"""Parse PR labels from JSON array string."""
|
||||
try:
|
||||
labels = json.loads(pr_labels_str)
|
||||
if not isinstance(labels, list):
|
||||
print("::warning::Failed to parse PR labels: not a list")
|
||||
return []
|
||||
return [item.get("name") for item in labels if item.get("name")]
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
print(f"::error::Error parsing PR labels: {e}")
|
||||
return []
|
||||
|
||||
def get_preserved_labels(pr_labels_str: str) -> list[str]:
|
||||
"""Get existing PR labels that should be preserved (exclude app: and t: labels)."""
|
||||
existing_labels = parse_pr_labels(pr_labels_str)
|
||||
print(f"🔍 Parsed PR labels: {existing_labels}")
|
||||
preserved_labels = [label for label in existing_labels if not (label.startswith("app:") or label.startswith("t:"))]
|
||||
if preserved_labels:
|
||||
print(f"🔍 Preserving existing labels: {', '.join(preserved_labels)}")
|
||||
return preserved_labels
|
||||
|
||||
def parse_args():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Label pull requests based on changed file paths and PR title patterns."
|
||||
)
|
||||
parser.add_argument(
|
||||
"pr_number",
|
||||
help="The pull request number"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"pr_labels",
|
||||
help="Current PR labels (JSON array)"
|
||||
)
|
||||
|
||||
mode_group = parser.add_mutually_exclusive_group()
|
||||
mode_group.add_argument(
|
||||
"-a", "--add",
|
||||
action="store_true",
|
||||
help="Add labels without removing existing ones (default)"
|
||||
)
|
||||
mode_group.add_argument(
|
||||
"-r", "--replace",
|
||||
action="store_true",
|
||||
help="Replace all existing labels"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-d", "--dry-run",
|
||||
action="store_true",
|
||||
help="Run without actually applying labels"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-c", "--config",
|
||||
default=DEFAULT_CONFIG_PATH,
|
||||
help=f"Path to JSON config file (default: {DEFAULT_CONFIG_PATH})"
|
||||
)
|
||||
args, unknown = parser.parse_known_args() # required to handle --dry-run passed as an empty string ("") by the workflow
|
||||
return args
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
config = load_config_json(args.config)
|
||||
LABEL_TITLE_PATTERNS = config["title_patterns"]
|
||||
LABEL_PATH_PATTERNS = config["path_patterns"]
|
||||
|
||||
pr_number = args.pr_number
|
||||
mode = "replace" if args.replace else "add"
|
||||
|
||||
if args.dry_run:
|
||||
print("🔍 DRY RUN MODE - Labels will not be applied")
|
||||
print(f"📌 Labeling mode: {mode}")
|
||||
print(f"🔍 Checking PR #{pr_number}...")
|
||||
|
||||
pr_title = gh_get_pr_title(pr_number)
|
||||
print(f"📋 PR Title: {pr_title}\n")
|
||||
|
||||
changed_files = gh_get_changed_files(pr_number)
|
||||
print("👀 Changed files:\n" + "\n".join(changed_files) + "\n")
|
||||
|
||||
filepath_labels = label_filepaths(changed_files, LABEL_PATH_PATTERNS)
|
||||
title_labels = label_title(pr_title, LABEL_TITLE_PATTERNS)
|
||||
all_labels = set(filepath_labels + title_labels)
|
||||
|
||||
if all_labels:
|
||||
print("--------------------------------")
|
||||
labels_str = ', '.join(sorted(all_labels))
|
||||
if mode == "add":
|
||||
print(f"::notice::🏷️ Adding labels: {labels_str}")
|
||||
if not args.dry_run:
|
||||
gh_add_labels(pr_number, list(all_labels))
|
||||
else:
|
||||
preserved_labels = get_preserved_labels(args.pr_labels)
|
||||
if preserved_labels:
|
||||
all_labels.update(preserved_labels)
|
||||
labels_str = ', '.join(sorted(all_labels))
|
||||
print(f"::notice::🏷️ Replacing labels with: {labels_str}")
|
||||
if not args.dry_run:
|
||||
gh_replace_labels(pr_number, list(all_labels))
|
||||
else:
|
||||
print("::warning::No matching patterns found, no labels applied.")
|
||||
|
||||
print("✅ Done")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
173
.github/workflows/_version.yml
vendored
173
.github/workflows/_version.yml
vendored
@@ -1,173 +0,0 @@
|
||||
name: Calculate Version Name and Number
|
||||
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app_codename:
|
||||
description: "App Name - e.g. 'bwpm' or 'bwa'"
|
||||
base_version_number:
|
||||
description: "Base Version Number - Will be added to the calculated version number"
|
||||
type: number
|
||||
default: 0
|
||||
version_name:
|
||||
description: "Version Name Override - e.g. '2024.8.1'"
|
||||
version_number:
|
||||
description: "Version Number Override - e.g. '1021'"
|
||||
patch_version:
|
||||
description: "Patch Version Override - e.g. '999'"
|
||||
distinct_id:
|
||||
description: "Unique ID for this dispatch, used by dispatch-and-download.yml"
|
||||
skip_checkout:
|
||||
description: "Skip checking out the repository"
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
app_codename:
|
||||
description: "App Name - e.g. 'bwpm' or 'bwa'"
|
||||
type: string
|
||||
base_version_number:
|
||||
description: "Base Version Number - Will be added to the calculated version number"
|
||||
type: number
|
||||
default: 0
|
||||
version_name:
|
||||
description: "Version Name Override - e.g. '2024.8.1'"
|
||||
type: string
|
||||
version_number:
|
||||
description: "Version Number Override - e.g. '1021'"
|
||||
type: string
|
||||
patch_version:
|
||||
description: "Patch Version Override - e.g. '999'"
|
||||
type: string
|
||||
distinct_id:
|
||||
description: "Unique ID for this dispatch, used by dispatch-and-download.yml"
|
||||
type: string
|
||||
skip_checkout:
|
||||
description: "Skip checking out the repository"
|
||||
type: boolean
|
||||
outputs:
|
||||
version_name:
|
||||
description: "Version Name"
|
||||
value: ${{ jobs.calculate-version.outputs.version_name }}
|
||||
version_number:
|
||||
description: "Version Number"
|
||||
value: ${{ jobs.calculate-version.outputs.version_number }}
|
||||
|
||||
env:
|
||||
APP_CODENAME: ${{ inputs.app_codename }}
|
||||
BASE_VERSION_NUMBER: ${{ inputs.base_version_number || 0 }}
|
||||
|
||||
jobs:
|
||||
calculate-version:
|
||||
name: Calculate Version Name and Number
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version_name: ${{ steps.calc-version-name.outputs.version_name }}
|
||||
version_number: ${{ steps.calc-version-number.outputs.version_number }}
|
||||
steps:
|
||||
- name: Log inputs to job summary
|
||||
uses: bitwarden/android/.github/actions/log-inputs@main
|
||||
with:
|
||||
inputs: "${{ toJson(inputs) }}"
|
||||
|
||||
- name: Echo distinct ID ${{ github.event.inputs.distinct_id }}
|
||||
env:
|
||||
_DISTINCT_ID: ${{ inputs.distinct_id }}
|
||||
run: echo "${_DISTINCT_ID}"
|
||||
|
||||
- name: Check out repository
|
||||
if: ${{ !inputs.skip_checkout || false }}
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Calculate version name
|
||||
id: calc-version-name
|
||||
env:
|
||||
_VERSION_NAME: ${{ inputs.version_name }}
|
||||
_PATCH_VERSION: ${{ inputs.patch_version }}
|
||||
run: |
|
||||
output() {
|
||||
local version_name=$1
|
||||
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
# override version name if provided
|
||||
if [[ ! -z "${_VERSION_NAME}" ]]; then
|
||||
version_name=${_VERSION_NAME}
|
||||
echo "::warning::Override applied: $version_name"
|
||||
output "$version_name"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_year=$(date +%Y)
|
||||
current_month=$(date +%-m)
|
||||
|
||||
latest_tag_version=$(git tag -l --sort=-creatordate | grep "$APP_CODENAME" | head -n 1)
|
||||
if [[ -z "$latest_tag_version" ]]; then
|
||||
version_name="${current_year}.${current_month}.${_PATCH_VERSION:-0}"
|
||||
echo "::warning::No tags found, did you checkout? Calculating version from current date: $version_name"
|
||||
output "$version_name"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Git tag was found, calculate version from latest tag
|
||||
latest_version=${latest_tag_version:1} # remove 'v' from tag version
|
||||
|
||||
latest_major_version=$(echo "$latest_version" | cut -d "." -f 1)
|
||||
latest_minor_version=$(echo "$latest_version" | cut -d "." -f 2)
|
||||
patch_version=0
|
||||
if [[ ! -z "${_PATCH_VERSION}" ]]; then
|
||||
patch_version=${_PATCH_VERSION}
|
||||
echo "::warning::Patch Version Override applied: $patch_version"
|
||||
elif [[ "$current_year" == "$latest_major_version" && "$current_month" == "$latest_minor_version" ]]; then
|
||||
latest_patch_version=$(echo "$latest_version" | cut -d "." -f 3)
|
||||
patch_version=$(($latest_patch_version + 1))
|
||||
fi
|
||||
|
||||
version_name="${current_year}.${current_month}.${patch_version}"
|
||||
output "$version_name"
|
||||
|
||||
- name: Calculate version number
|
||||
id: calc-version-number
|
||||
env:
|
||||
_VERSION_NUMBER: ${{ inputs.version_number }}
|
||||
run: |
|
||||
# override version number if provided
|
||||
if [[ ! -z "${_VERSION_NUMBER}" ]]; then
|
||||
version_number=${_VERSION_NUMBER}
|
||||
echo "::warning::Override applied: $version_number"
|
||||
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
version_number=$(($GITHUB_RUN_NUMBER + ${BASE_VERSION_NUMBER}))
|
||||
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create version info JSON
|
||||
env:
|
||||
_VERSION_NUMBER: ${{ steps.calc-version-number.outputs.version_number }}
|
||||
_VERSION_NAME: ${{ steps.calc-version-name.outputs.version_name }}
|
||||
run: |
|
||||
json=$(cat <<EOF
|
||||
{
|
||||
"version_number": "${_VERSION_NUMBER}",
|
||||
"version_name": "${_VERSION_NAME}"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
echo "$json" > version_info.json
|
||||
|
||||
echo "## version-info.json" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```json' >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "$json" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload version info artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: version-info
|
||||
path: version_info.json
|
||||
185
.github/workflows/build-authenticator.yml
vendored
185
.github/workflows/build-authenticator.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/**/*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-name:
|
||||
@@ -15,61 +14,35 @@ on:
|
||||
description: "Optional. Build number to use. Overrides default of GitHub run number."
|
||||
required: false
|
||||
type: number
|
||||
patch_version:
|
||||
description: "Order 999 - Overrides Patch version"
|
||||
type: boolean
|
||||
distribute-to-firebase:
|
||||
description: "Optional. Distribute artifacts to Firebase."
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
publish-to-play-store:
|
||||
description: "Optional. Deploy bundle artifact to Google Play Store"
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
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' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
JAVA_VERSION: 17
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Calculate Version Name and Number
|
||||
uses: bitwarden/android/.github/workflows/_version.yml@main
|
||||
with:
|
||||
app_codename: "bwa"
|
||||
base_version_number: 0
|
||||
version_name: ${{ inputs.version-name }}
|
||||
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
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -79,7 +52,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -88,18 +61,19 @@ jobs:
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.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
|
||||
|
||||
@@ -112,11 +86,8 @@ jobs:
|
||||
publish_playstore:
|
||||
name: Publish Authenticator Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -124,33 +95,23 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.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: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
uses: Azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-android
|
||||
secrets: "BWA-AAB-KEYSTORE-STORE-PASSWORD,BWA-AAB-KEYSTORE-KEY-PASSWORD,BWA-APK-KEYSTORE-STORE-PASSWORD,BWA-APK-KEYSTORE-KEY-PASSWORD"
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
env:
|
||||
@@ -160,54 +121,51 @@ jobs:
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
mkdir -p ${{ github.workspace }}/keystores
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_apk-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_apk-keystore.jks --output none
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_aab-keystore.jks --file ${{ github.workspace }}/keystores/authenticator_aab-keystore.jks --output none
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name com.bitwarden.authenticator-google-services.json --file ${{ github.workspace }}/authenticator/src/google-services.json --output none
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name com.bitwarden.authenticator.dev-google-services.json --file ${{ github.workspace }}/authenticator/src/debug/google-services.json --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if : ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_play_firebase-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json --output none
|
||||
|
||||
- name: Download Play Store credentials
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
|
||||
|
||||
- name: AZ Logout
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ env.PUBLISH_TO_PLAY_STORE }}
|
||||
if: ${{ inputs.publish-to-play-store }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key \
|
||||
json_key:"${{ github.workspace }}/secrets/authenticator_play_store-creds.json"
|
||||
json_key:${{ github.workspace }}/secrets/authenticator_play_store-creds.json }}
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -217,7 +175,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -226,74 +184,55 @@ jobs:
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
./scripts/update_app_ci_build_info.sh \
|
||||
"$GITHUB_REPOSITORY" \
|
||||
"$GITHUB_REF_NAME" \
|
||||
"$GITHUB_SHA" \
|
||||
"$GITHUB_RUN_ID" \
|
||||
"$GITHUB_RUN_ATTEMPT"
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
DEFAULT_VERSION_CODE=$GITHUB_RUN_NUMBER
|
||||
VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}"
|
||||
bundle exec fastlane setAuthenticatorBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:${{ inputs.version-name || '' }}
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
regex='versionName = "([^"]+)"'
|
||||
if [[ "$(cat authenticator/build.gradle.kts)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
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' }}
|
||||
env:
|
||||
STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-STORE-PASSWORD }}
|
||||
KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-AAB-KEYSTORE-KEY-PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane bundleAuthenticatorRelease \
|
||||
storeFile:"${{ github.workspace }}/keystores/authenticator_aab-keystore.jks" \
|
||||
storePassword:"$STORE_PASSWORD" \
|
||||
keyAlias:"authenticatorupload" \
|
||||
keyPassword:"$KEY_PASSWORD"
|
||||
storeFile:${{ github.workspace }}/keystores/authenticator_aab-keystore.jks \
|
||||
storePassword:'${{ secrets.BWA_AAB_KEYSTORE_STORE_PASSWORD }}' \
|
||||
keyAlias:authenticatorupload \
|
||||
keyPassword:'${{ secrets.BWA_AAB_KEYSTORE_KEY_PASSWORD }}'
|
||||
|
||||
- name: Generate release Play Store APK
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
env:
|
||||
STORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-STORE-PASSWORD }}
|
||||
KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.BWA-APK-KEYSTORE-KEY-PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane buildAuthenticatorRelease \
|
||||
storeFile:"${{ github.workspace }}/keystores/authenticator_apk-keystore.jks" \
|
||||
storePassword:"$STORE_PASSWORD" \
|
||||
keyAlias:"bitwardenauthenticator" \
|
||||
keyPassword:"$KEY_PASSWORD"
|
||||
storeFile:${{ github.workspace }}/keystores/authenticator_apk-keystore.jks \
|
||||
storePassword:'${{ secrets.BWA_APK_KEYSTORE_STORE_PASSWORD }}' \
|
||||
keyAlias:bitwardenauthenticator \
|
||||
keyPassword:'${{ secrets.BWA_APK_KEYSTORE_KEY_PASSWORD }}'
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
- name: Upload release Play Store .aab artifact
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.aab
|
||||
path: authenticator/build/outputs/bundle/release/com.bitwarden.authenticator.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
- name: Upload release .apk artifact
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.bitwarden.authenticator.apk
|
||||
path: authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk
|
||||
@@ -311,38 +250,40 @@ jobs:
|
||||
sha256sum "authenticator/build/outputs/apk/release/com.bitwarden.authenticator.apk" \
|
||||
> ./authenticator-android-apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for release
|
||||
if: ${{ matrix.variant == 'apk' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: authenticator-android-apk-sha256.txt
|
||||
path: ./authenticator-android-apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
- name: Upload .aab SHA file for release
|
||||
if: ${{ matrix.variant == 'aab' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: authenticator-android-aab-sha256.txt
|
||||
path: ./authenticator-android-aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Distribute to Firebase - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish release bundle to Firebase
|
||||
if: ${{ matrix.variant == 'aab' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/authenticator_play_firebase-creds.json
|
||||
run: |
|
||||
bundle exec fastlane distributeAuthenticatorReleaseBundleToFirebase \
|
||||
serviceCredentialsFile:"$FIREBASE_CREDS_PATH"
|
||||
serviceCredentialsFile:${{ env.FIREBASE_CREDS_PATH }}
|
||||
|
||||
- name: Publish to Play Store - prod.aab
|
||||
if: ${{ matrix.variant == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
# Only publish bundles to Play Store when `publish-to-play-store` is true while building
|
||||
# bundles
|
||||
- name: Publish release bundle to Google Play Store
|
||||
if: ${{ inputs.publish-to-play-store && matrix.variant == 'aab' }}
|
||||
env:
|
||||
PLAY_STORE_CREDS_FILE: ${{ github.workspace }}/secrets/authenticator_play_store-creds.json
|
||||
run: |
|
||||
bundle exec fastlane publishAuthenticatorReleaseToGooglePlayStore \
|
||||
serviceCredentialsFile:"$PLAY_STORE_CREDS_FILE" \
|
||||
serviceCredentialsFile:${{ env.PLAY_STORE_CREDS_FILE }} \
|
||||
|
||||
134
.github/workflows/build-testharness.yml
vendored
134
.github/workflows/build-testharness.yml
vendored
@@ -1,134 +0,0 @@
|
||||
name: Build Test Harness
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- testharness/**
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-name:
|
||||
description: "Optional. Version string to use, in X.Y.Z format. Overrides default in the project."
|
||||
required: false
|
||||
type: string
|
||||
version-code:
|
||||
description: "Optional. Build number to use. Overrides default of GitHub run number."
|
||||
required: false
|
||||
type: number
|
||||
patch_version:
|
||||
description: "Order 999 - Overrides Patch version"
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Calculate Version Name and Number
|
||||
uses: bitwarden/android/.github/workflows/_version.yml@main
|
||||
with:
|
||||
app_codename: "bwpm"
|
||||
base_version_number: 0
|
||||
version_name: ${{ inputs.version-name }}
|
||||
version_number: ${{ inputs.version-code }}
|
||||
patch_version: ${{ inputs.patch_version && '999' || '' }}
|
||||
|
||||
build:
|
||||
name: Build Test Harness
|
||||
runs-on: ubuntu-24.04
|
||||
needs: version
|
||||
|
||||
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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/libs.versions.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
key: ${{ runner.os }}-build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
DEFAULT_VERSION_CODE: ${{ github.run_number }}
|
||||
INPUT_VERSION_CODE: "${{ needs.version.outputs.version_number }}"
|
||||
INPUT_VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${INPUT_VERSION_CODE:-$DEFAULT_VERSION_CODE}"
|
||||
VERSION_NAME_INPUT="${INPUT_VERSION_NAME:-}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME_INPUT"
|
||||
|
||||
regex='appVersionName = "(.+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build Test Harness Debug APK
|
||||
run: ./gradlew :testharness:assembleDebug
|
||||
|
||||
- name: Upload Test Harness APK
|
||||
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
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create checksum for Test Harness APK
|
||||
run: |
|
||||
sha256sum "testharness/build/outputs/apk/debug/com.bitwarden.testharness.dev.apk" \
|
||||
> ./com.bitwarden.testharness.dev.apk-sha256.txt
|
||||
|
||||
- name: Upload Test Harness SHA file
|
||||
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
|
||||
if-no-files-found: error
|
||||
321
.github/workflows/build.yml
vendored
321
.github/workflows/build.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- release/**/*
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-name:
|
||||
@@ -15,63 +14,36 @@ on:
|
||||
description: "Optional. Build number to use. Overrides default of GitHub run number."
|
||||
required: false
|
||||
type: number
|
||||
patch_version:
|
||||
description: "Order 999 - Overrides Patch version"
|
||||
type: boolean
|
||||
distribute-to-firebase:
|
||||
description: "Optional. Distribute artifacts to Firebase."
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
publish-to-play-store:
|
||||
description: "Optional. Deploy bundle artifact to Google Play Store"
|
||||
required: false
|
||||
default: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 21
|
||||
JAVA_VERSION: 17
|
||||
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' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Calculate Version Name and Number
|
||||
uses: bitwarden/android/.github/workflows/_version.yml@main
|
||||
with:
|
||||
app_codename: "bwpm"
|
||||
# Start from 11000 to prevent collisions with mobile build version codes
|
||||
base_version_number: 11000
|
||||
version_name: ${{ inputs.version-name }}
|
||||
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
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -81,7 +53,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -90,18 +62,19 @@ jobs:
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.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
|
||||
|
||||
@@ -112,7 +85,7 @@ jobs:
|
||||
run: bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload test reports on failure
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
if: failure()
|
||||
with:
|
||||
name: test-reports
|
||||
@@ -121,11 +94,8 @@ jobs:
|
||||
publish_playstore:
|
||||
name: Publish Play Store artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -133,33 +103,23 @@ jobs:
|
||||
artifact: ["apk", "aab"]
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.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: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-android
|
||||
secrets: "UPLOAD-KEYSTORE-PASSWORD,UPLOAD-BETA-KEYSTORE-PASSWORD,UPLOAD-BETA-KEY-PASSWORD,PLAY-KEYSTORE-PASSWORD,PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
env:
|
||||
@@ -170,40 +130,37 @@ jobs:
|
||||
mkdir -p ${{ github.workspace }}/app/src/standardBeta
|
||||
mkdir -p ${{ github.workspace }}/app/src/standardRelease
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_play-keystore.jks --file ${{ github.workspace }}/keystores/app_play-keystore.jks --output none
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_upload-keystore.jks --output none
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_beta_play-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_play-keystore.jks --output none
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_beta_upload-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_upload-keystore.jks --output none
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name google-services.json --file ${{ github.workspace }}/app/src/standardRelease/google-services.json --output none
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ matrix.variant == 'prod' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -213,7 +170,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -222,7 +179,7 @@ jobs:
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
@@ -230,109 +187,106 @@ jobs:
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
./scripts/update_app_ci_build_info.sh \
|
||||
"$GITHUB_REPOSITORY" \
|
||||
"$GITHUB_REF_NAME" \
|
||||
"$GITHUB_SHA" \
|
||||
"$GITHUB_RUN_ID" \
|
||||
"$GITHUB_RUN_ATTEMPT"
|
||||
$GITHUB_REPOSITORY \
|
||||
$GITHUB_REF_NAME \
|
||||
$GITHUB_SHA \
|
||||
$GITHUB_RUN_ID \
|
||||
$GITHUB_RUN_ATTEMPT
|
||||
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
DEFAULT_VERSION_CODE=$((11000+$GITHUB_RUN_NUMBER))
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
versionCode:${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }} \
|
||||
versionName:${{ inputs.version-name }}
|
||||
|
||||
- name: Generate release Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' }}
|
||||
env:
|
||||
UPLOAD_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-KEYSTORE-PASSWORD }}
|
||||
UPLOAD_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane bundlePlayStoreRelease \
|
||||
storeFile:app_upload-keystore.jks \
|
||||
storePassword:$UPLOAD_KEYSTORE_PASSWORD \
|
||||
storePassword:${{ env.UPLOAD_KEYSTORE_PASSWORD }} \
|
||||
keyAlias:upload \
|
||||
keyPassword:$UPLOAD_KEYSTORE_PASSWORD
|
||||
keyPassword:${{ env.UPLOAD_KEYSTORE_PASSWORD }}
|
||||
|
||||
- name: Generate beta Play Store bundle
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
env:
|
||||
UPLOAD_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEYSTORE-PASSWORD }}
|
||||
UPLOAD_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.UPLOAD-BETA-KEY-PASSWORD }}
|
||||
UPLOAD_BETA_KEYSTORE_PASSWORD: ${{ secrets.UPLOAD_BETA_KEYSTORE_PASSWORD }}
|
||||
UPLOAD_BETA_KEY_PASSWORD: ${{ secrets.UPLOAD_BETA_KEY_PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane bundlePlayStoreBeta \
|
||||
storeFile:app_beta_upload-keystore.jks \
|
||||
storePassword:$UPLOAD_BETA_KEYSTORE_PASSWORD \
|
||||
storePassword:${{ env.UPLOAD_BETA_KEYSTORE_PASSWORD }} \
|
||||
keyAlias:bitwarden-beta-upload \
|
||||
keyPassword:$UPLOAD_BETA_KEY_PASSWORD
|
||||
keyPassword:${{ env.UPLOAD_BETA_KEY_PASSWORD }}
|
||||
|
||||
- name: Generate release Play Store APK
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
env:
|
||||
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-KEYSTORE-PASSWORD }}
|
||||
PLAY_KEYSTORE_PASSWORD: ${{ secrets.PLAY_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane assemblePlayStoreReleaseApk \
|
||||
storeFile:app_play-keystore.jks \
|
||||
storePassword:$PLAY_KEYSTORE_PASSWORD \
|
||||
storePassword:${{ env.PLAY_KEYSTORE_PASSWORD }} \
|
||||
keyAlias:bitwarden \
|
||||
keyPassword:$PLAY_KEYSTORE_PASSWORD
|
||||
keyPassword:${{ env.PLAY_KEYSTORE_PASSWORD }}
|
||||
|
||||
- name: Generate beta Play Store APK
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
env:
|
||||
PLAY_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
|
||||
PLAY_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
|
||||
PLAY_BETA_KEYSTORE_PASSWORD: ${{ secrets.PLAY_BETA_KEYSTORE_PASSWORD }}
|
||||
PLAY_BETA_KEY_PASSWORD: ${{ secrets.PLAY_BETA_KEY_PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane assemblePlayStoreBetaApk \
|
||||
storeFile:app_beta_play-keystore.jks \
|
||||
storePassword:$PLAY_BETA_KEYSTORE_PASSWORD \
|
||||
storePassword:${{ env.PLAY_BETA_KEYSTORE_PASSWORD }} \
|
||||
keyAlias:bitwarden-beta \
|
||||
keyPassword:$PLAY_BETA_KEY_PASSWORD
|
||||
keyPassword:${{ env.PLAY_BETA_KEY_PASSWORD }}
|
||||
|
||||
- name: Generate debug Play Store APKs
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
run: |
|
||||
bundle exec fastlane assembleDebugApks
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab
|
||||
- name: Upload release Play Store .aab artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab
|
||||
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab
|
||||
- name: Upload beta Play Store .aab artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab
|
||||
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden.beta.aab
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk
|
||||
- name: Upload release .apk artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk
|
||||
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden.apk
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk
|
||||
- name: Upload beta .apk artifact
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk
|
||||
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden.beta.apk
|
||||
if-no-files-found: error
|
||||
|
||||
# When building variants other than 'prod'
|
||||
- name: Upload to GitHub Artifacts - dev.apk
|
||||
- name: Upload debug .apk artifact
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
|
||||
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk
|
||||
@@ -368,75 +322,75 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden.dev.apk" \
|
||||
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for release
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for beta
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - prod.aab-sha256.txt
|
||||
- name: Upload .aab SHA file for release
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.aab-sha256.txt
|
||||
- name: Upload .aab SHA file for beta
|
||||
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Artifacts - debug.apk-sha256.txt
|
||||
- name: Upload .apk SHA file for debug
|
||||
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Distribute to Firebase - prod.apk
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish release artifacts to Firebase
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
|
||||
run: |
|
||||
bundle exec fastlane distributeReleasePlayStoreToFirebase \
|
||||
actionUrl:$GITHUB_ACTION_RUN_URL \
|
||||
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
|
||||
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
|
||||
service_credentials_file:${{ env.APP_PLAY_FIREBASE_CREDS_PATH }}
|
||||
|
||||
- name: Distribute to Firebase - beta.apk
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish beta artifacts to Firebase
|
||||
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && (inputs.distribute-to-firebase || github.event_name == 'push') }}
|
||||
env:
|
||||
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
|
||||
run: |
|
||||
bundle exec fastlane distributeBetaPlayStoreToFirebase \
|
||||
actionUrl:$GITHUB_ACTION_RUN_URL \
|
||||
service_credentials_file:$APP_PLAY_FIREBASE_CREDS_PATH
|
||||
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
|
||||
service_credentials_file:${{ env.APP_PLAY_FIREBASE_CREDS_PATH }}
|
||||
|
||||
- name: Verify Play Store credentials
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
if: ${{ matrix.variant == 'prod' && inputs.publish-to-play-store }}
|
||||
run: |
|
||||
bundle exec fastlane run validate_play_store_json_key
|
||||
|
||||
- name: Publish to Play Store - prod.aab
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && env.PUBLISH_TO_PLAY_STORE }}
|
||||
- name: Publish Play Store bundle
|
||||
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'aab' && (inputs.publish-to-play-store || github.ref_name == 'main') }}
|
||||
run: |
|
||||
bundle exec fastlane publishProdToPlayStore
|
||||
bundle exec fastlane publishBetaToPlayStore
|
||||
@@ -444,70 +398,54 @@ jobs:
|
||||
publish_fdroid:
|
||||
name: Publish F-Droid artifacts
|
||||
needs:
|
||||
- version
|
||||
- build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.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: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-android
|
||||
secrets: "FDROID-KEYSTORE-PASSWORD,FDROID-BETA-KEYSTORE-PASSWORD,FDROID-BETA-KEY-PASSWORD"
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_fdroid-keystore.jks --output none
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
|
||||
|
||||
- name: Download Firebase credentials
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \
|
||||
--name app_fdroid_firebase-creds.json --file ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json --output none
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -517,7 +455,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -526,7 +464,7 @@ jobs:
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env.JAVA_VERSION }}
|
||||
@@ -534,51 +472,50 @@ jobs:
|
||||
- name: Update app CI Build info
|
||||
run: |
|
||||
./scripts/update_app_ci_build_info.sh \
|
||||
"$GITHUB_REPOSITORY" \
|
||||
"$GITHUB_REF_NAME" \
|
||||
"$GITHUB_SHA" \
|
||||
"$GITHUB_RUN_ID" \
|
||||
"$GITHUB_RUN_ATTEMPT"
|
||||
$GITHUB_REPOSITORY \
|
||||
$GITHUB_REF_NAME \
|
||||
$GITHUB_SHA \
|
||||
$GITHUB_RUN_ID \
|
||||
$GITHUB_RUN_ATTEMPT
|
||||
|
||||
# Start from 11000 to prevent collisions with mobile build version codes
|
||||
- name: Increment version
|
||||
env:
|
||||
VERSION_CODE: ${{ needs.version.outputs.version_number }}
|
||||
VERSION_NAME: ${{ needs.version.outputs.version_name }}
|
||||
run: |
|
||||
VERSION_CODE="${VERSION_CODE:-$GITHUB_RUN_NUMBER}"
|
||||
DEFAULT_VERSION_CODE=$((11000+$GITHUB_RUN_NUMBER))
|
||||
VERSION_CODE="${{ inputs.version-code || '$DEFAULT_VERSION_CODE' }}"
|
||||
bundle exec fastlane setBuildVersionInfo \
|
||||
versionCode:$VERSION_CODE \
|
||||
versionName:$VERSION_NAME
|
||||
versionName:${{ inputs.version-name || '' }}
|
||||
|
||||
regex='appVersionName = "([^"]+)"'
|
||||
if [[ "$(cat gradle/libs.versions.toml)" =~ $regex ]]; then
|
||||
regex='versionName = "([^"]+)"'
|
||||
if [[ "$(cat app/build.gradle.kts)" =~ $regex ]]; then
|
||||
VERSION_NAME="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
echo "Version Name: ${VERSION_NAME}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Version Number: $VERSION_CODE" >> "$GITHUB_STEP_SUMMARY"
|
||||
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 }}
|
||||
FDROID_STORE_PASSWORD: ${{ secrets.FDROID_KEYSTORE_PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane assembleFDroidReleaseApk \
|
||||
storeFile:app_fdroid-keystore.jks \
|
||||
storePassword:$FDROID_STORE_PASSWORD \
|
||||
storePassword:"${{ env.FDROID_STORE_PASSWORD }}" \
|
||||
keyAlias:bitwarden \
|
||||
keyPassword:$FDROID_STORE_PASSWORD
|
||||
keyPassword:"${{ env.FDROID_STORE_PASSWORD }}"
|
||||
|
||||
- name: Generate F-Droid Beta Artifacts
|
||||
env:
|
||||
FDROID_BETA_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEYSTORE-PASSWORD }}
|
||||
FDROID_BETA_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.FDROID-BETA-KEY-PASSWORD }}
|
||||
FDROID_BETA_KEYSTORE_PASSWORD: ${{ secrets.FDROID_BETA_KEYSTORE_PASSWORD }}
|
||||
FDROID_BETA_KEY_PASSWORD: ${{ secrets.FDROID_BETA_KEY_PASSWORD }}
|
||||
run: |
|
||||
bundle exec fastlane assembleFDroidBetaApk \
|
||||
storeFile:app_beta_fdroid-keystore.jks \
|
||||
storePassword:$FDROID_BETA_KEYSTORE_PASSWORD \
|
||||
storePassword:"${{ env.FDROID_BETA_KEYSTORE_PASSWORD }}" \
|
||||
keyAlias:bitwarden-beta \
|
||||
keyPassword:$FDROID_BETA_KEY_PASSWORD
|
||||
keyPassword:"${{ env.FDROID_BETA_KEY_PASSWORD }}"
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid .apk artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk
|
||||
@@ -589,15 +526,15 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid.apk" \
|
||||
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid SHA file
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid Beta .apk artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk
|
||||
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk
|
||||
@@ -608,22 +545,22 @@ jobs:
|
||||
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden.beta-fdroid.apk" \
|
||||
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
|
||||
- name: Upload to GitHub Artifacts - beta.fdroid.apk-sha256.txt
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
- name: Upload F-Droid Beta SHA file
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
with:
|
||||
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install Firebase app distribution plugin
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
run: bundle exec fastlane add_plugin firebase_app_distribution
|
||||
|
||||
- name: Distribute to Firebase - fdroid.apk
|
||||
if: ${{ env.DISTRIBUTE_TO_FIREBASE }}
|
||||
- name: Publish release F-Droid artifacts to Firebase
|
||||
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
|
||||
env:
|
||||
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
|
||||
run: |
|
||||
bundle exec fastlane distributeReleaseFDroidToFirebase \
|
||||
actionUrl:$GITHUB_ACTION_RUN_URL \
|
||||
service_credentials_file:$APP_FDROID_FIREBASE_CREDS_PATH
|
||||
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
|
||||
service_credentials_file:${{ env.APP_FDROID_FIREBASE_CREDS_PATH }}
|
||||
|
||||
@@ -2,8 +2,8 @@ name: Cron / Sync Google Privileged Browsers List
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
# Run weekly on Monday at 00:00 UTC
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -21,26 +21,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2
|
||||
|
||||
- name: Download Google Privileged Browsers List
|
||||
run: curl -s "$SOURCE_URL" -o "$GOOGLE_FILE"
|
||||
run: curl -s $SOURCE_URL -o $GOOGLE_FILE
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if git diff --quiet -- "$GOOGLE_FILE"; then
|
||||
if git diff --quiet -- $GOOGLE_FILE; then
|
||||
echo "👀 No changes detected, skipping..."
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "👀 Changes detected, validating fido2_privileged_google.json..."
|
||||
|
||||
if ! python .github/scripts/validate-json/validate_json.py validate "$GOOGLE_FILE"; then
|
||||
python .github/scripts/validate-json/validate_json.py validate $GOOGLE_FILE
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "::error::JSON validation failed for $GOOGLE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -48,14 +47,14 @@ jobs:
|
||||
echo "👀 fido2_privileged_google.json is valid, checking for duplicates..."
|
||||
|
||||
# Check for duplicates between Google and Community files
|
||||
python .github/scripts/validate-json/validate_json.py duplicates "$GOOGLE_FILE" "$COMMUNITY_FILE" duplicates.txt
|
||||
python .github/scripts/validate-json/validate_json.py duplicates $GOOGLE_FILE $COMMUNITY_FILE duplicates.txt
|
||||
|
||||
if [ -f duplicates.txt ]; then
|
||||
echo "::warning::Duplicate package names found between Google and Community files."
|
||||
echo "duplicates_found=true" >> "$GITHUB_OUTPUT"
|
||||
echo "duplicates_found=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "✅ No duplicate package names found between Google and Community files"
|
||||
echo "duplicates_found=false" >> "$GITHUB_OUTPUT"
|
||||
echo "duplicates_found=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create branch and commit
|
||||
@@ -66,11 +65,11 @@ jobs:
|
||||
BRANCH_NAME="cron-sync-privileged-browsers/$GITHUB_RUN_NUMBER-sync"
|
||||
git config user.name "GitHub Actions Bot"
|
||||
git config user.email "actions@github.com"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git add "$GOOGLE_FILE"
|
||||
git checkout -b $BRANCH_NAME
|
||||
git add $GOOGLE_FILE
|
||||
git commit -m "Update Google privileged browsers list"
|
||||
git push origin "$BRANCH_NAME"
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> "$GITHUB_ENV"
|
||||
git push origin $BRANCH_NAME
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
echo "🌱 Branch created: $BRANCH_NAME"
|
||||
|
||||
- name: Create Pull Request
|
||||
@@ -90,10 +89,10 @@ jobs:
|
||||
fi
|
||||
|
||||
# Use echo -e to interpret escape sequences and pipe to gh pr create
|
||||
echo -e "$PR_BODY" | gh pr create \
|
||||
PR_URL=$(echo -e "$PR_BODY" | gh pr create \
|
||||
--title "Update Google privileged browsers list" \
|
||||
--body-file - \
|
||||
--base main \
|
||||
--head "$BRANCH_NAME" \
|
||||
--head $BRANCH_NAME \
|
||||
--label "automated-pr" \
|
||||
--label "t:deps"
|
||||
--label "t:ci")
|
||||
|
||||
56
.github/workflows/crowdin-pull-authenticator.yml
vendored
Normal file
56
.github/workflows/crowdin-pull-authenticator.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Crowdin Sync - Authenticator
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
schedule:
|
||||
- cron: '0 0 * * 5'
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
name: Autosync
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
_CROWDIN_PROJECT_ID: "673718"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Log in to Azure - CI Subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-gpg-private-key, github-gpg-private-key-passphrase"
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Download translations
|
||||
uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
|
||||
with:
|
||||
config: crowdin-bwa.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
github_user_name: "bitwarden-devops-bot"
|
||||
github_user_email: "106330231+bitwarden-devops-bot@users.noreply.github.com"
|
||||
commit_message: "Autosync the updated translations"
|
||||
localization_branch_name: crowdin-auto-sync
|
||||
create_pull_request: true
|
||||
pull_request_title: "Autosync Crowdin Translations"
|
||||
pull_request_body: "Autosync the updated translations"
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
58
.github/workflows/crowdin-pull.yml
vendored
58
.github/workflows/crowdin-pull.yml
vendored
@@ -1,40 +1,25 @@
|
||||
name: Cron / Crowdin Pull
|
||||
run-name: Crowdin Pull - ${{ github.event_name == 'workflow_dispatch' && 'Manual' || 'Scheduled' }}
|
||||
name: Crowdin Sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
schedule:
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
permissions: {}
|
||||
- cron: '0 0 * * 5'
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
name: Crowdin Pull - ${{ github.event_name }}
|
||||
name: Autosync
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
_CROWDIN_PROJECT_ID: "269690"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
- name: Login to Azure - CI Subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
@@ -43,24 +28,18 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write # for creating and pushing a new branch
|
||||
permission-pull-requests: write # for creating pull request
|
||||
app-id: ${{ secrets.BW_GHAPP_ID }}
|
||||
private-key: ${{ secrets.BW_GHAPP_KEY }}
|
||||
|
||||
- name: Download translations
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
|
||||
_CROWDIN_PROJECT_ID: "269690"
|
||||
with:
|
||||
config: crowdin.yml
|
||||
upload_sources: false
|
||||
@@ -68,11 +47,10 @@ jobs:
|
||||
download_translations: true
|
||||
github_user_name: "bitwarden-devops-bot"
|
||||
github_user_email: "106330231+bitwarden-devops-bot@users.noreply.github.com"
|
||||
commit_message: "Crowdin Pull"
|
||||
localization_branch_name: "crowdin-pull"
|
||||
commit_message: "Autosync the updated translations"
|
||||
localization_branch_name: crowdin-auto-sync
|
||||
create_pull_request: true
|
||||
pull_request_title: "Crowdin Pull"
|
||||
pull_request_body: ":inbox_tray: New translations received!"
|
||||
pull_request_labels: "automated-pr, t:misc"
|
||||
pull_request_title: "Autosync Crowdin Translations"
|
||||
pull_request_body: "Autosync the updated translations"
|
||||
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
|
||||
gpg_passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
|
||||
|
||||
30
.github/workflows/crowdin-push-authenticator.yml
vendored
Normal file
30
.github/workflows/crowdin-push-authenticator.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Crowdin Push - Authenticator
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
JAVA_VERSION: 17
|
||||
|
||||
jobs:
|
||||
crowdin-push:
|
||||
name: Crowdin Push
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
_CROWDIN_PROJECT_ID: "673718"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Upload sources
|
||||
uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_TOKEN }}
|
||||
with:
|
||||
config: crowdin-bwa.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
28
.github/workflows/crowdin-push.yml
vendored
28
.github/workflows/crowdin-push.yml
vendored
@@ -1,31 +1,25 @@
|
||||
name: CI / Crowdin Push
|
||||
run-name: Crowdin Push - ${{ github.event_name == 'workflow_dispatch' && 'Manual' || 'CI' }}
|
||||
name: Crowdin Push
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
crowdin-push:
|
||||
name: Crowdin Push - ${{ github.event_name }}
|
||||
name: Crowdin Push
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
_CROWDIN_PROJECT_ID: "269690"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
uses: Azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
@@ -35,15 +29,11 @@ jobs:
|
||||
secrets: "crowdin-api-token"
|
||||
|
||||
- name: Upload sources
|
||||
uses: crowdin/github-action@0749939f635900a2521aa6aac7a3766642b2dc71 # v2.11.0
|
||||
uses: crowdin/github-action@d1632879d4d4da358f2d040f79fa094571c9a649 # v2.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}
|
||||
_CROWDIN_PROJECT_ID: "269690"
|
||||
with:
|
||||
config: crowdin.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
306
.github/workflows/github-release.yml
vendored
306
.github/workflows/github-release.yml
vendored
@@ -3,291 +3,127 @@ name: Create GitHub Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version-name:
|
||||
description: 'Version Name - E.g. "2024.11.1"'
|
||||
required: true
|
||||
type: string
|
||||
version-number:
|
||||
description: 'Version Number - E.g. "123456"'
|
||||
required: true
|
||||
type: string
|
||||
artifact-run-id:
|
||||
description: "GitHub Action Run ID containing artifacts"
|
||||
description: 'GitHub Action Run ID containing artifacts'
|
||||
required: true
|
||||
type: string
|
||||
release-ticket-id:
|
||||
description: "Release Ticket ID - e.g. RELEASE-1762"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
draft:
|
||||
description: 'Create as draft release'
|
||||
type: boolean
|
||||
default: true
|
||||
prerelease:
|
||||
description: 'Mark as pre-release'
|
||||
type: boolean
|
||||
default: true
|
||||
make-latest:
|
||||
description: 'Set as the latest release'
|
||||
type: boolean
|
||||
branch-protection-type:
|
||||
description: 'Branch protection type'
|
||||
type: choice
|
||||
options:
|
||||
- Branch Name
|
||||
- GitHub API
|
||||
default: Branch Name
|
||||
env:
|
||||
ARTIFACTS_PATH: artifacts
|
||||
|
||||
ARTIFACTS_PATH: artifacts
|
||||
jobs:
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Log inputs to job summary
|
||||
uses: ./.github/actions/log-inputs
|
||||
with:
|
||||
inputs: ${{ toJson(inputs) }}
|
||||
|
||||
- name: Get branch from workflow run
|
||||
id: get_release_branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
BRANCH_PROTECTION_TYPE: ${{ inputs.branch-protection-type }}
|
||||
run: |
|
||||
workflow_data=$(gh run view "$ARTIFACT_RUN_ID" --json headBranch,workflowName)
|
||||
release_branch=$(echo "$workflow_data" | jq -r .headBranch)
|
||||
workflow_name=$(echo "$workflow_data" | jq -r .workflowName)
|
||||
release_branch=$(gh run view $ARTIFACT_RUN_ID --json headBranch -q .headBranch)
|
||||
|
||||
# branch protection check
|
||||
if [[ "$release_branch" != "main" && ! "$release_branch" =~ ^release/ ]]; then
|
||||
echo "::error::Branch '$release_branch' is not 'main' or a release branch starting with 'release/'. Releases must be created from protected branches."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔖 Release branch: $release_branch"
|
||||
echo "🔖 Workflow name: $workflow_name"
|
||||
echo "release_branch=$release_branch" >> "$GITHUB_OUTPUT"
|
||||
echo "workflow_name=$workflow_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
case "$workflow_name" in
|
||||
*"Password Manager"* | "Build")
|
||||
app_name="Password Manager"
|
||||
app_name_suffix="bwpm"
|
||||
case "$BRANCH_PROTECTION_TYPE" in
|
||||
"Branch Name")
|
||||
if [[ "$release_branch" != "main" && ! "$release_branch" =~ ^release/ ]]; then
|
||||
echo "::error::Branch '$release_branch' is not 'main' or a release branch starting with 'release/'. Releases must be created from protected branches."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*"Authenticator"*)
|
||||
app_name="Authenticator"
|
||||
app_name_suffix="bwa"
|
||||
"GitHub API")
|
||||
#NOTE requires token with "administration:read" scope
|
||||
if ! gh api "repos/${{ github.repository }}/branches/$release_branch/protection" | grep -q "required_status_checks"; then
|
||||
echo "::error::Branch '$release_branch' is not protected. Releases must be created from protected branches. If that's not correct, confirm if the github token user has the 'administration:read' scope."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown workflow name: $workflow_name"
|
||||
echo "::error::Unsupported branch protection type: $BRANCH_PROTECTION_TYPE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "🔖 App name: $app_name"
|
||||
echo "🔖 App name suffix: $app_name_suffix"
|
||||
echo "app_name=$app_name" >> "$GITHUB_OUTPUT"
|
||||
echo "app_name_suffix=$app_name_suffix" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get version info from run logs and set release tag name
|
||||
id: get_release_info
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
_APP_NAME_SUFFIX: ${{ steps.get_release_branch.outputs.app_name_suffix }}
|
||||
run: |
|
||||
workflow_log=$(gh run view "$ARTIFACT_RUN_ID" --log)
|
||||
|
||||
version_number_with_trailing_dot=$(grep -m 1 "Setting version code to" <<< "$workflow_log" | sed 's/.*Setting version code to //')
|
||||
version_number=${version_number_with_trailing_dot%.} # remove trailing dot
|
||||
|
||||
version_name_with_trailing_dot=$(grep -m 1 "Setting version name to" <<< "$workflow_log" | sed 's/.*Setting version name to //')
|
||||
version_name=${version_name_with_trailing_dot%.} # remove trailing dot
|
||||
|
||||
if [[ -z "$version_name" ]]; then
|
||||
echo "::warning::Version name not found. Using default value - 0.0.0"
|
||||
version_name="0.0.0"
|
||||
else
|
||||
echo "✅ Found version name: $version_name"
|
||||
fi
|
||||
|
||||
if [[ -z "$version_number" ]]; then
|
||||
echo "::warning::Version number not found. Using default value - 0"
|
||||
version_number="0"
|
||||
else
|
||||
echo "✅ Found version number: $version_number"
|
||||
fi
|
||||
|
||||
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
|
||||
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
tag_name="v$version_name-$_APP_NAME_SUFFIX" # e.g. v2025.6.0-bwpm
|
||||
echo "🔖 New tag name: $tag_name"
|
||||
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
|
||||
|
||||
last_release_tag=$(git tag -l --sort=-authordate | grep "$_APP_NAME_SUFFIX" | head -n 1)
|
||||
echo "🔖 Last release tag: $last_release_tag"
|
||||
echo "last_release_tag=$last_release_tag" >> "$GITHUB_OUTPUT"
|
||||
echo "release_branch=$release_branch" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
run: |
|
||||
gh run download "$ARTIFACT_RUN_ID" -D "$ARTIFACTS_PATH"
|
||||
file_count=$(find "$ARTIFACTS_PATH" -type f | wc -l)
|
||||
gh run download $ARTIFACT_RUN_ID -D $ARTIFACTS_PATH
|
||||
file_count=$(find $ARTIFACTS_PATH -type f | wc -l)
|
||||
echo "Downloaded $file_count file(s)."
|
||||
if [ "$file_count" -gt 0 ]; then
|
||||
echo "Downloaded files:"
|
||||
find "$ARTIFACTS_PATH" -type f
|
||||
find $ARTIFACTS_PATH -type f
|
||||
fi
|
||||
|
||||
# Files that won't be included in any release
|
||||
files_to_remove=(
|
||||
"com.x8bit.bitwarden.aab"
|
||||
"com.x8bit.bitwarden.aab-sha256.txt"
|
||||
|
||||
"com.x8bit.bitwarden.beta.apk"
|
||||
"com.x8bit.bitwarden.beta.apk-sha256.txt"
|
||||
"com.x8bit.bitwarden.beta.aab"
|
||||
"com.x8bit.bitwarden.beta.aab-sha256.txt"
|
||||
|
||||
"com.x8bit.bitwarden.beta-fdroid.apk"
|
||||
"com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt"
|
||||
|
||||
"com.x8bit.bitwarden.dev.apk"
|
||||
"com.x8bit.bitwarden.dev.apk-sha256.txt"
|
||||
|
||||
"com.bitwarden.authenticator.aab"
|
||||
"authenticator-android-aab-sha256.txt"
|
||||
)
|
||||
|
||||
for file in "${files_to_remove[@]}"; do
|
||||
find "$ARTIFACTS_PATH" -name "$file" -type f -delete
|
||||
done
|
||||
echo "🔖 Removed internal artifacts."
|
||||
echo ""
|
||||
echo "🔖 Files to be included in the release:"
|
||||
find "$ARTIFACTS_PATH" -type f
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-android
|
||||
secrets: "JIRA-API-EMAIL,JIRA-API-TOKEN"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Get product release notes
|
||||
id: get_release_notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_RELEASE_TICKET_ID: ${{ inputs.release-ticket-id }}
|
||||
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
|
||||
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
|
||||
run: |
|
||||
echo "Getting product release notes..."
|
||||
# capture output and exit code so this step continues even if we can't retrieve release notes.
|
||||
script_exit_code=0
|
||||
product_release_notes=$(python .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
|
||||
echo "--------------------------------"
|
||||
|
||||
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then
|
||||
echo "Script Output: $product_release_notes"
|
||||
echo "::warning::Failed to fetch release notes from Jira. Check script logs for more details."
|
||||
product_release_notes="<insert product release notes here>"
|
||||
else
|
||||
echo "✅ Product release notes:"
|
||||
echo "$product_release_notes"
|
||||
fi
|
||||
|
||||
echo "$product_release_notes" > product_release_notes.txt
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
_APP_NAME: ${{ steps.get_release_branch.outputs.app_name }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_VERSION_NUMBER: ${{ steps.get_release_info.outputs.version_number }}
|
||||
_TARGET_COMMIT: ${{ steps.get_release_branch.outputs.release_branch }}
|
||||
_TAG_NAME: ${{ steps.get_release_info.outputs.tag_name }}
|
||||
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
|
||||
run: |
|
||||
is_latest_release=false
|
||||
if [[ "$_APP_NAME" == "Password Manager" ]]; then
|
||||
is_latest_release=true
|
||||
fi
|
||||
|
||||
echo "⌛️ Creating release for $_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER) on $_TARGET_COMMIT"
|
||||
release_url=$(gh release create "$_TAG_NAME" \
|
||||
--title "$_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER)" \
|
||||
--target "$_TARGET_COMMIT" \
|
||||
--generate-notes \
|
||||
--notes-start-tag "$_LAST_RELEASE_TAG" \
|
||||
--latest=$is_latest_release \
|
||||
--draft \
|
||||
"$ARTIFACTS_PATH/*/*")
|
||||
|
||||
# Extract release tag from URL
|
||||
release_id_from_url=$(echo "$release_url" | sed 's/.*\/tag\///')
|
||||
echo "release_id_from_url=$release_id_from_url" >> "$GITHUB_OUTPUT"
|
||||
echo "url=$release_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "✅ Release created: $release_url"
|
||||
echo "🔖 Release ID from URL: $release_id_from_url"
|
||||
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
|
||||
with:
|
||||
tag_name: "v${{ inputs.version-name }}"
|
||||
name: "${{ inputs.version-name }} (${{ inputs.version-number }})"
|
||||
prerelease: ${{ inputs.prerelease }}
|
||||
draft: ${{ inputs.draft }}
|
||||
make_latest: ${{ inputs.make-latest }}
|
||||
target_commitish: ${{ steps.get_release_branch.outputs.release_branch }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*
|
||||
|
||||
- name: Update Release Description
|
||||
id: update_release_description
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_ID: ${{ steps.create_release.outputs.id }}
|
||||
RELEASE_URL: ${{ steps.create_release.outputs.url }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_RELEASE_ID: ${{ steps.create_release.outputs.release_id_from_url }}
|
||||
run: |
|
||||
echo "Getting current release body. Release ID: $_RELEASE_ID"
|
||||
current_body=$(gh release view "$_RELEASE_ID" --json body --jq .body)
|
||||
# Get current release body
|
||||
current_body=$(gh api /repos/${{ github.repository }}/releases/$RELEASE_ID --jq .body)
|
||||
|
||||
product_release_notes=$(cat product_release_notes.txt)
|
||||
|
||||
# Update release description with product release notes and builds source
|
||||
updated_body="# Overview
|
||||
${product_release_notes}
|
||||
|
||||
${current_body}
|
||||
# Append build source to the end
|
||||
updated_body="${current_body}
|
||||
**Builds Source:** https://github.com/${{ github.repository }}/actions/runs/$ARTIFACT_RUN_ID"
|
||||
|
||||
new_release_url=$(gh release edit "$_RELEASE_ID" --notes "$updated_body")
|
||||
# Update release
|
||||
gh api --method PATCH /repos/${{ github.repository }}/releases/$RELEASE_ID \
|
||||
-f body="$updated_body"
|
||||
|
||||
# draft release links change after editing
|
||||
echo "release_url=$new_release_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add Release Summary
|
||||
env:
|
||||
_RELEASE_TAG: ${{ steps.get_release_info.outputs.tag_name }}
|
||||
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
|
||||
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
|
||||
_VERSION_NUMBER: ${{ steps.get_release_info.outputs.version_number }}
|
||||
_RELEASE_BRANCH: ${{ steps.get_release_branch.outputs.release_branch }}
|
||||
_RELEASE_URL: ${{ steps.update_release_description.outputs.release_url }}
|
||||
run: |
|
||||
{
|
||||
echo "# :fish_cake: Release ready at:"
|
||||
echo "$_RELEASE_URL"
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [[ "$_VERSION_NAME" == "0.0.0" || "$_VERSION_NUMBER" == "0" ]]; then
|
||||
{
|
||||
echo "> [!CAUTION]"
|
||||
echo "> Version name or number wasn't previously found and a default value was used. You'll need to manually update the release Title, Tag and Description, specifically, the \"Full Changelog\" link."
|
||||
echo ""
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
{
|
||||
echo ":clipboard: Confirm that the defined GitHub Release options are correct:"
|
||||
echo " * :bookmark: New tag name: \`$_RELEASE_TAG\`"
|
||||
echo " * :palm_tree: Target branch: \`$_RELEASE_BRANCH\`"
|
||||
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`"
|
||||
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch"
|
||||
echo "> [!NOTE]"
|
||||
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "# :rocket: Release ready at:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "$RELEASE_URL" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
24
.github/workflows/publish-github-release-bwa.yml
vendored
24
.github/workflows/publish-github-release-bwa.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: Publish Authenticator GitHub Release as newest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 * * * 1-5' # Every hour on the hour on weekdays
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
publish-release-authenticator:
|
||||
name: Publish Authenticator Release
|
||||
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
|
||||
with:
|
||||
release_name: "Authenticator"
|
||||
workflow_name: "publish-github-release-bwa.yml"
|
||||
credentials_filename: "authenticator_play_store-creds.json"
|
||||
project_type: android
|
||||
make_latest: false
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.bitwarden.authenticator track:production
|
||||
secrets: inherit
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Publish Password Manager GitHub Release as newest
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 * * * 1-5' # Every hour on the hour on weekdays
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
publish-release-password-manager:
|
||||
name: Publish Password Manager Release
|
||||
uses: bitwarden/gh-actions/.github/workflows/_publish-mobile-github-release.yml@main
|
||||
with:
|
||||
release_name: "Password Manager"
|
||||
workflow_name: "publish-github-release-bwpm.yml"
|
||||
credentials_filename: "play_creds.json"
|
||||
project_type: android
|
||||
make_latest: true
|
||||
check_release_command: >
|
||||
bundle exec fastlane getLatestPlayStoreVersion package_name:com.x8bit.bitwarden track:production
|
||||
secrets: inherit
|
||||
181
.github/workflows/publish-store.yml
vendored
181
.github/workflows/publish-store.yml
vendored
@@ -1,181 +0,0 @@
|
||||
name: Publish to Google Play
|
||||
run-name: >
|
||||
${{ inputs.dry-run && ' (Dry Run)' || '' }}Promoting ${{ inputs.product }} ${{ inputs.version-code }} from ${{ inputs.track-from }} to ${{ inputs.track-target }}
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
product:
|
||||
description: "Which app is being released."
|
||||
type: choice
|
||||
options:
|
||||
- Password Manager
|
||||
- Authenticator
|
||||
version-name:
|
||||
description: "Version name to promote to production ex 2025.1.1"
|
||||
type: string
|
||||
version-code:
|
||||
description: "Build number to promote to production."
|
||||
required: true
|
||||
type: string
|
||||
rollout-percentage:
|
||||
description: "Percentage of users who will receive this version update."
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- 10%
|
||||
- 30%
|
||||
- 50%
|
||||
- 100%
|
||||
default: 10%
|
||||
release-notes:
|
||||
description: "Change notes to be included with this release."
|
||||
type: string
|
||||
default: "Bug fixes."
|
||||
required: true
|
||||
track-from:
|
||||
description: "Track to promote from."
|
||||
type: choice
|
||||
options:
|
||||
- internal
|
||||
- Fastlane Automation Source
|
||||
required: true
|
||||
default: "internal"
|
||||
track-target:
|
||||
description: "Track to promote to."
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- Fastlane Automation Target
|
||||
required: true
|
||||
dry-run:
|
||||
description: "Dry-Run, Run the workflow without publishing to the store"
|
||||
type: boolean
|
||||
default: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
id-token: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-24.04
|
||||
name: Promote build to Production in Play Store
|
||||
|
||||
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: 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:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-android
|
||||
secrets: "PLAY-BETA-KEYSTORE-PASSWORD,PLAY-BETA-KEY-PASSWORD"
|
||||
|
||||
- name: Retrieve secrets
|
||||
env:
|
||||
ACCOUNT_NAME: bitwardenci
|
||||
CONTAINER_NAME: mobile
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/secrets
|
||||
mkdir -p ${{ github.workspace }}/app/src/standardRelease
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
--name play_creds.json --file ${{ github.workspace }}/secrets/play_creds.json --output none
|
||||
|
||||
az storage blob download --account-name "$ACCOUNT_NAME" --container-name "$CONTAINER_NAME" \
|
||||
--name authenticator_play_store-creds.json --file ${{ github.workspace }}/secrets/authenticator_play_store-creds.json --output none
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Format Release Notes
|
||||
env:
|
||||
RELEASE_NOTES: ${{ inputs.release-notes }}
|
||||
run: |
|
||||
FORMATTED_MESSAGE="$(echo "$RELEASE_NOTES" | sed 's/ /\n/g')"
|
||||
{
|
||||
echo "RELEASE_NOTES<<EOF"
|
||||
printf '%s\n' "$FORMATTED_MESSAGE"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Promote Play Store version to production
|
||||
env:
|
||||
PLAY_KEYSTORE_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEYSTORE-PASSWORD }}
|
||||
PLAY_KEY_PASSWORD: ${{ steps.get-kv-secrets.outputs.PLAY-BETA-KEY-PASSWORD }}
|
||||
VERSION_CODE_INPUT: ${{ inputs.version-code }}
|
||||
VERSION_NAME: ${{inputs.version-name}}
|
||||
ROLLOUT_PERCENTAGE: ${{ inputs.rollout-percentage }}
|
||||
PRODUCT: ${{ inputs.product }}
|
||||
TRACK_FROM: ${{ inputs.track-from }}
|
||||
TRACK_TARGET: ${{ inputs.track-target }}
|
||||
run: |
|
||||
if [ "$PRODUCT" = "Password Manager" ]; then
|
||||
PACKAGE_NAME="com.x8bit.bitwarden"
|
||||
elif [ "$PRODUCT" = "Authenticator" ]; then
|
||||
PACKAGE_NAME="com.bitwarden.authenticator"
|
||||
else
|
||||
echo "Unsupported product: $PRODUCT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION_CODE=$(echo "${VERSION_CODE_INPUT}" | tr -d ',')
|
||||
|
||||
decimal=$(echo "scale=2; ${ROLLOUT_PERCENTAGE/\%/} / 100" | bc)
|
||||
|
||||
bundle exec fastlane updateReleaseNotes \
|
||||
releaseNotes:"$RELEASE_NOTES" \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
packageName:"$PACKAGE_NAME"
|
||||
|
||||
bundle exec fastlane promoteToProduction \
|
||||
versionCode:"$VERSION_CODE" \
|
||||
versionName:"$VERSION_NAME" \
|
||||
rolloutPercentage:"$decimal" \
|
||||
packageName:"$PACKAGE_NAME" \
|
||||
releaseNotes:"$RELEASE_NOTES" \
|
||||
track:"$TRACK_FROM" \
|
||||
trackPromoteTo:"$TRACK_TARGET"
|
||||
|
||||
- name: Enable Publish Github Release Workflow
|
||||
env:
|
||||
PRODUCT: ${{ inputs.product }}
|
||||
run: |
|
||||
if ${{ inputs.dry-run }} ; then
|
||||
gh workflow view publish-github-release-bwpm.yml
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PRODUCT" = "Password Manager" ]; then
|
||||
gh workflow enable publish-github-release-bwpm.yml
|
||||
elif [ "$PRODUCT" = "Authenticator" ]; then
|
||||
gh workflow enable publish-github-release-bwa.yml
|
||||
fi
|
||||
68
.github/workflows/release-branch.yml
vendored
68
.github/workflows/release-branch.yml
vendored
@@ -4,14 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_type:
|
||||
description: "Release Type"
|
||||
description: 'Release Type'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- RC
|
||||
- Hotfix Password Manager
|
||||
- Hotfix Authenticator
|
||||
- Test
|
||||
- Hotfix
|
||||
|
||||
jobs:
|
||||
create-release-branch:
|
||||
@@ -19,68 +17,42 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Create RC or Test Branch
|
||||
id: rc_branch
|
||||
if: inputs.release_type == 'RC' || inputs.release_type == 'Test'
|
||||
- name: Create RC Branch
|
||||
if: inputs.release_type == 'RC'
|
||||
env:
|
||||
_TEST_MODE: ${{ inputs.release_type == 'Test' }}
|
||||
_RELEASE_TYPE: ${{ inputs.release_type }}
|
||||
RC_PREFIX_DATE: "true" # replace with input if needed
|
||||
run: |
|
||||
current_date=$(date +'%Y.%-m')
|
||||
branch_name="${current_date}-rc${{ github.run_number }}"
|
||||
|
||||
if [ "$_TEST_MODE" = "true" ]; then
|
||||
branch_name="WORKFLOW-TEST-${branch_name}"
|
||||
if [ "$RC_PREFIX_DATE" = "true" ]; then
|
||||
current_date=$(date +'%Y.%m')
|
||||
branch_name="release/${current_date}-rc${{ github.run_number }}"
|
||||
else
|
||||
branch_name="release/rc${{ github.run_number }}"
|
||||
fi
|
||||
branch_name="release/${branch_name}"
|
||||
|
||||
git switch main
|
||||
git switch -c "$branch_name"
|
||||
git push origin "$branch_name"
|
||||
echo "# :cherry_blossom: ${_RELEASE_TYPE} branch: ${branch_name}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT"
|
||||
git switch -c $branch_name
|
||||
git push origin $branch_name
|
||||
echo "# :cherry_blossom: RC branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Create Hotfix Branch
|
||||
id: hotfix_branch
|
||||
if: startsWith(inputs.release_type, 'Hotfix')
|
||||
env:
|
||||
_RELEASE_TYPE: ${{ inputs.release_type }}
|
||||
if: inputs.release_type == 'Hotfix'
|
||||
run: |
|
||||
app_codename="bwpm"
|
||||
if [ "$_RELEASE_TYPE" == "Hotfix Authenticator" ]; then
|
||||
app_codename="bwa"
|
||||
fi
|
||||
echo "🌿 app codename: $app_codename"
|
||||
|
||||
latest_tag=$(git tag -l --sort=-creatordate | grep "$app_codename" | head -n 1)
|
||||
latest_tag=$(git tag -l --sort=-creatordate | head -n 1)
|
||||
if [ -z "$latest_tag" ]; then
|
||||
echo "::error::No tags found in the repository"
|
||||
exit 1
|
||||
fi
|
||||
branch_name="release/hotfix-${latest_tag}"
|
||||
echo "🌿 branch name: $branch_name"
|
||||
echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT"
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/$branch_name"; then
|
||||
echo "# :fire: :warning: Hotfix branch already exists: ${branch_name}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "# :fire: :warning: Hotfix branch already exists: ${branch_name}" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
git switch -c "$branch_name" "$latest_tag"
|
||||
git push origin "$branch_name"
|
||||
echo "# :fire: Hotfix branch: ${branch_name}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Trigger CI Workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
_BRANCH_NAME: ${{ steps.rc_branch.outputs.branch_name || steps.hotfix_branch.outputs.branch_name }}
|
||||
run: |
|
||||
echo "🌿 branch name: $_BRANCH_NAME"
|
||||
gh workflow run build.yml --ref "$_BRANCH_NAME" -f distribute-to-firebase=true -f publish-to-play-store=true
|
||||
gh workflow run build-authenticator.yml --ref "$_BRANCH_NAME" -f distribute-to-firebase=true -f publish-to-play-store=true
|
||||
git switch -c $branch_name $latest_tag
|
||||
git push origin $branch_name
|
||||
echo "# :fire: Hotfix branch: ${branch_name}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
28
.github/workflows/respond.yml
vendored
28
.github/workflows/respond.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Respond
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
respond:
|
||||
name: Respond
|
||||
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
21
.github/workflows/review-code.yml
vendored
21
.github/workflows/review-code.yml
vendored
@@ -1,21 +0,0 @@
|
||||
name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Review
|
||||
uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
61
.github/workflows/scan-ci.yml
vendored
61
.github/workflows/scan-ci.yml
vendored
@@ -6,30 +6,55 @@ on:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
sast:
|
||||
name: Checkmarx
|
||||
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
name: SAST scan
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
|
||||
with:
|
||||
project_name: ${{ github.repository }}
|
||||
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
|
||||
base_uri: https://ast.checkmarx.net/
|
||||
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
|
||||
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
|
||||
additional_params: |
|
||||
--report-format sarif \
|
||||
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
|
||||
--output-path .
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v3.28.1
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
|
||||
quality:
|
||||
name: Sonar
|
||||
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
name: Quality scan
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
uses: sonarsource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.organization=${{ github.repository_owner }}
|
||||
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
|
||||
|
||||
77
.github/workflows/scan.yml
vendored
77
.github/workflows/scan.yml
vendored
@@ -2,47 +2,72 @@ name: Scan Pull Requests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches-ignore:
|
||||
- main
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions: {}
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
sast:
|
||||
name: Checkmarx
|
||||
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
|
||||
name: SAST scan
|
||||
runs-on: ubuntu-24.04
|
||||
needs: check-run
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
|
||||
env:
|
||||
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
|
||||
with:
|
||||
project_name: ${{ github.repository }}
|
||||
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
|
||||
base_uri: https://ast.checkmarx.net/
|
||||
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
|
||||
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
|
||||
additional_params: |
|
||||
--report-format sarif \
|
||||
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@b6a472f63d85b9c78a3ac5e89422239fc15e9b3c # v3.28.1
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
|
||||
|
||||
quality:
|
||||
name: Sonar
|
||||
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
|
||||
name: Quality scan
|
||||
runs-on: ubuntu-24.04
|
||||
needs: check-run
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
uses: sonarsource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203 # v4.2.1
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.organization=${{ github.repository_owner }}
|
||||
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
|
||||
-Dsonar.pullrequest.key=${{ github.event.pull_request.number }}
|
||||
|
||||
64
.github/workflows/sdlc-enforce-labels.yml
vendored
64
.github/workflows/sdlc-enforce-labels.yml
vendored
@@ -1,64 +0,0 @@
|
||||
name: SDLC / Enforce PR labels
|
||||
run-name: Enforce labels for PR ${{ github.event.pull_request.number }}
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, reopened, edited, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
enforce-label:
|
||||
name: Enforce Label
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Enforce banned labels (e.g. hold, needs-qa)
|
||||
env:
|
||||
_HOLD_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'hold') }}
|
||||
_NEEDS_QA_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'needs-qa') }}
|
||||
run: |
|
||||
if [ "$_HOLD_LABEL" = "true" ]; then
|
||||
echo "::error::PR has banned label: hold"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$_NEEDS_QA_LABEL" = "true" ]; then
|
||||
echo "::error::PR has banned label: needs-qa"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ No banned labels found."
|
||||
|
||||
- name: Enforce exactly one Change Type (t:*) label
|
||||
env:
|
||||
_PR_ACTION: ${{ github.event.action }}
|
||||
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
|
||||
_REPO: ${{ github.repository }}
|
||||
_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [ "$_PR_ACTION" = "opened" ] || [ "$_PR_ACTION" = "reopened" ]; then
|
||||
echo "⏳ Waiting 15s for labeler to run..."
|
||||
sleep 15
|
||||
_PR_LABELS=$(gh api "repos/$_REPO/pulls/$_PR_NUMBER" --jq '.labels')
|
||||
echo "Labels fetched from PR: $_PR_LABELS"
|
||||
fi
|
||||
_IGNORE_FOR_RELEASE_LABEL=$(echo "$_PR_LABELS" | jq 'any(.[]; .name == "ignore-for-release")')
|
||||
if [ "$_IGNORE_FOR_RELEASE_LABEL" = "true" ]; then
|
||||
echo "⏭️ Skipping type label check - 'ignore-for-release' label present"
|
||||
exit 0
|
||||
fi
|
||||
_T_LABEL_COUNT=$(echo "$_PR_LABELS" | jq '[.[] | select(.name | startswith("t:"))] | length')
|
||||
case "$_T_LABEL_COUNT" in
|
||||
1)
|
||||
echo "✅ PR has exactly one Change Type (t:*) label"
|
||||
;;
|
||||
0)
|
||||
echo "::error::PR is missing a Change Type (t:*) label. PRs must have exactly one Change Type (t:*) label"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::PR has $_T_LABEL_COUNT Change Type (t:*) labels. PRs must have exactly one Change Type (t:*) label"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
90
.github/workflows/sdlc-label-pr.yml
vendored
90
.github/workflows/sdlc-label-pr.yml
vendored
@@ -1,90 +0,0 @@
|
||||
name: SDLC / Label PR
|
||||
run-name: Label PR ${{ github.event.pull_request.number || inputs.pr-number }}${{ github.event_name == 'workflow_dispatch' && format(' / mode "{0}" dry-run "{1}"', inputs.mode, inputs.dry-run) || '' }}
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr-number:
|
||||
description: "Pull Request Number"
|
||||
required: true
|
||||
type: number
|
||||
mode:
|
||||
description: "Labeling Mode"
|
||||
type: choice
|
||||
options:
|
||||
- add
|
||||
- replace
|
||||
default: add
|
||||
dry-run:
|
||||
description: "Dry Run - Don't apply labels"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
_PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr-number }}
|
||||
|
||||
jobs:
|
||||
label-pr:
|
||||
name: Label PR by Changed Files
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write # required to update labels
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Determine label mode for Pull Request
|
||||
id: label-mode
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
_PR_USER: ${{ github.event.pull_request.user.login }}
|
||||
_IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
run: |
|
||||
# Support workflow_dispatch testing by retrieving PR data
|
||||
if [ -z "$_PR_USER" ]; then
|
||||
echo "👀 PR User is empty, retrieving PR data for PR #$_PR_NUMBER..."
|
||||
PR_DATA=$(gh pr view "$_PR_NUMBER" --json author,isCrossRepository)
|
||||
_PR_USER=$(echo "$PR_DATA" | jq -r '.author.login')
|
||||
_IS_FORK=$(echo "$PR_DATA" | jq -r '.isCrossRepository')
|
||||
fi
|
||||
|
||||
echo "📋 PR User: $_PR_USER"
|
||||
echo "📋 Is Fork: $_IS_FORK"
|
||||
|
||||
# Handle PRs with labels set by other automations by adding instead of replacing
|
||||
if [ "$_IS_FORK" = "true" ]; then
|
||||
echo "➡️ Fork PR ($_PR_USER). Label mode: --add"
|
||||
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$_PR_USER" == app/* || "$_PR_USER" == *\[bot\] ]]; then
|
||||
echo "➡️ Bot PR ($_PR_USER). Label mode: --add"
|
||||
echo "label_mode=--add" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "➡️ Normal PR. Label mode: --replace"
|
||||
echo "label_mode=--replace" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Label PR based on changed files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
_LABEL_MODE: ${{ inputs.mode && format('--{0}', inputs.mode) || steps.label-mode.outputs.label_mode }}
|
||||
_DRY_RUN: ${{ inputs.dry-run == true && '--dry-run' || '' }}
|
||||
_PR_LABELS: ${{ toJSON(github.event.pull_request.labels) }}
|
||||
run: |
|
||||
if [ -z "$_PR_LABELS" ] || [ "$_PR_LABELS" = "null" ] || [ "$_PR_LABELS" = "[]" ]; then
|
||||
echo "🔍 No current PR labels found, retrieving PR data for PR #$_PR_NUMBER..."
|
||||
_PR_LABELS=$(gh pr view "$_PR_NUMBER" --json labels --jq '.labels')
|
||||
fi
|
||||
echo "🔍 Labeling PR #$_PR_NUMBER with mode: \"$_LABEL_MODE\" and dry-run: \"$_DRY_RUN\" and current PR labels: \"$_PR_LABELS\"..."
|
||||
echo "🐍 Running label-pr.py script..."
|
||||
echo ""
|
||||
python3 .github/scripts/label-pr.py "$_PR_NUMBER" "$_PR_LABELS" "$_LABEL_MODE" "$_DRY_RUN"
|
||||
|
||||
230
.github/workflows/sdlc-sdk-update.yml
vendored
230
.github/workflows/sdlc-sdk-update.yml
vendored
@@ -1,230 +0,0 @@
|
||||
name: SDLC / SDK Update
|
||||
run-name: "SDK ${{inputs.run-mode == 'Update' && format('Update - {0}', inputs.sdk-version) || format('Test #{0} - {1}', inputs.pr-id, inputs.sdk-version)}}"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run-mode:
|
||||
description: "Run Mode"
|
||||
type: choice
|
||||
options:
|
||||
- Test # used for testing sdk-internal repo PRs
|
||||
- Update # opens a PR in this repo updating the SDK
|
||||
default: Test
|
||||
sdk-package:
|
||||
description: "SDK Package ID"
|
||||
required: true
|
||||
default: "com.bitwarden:sdk-android.dev"
|
||||
sdk-version:
|
||||
description: "SDK Version"
|
||||
required: true
|
||||
default: "1.0.0-2686-km-update-kdf-sdk"
|
||||
pr-id:
|
||||
description: "Pull Request ID"
|
||||
|
||||
env:
|
||||
_BOT_NAME: "bw-ghapp[bot]"
|
||||
_BOT_EMAIL: "178206702+bw-ghapp[bot]@users.noreply.github.com"
|
||||
|
||||
jobs:
|
||||
update:
|
||||
name: Update and PR
|
||||
if: ${{ inputs.run-mode == 'Update' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-pull-requests: write
|
||||
permission-actions: read
|
||||
permission-contents: write
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Log inputs to job summary
|
||||
uses: ./.github/actions/log-inputs
|
||||
with:
|
||||
inputs: ${{ toJson(inputs) }}
|
||||
|
||||
- name: Switch to branch
|
||||
id: switch-branch
|
||||
run: |
|
||||
BRANCH_NAME="sdlc/sdk-update"
|
||||
echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if git switch "$BRANCH_NAME"; then
|
||||
echo "✅ Switched to existing branch: $BRANCH_NAME"
|
||||
echo "updating_existing_branch=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "📝 Creating new branch: $BRANCH_NAME"
|
||||
git switch -c "$BRANCH_NAME"
|
||||
echo "updating_existing_branch=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Prevent updating the branch when the last committer isn't the bot
|
||||
if: ${{ steps.switch-branch.outputs.updating_existing_branch == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
|
||||
run: |
|
||||
LATEST_COMMIT_AUTHOR=$(git log -1 --format='%ae' "$_BRANCH_NAME")
|
||||
|
||||
echo "Latest commit author in branch ($_BRANCH_NAME): $LATEST_COMMIT_AUTHOR"
|
||||
echo "Expected bot email: $_BOT_EMAIL"
|
||||
|
||||
if [ "$LATEST_COMMIT_AUTHOR" != "$_BOT_EMAIL" ]; then
|
||||
echo "::error::Branch $_BRANCH_NAME has a commit not made by the bot." \
|
||||
"This indicates manual changes have been made to the branch," \
|
||||
"PR has to be merged or closed before running this workflow again."
|
||||
echo "👀 Fetching existing PR..."
|
||||
gh pr list --head "$_BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty'
|
||||
EXISTING_PR=$(gh pr list --head "$_BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty')
|
||||
if [ -z "$EXISTING_PR" ]; then
|
||||
echo "::error::Couldn't find an existing PR for branch $_BRANCH_NAME."
|
||||
exit 1
|
||||
fi
|
||||
PR_URL="https://github.com/${{ github.repository }}/pull/$EXISTING_PR"
|
||||
echo "## ❌ Merge or close: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Branch tip commit was made by the bot. Safe to proceed."
|
||||
|
||||
# Using main to retrieve the changelog on consecutive updates of the same PR.
|
||||
- name: Get current SDK version from main branch
|
||||
id: get-current-sdk
|
||||
run: |
|
||||
git show origin/main:gradle/libs.versions.toml
|
||||
SDK_VERSION=$(git show origin/main:gradle/libs.versions.toml | grep "bitwardenSdk =" | cut -d'"' -f2)
|
||||
if [ -z "$SDK_VERSION" ]; then
|
||||
echo "::error::Failed to get current SDK version from main branch."
|
||||
exit 1
|
||||
fi
|
||||
GIT_REF=$(echo "$SDK_VERSION" | cut -d'-' -f3-) # handles both commit hashes and branch names
|
||||
echo "Current SDK version (from main): $SDK_VERSION"
|
||||
echo "Current SDK git ref: $GIT_REF"
|
||||
echo "version=$SDK_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "git_ref=$GIT_REF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update SDK Version
|
||||
env:
|
||||
_SDK_PACKAGE: ${{ inputs.sdk-package }}
|
||||
_SDK_VERSION: ${{ inputs.sdk-version }}
|
||||
run: |
|
||||
./scripts/update-sdk-version.sh "$_SDK_PACKAGE" "$_SDK_VERSION"
|
||||
|
||||
- name: Create branch and commit
|
||||
env:
|
||||
_SDK_PACKAGE: ${{ inputs.sdk-package }}
|
||||
_SDK_VERSION: ${{ inputs.sdk-version }}
|
||||
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
|
||||
run: |
|
||||
echo "👀 Committing SDK version update..."
|
||||
|
||||
git config user.name "$_BOT_NAME"
|
||||
git config user.email "$_BOT_EMAIL"
|
||||
|
||||
git add gradle/libs.versions.toml
|
||||
git commit -m "SDK Update - $_SDK_PACKAGE $_SDK_VERSION"
|
||||
git push origin "$_BRANCH_NAME"
|
||||
|
||||
- name: Create or Update Pull Request
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
_BRANCH_NAME: ${{ steps.switch-branch.outputs.branch_name }}
|
||||
_SDK_PACKAGE: ${{ inputs.sdk-package }}
|
||||
_SDK_VERSION: ${{ inputs.sdk-version }}
|
||||
_OLD_SDK_VERSION: ${{ steps.get-current-sdk.outputs.version }}
|
||||
_OLD_SDK_GIT_REF: ${{ steps.get-current-sdk.outputs.git_ref }}
|
||||
run: |
|
||||
NEW_SDK_GIT_REF=$(echo "$_SDK_VERSION" | cut -d'-' -f3-)
|
||||
CHANGELOG=$(./scripts/get-repo-changelog.sh "bitwarden/sdk-internal" "$_OLD_SDK_GIT_REF" "$NEW_SDK_GIT_REF")
|
||||
PR_BODY="Updates the SDK version from \`$_OLD_SDK_VERSION\` to \`$_SDK_PACKAGE $_SDK_VERSION\`
|
||||
|
||||
## What's Changed
|
||||
|
||||
$CHANGELOG"
|
||||
|
||||
EXISTING_PR=$(gh pr list --head "$_BRANCH_NAME" --base main --state open --json number --jq '.[0].number // empty')
|
||||
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
echo "🔄 Updating existing PR #$EXISTING_PR..."
|
||||
echo -e "$PR_BODY" | gh pr edit "$EXISTING_PR" \
|
||||
--title "Update SDK to $_SDK_VERSION" \
|
||||
--body-file -
|
||||
PR_URL="https://github.com/${{ github.repository }}/pull/$EXISTING_PR"
|
||||
echo "## ✅ Updated PR: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "📝 Creating new PR..."
|
||||
PR_URL=$(echo -e "$PR_BODY" | gh pr create \
|
||||
--title "Update SDK to $_SDK_VERSION" \
|
||||
--body-file - \
|
||||
--base main \
|
||||
--head "$_BRANCH_NAME" \
|
||||
--label "automated-pr" \
|
||||
--label "t:deps")
|
||||
echo "## 🚀 Created PR: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
test:
|
||||
name: Test Update
|
||||
if: ${{ inputs.run-mode == 'Test' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log inputs to job summary
|
||||
uses: ./.github/actions/log-inputs
|
||||
with:
|
||||
inputs: ${{ toJson(inputs) }}
|
||||
|
||||
- name: Setup Android Build
|
||||
uses: ./.github/actions/setup-android-build
|
||||
|
||||
- name: Update SDK Version
|
||||
env:
|
||||
_SDK_PACKAGE: ${{ inputs.sdk-package }}
|
||||
_SDK_VERSION: ${{ inputs.sdk-version }}
|
||||
run: |
|
||||
./scripts/update-sdk-version.sh "$_SDK_PACKAGE" "$_SDK_VERSION"
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Used in settings.gradle.kts to download the SDK from GitHub Maven Packages
|
||||
run: |
|
||||
./gradlew assembleDebug --warn
|
||||
16
.github/workflows/test-device.yml
vendored
16
.github/workflows/test-device.yml
vendored
@@ -1,16 +0,0 @@
|
||||
name: Test Device
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test Device
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Placeholder step
|
||||
run: echo "Placeholder workflow step"
|
||||
38
.github/workflows/test.yml
vendored
38
.github/workflows/test.yml
vendored
@@ -9,13 +9,14 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
type: [checks_requested]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
_JAVA_VERSION: 21
|
||||
_JAVA_VERSION: 17
|
||||
_GITHUB_ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
|
||||
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
@@ -26,15 +27,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate Gradle wrapper
|
||||
uses: gradle/actions/wrapper-validation@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/wrapper-validation@0bdd871935719febd78681f197cd39af5b6e16a6 # v4.2.2
|
||||
|
||||
- name: Cache Gradle files
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
@@ -44,7 +43,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-v2-
|
||||
|
||||
- name: Cache build output
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/build-cache
|
||||
@@ -53,18 +52,19 @@ jobs:
|
||||
${{ runner.os }}-build-
|
||||
|
||||
- name: Configure Ruby
|
||||
uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0
|
||||
uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3 # v1.213.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Configure JDK
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: ${{ env._JAVA_VERSION }}
|
||||
|
||||
- name: Install Fastlane
|
||||
run: |
|
||||
gem install bundler:2.2.27
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
bundle exec fastlane check
|
||||
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
if: always()
|
||||
with:
|
||||
name: test-reports
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- name: Upload to codecov.io
|
||||
id: upload-to-codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1
|
||||
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
@@ -103,14 +103,14 @@ jobs:
|
||||
- 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 }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RUN_ACTOR: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
echo "> [!WARNING]" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "> [!WARNING]" >> $GITHUB_STEP_SUMMARY
|
||||
echo "> Uploading code coverage report failed. Please check the \"Upload to codecov.io\" step of \"Process Test Reports\" job for more details." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
if [ ! -z "$PR_NUMBER" ]; then
|
||||
message=$'> [!WARNING]\n> @'$RUN_ACTOR' Uploading code coverage report failed. Please check the "Upload to codecov.io" step of [Process Test Reports job]('$_GITHUB_ACTION_RUN_URL') for more details.'
|
||||
gh pr comment --repo "$GITHUB_REPOSITORY" "$PR_NUMBER" --body "$message"
|
||||
gh pr comment --repo $GITHUB_REPOSITORY $PR_NUMBER --body "$message"
|
||||
fi
|
||||
|
||||
5
.github/zizmor.yml
vendored
5
.github/zizmor.yml
vendored
@@ -1,5 +0,0 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
bitwarden/gh-actions/*: ref-pin
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,13 +3,6 @@
|
||||
fastlane/report.xml
|
||||
fastlane/README.md
|
||||
|
||||
# Ruby / Bundler
|
||||
.bundle/
|
||||
vendor/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -1 +1 @@
|
||||
3.4.2
|
||||
3.3.1
|
||||
|
||||
12
Gemfile
12
Gemfile
@@ -7,15 +7,3 @@ gem 'time'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
|
||||
# Since ruby 3.4.0 these are not included in the standard library
|
||||
gem 'abbrev'
|
||||
gem 'logger'
|
||||
gem 'mutex_m'
|
||||
gem 'csv'
|
||||
|
||||
# Since ruby 3.4.1 these are not included in the standard library
|
||||
gem 'nkf'
|
||||
|
||||
# Starting with Ruby 3.5.0, these are not included in the standard library
|
||||
gem 'ostruct'
|
||||
|
||||
83
Gemfile.lock
83
Gemfile.lock
@@ -1,41 +1,40 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1213.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1084.0)
|
||||
aws-sdk-core (3.222.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (1.99.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-s3 (1.183.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (4.0.1)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
date (3.5.1)
|
||||
date (3.4.1)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
@@ -43,7 +42,7 @@ GEM
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.5)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@@ -55,14 +54,14 @@ GEM
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.2.0)
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
@@ -72,9 +71,8 @@ GEM
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.229.0)
|
||||
fastlane (2.227.1)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
abbrev (~> 0.1.2)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
@@ -82,7 +80,6 @@ GEM
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
@@ -102,7 +99,6 @@ GEM
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
@@ -117,7 +113,7 @@ GEM
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-plugin-firebase_app_distribution (0.10.1)
|
||||
fastlane-plugin-firebase_app_distribution (0.10.0)
|
||||
google-apis-firebaseappdistribution_v1 (~> 0.3.0)
|
||||
google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
@@ -169,38 +165,37 @@ GEM
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.18.1)
|
||||
jwt (2.10.2)
|
||||
json (2.10.2)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.19.1)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
naturally (2.2.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.8.1)
|
||||
optparse (0.6.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
public_suffix (7.0.2)
|
||||
rake (13.3.1)
|
||||
public_suffix (6.0.1)
|
||||
rake (13.2.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rexml (3.4.1)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
signet (0.19.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
@@ -209,7 +204,7 @@ GEM
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
time (0.4.2)
|
||||
time (0.4.1)
|
||||
date
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
@@ -235,18 +230,12 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
abbrev
|
||||
csv
|
||||
fastlane
|
||||
fastlane-plugin-firebase_app_distribution
|
||||
logger
|
||||
mutex_m
|
||||
nkf
|
||||
ostruct
|
||||
time
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.2p28
|
||||
ruby 3.3.1p55
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.2
|
||||
2.6.6
|
||||
|
||||
61
README-bwa.md
Normal file
61
README-bwa.md
Normal file
@@ -0,0 +1,61 @@
|
||||
[](https://github.com/bitwarden/authenticator-android/actions/workflows/build-authenticator.yml?query=branch:main)
|
||||
[](https://gitter.im/bitwarden/Lobby)
|
||||
|
||||
# Bitwarden Authenticator Android App
|
||||
|
||||
<a href="https://play.google.com/store/apps/details?id=com.bitwarden.authenticator" target="_blank"><img alt="Get it on Google Play" src="https://imgur.com/YQzmZi9.png" width="153" height="46"></a>
|
||||
|
||||
Bitwarden Authenticator allows you easily store and generate two-factor authentication codes on your device. The Bitwarden Authenticator Android application is written in Kotlin.
|
||||
|
||||
<img src="https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/authenticator-android-codes.png" alt="" width="325" height="650" />
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Minimum SDK**: 28
|
||||
- **Target SDK**: 34
|
||||
- **Device Types Supported**: Phone and Tablet
|
||||
- **Orientations Supported**: Portrait and Landscape
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```sh
|
||||
$ git clone https://github.com/bitwarden/authenticator-android
|
||||
```
|
||||
|
||||
2. Create a `user.properties` file in the root directory of the project and add the following properties:
|
||||
|
||||
- `gitHubToken`: A "classic" Github Personal Access Token (PAT) with the `read:packages` scope (ex: `gitHubToken=gph_xx...xx`). These can be generated by going to the [Github tokens page](https://github.com/settings/tokens). See [the Github Packages user documentation concerning authentication](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-gradle-registry#authenticating-to-github-packages) for more details.
|
||||
|
||||
3. Setup the code style formatter:
|
||||
|
||||
All code must follow the guidelines described in the [Code Style Guidelines document](docs/STYLE_AND_BEST_PRACTICES.md). To aid in adhering to these rules, all contributors should apply `docs/bitwarden-style.xml` as their code style scheme. In IntelliJ / Android Studio:
|
||||
|
||||
- Navigate to `Preferences > Editor > Code Style`.
|
||||
- Hit the `Manage` button next to `Scheme`.
|
||||
- Select `Import`.
|
||||
- Find the `bitwarden-style.xml` file in the project's `docs/` directory.
|
||||
- Import "from" `BitwardenStyle` "to" `BitwardenStyle`.
|
||||
- Hit `Apply` and `OK` to save the changes and exit Preferences.
|
||||
|
||||
Note that in some cases you may need to restart Android Studio for the changes to take effect.
|
||||
|
||||
All code should be formatted before submitting a pull request. This can be done manually but it can also be helpful to create a macro with a custom keyboard binding to auto-format when saving. In Android Studio on OS X:
|
||||
|
||||
- Select `Edit > Macros > Start Macro Recording`
|
||||
- Select `Code > Optimize Imports`
|
||||
- Select `Code > Reformat Code`
|
||||
- Select `File > Save All`
|
||||
- Select `Edit > Macros > Stop Macro Recording`
|
||||
|
||||
This can then be mapped to a set of keys by navigating to `Android Studio > Preferences` and editing the macro under `Keymap` (ex : shift + command + s).
|
||||
|
||||
Please avoid mixing formatting and logical changes in the same commit/PR. When possible, fix any large formatting issues in a separate PR before opening one to make logical changes to the same code. This helps others focus on the meaningful code changes when reviewing the code.
|
||||
|
||||
## Contribute
|
||||
|
||||
Code contributions are welcome! Please commit any pull requests against the `main` branch. Learn more about how to contribute by reading the [Contributing Guidelines](https://contributing.bitwarden.com/contributing/). Check out the [Contributing Documentation](https://contributing.bitwarden.com/) for how to get started with your first contribution.
|
||||
|
||||
Security audits and feedback are welcome. Please open an issue or email us privately if the report is sensitive in nature. You can read our security policy in the [`SECURITY.md`](SECURITY.md) file.
|
||||
46
README.md
46
README.md
@@ -8,13 +8,14 @@
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Minimum SDK**: 29 (Android 10)
|
||||
- **Target SDK**: 36 (Android 16)
|
||||
- **Minimum SDK**: 29
|
||||
- **Target SDK**: 35
|
||||
- **Device Types Supported**: Phone and Tablet
|
||||
- **Orientations Supported**: Portrait and Landscape
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```sh
|
||||
@@ -51,47 +52,6 @@
|
||||
|
||||
Please avoid mixing formatting and logical changes in the same commit/PR. When possible, fix any large formatting issues in a separate PR before opening one to make logical changes to the same code. This helps others focus on the meaningful code changes when reviewing the code.
|
||||
|
||||
4. Setup JDK `Version` `21`:
|
||||
|
||||
- Navigate to `Preferences > Build, Execution, Deployment > Build Tools > Gradle`.
|
||||
- Hit the selected Gradle JDK next to `Gradle JDK:`.
|
||||
- Select a `21.x` version or hit `Download JDK...` if not present.
|
||||
- Select `Version` `21`.
|
||||
- Select your preferred `Vendor`.
|
||||
- Hit `Download`.
|
||||
- Hit `Apply`.
|
||||
|
||||
5. Setup `detekt` pre-commit hook (optional):
|
||||
|
||||
Run the following script from the root of the repository to install the hook. This will overwrite any existing pre-commit hook if present.
|
||||
|
||||
```shell
|
||||
echo "Writing detekt pre-commit hook..."
|
||||
cat << 'EOL' > .git/hooks/pre-commit
|
||||
#!/usr/bin/env bash
|
||||
|
||||
echo "Running detekt check..."
|
||||
OUTPUT="/tmp/detekt-$(date +%s)"
|
||||
./gradlew -Pprecommit=true detekt > $OUTPUT
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
cat $OUTPUT
|
||||
rm $OUTPUT
|
||||
echo "***********************************************"
|
||||
echo " detekt failed "
|
||||
echo " Please fix the above issues before committing "
|
||||
echo "***********************************************"
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
rm $OUTPUT
|
||||
EOL
|
||||
echo "detekt pre-commit hook written to .git/hooks/pre-commit"
|
||||
echo "Making the hook executable"
|
||||
chmod +x .git/hooks/pre-commit
|
||||
|
||||
echo "detekt pre-commit hook installed successfully to .git/hooks/pre-commit"
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Application Dependencies
|
||||
|
||||
1
annotation/.gitignore
vendored
1
annotation/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/build
|
||||
@@ -1,45 +0,0 @@
|
||||
import com.android.build.api.dsl.LibraryExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> {
|
||||
namespace = "com.bitwarden.annotation"
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdkBwa.get().toInt())
|
||||
}
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility(libs.versions.jvmTarget.get())
|
||||
targetCompatibility(libs.versions.jvmTarget.get())
|
||||
}
|
||||
@Suppress("UnstableApiUsage")
|
||||
testFixtures {
|
||||
enable = true
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
}
|
||||
}
|
||||
21
annotation/proguard-rules.pro
vendored
21
annotation/proguard-rules.pro
vendored
@@ -1,21 +0,0 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -1,21 +1,20 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
import com.android.utils.cxx.io.removeExtensionIfPresent
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.tasks.InjectMappingFileIdTask
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.tasks.UploadMappingFileTask
|
||||
import com.google.gms.googleservices.GoogleServicesTask
|
||||
import org.gradle.kotlin.dsl.support.uppercaseFirstChar
|
||||
import dagger.hilt.android.plugin.util.capitalize
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.androidx.room)
|
||||
// Crashlytics is enabled for all builds initially but removed for FDroid builds in gradle and
|
||||
// standardDebug builds in the merged manifest.
|
||||
alias(libs.plugins.crashlytics)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose.compiler)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
@@ -43,44 +42,30 @@ val ciProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
base {
|
||||
// Set the base archive name for publishing purposes. This is used to derive the
|
||||
// APK and AAB artifact names when uploading to Firebase and Play Store.
|
||||
archivesName.set("com.x8bit.bitwarden")
|
||||
}
|
||||
|
||||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
configure<ApplicationExtension> {
|
||||
android {
|
||||
namespace = "com.x8bit.bitwarden"
|
||||
compileSdk {
|
||||
version = release(libs.versions.compileSdk.get().toInt())
|
||||
}
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.x8bit.bitwarden"
|
||||
minSdk {
|
||||
version = release(libs.versions.minSdk.get().toInt())
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "2025.4.0"
|
||||
|
||||
setProperty("archivesBaseName", "com.x8bit.bitwarden")
|
||||
|
||||
ksp {
|
||||
// The location in which the generated Room Database Schemas will be stored in the repo.
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
targetSdk {
|
||||
version = release(libs.versions.targetSdk.get().toInt())
|
||||
}
|
||||
versionCode = libs.versions.appVersionCode.get().toInt()
|
||||
versionName = libs.versions.appVersionName.get()
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField(
|
||||
type = "String",
|
||||
name = "CI_INFO",
|
||||
value = "${ciProperties.getOrDefault("ci.info", "\"\uD83D\uDCBB local\"")}",
|
||||
)
|
||||
buildConfigField(
|
||||
type = "String",
|
||||
name = "SDK_VERSION",
|
||||
value = "\"${libs.versions.bitwardenSdk.get()}\"",
|
||||
value = "${ciProperties.getOrDefault("ci.info", "\"local\"")}",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,7 +99,6 @@ configure<ApplicationExtension> {
|
||||
applicationIdSuffix = ".beta"
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
matchingFallbacks += listOf("release")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
@@ -127,7 +111,6 @@ configure<ApplicationExtension> {
|
||||
release {
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
@@ -149,6 +132,39 @@ configure<ApplicationExtension> {
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
|
||||
outputs
|
||||
.mapNotNull { it as? BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val fileNameWithoutExtension = when (flavorName) {
|
||||
"fdroid" -> "$applicationId-$flavorName"
|
||||
"standard" -> "$applicationId"
|
||||
else -> output.outputFileName.removeExtensionIfPresent(".apk")
|
||||
}
|
||||
|
||||
// Set the APK output filename.
|
||||
output.outputFileName = "$fileNameWithoutExtension.apk"
|
||||
|
||||
val variantName = name
|
||||
val renameTaskName = "rename${variantName.capitalize()}AabFiles"
|
||||
tasks.register(renameTaskName) {
|
||||
group = "build"
|
||||
description = "Renames the bundle files for $variantName variant"
|
||||
doLast {
|
||||
renameFile(
|
||||
"$bundlesDir/$variantName/$namespace-$flavorName-${buildType.name}.aab",
|
||||
"$fileNameWithoutExtension.aab",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Force renaming task to execute after the variant is built.
|
||||
tasks
|
||||
.getByName("bundle${variantName.capitalize()}")
|
||||
.finalizedBy(renameTaskName)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility(libs.versions.jvmTarget.get())
|
||||
targetCompatibility(libs.versions.jvmTarget.get())
|
||||
@@ -175,47 +191,6 @@ configure<ApplicationExtension> {
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { appVariant ->
|
||||
val bundlesDir = "${layout.buildDirectory.get()}/outputs/bundle"
|
||||
val applicationId = appVariant.applicationId.get()
|
||||
val flavorName = appVariant.flavorName
|
||||
val variantName = appVariant.name
|
||||
val buildType = appVariant.buildType
|
||||
appVariant
|
||||
.outputs
|
||||
.mapNotNull { it as? VariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val fileNameWithoutExtension = when (flavorName) {
|
||||
"fdroid" -> "$applicationId-$flavorName"
|
||||
"standard" -> applicationId
|
||||
else -> output.outputFileName.get().removeExtensionIfPresent(".apk")
|
||||
}
|
||||
|
||||
// Set the APK output filename.
|
||||
output.outputFileName.set("$fileNameWithoutExtension.apk")
|
||||
|
||||
val renameTaskName = "rename${variantName.uppercaseFirstChar()}AabFiles"
|
||||
tasks.register(renameTaskName) {
|
||||
group = "build"
|
||||
description = "Renames the bundle files for $variantName variant"
|
||||
doLast {
|
||||
val namespace = appVariant.namespace.get()
|
||||
renameFile(
|
||||
"$bundlesDir/$variantName/$namespace-$flavorName-$buildType.aab",
|
||||
"$fileNameWithoutExtension.aab",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Force renaming task to execute after the variant is built.
|
||||
val bundleTaskName = "bundle${variantName.uppercaseFirstChar()}"
|
||||
tasks
|
||||
.named { it == bundleTaskName }
|
||||
.configureEach { finalizedBy(renameTaskName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvmTarget.get()))
|
||||
@@ -236,11 +211,9 @@ dependencies {
|
||||
add("standardImplementation", dependencyNotation)
|
||||
}
|
||||
|
||||
implementation(project(":authenticatorbridge"))
|
||||
implementation(files("libs/authenticatorbridge-1.0.0-release.aar"))
|
||||
|
||||
implementation(project(":annotation"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":cxf"))
|
||||
implementation(project(":data"))
|
||||
implementation(project(":network"))
|
||||
implementation(project(":ui"))
|
||||
@@ -251,7 +224,8 @@ dependencies {
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(libs.androidx.biometrics)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.compose)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.animation)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
@@ -261,8 +235,6 @@ dependencies {
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.androidx.credentials.providerevents)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
@@ -276,18 +248,19 @@ dependencies {
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.bitwarden.sdk)
|
||||
implementation(libs.bumptech.glide)
|
||||
implementation(libs.bumptech.glide.okhttp)
|
||||
ksp(libs.bumptech.glide.compiler)
|
||||
implementation(libs.androidx.credentials)
|
||||
implementation(libs.google.hilt.android)
|
||||
ksp(libs.google.hilt.compiler)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization)
|
||||
implementation(platform(libs.square.okhttp.bom))
|
||||
implementation(libs.square.okhttp)
|
||||
implementation(libs.square.okhttp.logging)
|
||||
implementation(platform(libs.square.retrofit.bom))
|
||||
implementation(libs.square.retrofit)
|
||||
implementation(libs.square.retrofit.kotlinx.serialization)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.zxing.zxing.core)
|
||||
|
||||
// For now we are restricted to running Compose tests for debug builds only
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
@@ -300,23 +273,32 @@ dependencies {
|
||||
standardImplementation(libs.google.play.review)
|
||||
|
||||
// Pull in test fixtures from other modules
|
||||
testImplementation(testFixtures(project(":core")))
|
||||
testImplementation(testFixtures(project(":data")))
|
||||
testImplementation(testFixtures(project(":network")))
|
||||
testImplementation(testFixtures(project(":ui")))
|
||||
|
||||
testImplementation(libs.androidx.compose.ui.test)
|
||||
testImplementation(libs.google.hilt.android.testing)
|
||||
testImplementation(platform(libs.junit.bom))
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testImplementation(libs.junit.junit5)
|
||||
testImplementation(libs.junit.vintage)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk.mockk)
|
||||
testImplementation(libs.robolectric.robolectric)
|
||||
testImplementation(libs.square.okhttp.mockwebserver)
|
||||
testImplementation(libs.square.turbine)
|
||||
}
|
||||
|
||||
tasks {
|
||||
withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "2g"
|
||||
maxParallelForks = Runtime.getRuntime().availableProcessors()
|
||||
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC"
|
||||
android.sourceSets["main"].res.srcDirs("src/test/res")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
// Disable Fdroid-specific tasks that we want to exclude
|
||||
val fdroidTasksToDisable = tasks.withType<GoogleServicesTask>() +
|
||||
@@ -338,7 +320,6 @@ private fun renameFile(path: String, newName: String) {
|
||||
if (originalFile.renameTo(newFile)) {
|
||||
println("Renamed $originalFile to $newFile")
|
||||
} else {
|
||||
@Suppress("TooGenericExceptionThrown")
|
||||
throw RuntimeException("Failed to rename $originalFile to $newFile")
|
||||
}
|
||||
}
|
||||
|
||||
BIN
app/libs/authenticatorbridge-1.0.0-release.aar
Normal file
BIN
app/libs/authenticatorbridge-1.0.0-release.aar
Normal file
Binary file not shown.
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "ce40856ec88770d11b7afb587c7deabc",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "privileged_apps",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`package_name` TEXT NOT NULL, `signature` TEXT NOT NULL, PRIMARY KEY(`package_name`, `signature`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "packageName",
|
||||
"columnName": "package_name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "signature",
|
||||
"columnName": "signature",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"package_name",
|
||||
"signature"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ce40856ec88770d11b7afb587c7deabc')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "2835802f9de260f6f5109c81081e9b46",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "organization_events",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL, `organization_id` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationEventType",
|
||||
"columnName": "organization_event_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherId",
|
||||
"columnName": "cipher_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "date",
|
||||
"columnName": "date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_organization_events_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2835802f9de260f6f5109c81081e9b46')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"identityHash": "4c6ad1f5268d7e8add7407201788aa2e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ciphers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasTotp",
|
||||
"columnName": "has_totp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherType",
|
||||
"columnName": "cipher_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherJson",
|
||||
"columnName": "cipher_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ciphers_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shouldHidePasswords",
|
||||
"columnName": "should_hide_passwords",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "externalId",
|
||||
"columnName": "external_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "canManage",
|
||||
"columnName": "manage",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "domains",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domainsJson",
|
||||
"columnName": "domains_json",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisionDate",
|
||||
"columnName": "revision_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_folders_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "sends",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendType",
|
||||
"columnName": "send_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendJson",
|
||||
"columnName": "send_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_sends_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c6ad1f5268d7e8add7407201788aa2e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "11387825dab701f9d2dd2e940ffbd794",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ciphers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasTotp",
|
||||
"columnName": "has_totp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherType",
|
||||
"columnName": "cipher_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherJson",
|
||||
"columnName": "cipher_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ciphers_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shouldHidePasswords",
|
||||
"columnName": "should_hide_passwords",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "externalId",
|
||||
"columnName": "external_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "canManage",
|
||||
"columnName": "manage",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultUserCollectionEmail",
|
||||
"columnName": "default_user_collection_email",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'0'"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "domains",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domainsJson",
|
||||
"columnName": "domains_json",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisionDate",
|
||||
"columnName": "revision_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_folders_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "sends",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendType",
|
||||
"columnName": "send_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendJson",
|
||||
"columnName": "send_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_sends_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '11387825dab701f9d2dd2e940ffbd794')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "61353072161e3101ade140e2c4b65495",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ciphers",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `has_totp` INTEGER NOT NULL DEFAULT 1, `cipher_type` TEXT NOT NULL, `cipher_json` TEXT NOT NULL, `organization_id` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasTotp",
|
||||
"columnName": "has_totp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherType",
|
||||
"columnName": "cipher_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cipherJson",
|
||||
"columnName": "cipher_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ciphers_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
},
|
||||
{
|
||||
"name": "index_ciphers_user_id_organization_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id",
|
||||
"organization_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ciphers_user_id_organization_id` ON `${TABLE_NAME}` (`user_id`, `organization_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "collections",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `organization_id` TEXT NOT NULL, `should_hide_passwords` INTEGER NOT NULL, `name` TEXT NOT NULL, `external_id` TEXT, `read_only` INTEGER NOT NULL, `manage` INTEGER, `default_user_collection_email` TEXT, `type` TEXT NOT NULL DEFAULT '0', PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "organizationId",
|
||||
"columnName": "organization_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "shouldHidePasswords",
|
||||
"columnName": "should_hide_passwords",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "externalId",
|
||||
"columnName": "external_id",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isReadOnly",
|
||||
"columnName": "read_only",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "canManage",
|
||||
"columnName": "manage",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultUserCollectionEmail",
|
||||
"columnName": "default_user_collection_email",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'0'"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_collections_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "domains",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `domains_json` TEXT, PRIMARY KEY(`user_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domainsJson",
|
||||
"columnName": "domains_json",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "folders",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `name` TEXT, `revision_date` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "revisionDate",
|
||||
"columnName": "revision_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_folders_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_folders_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "sends",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `user_id` TEXT NOT NULL, `send_type` TEXT NOT NULL, `send_json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendType",
|
||||
"columnName": "send_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sendJson",
|
||||
"columnName": "send_json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_sends_user_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_sends_user_id` ON `${TABLE_NAME}` (`user_id`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61353072161e3101ade140e2c4b65495')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application tools:ignore="MissingApplicationIcon">
|
||||
<activity
|
||||
android:name=".MainActivity">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*.bitwarden.pw" />
|
||||
<data android:pathPattern="/redirect-connector.*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -7,20 +7,6 @@
|
||||
<meta-data
|
||||
android:name="firebase_crashlytics_collection_enabled"
|
||||
android:value="false" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
tools:ignore="IntentFilterExportedReceiver">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*.bitwarden.pw" />
|
||||
<data android:pathPattern="/redirect-connector.*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<base-config
|
||||
cleartextTrafficPermitted="true"
|
||||
tools:ignore="InsecureBaseConfiguration">
|
||||
<trust-anchors>
|
||||
<!-- Trust pre-installed CAs -->
|
||||
<certificates src="system" />
|
||||
<!-- Additionally trust user added CAs -->
|
||||
<certificates
|
||||
src="user"
|
||||
tools:ignore="AcceptsUserCertificates" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<domain-config cleartextTrafficPermitted="false">
|
||||
<domain includeSubdomains="true">bitwarden.com</domain>
|
||||
<domain includeSubdomains="true">bitwarden.eu</domain>
|
||||
<domain includeSubdomains="true">bitwarden.pw</domain>
|
||||
<trust-anchors>
|
||||
<!-- Only trust pre-installed CAs for Bitwarden domains and all subdomains -->
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</domain-config>
|
||||
|
||||
</network-security-config>
|
||||
@@ -1,44 +0,0 @@
|
||||
Recognized as best password manager by PCMag, WIRED, The Verge, CNET, G2, and more!
|
||||
|
||||
SECURE YOUR DIGITAL LIFE
|
||||
Secure your digital life and protect against data breaches by generating and saving unique, strong passwords for every account. Maintain everything in an end-to-end encrypted password vault that only you can access.
|
||||
|
||||
ACCESS YOUR DATA, ANYWHERE, ANYTIME, ON ANY DEVICE
|
||||
Easily manage, store, secure, and share unlimited passwords and passkeys across unlimited devices without restrictions.
|
||||
|
||||
USE PASSKEYS WHEREVER YOU LOG IN
|
||||
Create, store, and sync passkeys across the Bitwarden mobile app and browser extensions for a secure, passwordless experience no matter what device you're on.
|
||||
|
||||
EVERYONE SHOULD HAVE THE TOOLS TO STAY SAFE ONLINE
|
||||
Utilize Bitwarden for free with no ads or selling data. Bitwarden believes everyone should have the ability to stay safe online. Premium plans offer access to advanced features.
|
||||
|
||||
EMPOWER YOUR TEAMS WITH BITWARDEN
|
||||
Plans for Teams and Enterprise come with professional business features. Some examples include SSO integration, self-hosting, directory integration and SCIM provisioning, global policies, API access, event logs, and more.
|
||||
|
||||
Use Bitwarden to secure your workforce and share sensitive information with colleagues.
|
||||
|
||||
More reasons to choose Bitwarden:
|
||||
|
||||
World-Class Encryption
|
||||
Passwords are protected with advanced end-to-end encryption (AES-256 bit, salted hashing, and PBKDF2 SHA-256) so your data stays secure and private.
|
||||
|
||||
3rd-party Audits
|
||||
Bitwarden regularly conducts comprehensive third-party security audits with notable security firms. These annual audits include source code assessments and penetration testing across Bitwarden IPs, servers, and web applications.
|
||||
|
||||
Advanced 2FA
|
||||
Secure your login with a third-party authenticator, emailed codes, or FIDO2 WebAuthn credentials such as a hardware security key or passkey.
|
||||
|
||||
Bitwarden Send
|
||||
Transmit data directly to others while maintaining end-to-end encrypted security and limiting exposure.
|
||||
|
||||
Built-in Generator
|
||||
Create long, complex, and distinct passwords and unique usernames for every site you visit. Integrate with email alias providers for additional privacy.
|
||||
|
||||
Global Translations
|
||||
Bitwarden translations exist for more than 50 languages.
|
||||
|
||||
Cross-Platform Applications
|
||||
Secure and share sensitive data within your Bitwarden Vault from any browser, mobile device, or desktop OS, and more.
|
||||
|
||||
|
||||
Accessibility Services Disclosure: Bitwarden offers the ability to use the Accessibility Service to augment Autofill on older devices or in cases where autofill is not working correctly. When enabled, the Accessibility Service is used to search for login fields in apps and websites. This establishes the appropriate field IDs when a match for the app or site is found and inserts credentials. When the Accessibility Service is active Bitwarden does not store information or control any on-screen elements beyond inserting credentials.
|
||||
@@ -1 +0,0 @@
|
||||
Bitwarden is a login and password manager that helps keep you safe while online.
|
||||
@@ -1 +0,0 @@
|
||||
Bitwarden Password Manager
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import com.x8bit.bitwarden.BuildConfig
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* [LogsManager] implementation for F-droid flavor builds.
|
||||
*/
|
||||
class LogsManagerImpl(
|
||||
settingsRepository: SettingsRepository,
|
||||
legacyAppCenterMigrator: LegacyAppCenterMigrator,
|
||||
) : LogsManager {
|
||||
init {
|
||||
if (BuildConfig.HAS_LOGS_ENABLED) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
}
|
||||
|
||||
override var isEnabled: Boolean = false
|
||||
|
||||
override fun setUserData(userId: String?, environmentType: Environment.Type) = Unit
|
||||
|
||||
override fun trackNonFatalException(throwable: Throwable) = Unit
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.x8bit.bitwarden.data.platform.manager
|
||||
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.x8bit.bitwarden.BuildConfig
|
||||
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator
|
||||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* [LogsManager] implementation for F-droid flavor builds.
|
||||
*/
|
||||
class LogsManagerImpl(
|
||||
settingsRepository: SettingsRepository,
|
||||
legacyAppCenterMigrator: LegacyAppCenterMigrator,
|
||||
) : LogsManager {
|
||||
init {
|
||||
if (BuildConfig.HAS_LOGS_ENABLED) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
}
|
||||
|
||||
override var isEnabled: Boolean = false
|
||||
|
||||
override fun setUserData(userId: String?, environmentType: Environment.Type) = Unit
|
||||
|
||||
override fun trackNonFatalException(throwable: Throwable) = Unit
|
||||
}
|
||||
@@ -13,10 +13,9 @@
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="horizons.permission.HEADSET_CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_USER_DICTIONARY" />
|
||||
<uses-permission android:name="android.permission.READ_USER_DICTIONARY"/>
|
||||
<!-- Protect access to AuthenticatorBridgeService using this custom permission.
|
||||
|
||||
Note that each build type uses a different value for knownCerts.
|
||||
@@ -38,18 +37,15 @@
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:intentMatchingFlags="enforceIntentFilter"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/LaunchTheme"
|
||||
tools:ignore="CredentialDependency"
|
||||
tools:replace="appComponentFactory"
|
||||
tools:targetApi="36">
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="true"
|
||||
android:launchMode="@integer/launchModeAPIlevel"
|
||||
android:theme="@style/LaunchTheme"
|
||||
@@ -80,10 +76,19 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="*.bitwarden.com" />
|
||||
<data android:host="*.bitwarden.eu" />
|
||||
|
||||
<data android:host="vault.bitwarden.com" />
|
||||
<data android:host="vault.bitwarden.eu" />
|
||||
<data android:host="*.bitwarden.pw" />
|
||||
<data android:pathPattern="/redirect-connector.*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -98,46 +103,6 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
<!-- Handle Credential Exchange transfer requests -->
|
||||
<intent-filter
|
||||
android:autoVerify="true"
|
||||
tools:ignore="AppLinkUrlError">
|
||||
<action android:name="androidx.identitycredentials.action.IMPORT_CREDENTIALS" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:mimeType="application/octet-stream"
|
||||
android:scheme="content"
|
||||
tools:ignore="AppLinkUriRelativeFilterGroupError" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Credential Provider Activity for handling passkey and password credential operations.
|
||||
This activity is NOT exported to protect against external apps attempting to extract
|
||||
vault credentials by sending malicious intents. Only our own PendingIntents can
|
||||
launch this activity.
|
||||
|
||||
This is a transparent trampoline activity that launches MainActivity for credential
|
||||
operations and forwards results back to the Credential Manager framework.
|
||||
Uses Theme.Translucent.NoTitleBar for invisibility while allowing normal lifecycle
|
||||
(Theme.NoDisplay requires finish() before onResume(), incompatible with ActivityResult).
|
||||
|
||||
Note: Unlike AuthCallbackActivity, this does NOT use noHistory="true" because it
|
||||
must remain in the back stack to receive the ActivityResult callback from
|
||||
MainActivity. -->
|
||||
<activity
|
||||
android:name=".CredentialProviderActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSKEY" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_CREATE_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_GET_PASSWORD" />
|
||||
<action android:name="com.x8bit.bitwarden.credentials.ACTION_UNLOCK_ACCOUNT" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
@@ -148,11 +113,11 @@
|
||||
android:theme="@android:style/Theme.NoDisplay" />
|
||||
|
||||
<activity
|
||||
android:name=".AutofillCallbackActivity"
|
||||
android:name=".AutofillTotpCopyActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/AutofillCallbackTheme" />
|
||||
android:theme="@style/AutofillTotpCopyTheme" />
|
||||
|
||||
<activity
|
||||
android:name=".AuthCallbackActivity"
|
||||
@@ -160,18 +125,15 @@
|
||||
android:launchMode="singleTop"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.NoDisplay">
|
||||
<intent-filter android:autoVerify="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<data android:host="bitwarden.com" />
|
||||
<data android:host="bitwarden.eu" />
|
||||
<data android:pathPattern="/duo-callback" />
|
||||
<data android:pathPattern="/sso-callback" />
|
||||
<data android:pathPattern="/webauthn-callback" />
|
||||
<data
|
||||
android:host="captcha-callback"
|
||||
android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -203,16 +165,6 @@
|
||||
android:host="webauthn-callback"
|
||||
android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="sso-cookie-vendor"
|
||||
android:scheme="bitwarden" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
@@ -305,7 +257,7 @@
|
||||
android:name="com.x8bit.bitwarden.AutofillTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_notification"
|
||||
android:label="@string/autofill_verb"
|
||||
android:label="@string/autofill"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:ignore="MissingClass">
|
||||
<intent-filter>
|
||||
@@ -358,14 +310,6 @@
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.AUTHENTICATOR_BRIDGE_SERVICE" />
|
||||
|
||||
<!-- Firebase SDK initOrder is 100. We use a higher order to initialize first -->
|
||||
<provider
|
||||
android:name=".data.platform.contentprovider.UncaughtErrorLoggingContentProvider"
|
||||
android:authorities="${applicationId}"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="false"
|
||||
android:initOrder="101" />
|
||||
|
||||
</application>
|
||||
|
||||
<queries>
|
||||
@@ -376,19 +320,11 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
</intent>
|
||||
<!-- To Query Privileged Apps -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:scheme="http" />
|
||||
</intent>
|
||||
<!-- To Query Chrome Beta: -->
|
||||
<package android:name="com.chrome.beta" />
|
||||
|
||||
<!-- To Query Chrome Stable: -->
|
||||
<package android:name="com.android.chrome" />
|
||||
|
||||
<!-- To Query Brave Stable: -->
|
||||
<package android:name="com.brave.browser" />
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
{
|
||||
"apps": [
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.iode.firefox",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "C9:96:DA:AB:86:A8:CD:32:53:77:49:A5:EE:1D:C2:F9:84:F2:9D:43:F3:06:7D:2C:0A:54:BF:8B:BF:AB:62:C0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "eu.weblibre.gecko",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "BB:2A:97:F5:61:53:35:C9:E5:7C:86:6F:1C:30:ED:4F:D7:D7:BD:DC:BC:BC:06:68:FE:93:A5:79:17:3D:3D:2D"
|
||||
},
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "8F:52:6E:1E:53:D6:BD:4D:FB:F4:F4:B9:3C:2A:91:EC:B5:CB:8D:A5:E1:4A:D9:4C:25:70:E1:E3:C7:13:52:7F"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
@@ -40,6 +12,18 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "org.chromium.chrome",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "A8:56:48:50:79:BC:B3:57:BF:BE:69:BA:19:A9:BA:43:CD:0A:D9:AB:22:67:52:C7:80:B6:88:8A:FD:48:21:6B"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
@@ -64,18 +48,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "org.ironfoxoss.ironfox.nightly",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "C5:E2:91:B5:A5:71:F9:C8:CD:9A:97:99:C2:C9:4E:02:EC:97:03:94:88:93:F2:CA:75:6D:67:B9:42:04:F9:04"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
|
||||
@@ -779,74 +779,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "cz.seznam.sbrowser",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "DB:95:40:66:10:78:83:6E:4E:B1:66:F6:9E:F4:07:30:9E:8D:AE:33:34:68:5E:C8:F6:FA:2F:13:81:B9:AC:F6"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.opera.mini.native",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.opera.mini.native.beta",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "57:AC:BC:52:5F:1B:2E:BD:19:19:6C:D6:F0:14:39:7C:C9:10:FD:18:84:1E:0A:E8:50:FE:BC:3E:1E:59:3F:F2"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.zoho.primeum.stable",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "A9:D6:D0:A2:AF:DB:15:84:9B:8C:D3:1D:51:FE:73:B8:E1:B1:70:BA:A5:70:C2:F8:F2:A3:F8:65:28:29:CB:BD"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "android",
|
||||
"info": {
|
||||
"package_name": "com.amazon.cloud9",
|
||||
"signatures": [
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "2F:19:AD:EB:28:4E:B3:6F:7F:07:78:61:52:B9:A1:D1:4B:21:65:32:03:AD:0B:04:EB:BF:9C:73:AB:6D:76:25"
|
||||
},
|
||||
{
|
||||
"build": "release",
|
||||
"cert_fingerprint_sha256": "70:D5:68:EC:6A:E6:F3:38:BC:1A:63:99:A6:53:7E:E0:69:08:CA:1D:72:FB:8F:F0:48:74:AB:95:43:3B:25:0E"
|
||||
},
|
||||
{
|
||||
"build": "userdebug",
|
||||
"cert_fingerprint_sha256": "7C:AC:39:19:37:98:1B:61:34:BD:CE:1F:D9:83:4C:25:31:81:F5:AB:F9:1D:ED:60:78:21:0D:0F:91:AC:E3:60"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.bitwarden.core.annotation.OmitFromCoverage
|
||||
|
||||
/**
|
||||
* An activity to be launched and then immediately closed so that the OS Shade can be collapsed
|
||||
* after the user clicks on the Autofill Quick Tile.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
class AccessibilityActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.bitwarden.core.annotation.OmitFromCoverage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* An activity to receive external authentication-related callbacks so the current state of the
|
||||
* task holding the [MainActivity] can remain undisturbed.
|
||||
*
|
||||
* These callbacks can be from Custom Chrome tabs or other auth related flows, including NFC
|
||||
* related transmissions.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@AndroidEntryPoint
|
||||
class AuthCallbackActivity : AppCompatActivity() {
|
||||
|
||||
private val viewModel: AuthCallbackViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.trySendAction(AuthCallbackAction.IntentReceive(intent = intent))
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
.apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP,
|
||||
)
|
||||
}
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.content.Intent
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getCookieCallbackResultOrNull
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getDuoCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getSsoCallbackResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.getWebAuthResultOrNull
|
||||
import com.x8bit.bitwarden.data.auth.util.getYubiKeyResultOrNull
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -27,14 +27,20 @@ class AuthCallbackViewModel @Inject constructor(
|
||||
private fun handleIntentReceived(action: AuthCallbackAction.IntentReceive) {
|
||||
val yubiKeyResult = action.intent.getYubiKeyResultOrNull()
|
||||
val webAuthResult = action.intent.getWebAuthResultOrNull()
|
||||
val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult()
|
||||
val duoCallbackTokenResult = action.intent.getDuoCallbackTokenResult()
|
||||
val ssoCallbackResult = action.intent.getSsoCallbackResult()
|
||||
val cookieCallbackResult = action.intent.getCookieCallbackResultOrNull()
|
||||
when {
|
||||
yubiKeyResult != null -> {
|
||||
authRepository.setYubiKeyResult(yubiKeyResult = yubiKeyResult)
|
||||
}
|
||||
|
||||
captchaCallbackTokenResult != null -> {
|
||||
authRepository.setCaptchaCallbackTokenResult(
|
||||
tokenResult = captchaCallbackTokenResult,
|
||||
)
|
||||
}
|
||||
|
||||
duoCallbackTokenResult != null -> {
|
||||
authRepository.setDuoCallbackTokenResult(
|
||||
tokenResult = duoCallbackTokenResult,
|
||||
@@ -47,12 +53,6 @@ class AuthCallbackViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
cookieCallbackResult != null -> {
|
||||
authRepository.setCookieCallbackResult(
|
||||
result = cookieCallbackResult,
|
||||
)
|
||||
}
|
||||
|
||||
webAuthResult != null -> {
|
||||
authRepository.setWebAuthResult(webAuthResult = webAuthResult)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.x8bit.bitwarden
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bitwarden.core.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* An activity for copying a TOTP code to the clipboard. This is done when an autofill item is
|
||||
* selected and it requires TOTP authentication. Due to the constraints of the autofill framework,
|
||||
* we also have to re-fulfill the autofill for the views that are being filled.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
@AndroidEntryPoint
|
||||
class AutofillTotpCopyActivity : AppCompatActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var autofillCompletionManager: AutofillCompletionManager
|
||||
|
||||
private val autofillTotpCopyViewModel: AutofillTotpCopyViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
observeViewModelEvents()
|
||||
|
||||
autofillTotpCopyViewModel.trySendAction(
|
||||
AutofillTotpCopyAction.IntentReceived(
|
||||
intent = intent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeViewModelEvents() {
|
||||
autofillTotpCopyViewModel
|
||||
.eventFlow
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is AutofillTotpCopyEvent.CompleteAutofill -> {
|
||||
handleCompleteAutofill(event)
|
||||
}
|
||||
|
||||
is AutofillTotpCopyEvent.FinishActivity -> {
|
||||
finishActivity()
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete autofill with the provided data.
|
||||
*/
|
||||
private fun handleCompleteAutofill(event: AutofillTotpCopyEvent.CompleteAutofill) {
|
||||
autofillCompletionManager.completeAutofill(
|
||||
activity = this,
|
||||
cipherView = event.cipherView,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish the activity.
|
||||
*/
|
||||
private fun finishActivity() {
|
||||
setResult(RESULT_CANCELED)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user