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.
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.
All ViewModels follow the State-Action-Event (SAE) pattern via BaseViewModel<State, Event, Action>.
Key Requirements:
@HiltViewModel@Parcelize data class : ParcelablehandleAction(action: A) - MUST be synchronoussendAction()SavedStateHandle[KEY_STATE]private fun handle* naming conventionTemplate: See
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 (see handleAction method)app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt (see class declaration)Critical Gotchas:
mutableStateFlow directly inside coroutineshandleAction()@IgnoredOnParcel for sensitive data (causes security leak)@Parcelize on state classes for process death recoverySavedStateHandleAll navigation uses type-safe routes with kotlinx.serialization.
Pattern Structure:
@Serializable route data class with parameters...Args helper class for extracting from SavedStateHandleNavGraphBuilder.{screen}Destination() extension for adding screen to graphNavController.navigateTo{Screen}() extension for navigation callsTemplate: 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:
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:
hiltViewModel() for dependency injectioncollectAsStateWithLifecycle() for state (not collectAsState())EventsEffect(viewModel) for one-shot eventsBitwarden* prefixed components from :ui moduleState Hoisting Rules:
remember or rememberSaveableThe 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.ktThree-Layer Data Architecture:
Result<T>, never throw.Critical Rules:
...Impl patternResult<T>, repositories return domain sealed classesStateFlow for continuously observed dataUse 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 indicatorsComponent 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:
New Component Requirements:
BitwardenBitwardenThemeString Resources:
New strings belong in the :ui module: ui/src/main/res/values/strings.xml
you’ll not you\'ll, “word” not \"word\"BitwardenString resource IDsEncrypted 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):
BiometricsEncryptionManagerInput 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:
@EncryptedPreferences for credentials, keys, tokens@UnencryptedPreferences for UI state, preferences@IgnoredOnParcel for sensitive ViewModel stateViewModel 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:
BaseViewModelTest for proper dispatcher managementrunTest from kotlinx.coroutines.test.test { awaitItem() } for Flow assertionscoEvery for suspend functions, every for syncFlow 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
All code needing current time must inject Clock for testability.
Key Requirements:
Clock via Hilt in ViewModelsClock as parameter in extension functionsclock.instant() to get current timeInstant.now() or DateTime.now() directlymockkStatic for datetime classes in testsPattern 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 (see provideClock function)Critical Gotchas:
Instant.now() creates hidden dependency, non-testablemockkStatic(Instant::class) is fragile, can leak between testsClock.fixed(...) provides deterministic test behaviorGeneral 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
handleAction()❌ NEVER inject ...Impl classes
❌ NEVER create navigation without @Serializable routes
❌ NEVER use raw Result<T> in repositories
❌ NEVER make state classes without @Parcelize
❌ NEVER skip SavedStateHandle persistence for ViewModels
❌ NEVER forget @IgnoredOnParcel for passwords/tokens
❌ NEVER use generic Exception catching
RemoteException, IOException)❌ NEVER call Instant.now() or DateTime.now() directly
Clock via Hilt, use clock.instant() for testabilityFor 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)