From b10568a3aeff352507c97b097160744b527451b4 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:09:15 -0500 Subject: [PATCH] Add implementing-android-code skill and deduplicate CLAUDE.md (#6534) Co-authored-by: Claude Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- .claude/CLAUDE.md | 73 +- .../implementing-android-code/CHANGELOG.md | 23 + .../implementing-android-code/CONTRIBUTING.md | 44 ++ .../implementing-android-code/README.md | 77 +++ .../skills/implementing-android-code/SKILL.md | 497 ++++++++++++++ .../implementing-android-code/templates.md | 644 ++++++++++++++++++ 6 files changed, 1324 insertions(+), 34 deletions(-) create mode 100644 .claude/skills/implementing-android-code/CHANGELOG.md create mode 100644 .claude/skills/implementing-android-code/CONTRIBUTING.md create mode 100644 .claude/skills/implementing-android-code/README.md create mode 100644 .claude/skills/implementing-android-code/SKILL.md create mode 100644 .claude/skills/implementing-android-code/templates.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 60769aecf3..09d6d0b6bd 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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` 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` 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`, 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`** (`data/`) - Async data wrapper: Loading, Loaded, Pending, Error, NoNetwork - **`NetworkResult`** (`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` 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/) diff --git a/.claude/skills/implementing-android-code/CHANGELOG.md b/.claude/skills/implementing-android-code/CHANGELOG.md new file mode 100644 index 0000000000..e605230bf9 --- /dev/null +++ b/.claude/skills/implementing-android-code/CHANGELOG.md @@ -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`, 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 \ No newline at end of file diff --git a/.claude/skills/implementing-android-code/CONTRIBUTING.md b/.claude/skills/implementing-android-code/CONTRIBUTING.md new file mode 100644 index 0000000000..1fc0609c64 --- /dev/null +++ b/.claude/skills/implementing-android-code/CONTRIBUTING.md @@ -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 \ No newline at end of file diff --git a/.claude/skills/implementing-android-code/README.md b/.claude/skills/implementing-android-code/README.md new file mode 100644 index 0000000000..d7f7280a8a --- /dev/null +++ b/.claude/skills/implementing-android-code/README.md @@ -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` 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. \ No newline at end of file diff --git a/.claude/skills/implementing-android-code/SKILL.md b/.claude/skills/implementing-android-code/SKILL.md new file mode 100644 index 0000000000..6f3b2d50f7 --- /dev/null +++ b/.claude/skills/implementing-android-code/SKILL.md @@ -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`. + +**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( + 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() + 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 { + 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> +} + +// 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`, 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`, 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` 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` 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` diff --git a/.claude/skills/implementing-android-code/templates.md b/.claude/skills/implementing-android-code/templates.md new file mode 100644 index 0000000000..ea60b946a6 --- /dev/null +++ b/.claude/skills/implementing-android-code/templates.md @@ -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, + ) : 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( + 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() + 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 { + 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> +} +``` + +### 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> + 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() + private val mutableDataFlow = MutableStateFlow>(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(), + ) + } +} +```