mcpField
I
implementing-android-code

implementing-android-code

github.com/bitwarden開発9.6k50 回インストール今日更新
リポジトリ

implementing-android-code について

Bitwarden の Android 開発者向け実装ガイドです。ViewModel、ナビゲーション、データ層、Compose UI、セキュリティ、テストの規約を説明します。

主な機能

  1. 01State-Action-Event の ViewModel
  2. 02型安全なナビゲーションルート
  3. 03レイヤー化されたデータ設計
  4. 04Compose UI の規約
  5. 05セキュリティとテストのパターン

活用シーン

  1. 01状態復元に対応する ViewModel の実装
  2. 02型安全な画面ルートの追加
  3. 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 を使った 型安全なルートを使用します。

パターンの構成:

  1. パラメーターを持つ @Serializable ルートデータクラス
  2. SavedStateHandle から値を取り出すための ...Args ヘルパークラス
  3. グラフに画面を追加するための NavGraphBuilder.{screen}Destination() 拡張関数
  4. ナビゲーション呼び出し用の 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.kt
  • app/src/main/kotlin/com/x8bit/bitwarden/data/tools/generator/repository/di/GeneratorRepositoryModule.kt

3 層のデータアーキテクチャ:

  1. データソース - 生データへのアクセス(ネットワーク、ディスク、SDK)。Result<T> を返し、例外はスローしません。
  2. マネージャー - 単一の責務を担うビジネスロジック。OS/外部サービスをラップします。
  3. リポジトリ - ソース/マネージャーを集約します。ドメイン固有のシールドクラスを返します。

重要なルール:

  • ❌ データレイヤーでは例外をスローしないでください。
  • ✅ 必ずインターフェースと ...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 メソッドを参照)

コメント