
Implementing Android Code
- 45 installs
- 9.2k repo stars
- Updated August 4, 2026
- bitwarden/android
implementing-android-code is a Claude Code skill giving tactical, Bitwarden-specific guidance for implementing ViewModels, navigation, screens, and repositories in the Bitwarden Android app.
About
This skill gives tactical guidance for implementing features in the Bitwarden Android app, covering the codebase's critical patterns, gotchas, and anti-patterns. It documents the State-Action-Event ViewModel pattern, type-safe navigation with kotlinx.serialization, Jetpack Compose screen structure, and Hilt dependency injection. A developer uses it when creating a screen, ViewModel, navigation, repository, or data source in the Android repo.
- Bitwarden-specific Android implementation patterns and gotchas
- Covers the State-Action-Event ViewModel pattern and type-safe navigation
- Includes Jetpack Compose screen patterns and Hilt DI conventions
Implementing Android Code by the numbers
- 45 all-time installs (skills.sh)
- Ranked #609 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
implementing-android-code capabilities & compatibility
- Capabilities
- build test verify · frontend
- Use cases
- frontend · refactoring
What implementing-android-code says it does
This skill should be used when implementing Android code in Bitwarden.
All ViewModels follow the **State-Action-Event (SAE)** pattern via `BaseViewModel<State, Event, Action>`.
All navigation uses **type-safe routes** with kotlinx.serialization.
npx skills add https://github.com/bitwarden/android --skill implementing-android-codeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 9.2k |
| Last updated | August 4, 2026 |
| Repository | bitwarden/android ↗ |
What it does
Implement Bitwarden Android screens, ViewModels, navigation, and repositories following the codebase's required patterns.
Who is it for?
Implementing screens, ViewModels, navigation, and data sources following Bitwarden Android's patterns.
Skip if: Non-Bitwarden Android codebases or generic Android tutorials without these specific patterns.
When should I use this skill?
You are implementing a ViewModel, screen, navigation, or repository in the Bitwarden Android app.
What you get
Feature code that correctly uses the State-Action-Event pattern, type-safe navigation, and Compose/Hilt conventions.
- ViewModels, screens, navigation, and repositories following Bitwarden patterns
By the numbers
- State-Action-Event pattern via BaseViewModel<State, Event, Action>
- 4-part type-safe navigation structure (route, Args, destination, navigateTo)
Files
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
Pattern Summary:
@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(seehandleActionmethod)app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt(see class declaration)
Critical Gotchas:
- ❌ NEVER update
mutableStateFlowdirectly inside coroutines - ✅ ALWAYS post internal actions from coroutines, update state in
handleAction() - ❌ NEVER forget
@IgnoredOnParcelfor sensitive data (causes security leak) - ✅ ALWAYS use
@Parcelizeon 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
Pattern Summary:
@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
Key Patterns:
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
}
}
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(R.string.title),
navigationIcon = rememberVectorPainter(BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
// UI content
}
}Reference: app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt (see LoginScreen composable)
Essential Requirements:
- ✅ Use
hiltViewModel()for dependency injection - ✅ Use
collectAsStateWithLifecycle()for state (notcollectAsState()) - ✅ Use
EventsEffect(viewModel)for one-shot events - ✅ Use
Bitwarden*prefixed components from:uimodule
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
rememberorrememberSaveable
---
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
Pattern Summary:
// 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.ktapp/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 +
...Implpattern - ✅ ALWAYS inject interfaces, never implementations
- ✅ Data sources return
Result<T>, repositories return domain sealed classes - ✅ Use
StateFlowfor continuously observed data
KDoc on Interfaces vs. Implementations
KDoc on an interface member describes the contract — what the caller can rely on. It must not describe how the implementation fulfills that contract. Implementation details (which data source is consulted, what is cached, what is logged, which manager is delegated to, ordering of internal calls, etc.) belong on the ...Impl member — and only when the why is non-obvious from the code itself.
This applies to every interface in the codebase: repositories, managers, data sources, validators, and UI-layer interfaces alike. The rule is the same one CLAUDE.md states for comments in general — describe the contract or the non-obvious why, never the what a well-named identifier already conveys.
❌ Wrong — interface KDoc leaks the implementation:
interface ExampleRepository {
/**
* Fetches data by reading from [ExampleDiskSource] first, falling back to
* [ExampleService] on cache miss and writing the network result back to disk.
*/
suspend fun fetchData(id: String): ExampleResult
}✅ Right — interface KDoc describes the contract; implementation details (if needed at all) live on the override:
interface ExampleRepository {
/** Returns the [ExampleData] for [id], or an error result on failure. */
suspend fun fetchData(id: String): ExampleResult
}
class ExampleRepositoryImpl(...) : ExampleRepository {
// No KDoc needed — the disk-then-network pattern is visible in the code.
override suspend fun fetchData(id: String): ExampleResult { ... }
}Red flags that an interface KDoc has drifted into implementation territory:
- Names a concrete collaborator (
...DiskSource,...Service,...Manager,...Impl) - Describes ordering of internal calls ("first ... then ...", "falls back to ...")
- Mentions caching, retries, logging, or threading behavior that callers don't depend on
- Restates the method signature in prose ("Suspends until X returns Y")
If callers genuinely depend on a behavior (e.g., "this method is safe to call before vault unlock", "result is cached for the session"), that is part of the contract and belongs on the interface. The test: would a different valid implementation be free to change this? If yes, it's implementation detail — strip it.
---
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 buttonsBitwardenOutlinedButton- Secondary action buttonsBitwardenTextField- Text input fieldsBitwardenPasswordField- Password input with show/hideBitwardenSwitch- Toggle switchesBitwardenTopAppBar- Toolbar/app barBitwardenScaffold- Screen container with scaffoldBitwardenBasicDialog- Simple dialogsBitwardenLoadingDialog- Loading indicators
Component Discovery: Search ui/src/main/kotlin/com/bitwarden/ui/platform/components/ for existing Bitwarden* components. For build, test, and codebase discovery commands, use the `build-test-verify` skill.
When to Create New Reusable Components:
- Component used in 3+ places
- Component needs consistent theming across app
- Component has semantic meaning (accessibility)
- Component has complex state management
New Component Requirements:
- Prefix with
Bitwarden - Accept themed colors/styles from
BitwardenTheme - Include preview composables for testing
- Support accessibility (content descriptions, semantics)
String Resources:
New strings belong in the :ui module: ui/src/main/res/values/strings.xml
- Use typographic apostrophes and quotes to avoid escape characters:
you’llnotyou\'ll,“word”not\"word\" - Reference strings via generated
BitwardenStringresource IDs - Do not add strings to other modules unless explicitly instructed
---
F. Security Patterns
Encrypted vs Unencrypted Storage:
Template: See Security templates
Pattern Summary:
class ExampleDiskSourceImpl(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
fun storeAuthToken(token: String) {
putEncryptedString(KEY_TOKEN, token) // Sensitive — uses base class method
}
fun storeThemePreference(isDark: Boolean) {
putBoolean(KEY_THEME, isDark) // Non-sensitive — uses base class method
}
}Android Keystore (Biometric Keys):
- User-scoped encryption keys:
BiometricsEncryptionManager - Keys stored in Android Keystore (hardware-backed when available)
- Integrity validation on biometric state changes
Input Validation:
// 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
@EncryptedPreferencesfor credentials, keys, tokens - ✅ Use
@UnencryptedPreferencesfor UI state, preferences - ✅ Use
@IgnoredOnParcelfor 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
Pattern Summary:
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
BaseViewModelTestfor proper dispatcher management - ✅ Use
runTestfromkotlinx.coroutines.test - ✅ Use Turbine's
.test { awaitItem() }for Flow assertions - ✅ Use MockK:
coEveryfor suspend functions,everyfor sync - ✅ Test both state changes and event emissions
- ✅ Test both success and failure Result paths
Flow Testing with Turbine:
// 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:
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
Clockvia Hilt in ViewModels - ✅ Pass
Clockas parameter in extension functions - ✅ Use
clock.instant()to get current time - ❌ Never call
Instant.now()orDateTime.now()directly - ❌ Never use
mockkStaticfor datetime classes in tests
Pattern Summary:
// 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(seeprovideClockfunction)
Critical Gotchas:
- ❌
Instant.now()creates hidden dependency, non-testable - ❌
mockkStatic(Instant::class)is fragile, can leak between tests - ✅
Clock.fixed(...)provides deterministic test behavior
---
I. Kotlin Style Rules
Project-specific style conventions enforced in code review. These supplement (not replace) docs/STYLE_AND_BEST_PRACTICES.md.
`when` branches with wrapped right-hand side require curly braces.
When a when branch's expression is too long to fit on the same line as the arrow and is wrapped to the next line, wrap the body in { }. A bare -> followed by an indented expression on its own line is rejected in review.
❌ Wrong — wrapped body without braces:
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT ->
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
VaultItemCipherType.DRIVERS_LICENSE ->
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
}✅ Right — wrapped body with braces:
when (type) {
VaultItemCipherType.LOGIN -> VaultAddEditState.ViewState.Content.ItemType.Login()
VaultItemCipherType.BANK_ACCOUNT -> {
VaultAddEditState.ViewState.Content.ItemType.BankAccount()
}
VaultItemCipherType.DRIVERS_LICENSE -> {
VaultAddEditState.ViewState.Content.ItemType.DriversLicense()
}
}Single-line branches (body fits on the same line as ->) do not need braces.
---
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
Clockvia Hilt, useclock.instant()for testability
❌ NEVER put implementation details in interface KDoc
- Interface KDoc describes the contract; concrete collaborators, call ordering, caching, and fallback behavior belong on the
...Imploverride (and only when the why is non-obvious). See section D for examples.
---
Quick Reference
For build, test, and codebase discovery commands, use the `build-test-verify` skill.
File Reference Format: When pointing to specific code, use: file_path:line_number
Example: ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt (see handleAction method)
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
@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
/**
* 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)
/**
* 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
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
@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
package com.x8bit.bitwarden.ui.feature.example
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
/**
* The Example screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExampleScreen(
onNavigateBack: () -> Unit,
viewModel: ExampleViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
ExampleEvent.NavigateBack -> onNavigateBack()
is ExampleEvent.ShowToast -> {
// Handle toast
}
}
}
// Dialogs
ExampleDialogs(
dialogState = state.dialogState,
onDismissRequest = { viewModel.trySendAction(ExampleAction.ErrorDialogDismiss) },
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.example),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
onNavigationIconClick = { viewModel.trySendAction(ExampleAction.BackClick) },
)
},
) {
ExampleScreenContent(
state = state,
onInputChanged = { viewModel.trySendAction(ExampleAction.InputChanged(it)) },
onSubmitClick = { viewModel.trySendAction(ExampleAction.SubmitClick) },
modifier = Modifier
.fillMaxSize(),
)
}
}---
Data Layer Template (Repository + Hilt Module)
Based on: app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt
Interface
/**
* 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
/**
* Domain-specific result for Example operations.
*/
sealed class ExampleResult {
data class Success(val data: String) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}Implementation
/**
* 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
@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)
@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)
/**
* 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
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
@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(),
)
}
}Related skills
FAQ
What ViewModel pattern does Bitwarden Android use?
All ViewModels follow the State-Action-Event pattern via BaseViewModel<State, Event, Action>, with a synchronous handleAction and internal actions posted from coroutines.
How is navigation handled?
Navigation uses type-safe routes with kotlinx.serialization: a @Serializable route data class, an Args helper, a destination extension, and a navigateTo extension.