implementing-android-code について
Bitwarden の Android 開発者向け実装ガイドです。ViewModel、ナビゲーション、データ層、Compose UI、セキュリティ、テストの規約を説明します。
主な機能
- 01State-Action-Event の ViewModel
- 02型安全なナビゲーションルート
- 03レイヤー化されたデータ設計
- 04Compose UI の規約
- 05セキュリティとテストのパターン
活用シーン
- 01状態復元に対応する ViewModel の実装
- 02型安全な画面ルートの追加
- 03Repository とデータソース層の構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ bitwarden/ android --skill implementing-android-codeインストール後、Claude Code で/implementing-android-codeを入力して読み込まれたか確認します。
同梱ファイルtemplates.md
詳細
Android コードの実装 - Bitwarden クイックリファレンス
このスキルでは、Bitwarden 固有のパターンに関する実践的なガイダンスを提供します。 アーキテクチャの包括的な決定事項と完全なコードスタイルのルールについては、docs/ARCHITECTURE.md と docs/STYLE_AND_BEST_PRACTICES.md を参照してください。
重要なパターンのリファレンス
A. ViewModel の実装(State-Action-Event パターン)
すべての ViewModel は、BaseViewModel<State, Event, Action> を介して State-Action-Event(SAE) パターンに従います。
主な要件:
@HiltViewModelを付与する- State クラスは必ず
@Parcelize data class : Parcelableにする handleAction(action: A)を実装する(同期処理でなければなりません)- コルーチンから
sendAction()を使って内部アクションをポストする SavedStateHandle[KEY_STATE]を介して状態を保存/復元する- プライベートなアクションハンドラーには
private fun handle*の命名規則を使用する
テンプレート:ViewModel テンプレートを参照してください
パターンの概要:
@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) }
}
}
リファレンス:
ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt(handleActionメソッドを参照)app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginViewModel.kt(クラス宣言を参照)
特に注意すべき点:
- ❌ コルーチン内で
mutableStateFlowを直接更新することは絶対に避けてください - ✅ コルーチンから内部アクションを必ずポストし、
handleAction()内で状態を更新してください - ❌ 機密データに対する
@IgnoredOnParcelの付け忘れは絶対に避けてください(セキュリティ上の漏えいにつながります) - ✅ プロセス終了後の復元に備えて、State クラスには必ず
@Parcelizeを使用してください - ✅
SavedStateHandleに適切に保存されていれば、状態は自動的に復元されます
B. ナビゲーションの実装(型安全)
すべてのナビゲーションで、kotlinx.serialization を使った 型安全なルートを使用します。
パターンの構成:
- パラメーターを持つ
@Serializableルートデータクラス SavedStateHandleから値を取り出すための...Argsヘルパークラス- グラフに画面を追加するための
NavGraphBuilder.{screen}Destination()拡張関数 - ナビゲーション呼び出し用の
NavController.navigateTo{Screen}()拡張関数
テンプレート:ナビゲーションテンプレートを参照してください
パターンの概要:
@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)
}
}
リファレンス: app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginNavigation.kt(LoginRoute と拡張関数を参照)
主なメリット:
- ✅ 型安全性:パラメーターの不足をコンパイル時に検出できます
- ✅ ナビゲーションコードで文字列リテラルを使いません
- ✅ シリアライズ/デシリアライズが自動で行われます
- ✅ 画面の依存関係が明確になります
C. 画面/Compose の実装
すべての画面で、一貫した Compose パターンに従います。
テンプレート:画面/Compose テンプレートを参照してください
主なパターン:
@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
}
}
リファレンス: app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/login/LoginScreen.kt(LoginScreen コンポーザブルを参照)
必須要件:
- ✅ 依存関係の注入には
hiltViewModel()を使用する - ✅ 状態の取得には
collectAsState()ではなくcollectAsStateWithLifecycle()を使用する - ✅ ワンショットイベントには
EventsEffect(viewModel)を使用する - ✅
:uiモジュールのBitwarden*を接頭辞に持つコンポーネントを使用する
状態ホイスティングのルール:
- ViewModel の状態:プロセス終了後も維持する必要があるデータ、またはビジネスロジックに影響するデータ
- UI のみの状態:スクロール位置やテキストフィールドのフォーカスなど、
rememberまたはrememberSaveableを使用する一時的な UI 状態
D. データレイヤーの実装
データレイヤーでは、リポジトリ、マネージャー、データソースに厳格なパターンを適用します。
インターフェースと実装の分離(必須)
テンプレート:データレイヤーのテンプレートを参照してください。
パターンの概要:
// 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)
}
参考資料:
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
3 層のデータアーキテクチャ:
- データソース - 生データへのアクセス(ネットワーク、ディスク、SDK)。
Result<T>を返し、例外はスローしません。 - マネージャー - 単一の責務を担うビジネスロジック。OS/外部サービスをラップします。
- リポジトリ - ソース/マネージャーを集約します。ドメイン固有のシールドクラスを返します。
重要なルール:
- ❌ データレイヤーでは例外をスローしないでください。
- ✅ 必ずインターフェースと
...Implのパターンを使用してください。 - ✅ 必ずインターフェースをインジェクトし、実装はインジェクトしないでください。
- ✅ データソースは
Result<T>を返し、リポジトリはドメイン固有のシールドクラスを返します。 - ✅ 継続的に監視するデータには
StateFlowを使用してください。
インターフェースと実装の KDoc
インターフェースメンバーの KDoc は、呼び出し側が信頼できる内容、つまり契約を説明します。実装がその契約をどのように満たすかを説明してはいけません。実装の詳細(参照するデータソース、キャッシュする内容、ログに記録する内容、委譲先のマネージャー、内部呼び出しの順序など)は ...Impl のメンバーに記述します。ただし、その理由がコード自体から明らかでない場合に限ります。
このルールは、リポジトリ、マネージャー、データソース、バリデーター、UI レイヤーのインターフェースなど、コードベース内のすべてのインターフェースに適用されます。このルールは、コメント全般について CLAUDE.md に記載されているものと同じです。契約、または自明でない理由を説明し、適切な名前がすでに示している内容は説明しないでください。
❌ 誤り — インターフェースの KDoc に実装の詳細が漏れています:
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
}
✅ 正しい — インターフェースの KDoc では契約を説明し、必要であれば実装の詳細をオーバーライド側に記述します:
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 { ... }
}
インターフェースの KDoc が実装の領域に踏み込んでいる兆候:
- 具体的な連携先(
...DiskSource、...Service、...Manager、...Impl)の名前を挙げている - 内部呼び出しの順序を説明している(「まず……してから……する」、「……にフォールバックする」)
- 呼び出し側に関係のないキャッシュ、再試行、ログ記録、スレッド処理の動作に言及している
- メソッドシグネチャを文章で言い換えている(「X が Y を返すまで中断します」)
呼び出し側が実際に依存する動作(例:「このメソッドはボールトのロック解除前でも安全に呼び出せます」、「結果はセッション中キャッシュされます」)は、契約の一部であり、インターフェースに記載します。確認方法は、その動作を別の妥当な実装で変更してもよいかどうかです。変更してよい場合は実装の詳細なので、削除してください。
E. UI コンポーネント
既存のコンポーネントを優先して使用する:
:ui モジュールには、再利用可能な Bitwarden* プレフィックス付きコンポーネントが用意されています。新しいコンポーネントを作成する前に検索してください。
一般的なコンポーネント:
BitwardenFilledButton- 主要なアクションボタンBitwardenOutlinedButton- 二次的なアクションボタンBitwardenTextField- テキスト入力フィールドBitwardenPasswordField- 表示/非表示を切り替えられるパスワード入力BitwardenSwitch- トグルスイッチBitwardenTopAppBar- ツールバー/アプリバーBitwardenScaffold- スキャフォールド付き画面コンテナーBitwardenBasicDialog- シンプルなダイアログBitwardenLoadingDialog- 読み込みインジケーター
コンポーネントの検索:
ui/src/main/kotlin/com/bitwarden/ui/platform/components/ を検索して、既存の Bitwarden* コンポーネントを探してください。ビルド、テスト、コードベースの検索に使うコマンドについては、build-test-verify スキルを使用してください。
新しい再利用可能なコンポーネントを作成する場合:
- 3 か所以上で使用されるコンポーネント
- アプリ全体で一貫したテーマ設定が必要なコンポーネント
- 意味的な役割を持つコンポーネント(アクセシビリティ)
- 複雑な状態管理を伴うコンポーネント
新しいコンポーネントの要件:
Bitwardenをプレフィックスに付けるBitwardenThemeからテーマカラー/スタイルを受け取る- テスト用のプレビューコンポーザブルを含める
- アクセシビリティをサポートする(コンテンツの説明、セマンティクス)
文字列リソース:
新しい文字列は :ui モジュールに追加します:ui/src/main/res/values/strings.xml
- エスケープ文字を避けるため、アポストロフィと引用符にはタイポグラフィ上の記号を使用します:
you’ll(you\'llではなく)、“word”(\"word\"ではなく) - 生成された
BitwardenStringリソース ID を使用して文字列を参照します - 明示的な指示がない限り、他のモジュールに文字列を追加しないでください
F. セキュリティパターン
暗号化ストレージと非暗号化ストレージ:
テンプレート:セキュリティテンプレートを参照してください
パターンの概要:
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(生体認証キー):
- ユーザーごとの暗号化キー:
BiometricsEncryptionManager - キーは Android Keystore に保存されます(利用可能な場合はハードウェアで保護)
- 生体認証の状態が変化した際に整合性を検証します
入力検証:
// 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
}
セキュリティチェックリスト:
- ✅ 認証情報、キー、トークンには
@EncryptedPreferencesを使用する - ✅ UI の状態や設定には
@UnencryptedPreferencesを使用する - ✅ 機密性の高い ViewModel の状態には
@IgnoredOnParcelを使用する - ❌ 機密データ(パスワード、トークン、保管庫アイテム)を絶対にログに記録しない
- ✅ すべてのユーザー入力を処理前に検証する
- ✅ 機密性のないログ記録にのみ Timber を使用する
G. テストパターン
ViewModel のテスト:
テンプレート:テストテンプレートを参照してください
パターンの概要:
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,
)
}
参照: app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorViewModelTest.kt
主なテストパターン:
- ✅ 適切なディスパッチャー管理のため、
BaseViewModelTestを継承する - ✅
kotlinx.coroutines.testのrunTestを使用する - ✅ Flow のアサーションには Turbine の
.test { awaitItem() }を使用する - ✅ MockK を使用する:サスペンド関数には
coEvery、通常の関数にはevery - ✅ 状態の変化とイベントの発行の両方をテストする
- ✅ Result の成功パスと失敗パスの両方をテストする
Turbine を使った Flow のテスト:
// 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 クイックリファレンス:
coEvery { repository.fetchData(any()) } returns Result.success("data") // Suspend
every { diskSource.getData() } returns "cached" // Sync
coVerify { repository.fetchData("123") } // Verify
H. Clock/時刻の扱い
現在時刻を必要とするコードには、テスト可能性を確保するために Clock を注入する必要があります。
主な要件:
- ✅ ViewModel では Hilt 経由で
Clockを注入する - ✅ 拡張関数では
Clockをパラメーターとして渡す - ✅ 現在時刻の取得には
clock.instant()を使用する - ❌
Instant.now()やDateTime.now()を直接呼び出さない - ❌ テストで日時クラスに
mockkStaticを使用しない
パターンの概要:
// 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,
)
参照:
docs/STYLE_AND_BEST_PRACTICES.md(「Time and Clock Handling」セクションを参照)core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt(provideClock関数を参照)
特に注意が必要な点:
- ❌
Instant.now()は隠れた依存関係を生み、テストできなくなります - ❌
mockkStatic(Instant::class)は不安定で、テスト間に影響が残る場合があります - ✅
Clock.fixed(...)を使うと、テストの動作が決定的になります
I. Kotlin のスタイル規則
コードレビューで適用されるプロジェクト固有のスタイル規約です。これらは docs/STYLE_AND_BEST_PRACTICES.md を補足するものであり、置き換えるものではありません。
右辺が改行される when 分岐では、中括弧が必要です。
when 分岐の式が長すぎて矢印と同じ行に収まらず、次の行に折り返される場合は、本文を { } で囲んでください。裸の -> の後に、インデントされた式が単独の行に続く書き方は、レビューで却下されます。
❌ 誤り — 本文が改行されているのに中括弧がない場合:
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()
}
}
1 行に収まる分岐(本文が -> と同じ行に収まる場合)では、中括弧は不要です。
Bitwarden 固有のアンチパターン
一般的なアンチパターンは CLAUDE.md に記載されています。 このセクションでは、Bitwarden の State-Action-Event、ナビゲーション、データレイヤーのパターンに固有の違反を取り上げます。
❌ コルーチン内で ViewModel の状態を直接更新しないでください
- 内部アクションを送信し、
handleAction()内で同期的に状態を更新します
❌ ...Impl クラスをインジェクトしないでください
- Hilt 経由でインジェクトするのはインターフェースのみです
❌ @Serializable のルートを使わずにナビゲーションを作成しないでください
- 文字列ベースのナビゲーションは禁止し、常に型安全にします
❌ リポジトリで生の Result<T> を使用しないでください
- エラー処理を改善するため、ドメイン固有の sealed クラスを使用します
❌ @Parcelize のない状態クラスを作成しないでください
- すべての ViewModel の状態は、プロセス終了後も保持される必要があります
❌ ViewModel の SavedStateHandle による永続化を省略しないでください
- プロセス終了後に、ユーザーはフォームの入力内容を失います
❌ パスワードやトークンに @IgnoredOnParcel を付け忘れないでください
- セキュリティ上の脆弱性(機密データが parcel に含まれる)の原因になります
❌ 汎用的な Exception の捕捉を使用しないでください
- 特定の例外(
RemoteException、IOException)のみを捕捉します
❌ Instant.now() や DateTime.now() を直接呼び出さないでください
- Hilt 経由で
Clockをインジェクトし、テストしやすいようにclock.instant()を使用します
❌ インターフェースの KDoc に実装の詳細を書かないでください
- インターフェースの KDoc には契約を記述します。具体的なコラボレーター、呼び出し順序、キャッシュ、フォールバック動作は
...Implのオーバーライド側に記述します(また、その理由が明白でない場合に限ります)。例についてはセクション D を参照してください。
クイックリファレンス
ビルド、テスト、コードベースの調査に使うコマンドについては、build-test-verify スキルを使用してください。
ファイル参照形式:
特定のコードを示すときは、file_path:line_number を使用します。
例:ui/src/main/kotlin/com/bitwarden/ui/platform/base/BaseViewModel.kt(handleAction メソッドを参照)