npx skills add ...
npx skills add bitwarden/android --skill implementing-android-code
npx skills add bitwarden/android --skill implementing-android-code
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 ViewModel template
Pattern Summary:
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:
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:
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:
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 dataKDoc 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:
✅ Right — interface KDoc describes the contract; implementation details (if needed at all) live on the override:
Red flags that an interface KDoc has drifted into implementation territory:
...DiskSource, ...Service, ...Manager, ...Impl)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.
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 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:
Android Keystore (Biometric Keys):
BiometricsEncryptionManagerInput Validation:
Security Checklist:
@EncryptedPreferences for credentials, keys, tokens@UnencryptedPreferences for UI state, preferences@IgnoredOnParcel for sensitive ViewModel stateViewModel Testing:
Template: See Testing templates
Pattern Summary:
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:
MockK Quick Reference:
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:
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 behaviorProject-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:
✅ Right — wrapped body with braces:
Single-line branches (body fits on the same line as ->) do not need braces.
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
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 testability❌ NEVER put implementation details in interface KDoc
...Impl override (and only when the why is non-obvious). See section D for examples.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)
@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)
}
}@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
}
}// 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)
}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
}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 { ... }
}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
}
}// 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
}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,
)
}// Test state and events simultaneously
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
viewModel.trySendAction(ExampleAction.Submit)
assertEquals(ExpectedState.Loading, stateFlow.awaitItem())
assertEquals(ExampleEvent.ShowSuccess, eventFlow.awaitItem())
}coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
every { diskSource.getData() } returns "cached" // Sync
coVerify { repository.fetchData("123") } // Verify// 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,
)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()
}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()
}
}