JetpackComposeでFirebaseAuth(パスワード認証)

# JetpackComposeでFirebaseAuth(パスワード認証)
FirebaseAuthのパスワード認証をJetpackComposeで実装したいと思います。
The Firebase Blogの「[Adding Firebase Authentication to a Jetpack Compose app](https://firebase.blog/posts/2022/05/adding-firebase-auth-to-jetpack-compose-app)」を参考にしたのですが...いかんせんわかりづらいですね...
もう少しかみ砕いた感じで実装していきます。

## 関連記事
- [JetpackComposeでFirebaseAuth(GoogleOneTap認証)](https://qiita.com/jozuko_dev/items/706fc6c89e6982bfd7e2)
- [JetpackComposeでFirebaseAuth(Google認証)](https://qiita.com/jozuko_dev/items/db904cfa28c8a21bf213)
- [JetpackComposeでFirebaseAuth(メールリンク認証)](https://qiita.com/jozuko_dev/items/bf343ba3f2b37a0184be)

## FirebaseAuth(パスワード認証, 匿名ユーザ)のエンドポイント

| エンドポイント |  |
|:--|:--|
| auth.signInAnonymously | 匿名ユーザの作成とログイン |
| auth.signInWithEmailAndPassword | パスワード認証ユーザのログイン |
| auth.createUserWithEmailAndPassword | パスワード認証ユーザの作成 |
| auth.addAuthStateListener | 認証情報が変化した時のリスナーを登録 |
| auth.removeAuthStateListener | 認証情報が変化した時のリスナーを削除 |
| auth.currentUser　| ユーザ情報 |

匿名ユーザとパスワード認証のユーザで、作成・ログインを行う場合に大体使用するのはこのあたりになります。
ユーザ情報は、signInAnonymously（）などの操作系関数の戻り値でも取得できますし、
auth.currentUserの状態変化時に発火するリスナー経由でも取得できます。

- <b>匿名ユーザの作成シーケンス</b>
1. auth.addAuthStateListener()で、auth.currentUserの状態変化時に発火するリスナーを登録する
1. Firebase.auth.signInAnonymously（）を呼ぶ

- <b>パスワード認証ユーザの作成シーケンス</b>
1. auth.addAuthStateListener()で、auth.currentUserの状態変化時に発火するリスナーを登録する
1. Firebase.auth.createUserWithEmailAndPassword（email, password）を呼ぶ

- <b>パスワード認証ユーザのログインシーケンス</b>
1. auth.addAuthStateListener()で、auth.currentUserの状態変化時に発火するリスナーを登録する
1. Firebase.auth.signInWithEmailAndPassword（email, password）を呼ぶ


## FirebaseAuthの導入
Compose、Firebase（google-services.jsonの配置とか）、Hiltの導入は完了している前提です。
まずは、gradleに依存関係を入れていきます。
```diff_gradle:app/build.gradle
    // firebase
    implementation platform('com.google.firebase:firebase-bom:32.2.0')
+    implementation 'com.google.firebase:firebase-auth-ktx'
```

## モジュール構成

<details><summary>モジュール構成が気になる方は展開してください</summary><div>

### ドメイン層
| クラス名 |　タイプ | 定義 |
|:--|:--|:--|
| Account | data class | FirebaseUserクラスの情報を表現します |
| AccountFuture&lt;out Account&gt; | sealed class | Accountの状態（認証済orまだ）を表現します |
| AccountRepository | interface | Accountの作成、ログインを行います |

### インフラストラクチャ層
| クラス名 |　タイプ | 定義 |
|:--|:--|:--|
| AccountRepositoryImpl | class | AccountRepositoryの実態 |

### ユースケース（application）層
| クラス名 |　タイプ | 定義 |
|:--|:--|:--|
| SigninUsecase | class | ユーザがAuthに対してログインする |
| SignupUsecase | class | ユーザがAuthに対して新規作成する |

### プレゼンテーション層
| クラス名 |　タイプ | 定義 |
|:--|:--|:--|
| SigninScreen | class | ログイン・新規作成を行う画面 |
| SigninViewModel | class | SigninScreenのViewModel |
| SigninUiState | class | SigninScreenのUiStateモデル |

</div></details>

## AccountRepositoryImpl(インフラ層)の実装

### クラスの定義
```kotlin
interface AccountRepository { /* ... */ }

class AccountRepositoryImpl @Inject constructor(
    private val auth: FirebaseAuth,
) : AccountRepository {
    /* ... */
}
```
DI
```kotlin
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    fun auth(): FirebaseAuth = Firebase.auth

    @Provides
    fun provideAccountRepository(auth: FirebaseAuth): AccountRepository = AccountRepositoryImpl(auth)
}
```

### 実装
```diff_kotlin
interface AccountRepository {
+    val accountFuture: Flow<AccountFuture<Account>>
+    suspend fun createAnonymousAccount()
+   suspend fun signin(email: String, password: String)
+   suspend fun signup(email: String, password: String)
}

class AccountRepositoryImpl @Inject constructor(
    private val auth: FirebaseAuth,
) : AccountRepository {
+    override val accountFuture: Flow<AccountFuture<Account>>
+        get() = callbackFlow {
+            val listener = FirebaseAuth.AuthStateListener { auth ->
+                this.trySend(AccountFuture.fromFirebaseUser(auth.currentUser))
+            }
+            auth.addAuthStateListener(listener)
+            awaitClose { auth.removeAuthStateListener(listener) }
+        }

+    override suspend fun createAnonymousAccount() {
+        auth.signInAnonymously().await()
+    }

+    override suspend fun signin(email: String, password: String) {
+        auth.signInWithEmailAndPassword(email, password).await()
+    }

+    override suspend fun signup(email: String, password: String) {
+        val currentUser = auth.currentUser
+        if (currentUser != null && currentUser.isAnonymous) {
+            val credential = EmailAuthProvider.getCredential(email, password)
+            currentUser.linkWithCredential(credential).await()
+        } else {
+            auth.createUserWithEmailAndPassword(email, password).await()
+        }
+    }
}
```
| 関数 | 役割 |
|:--|:--|
| accountFuture | addAuthStateListenerに追加したリスナーが発火したときにcallbackするためのcallbackFlow |
| createAnonymousAccount | 匿名ユーザの作成を行う |
| signin | パスワード認証ユーザのログインを行う |
| signup | 匿名ユーザでログイン中の場合は、そのアカウントをパスワード認証として紐付ける。<br>それ以外は、パスワード認証のユーザを作成する |

## ユースケース層(SignupUsecase,SigninUsecase)の実装
あまりにもちっさいクラスなので、Interfaceは作ってないです。
それぞれRepositoryの対応する関数を呼ぶだけで、認証情報はコールバックを利用して取得しようと思います。
```kotlin
class SigninUsecase @Inject constructor(
    private val accountRepository: AccountRepository,
) {
    suspend fun signin(email: String, password: String) {
        accountRepository.signin(email, password)
    }
}

class SignupUsecase @Inject constructor(
    private val accountRepository: AccountRepository,
) {
    suspend fun signup(email: String, password: String) {
        accountRepository.signup(email, password)
    }
}
```

## ViewModel(SigninViewModel)の実装
### 1. uiStateのStateを作ります。
```kotlin
@HiltViewModel
class SigninViewModel : ViewModel() {
    private val _uiState: MutableState<SigninUiState> = mutableStateOf(SigninUiState.initial)
    val uiState: State<SigninUiState> = _uiState
}
```

### 2. 認証情報のcallbackFlowを受信できるようにします
```diff_kotlin
@HiltViewModel
class SigninViewModel @Inject constructor(
+    accountRepository: AccountRepository,
) : ViewModel() {
    private val _uiState: MutableState<SigninUiState> = mutableStateOf(SigninUiState.initial)
    val uiState: State<SigninUiState> = _uiState

+    val accountState: StateFlow<AccountFuture<Account>> = accountRepository.accountFuture
+        .map {
+            _uiState.value = _uiState.value.updateCurrentUser(it)
+            it
+        }.stateIn(
+            scope = viewModelScope,
+            started = SharingStarted.Lazily,
+            initialValue = AccountFuture.Idle,
+        )
}
```

### 3． signinをScreenから呼べるようにします
```diff_kotlin
@HiltViewModel
class SigninViewModel @Inject constructor(
+    private val signinUsecase: SigninUsecase,
    accountRepository: AccountRepository,
) : ViewModel() {
    /*...なんかいろいろ実装...*/

+    fun onSigninClick() {
+       val validatedMessage = _uiState.value.validateSignin()
+       if (validatedMessage != 0) {
+           SnackbarManager.showMessage(validatedMessage)
+           return
+       }
+
+       _uiState.value.doSignProcess()
+       viewModelScope.launch(
+           context = CoroutineExceptionHandler { /*coroutineContext*/_, throwable ->
+               SnackbarManager.showMessage(throwable.toSnackbarMessage())
+           },
+           block = {
+               signinUsecase.signin(_uiState.value.email, _uiState.value.password)
+           },
+       )
+   }
}
```
入力値は、TextFieldの変更に合わせて_uiStateの値を更新している前提です。
また、SnackbarManager.showMessageは[これ](https://firebase.blog/posts/2022/05/adding-firebase-auth-to-jetpack-compose-app)をお手本にしてメッセージをSnackbarで表示します。
1. validateSignin()で、signin用のvalidationを実行
1. doSignProcess()で、UIを処理中モードに変更
1. signinUsecase.signin()を呼び出し
<b>※</b>signin処理での例外発生時（パスワードが違うとか）は、CoroutineExceptionHandlerで処理されます。

### 4. signupをScreenから呼べるようにします
まぁSigninと同じですね。
```diff_kotlin
@HiltViewModel
class SigninViewModel @Inject constructor(
    private val signinUsecase: SigninUsecase,
+    private val signupUsecase: SignupUsecase,
    accountRepository: AccountRepository,
) : ViewModel() {
    /*...なんかいろいろ実装...*/

+    fun onSignupClick() {
+        val validatedMessage = _uiState.value.validateSignup()
+        if (validatedMessage != 0) {
+            SnackbarManager.showMessage(validatedMessage)
+            return
+        }
+
+        _uiState.value.doSignProcess()
+        viewModelScope.launch(
+            context = CoroutineExceptionHandler { /*coroutineContext*/_, throwable ->
+                SnackbarManager.showMessage(throwable.toSnackbarMessage())
+            },
+            block = {
+                signupUsecase.signup(_uiState.value.email, _uiState.value.password)
+            },
+        )
+    }
}
```

## Screenの実装
```kotlin
@Composable
fun SigninScreen(
    modifier: Modifier = Modifier,
    viewModel: SigninViewModel = hiltViewModel(),
) {
    viewModel.accountState.collectAsState()
    val uiState by viewModel.uiState

    /*...Composeとか実装...*/
}
```
「viewModel.accountState」は、StateFlowなので、collectAsState()を呼び出して状態を収集できるようにします。
Composeの定義などは、ご自由にどうぞ。

## いざ実行
Signinをタップすると以下の画面になります（動画がうまく撮れなかった(>_<)）
コードは[こちら](https://github.com/jozuko/ComposeFirebaseSample/tree/v1.0.0)です。
![signin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/5cdf868f-ba3a-fb39-7366-1756d5625ab8.png)