| name | implementing-android-code |
| version | 0.1.2 |
| 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<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) {
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))
}
}
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:
@Serializable route data class with parameters
...Args helper class for extracting from SavedStateHandle
NavGraphBuilder.{screen}Destination() extension for adding screen to graph
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 = remember(viewModel) {
{ viewModel.trySendAction(ExampleAction.BackClick) }
},
)
},
) {
}
}
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
Pattern Summary:
interface ExampleRepository {
suspend fun fetchData(id: String): ExampleResult
val dataFlow: StateFlow<DataState<ExampleData>>
}
class ExampleRepositoryImpl(
private val exampleDiskSource: ExampleDiskSource,
private val exampleService: ExampleService,
) : ExampleRepository {
override suspend fun fetchData(id: String): ExampleResult {
return exampleService.getData(id).fold(
onSuccess = { ExampleResult.Success(it.toModel()) },
onFailure = { ExampleResult.Error(it.message) },
)
}
}
sealed class ExampleResult {
data class Success(val data: ExampleData) : ExampleResult()
data class Error(val message: String?) : ExampleResult()
}
@Module
@InstallIn(SingletonComponent::class)
object ExampleRepositoryModule {
@Provides
@Singleton
: 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:
- 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 +
...Impl pattern
- ✅ ALWAYS inject interfaces, never implementations
- ✅ Data sources return
Result<T>, 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. 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’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
Pattern Summary:
class ExampleDiskSourceImpl(
@EncryptedPreferences encryptedSharedPreferences: SharedPreferences,
@UnencryptedPreferences sharedPreferences: SharedPreferences,
) : BaseEncryptedDiskSource(
encryptedSharedPreferences = encryptedSharedPreferences,
sharedPreferences = sharedPreferences,
),
ExampleDiskSource {
fun storeAuthToken(token: String) {
putEncryptedString(KEY_TOKEN, token)
}
fun storeThemePreference(isDark: Boolean) {
putBoolean(KEY_THEME, isDark)
}
}
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:
interface RequestValidator {
fun validate(request: Request): Boolean
}
fun String?.sanitizeTotpUri(issuer: String?, username: String?): String? {
if (this.isNullOrBlank()) return null
}
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
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
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:
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")
every { diskSource.getData() } returns "cached"
coVerify { repository.fetchData("123") }
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:
class MyViewModel @Inject constructor(
private val clock: Clock,
) {
val timestamp = clock.instant()
}
fun State.getTimestamp(clock: Clock): Instant =
existingTime ?: clock.instant()
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<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
Clock via Hilt, use clock.instant() for testability
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)