17 KiB
name, version, description
| name | version | description |
|---|---|---|
| implementing-android-code | 0.1.1 | This skill should be used when implementing Android code in Bitwarden. Covers critical patterns, gotchas, and anti-patterns unique to this codebase. Triggered by "How do I implement a ViewModel?", "Create a new screen", "Add navigation", "Write a repository", "BaseViewModel pattern", "State-Action-Event", "type-safe navigation", "@Serializable route", "SavedStateHandle persistence", "process death recovery", "handleAction", "sendAction", "Hilt module", "Repository pattern", "implementing a screen", "adding a data source", "handling navigation", "encrypted storage", "security patterns", "Clock injection", "DataState", or any questions about implementing features, screens, ViewModels, data sources, or navigation in the Bitwarden Android app. |
Implementing Android Code - Bitwarden Quick Reference
This skill provides tactical guidance for Bitwarden-specific patterns. For comprehensive architecture decisions and complete code style rules, consult docs/ARCHITECTURE.md and docs/STYLE_AND_BEST_PRACTICES.md.
Critical Patterns Reference
A. ViewModel Implementation (State-Action-Event Pattern)
All ViewModels follow the State-Action-Event (SAE) pattern via BaseViewModel<State, Event, Action>.
Key Requirements:
- Annotate with
@HiltViewModel - State class MUST be
@Parcelize data class : Parcelable - Implement
handleAction(action: A)- MUST be synchronous - Post internal actions from coroutines using
sendAction() - Save/restore state via
SavedStateHandle[KEY_STATE] - Private action handlers:
private fun handle*naming convention
Template: See ViewModel template
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:
@Serializableroute data class with parameters...Argshelper class for extracting fromSavedStateHandleNavGraphBuilder.{screen}Destination()extension for adding screen to graphNavController.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 = 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 (notcollectAsState()) - ✅ Use
EventsEffect(viewModel)for one-shot events - ✅ Use
remember(viewModel) { }for stable callbacks to prevent recomposition - ✅ 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:
- Data Sources - Raw data access (network, disk, SDK). Return
Result<T>, never throw. - Managers - Single responsibility business logic. Wrap OS/external services.
- 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
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. 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’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
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
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)