mirror of
https://github.com/bitwarden/android.git
synced 2026-03-11 20:54:58 -05:00
Add implementing-android-code skill and deduplicate CLAUDE.md (#6534)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
@@ -74,11 +74,11 @@ android/
|
||||
|
||||
### Core Patterns
|
||||
|
||||
- **BaseViewModel**: Enforces UDF with State/Action/Event pattern. See `ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt` and `docs/ARCHITECTURE.md` for full templates and usage examples.
|
||||
- **Repository Result Pattern**: Type-safe error handling using custom sealed classes for discrete operations and `DataState<T>` wrapper for streaming data. See `docs/ARCHITECTURE.md` for implementation details.
|
||||
- **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, code templates, and examples, see `docs/ARCHITECTURE.md`.
|
||||
> For complete architecture patterns and code templates, see `docs/ARCHITECTURE.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,7 +86,7 @@ android/
|
||||
|
||||
### Adding New Feature Screen
|
||||
|
||||
Follow these steps (see `docs/ARCHITECTURE.md` for full templates and patterns):
|
||||
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
|
||||
@@ -98,15 +98,42 @@ Follow these steps (see `docs/ARCHITECTURE.md` for full templates and patterns):
|
||||
|
||||
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 (see source files and `docs/ARCHITECTURE.md` for full definitions):
|
||||
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
|
||||
- **`DataState<T>`** (`data/`) - Async data wrapper: Loading, Loaded, Pending, Error, NoNetwork
|
||||
- **`NetworkResult<T>`** (`network/`) - HTTP operation result: Success or Failure
|
||||
- **`BitwardenError`** (`network/`) - Error classification: Http, Network, Other
|
||||
|
||||
@@ -183,8 +210,6 @@ ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseCom
|
||||
- **Flow Testing**: Turbine with `stateEventFlow()` helper from `BaseViewModelTest`
|
||||
- **Time Control**: Inject `Clock` for deterministic time testing
|
||||
|
||||
> For comprehensive test templates (ViewModel, Screen, Repository, DataSource, Network), use the `testing-android-code` skill.
|
||||
|
||||
---
|
||||
|
||||
## Code Style & Standards
|
||||
@@ -192,6 +217,7 @@ ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseCom
|
||||
- **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`.
|
||||
|
||||
@@ -199,17 +225,15 @@ ui/src/testFixtures/ # UI test utilities (BaseViewModelTest, BaseCom
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
In addition to the Key Principles above, follow these rules:
|
||||
|
||||
### DO
|
||||
- Use `Result<T>` or sealed classes for operations that can fail
|
||||
- Hoist state to ViewModel when it affects business logic
|
||||
- Use `remember(viewModel)` for lambdas passed to composables
|
||||
- Map async results to internal actions before updating state
|
||||
- Use interface-based DI with Hilt
|
||||
- Inject `Clock` for time-dependent operations
|
||||
- Return early to reduce nesting
|
||||
|
||||
### DON'T
|
||||
- Throw exceptions from data layer functions
|
||||
- Update state directly inside coroutines (use internal actions)
|
||||
- Use `any` types or suppress null safety
|
||||
- Catch generic `Exception` (catch specific types)
|
||||
@@ -305,25 +329,6 @@ Follow semantic versioning pattern: `YEAR.MONTH.PATCH`
|
||||
|
||||
## References
|
||||
|
||||
### Internal Documentation
|
||||
- `docs/ARCHITECTURE.md` - Complete architecture patterns, BaseViewModel, Repository Result, DataState
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Kotlin and Compose code style, formatting, imports, documentation
|
||||
|
||||
### Skills & Tools
|
||||
- `testing-android-code` - Comprehensive test templates and patterns (ViewModel, Screen, Repository, DataSource, Network)
|
||||
- `reviewing-changes` - Structured code review checklists with MVVM/Compose pattern validation
|
||||
- `bitwarden-code-review:code-review` - Automated GitHub PR review with inline comments
|
||||
- `bitwarden-code-review:code-review-local` - Local change review written to files
|
||||
|
||||
### External Documentation
|
||||
- [Bitwarden SDK](https://github.com/bitwarden/sdk) - Cryptographic SDK
|
||||
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - UI framework
|
||||
- [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-guide.html) - Async programming
|
||||
- [Hilt DI](https://dagger.dev/hilt/) - Dependency injection
|
||||
- [Turbine](https://github.com/cashapp/turbine) - Flow testing
|
||||
|
||||
### Tools & Libraries
|
||||
- [MockK](https://mockk.io/) - Kotlin mocking library
|
||||
- [Retrofit](https://square.github.io/retrofit/) - HTTP client
|
||||
- [Room](https://developer.android.com/training/data-storage/room) - Database
|
||||
- [Detekt](https://detekt.dev/) - Static analysis
|
||||
- `docs/ARCHITECTURE.md` - Architecture patterns, templates, examples
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md` - Code style, formatting, Compose conventions
|
||||
- [Bitwarden SDK](https://github.com/bitwarden/sdk) | [Jetpack Compose](https://developer.android.com/jetpack/compose) | [Hilt DI](https://dagger.dev/hilt/) | [Turbine](https://github.com/cashapp/turbine) | [MockK](https://mockk.io/)
|
||||
|
||||
23
.claude/skills/implementing-android-code/CHANGELOG.md
Normal file
23
.claude/skills/implementing-android-code/CHANGELOG.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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.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
|
||||
44
.claude/skills/implementing-android-code/CONTRIBUTING.md
Normal file
44
.claude/skills/implementing-android-code/CONTRIBUTING.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 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
|
||||
77
.claude/skills/implementing-android-code/README.md
Normal file
77
.claude/skills/implementing-android-code/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
497
.claude/skills/implementing-android-code/SKILL.md
Normal file
497
.claude/skills/implementing-android-code/SKILL.md
Normal file
@@ -0,0 +1,497 @@
|
||||
---
|
||||
name: implementing-android-code
|
||||
version: 0.1.0
|
||||
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(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This skill captures **Bitwarden-specific patterns** that distinguish this codebase:
|
||||
|
||||
1. **State-Action-Event ViewModel pattern** - Synchronous state updates via `handleAction()`
|
||||
2. **Type-safe navigation** - No strings, `@Serializable` routes
|
||||
3. **No-throw error handling** - `Result<T>` and sealed classes
|
||||
4. **Interface/Impl separation** - Testability and DI safety
|
||||
5. **SavedStateHandle persistence** - Process death recovery
|
||||
6. **Security patterns** - Encrypted storage, Keystore, input validation
|
||||
7. **Clock injection** - Deterministic time handling via injected `Clock`
|
||||
|
||||
For comprehensive details on architecture, module organization, and complete code style rules, always consult:
|
||||
- `docs/ARCHITECTURE.md`
|
||||
- `docs/STYLE_AND_BEST_PRACTICES.md`
|
||||
644
.claude/skills/implementing-android-code/templates.md
Normal file
644
.claude/skills/implementing-android-code/templates.md
Normal file
@@ -0,0 +1,644 @@
|
||||
# Code Templates - Bitwarden Android
|
||||
|
||||
Copy-pasteable templates derived from actual codebase patterns. Replace `Example` with your feature name.
|
||||
|
||||
---
|
||||
|
||||
## ViewModel Template (State-Action-Event Pattern)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt`
|
||||
|
||||
### State Class
|
||||
|
||||
```kotlin
|
||||
@Parcelize
|
||||
data class ExampleState(
|
||||
val isLoading: Boolean = false,
|
||||
val data: String? = null,
|
||||
@IgnoredOnParcel val sensitiveInput: String = "", // Sensitive data excluded from parcel
|
||||
val dialogState: DialogState? = null,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* Dialog states for the Example screen.
|
||||
*/
|
||||
sealed class DialogState : Parcelable {
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val title: Text? = null,
|
||||
val message: Text,
|
||||
val error: Throwable? = null,
|
||||
) : DialogState()
|
||||
|
||||
@Parcelize
|
||||
data class Loading(val message: Text) : DialogState()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Sealed Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* One-shot UI events for the Example screen.
|
||||
*/
|
||||
sealed class ExampleEvent {
|
||||
data object NavigateBack : ExampleEvent()
|
||||
|
||||
data class ShowToast(val message: Text) : ExampleEvent()
|
||||
}
|
||||
```
|
||||
|
||||
### Action Sealed Class (with Internal)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* User and system actions for the Example screen.
|
||||
*/
|
||||
sealed class ExampleAction {
|
||||
data object BackClick : ExampleAction()
|
||||
|
||||
data object SubmitClick : ExampleAction()
|
||||
|
||||
data class InputChanged(val input: String) : ExampleAction()
|
||||
|
||||
data object ErrorDialogDismiss : ExampleAction()
|
||||
|
||||
/**
|
||||
* Internal actions dispatched by the ViewModel from coroutines.
|
||||
*/
|
||||
sealed class Internal : ExampleAction() {
|
||||
data class ReceiveDataState(
|
||||
val dataState: DataState<ExampleData>,
|
||||
) : Internal()
|
||||
|
||||
data class ReceiveDataResult(
|
||||
val result: ExampleResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel
|
||||
|
||||
```kotlin
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* ViewModel for the Example screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ExampleViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val exampleRepository: ExampleRepository,
|
||||
) : BaseViewModel<ExampleState, ExampleEvent, ExampleAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: run {
|
||||
val args = savedStateHandle.toExampleArgs()
|
||||
ExampleState(
|
||||
data = args.itemId,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
||||
init {
|
||||
// Persist state for process death recovery
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
// Collect repository flows as internal actions
|
||||
exampleRepository.dataFlow
|
||||
.map { ExampleAction.Internal.ReceiveDataState(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: ExampleAction) {
|
||||
when (action) {
|
||||
ExampleAction.BackClick -> handleBackClick()
|
||||
ExampleAction.SubmitClick -> handleSubmitClick()
|
||||
ExampleAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
|
||||
is ExampleAction.InputChanged -> handleInputChanged(action)
|
||||
is ExampleAction.Internal.ReceiveDataState -> {
|
||||
handleReceiveDataState(action)
|
||||
}
|
||||
is ExampleAction.Internal.ReceiveDataResult -> {
|
||||
handleReceiveDataResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleBackClick() {
|
||||
sendEvent(ExampleEvent.NavigateBack)
|
||||
}
|
||||
|
||||
private fun handleErrorDialogDismiss() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleSubmitClick() {
|
||||
viewModelScope.launch {
|
||||
val result = exampleRepository.submitData(state.data.orEmpty())
|
||||
sendAction(ExampleAction.Internal.ReceiveDataResult(result))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInputChanged(action: ExampleAction.InputChanged) {
|
||||
mutableStateFlow.update { it.copy(sensitiveInput = action.input) }
|
||||
}
|
||||
|
||||
private fun handleReceiveDataState(
|
||||
action: ExampleAction.Internal.ReceiveDataState,
|
||||
) {
|
||||
when (action.dataState) {
|
||||
is DataState.Loaded -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = action.dataState.data.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DataState.Loading -> {
|
||||
mutableStateFlow.update { it.copy(isLoading = true) }
|
||||
}
|
||||
|
||||
is DataState.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = BitwardenString.generic_error_message.asText(),
|
||||
error = action.dataState.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveDataResult(
|
||||
action: ExampleAction.Internal.ReceiveDataResult,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is ExampleResult.Success -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
data = result.data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ExampleResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
dialogState = ExampleState.DialogState.Error(
|
||||
message = result.message?.asText()
|
||||
?: BitwardenString.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation Template (Type-Safe Routes)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt`
|
||||
|
||||
```kotlin
|
||||
@file:OmitFromCoverage
|
||||
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.toRoute
|
||||
import com.bitwarden.annotation.OmitFromCoverage
|
||||
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Route for the Example screen.
|
||||
*/
|
||||
@Serializable
|
||||
@OmitFromCoverage
|
||||
data class ExampleRoute(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Args extracted from [SavedStateHandle] for the Example screen.
|
||||
*/
|
||||
@OmitFromCoverage
|
||||
data class ExampleArgs(
|
||||
val itemId: String,
|
||||
val isEditMode: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Extracts [ExampleArgs] from the [SavedStateHandle].
|
||||
*/
|
||||
fun SavedStateHandle.toExampleArgs(): ExampleArgs {
|
||||
val route = this.toRoute<ExampleRoute>()
|
||||
return ExampleArgs(
|
||||
itemId = route.itemId,
|
||||
isEditMode = route.isEditMode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the Example screen.
|
||||
*/
|
||||
fun NavController.navigateToExample(
|
||||
itemId: String,
|
||||
isEditMode: Boolean = false,
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
this.navigate(
|
||||
route = ExampleRoute(
|
||||
itemId = itemId,
|
||||
isEditMode = isEditMode,
|
||||
),
|
||||
navOptions = navOptions,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Example screen destination to the navigation graph.
|
||||
*/
|
||||
fun NavGraphBuilder.exampleDestination(
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
composableWithSlideTransitions<ExampleRoute> {
|
||||
ExampleScreen(
|
||||
onNavigateBack = onNavigateBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen/Compose Template
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt`
|
||||
|
||||
```kotlin
|
||||
package com.x8bit.bitwarden.ui.feature.example
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* The Example screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExampleScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: ExampleViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExampleEvent.NavigateBack -> onNavigateBack()
|
||||
is ExampleEvent.ShowToast -> {
|
||||
// Handle toast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
ExampleDialogs(
|
||||
dialogState = state.dialogState,
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) }
|
||||
},
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = BitwardenString.example),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
|
||||
onNavigationIconClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.BackClick) }
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
ExampleScreenContent(
|
||||
state = state,
|
||||
onInputChanged = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.InputChanged(it)) }
|
||||
},
|
||||
onSubmitClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(ExampleAction.SubmitClick) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Layer Template (Repository + Hilt Module)
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt`
|
||||
|
||||
### Interface
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Provides data operations for the Example feature.
|
||||
*/
|
||||
interface ExampleRepository {
|
||||
/**
|
||||
* Submits data and returns a typed result.
|
||||
*/
|
||||
suspend fun submitData(input: String): ExampleResult
|
||||
|
||||
/**
|
||||
* Continuously observed data stream.
|
||||
*/
|
||||
val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
}
|
||||
```
|
||||
|
||||
### Sealed Result Class
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Domain-specific result for Example operations.
|
||||
*/
|
||||
sealed class ExampleResult {
|
||||
data class Success(val data: String) : ExampleResult()
|
||||
data class Error(val message: String?) : ExampleResult()
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Default implementation of [ExampleRepository].
|
||||
*/
|
||||
class ExampleRepositoryImpl(
|
||||
private val exampleDiskSource: ExampleDiskSource,
|
||||
private val exampleService: ExampleService,
|
||||
private val dispatcherManager: DispatcherManager,
|
||||
) : ExampleRepository {
|
||||
|
||||
override val dataFlow: StateFlow<DataState<ExampleData>>
|
||||
get() = // ...
|
||||
|
||||
override suspend fun submitData(input: String): ExampleResult {
|
||||
return exampleService
|
||||
.postData(input)
|
||||
.fold(
|
||||
onSuccess = { ExampleResult.Success(it.toModel()) },
|
||||
onFailure = { ExampleResult.Error(it.message) },
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Hilt Module
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleRepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleRepository(
|
||||
exampleDiskSource: ExampleDiskSource,
|
||||
exampleService: ExampleService,
|
||||
dispatcherManager: DispatcherManager,
|
||||
): ExampleRepository = ExampleRepositoryImpl(
|
||||
exampleDiskSource = exampleDiskSource,
|
||||
exampleService = exampleService,
|
||||
dispatcherManager = dispatcherManager,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Templates
|
||||
|
||||
**Based on**: `app/src/main/kotlin/com/x8bit/bitwarden/data/auth/datasource/disk/di/AuthDiskModule.kt` and `AuthDiskSourceImpl.kt`
|
||||
|
||||
### Encrypted Disk Source (Module)
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ExampleDiskModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExampleDiskSource(
|
||||
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
|
||||
@UnencryptedPreferences sharedPreferences: SharedPreferences,
|
||||
json: Json,
|
||||
): ExampleDiskSource = ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
json = json,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Encrypted Disk Source (Implementation)
|
||||
|
||||
```kotlin
|
||||
/**
|
||||
* Disk source for Example data using encrypted and unencrypted storage.
|
||||
*/
|
||||
class ExampleDiskSourceImpl(
|
||||
encryptedSharedPreferences: SharedPreferences,
|
||||
sharedPreferences: SharedPreferences,
|
||||
private val json: Json,
|
||||
) : BaseEncryptedDiskSource(
|
||||
encryptedSharedPreferences = encryptedSharedPreferences,
|
||||
sharedPreferences = sharedPreferences,
|
||||
),
|
||||
ExampleDiskSource {
|
||||
|
||||
private companion object {
|
||||
const val ENCRYPTED_TOKEN_KEY = "exampleToken"
|
||||
const val UNENCRYPTED_PREF_KEY = "examplePreference"
|
||||
}
|
||||
|
||||
override var authToken: String?
|
||||
get() = getEncryptedString(ENCRYPTED_TOKEN_KEY)
|
||||
set(value) { putEncryptedString(ENCRYPTED_TOKEN_KEY, value) }
|
||||
|
||||
override var uiPreference: Boolean
|
||||
get() = getBoolean(UNENCRYPTED_PREF_KEY) ?: false
|
||||
set(value) { putBoolean(UNENCRYPTED_PREF_KEY, value) }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Templates
|
||||
|
||||
**Based on**: `app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt`
|
||||
|
||||
### ViewModel Test
|
||||
|
||||
```kotlin
|
||||
class ExampleViewModelTest : BaseViewModelTest() {
|
||||
|
||||
// Mock dependencies
|
||||
private val mockRepository = mockk<ExampleRepository>()
|
||||
private val mutableDataFlow = MutableStateFlow<DataState<ExampleData>>(DataState.Loading)
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
every { mockRepository.dataFlow } returns mutableDataFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is no saved state`() {
|
||||
val viewModel = createViewModel(state = null)
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when there is a saved state`() {
|
||||
val savedState = DEFAULT_STATE.copy(data = "saved")
|
||||
val viewModel = createViewModel(state = savedState)
|
||||
assertEquals(savedState, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should call repository and update state on success`() = runTest {
|
||||
val expected = ExampleResult.Success(data = "result")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
// Initial state
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Updated state after result
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "result", isLoading = false),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitClick should show error dialog on failure`() = runTest {
|
||||
val expected = ExampleResult.Error(message = "Network error")
|
||||
coEvery { mockRepository.submitData(any()) } returns expected
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
val errorState = awaitItem()
|
||||
assertTrue(errorState.dialogState is ExampleState.DialogState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BackClick should emit NavigateBack event`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExampleAction.BackClick)
|
||||
assertEquals(ExampleEvent.NavigateBack, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create ViewModel with optional saved state
|
||||
private fun createViewModel(
|
||||
state: ExampleState? = DEFAULT_STATE,
|
||||
): ExampleViewModel = ExampleViewModel(
|
||||
savedStateHandle = SavedStateHandle(
|
||||
mapOf(KEY_STATE to state),
|
||||
),
|
||||
exampleRepository = mockRepository,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val DEFAULT_STATE = ExampleState(
|
||||
isLoading = false,
|
||||
data = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Flow Testing with stateEventFlow
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `SubmitClick should update state and emit event`() = runTest {
|
||||
coEvery { mockRepository.submitData(any()) } returns ExampleResult.Success("data")
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
viewModel.trySendAction(ExampleAction.SubmitClick)
|
||||
|
||||
// Assert state change
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(data = "data"),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
|
||||
// Assert event emission
|
||||
assertEquals(
|
||||
ExampleEvent.ShowToast("Success".asText()),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user