JetpackComposeでFirebaseAuth(メールリンク認証)

# JetpackComposeでFirebaseAuth(メールリンク認証)
FirebaseAuthのメールリンク認証を実装していきます。

メールリンク認証とは、以下のフローで認証を行うものです。
1. ユーザーが、アプリでメールアドレスを入力する(パスワードはいらないです)
1. Firebaseが、認証情報をDeepLinkにしたDynamicLinksを載せたメールをそのメールアドレス宛に送信する
1. ユーザーが、DynamicLinksを開く（=タップする）
1. アプリが、メールアドレスとDynamic LinksのDeepLinkを使って、FirebaseAuthにSignInする

メールアドレスの正当性だけでアカウントが作れる様なアプリの場合は使えそうですね。

<details><summary>(小ネタ)DynamicLinks</summary><div>

メールやSNS、Webサイトにあるリンクを押すと、連動するアプリが起動するよ。という機能です。
</div></details>

<details><summary>(小ネタ)DynamicLinks終了のお知らせ</summary><div>

Firebaseが提供している機能の一つにDynamicLinksというものがあります。。。というかありましたになりそうです。

https://firebase.google.com/support/dynamic-links-faq?hl=ja&authuser=0

:::note warn
注: Firebase Dynamic Links は新しいプロジェクトには推奨されなくなりました。将来、Dynamic Links サービスは終了しますが、発表日から少なくとも 12 か月以内に移行してください。詳細については、2023 年第 3 四半期に発表する予定です。
:::

と、公式のページに書いてあります。
ただ、「Firebase Authentication のメール リンク認証は引き続き機能しますか?」というセッションを見ると、

:::note info
Firebase Dynamic Links サービスがシャットダウンされた後もこの機能が引き続き動作するように更新を提供する予定です。
:::

とありますので、メールリンクでは使えそうですね。

</div></details>

## 関連記事
- [JetpackComposeでFirebaseAuth(パスワード認証)](https://qiita.com/jozuko_dev/items/f8106009ad4571740d1f)
- [JetpackComposeでFirebaseAuth(GoogleOneTap認証)](https://qiita.com/jozuko_dev/items/706fc6c89e6982bfd7e2)
- [JetpackComposeでFirebaseAuth(Google認証)](https://qiita.com/jozuko_dev/items/db904cfa28c8a21bf213)

## まずはFirebaseの設定です。
AuthenticationとDynamic Linksを行き来しながら設定していきます。
(Firebase　Consoleの設定は普段メモしないのですが、あまりにも入り組んでいるので今回は残そうと思っています)

### メールリンクをしくじったときのURLを決める
「メールリンクをしくじったときのURL」は、後ほどkotlinで指定するんですが、これは、DynamicLinksをタップしたのにアプリが起動しない様な場合、ブラウザでその案内を表示するページにリダイレクトするのに使われます。
つまり、認証用にFirebaseが生成したDynamicLinksから、このURLにリダイレクトがかかるんですが、あらかじめリダイレクト先として正しいドメインであることをFirebaseAuthとDynamicLinksへ登録する必要があります。

この記事では、「https://qiita.com/jozuko_dev」 にリダイレクトさせます。

### FirebaseAuthのメールリンクを有効化します
1.Authentication > Sign-in method　で、「メール/パスワード」の鉛筆アイコンをクリックします
※プロバイダに「メール/パスワード」がない方は、「新しいプロバイダを追加」ボタンを押して、追加してください。
![FirebaseAuth_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/2db98e9f-0708-6309-7206-46674a126eb0.png)

2.メールリンクを有効にします
デフォルトは、「メールリンク（パスワードなしでログイン）」が無効になっていると思いますので、それを有効にします。
![FirebaseAuth_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/45a0e410-9749-662d-6e6b-f2b74f398aa9.png)

### AuthenticationにWhiteListドメインを追加します
1. <b>Authentication</b> > Settings > 認証済みドメインを開きます。
1. 「ドメインの追加」ボタンを押して、ドメインを追加します。この記事では「qiita.com」をリダイレクト先ドメインとしてホワイトリスト登録しています。
![FirebaseAuth_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/c77e7d10-628d-2c02-724d-8014890f0251.png)


### Dynamic LinksにWhiteListドメインを追加します
※DynamicLinksがまだ開始できていない場合は、[こちらの公式ページ](https://firebase.google.com/docs/dynamic-links?authuser=0&hl=ja)を見ながらまずは開始してください。
1.先ほどの Authentication > Settings > 認証済みドメインを開きます。（<font color="#F00">Dynamic Linksではないですよ</font>）

![FirebaseAuth_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/3212994b-ae54-b536-efa2-1779163407b9.png)

ここに書いているドメインのうち、（私のスクショでは）上から2つ目にある
<b><i>project-id</i>.firebaseapp.com</b>
をコピーします。

2.<b>Dynamic Links</b>を開いて、画面上部の赤字の「ドメインを設定する」を選択します。
するとドメインの登録画面が表示されると思います。
![DynamicLinks_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/cf4a29f9-c62a-3cdd-a899-f6e847661530.png)

3.ドメインを登録します
Authenticationでコピーしておいたドメイン（<i>project-id</i>.firebaseapp.com）と、リダイレクト先ドメイン（qiita.com）を登録します。
![DynamicLinks_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3491965/6f5e6cfa-b957-f73f-16a4-d6929468e44f.png)

### Firebase完了！
これでFirebaseの設定は完了です。

## DynamicLinksからの起動
DynamicLinksから起動できるように追記していきます。

### build.gradle
ダイナミックリンクの依存関係を記述します。
```diff_gradle:app/build.gradle
    // firebase
    implementation platform('com.google.firebase:firebase-bom:32.2.0')
    implementation 'com.google.firebase:firebase-analytics-ktx'
    implementation 'com.google.firebase:firebase-auth-ktx'
+    // Dynamic Links
+    implementation 'com.google.firebase:firebase-dynamic-links-ktx'
```

### AndroidManifest.xml
DynamicLinksを受信できるようにAndroidManifest.xmlに追記します。
今回はアプリタップで開くMainActivityを、DynamicLinksからの起動するようにします。
そのために、MainActivityに2つ目のDynamicLinks用のintent-filterを追加します。

<b>android:host="<i>jozu.page.link</i>"</b>の「<i>jozu.page.link</i>」の部分は、FirebaseのDynamicLinksを設定したときのもので、そのページに書いてあります。

```diff_xml
  <?xml version="1.0" encoding="utf-8"?>
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:tools="http://schemas.android.com/tools">
      <application>
          <activity
              android:name=".MainActivity"
              android:exported="true"
              android:label="@string/app_name"
              android:theme="@style/Theme.ComposeFirebaseSample">
              <intent-filter>
                  <action android:name="android.intent.action.MAIN" />
 
                  <category android:name="android.intent.category.LAUNCHER" />
              </intent-filter>
+             <intent-filter>
+                 <action android:name="android.intent.action.VIEW"/>
+                 <category android:name="android.intent.category.DEFAULT"/>
+                 <category android:name="android.intent.category.BROWSABLE"/>
+                 <data
+                     android:host="jozu.page.link"
+                     android:scheme="https"/>
+             </intent-filter>
          </activity>
      </application>
  </manifest>
```

### MainActivity
DynamicLinksはintentで飛んできますので（intent-filterで書いたので当たり前ですね。。。）それを解析して、メールリンク認証か否かを切り分けます。

```diff_kotlin:MainActivity.kt
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

+        Firebase.dynamicLinks
+            .getDynamicLink(intent)
+            .addOnSuccessListener { linkData ->//①成功遷移
+                val mailLink = linkData?.link?.toString()
+                // ② リンクURLのチェック
+                if (mailLink != null && Firebase.auth.isSignInWithEmailLink(mailLink)) {
+                    // ③ リンクURLをComposeに渡す
+                    setContent { ComposeFirebaseApp(mailLink = mailLink) }
+                } else {
+                    setContent { ComposeFirebaseApp() }
+                }
+            }
+            .addOnFailureListener {
+                setContent { ComposeFirebaseApp() }
+            }
    }
}
```

①成功遷移
Firebase.dynamicLinks.getDynamicLink(intent)が成功した場合、linkDataが取得できます。
DynamicLinksから起動したときはもちろんですが、ランチャーからアプリアイコンをタップして起動したときもこの成功遷移に入ります。
ただ、ランチャーからアプリアイコンをタップして起動したときには、linkDataがnullです。

② リンクURLのチェック
認証とは関係のないDynamicLinksかもしれませんので、
Firebase.auth.isSignInWithEmailLink（メールリンクのURL文字列）
で検証をかけます。

③ リンクURLをComposeに渡す
メールリンクのURLだった場合は、Composeに渡しています。

### ComposeFirebaseApp
メールリンクを引数にもらって、SigninScreenに渡しています。

```diff_kotlin
@Composable
fun ComposeFirebaseApp(mailLink: String? = null) {
    ComposeFirebaseSampleTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background,
        ) {
/*...いろいろ実装...*/

+                    SigninScreen(mailLink = mailLink)

/*...いろいろ実装...*/
        }
    }
}
```

### SigninScreen
メールリンクURLを受け取り、LaunchedEffectで認証処理を行うようにします。
認証処理の呼び出しは最後に記述します。

```diff_kotlin:SigninScreen.kt
@Composable
fun SigninScreen(
    mailLink: String?,
    modifier: Modifier = Modifier,
    viewModel: SigninViewModel = hiltViewModel(),
) {
    viewModel.accountState.collectAsState()
    val uiState by viewModel.uiState

    LaunchedEffect(Unit) {
        /*後ほどSignin処理を書く予定*/
    }
```

## メールリンク認証の実装

### インフラ層の実装
ユーザが入力したメールアドレスに、メールリンク付きメールを送信するための関数と、
メールリンクから起動したときの認証処理を行う関数の2つを追加します。
```diff_kotlin:AccountRepository.kt
interface AccountRepository {
+    suspend fun sendMailLinkSignInMail(email: String)
+    suspend fun signinMailLink(mailLink: String?)
}
```
```diff_kotlin:
class AccountRepositoryImpl(
    private val applicationContext: Context,
    private val auth: FirebaseAuth,
    private val signInRequest: BeginSignInRequest,
    private val signInClient: SignInClient,
    private val googleSignInClient: GoogleSignInClient,
+    // ① 入力メールアドレスの一時保存先
+    private val sharedPref: SharedPref,
) : AccountRepository {
+    override suspend fun sendMailLinkSignInMail(email: String) {
+        // ②　actionCodeSettings
+        val actionCodeSettings = actionCodeSettings {
+            url = "https://qiita.com/jozuko_dev"
+            handleCodeInApp = true
+            setAndroidPackageName(
+                /* package-name */applicationContext.packageName,
+                /* installIfNotAvailable */true,
+                /* minimumVersion */null,
+            )
+        }
+
+        // ③　認証メール送信
+        auth.sendSignInLinkToEmail(email, actionCodeSettings).await()
+
+        // ④　入力メールアドレスを保存
+        sharedPref.mailLinkAddress = email
+    }

+    override suspend fun signinMailLink(mailLink: String?) {
+        // ⑤　入力メールアドレスを取得
+        val email = sharedPref.mailLinkAddress
+        if (mailLink != null) {
+            if (email.isEmpty()) {
+                throw IllegalArgumentException("not saved e-mail address.")
+            }
+            // ⑥　メールリンク認証
+            auth.signInWithEmailLink(email, mailLink).await()
+        }
+        // ⑦　入力メールアドレス削除
+        sharedPref.mailLinkAddress = ""
+    }
}
```
① 入力メールアドレスの一時保存先
SharedPrefは、自作クラスです。
<details><summary>気になる方は展開してください</summary><div>

```kotlin:SharedPref.kt
enum class SharedPrefKey {
    MAIL_LINK_ADDRESS,
}

class SharedPref(
    private val sharedPreferences: SharedPreferences,
) {
    var mailLinkAddress: String
        get() = sharedPreferences.getString(SharedPrefKey.MAIL_LINK_ADDRESS.name, null) ?: ""
        set(value) = sharedPreferences.edit {
            putString(SharedPrefKey.MAIL_LINK_ADDRESS.name, value)
        }
}
```

</div></details>

②　actionCodeSettings
DynamicLinksに関する設定です。
| param | value |
|:--|:--|
| url | メールリンク認証をしくじったときのリダイレクト先URL |
| handleCodeInApp | true(固定) |
| setAndroidPackageName | 3つ指定します。パッケージ名、インストールされていないときにインストールするか、最低バージョン |

③　認証メール送信
入力メールアドレスと、②　actionCodeSettingsを引数にして、メール送信をFirebaseに依頼します。

④　入力メールアドレスを保存
アプリ内に入力メールアドレスを一時保存しておきます。
次のアプリ起動時に動作するメールリンク認証で使用します。

⑤　入力メールアドレスを取得
メールリンク認証を行うために、一時保存しておいたメールアドレスを取り出します。

⑥　メールリンク認証
入力メールアドレスとメールリンクURLを引数にして、Firebaseに認証を依頼します。

⑦　入力メールアドレス削除
一応ここで入力値のクリアは行っていますが、ここでやることが必須ではないです。
例えば認証に成功した後とか、次にメールリンク認証を始めたときとか、実態に合わせて適宜クリアしてください。

### UseCase層の実装
リポジトリを呼び出しているだけです。

```kotlin:MailLinkCase.kt
class MailLinkCase @Inject constructor(
    private val accountRepository: AccountRepository,
) {
    suspend fun sendMailLink(email: String) {
        accountRepository.sendMailLinkSignInMail(email)
    }

    suspend fun signinMailLink(mailLink: String?) {
        accountRepository.signinMailLink(mailLink)
    }
}
```

### UiStateの実装
メールリンクを送信する先のメールアドレス入力値を保持するための変数を追加します。
また、メール送信する前にValidationを行うので、そのメソッドも追加します。

```diff_kotlin:SigninUiState.kt
data class SigninUiState(
    val email: String,
    val password: String,
    val passwordConfirm: String,
    val isCreateUserMode: Boolean,
+   val mailLinkEmail: String,
    val status: SigninUiStatus,
    val account: Account?,
) {

+   @StringRes
+   fun validateMailLink(): Int {
+       if (!mailLinkEmail.isValidEmail()) {
+           return R.string.email_invalid
+       }
+        return 0
+   }

}
```

### ViewModel層の実装
ViewModelBaseという自作クラスを作りました。launchCatchingはこのクラスの関数です。

<details><summary>気になる方はこちらをどうぞ。</summary><div>

```kotlin:ViewModelBase.kt
abstract class ViewModelBase : ViewModel() {
    fun launchCatching(logTag: String, block: suspend CoroutineScope.() -> Unit): Job {
        return viewModelScope.launch(
            context = CoroutineExceptionHandler { _, throwable ->
                Log.e("ViewModel", "[${logTag}]Error ${throwable.localizedMessage}")
                SnackbarManager.showMessage(throwable.toSnackbarMessage())
            },
            block = {
                withContext(Dispatchers.IO) {
                    Log.d("ViewModel", "[${logTag}]Thread => ${Thread.currentThread().name}")
                    block.invoke(this)
                }
            }
        )
    }
}
```
</div></details>


```diff_kotlin:
@HiltViewModel
class SigninViewModel @Inject constructor(
    private val signinUsecase: SigninUsecase,
    private val signupUsecase: SignupUsecase,
    private val signOutUsecase: SignOutUsecase,
    private val googleOneTapSigninCase: GoogleOneTapSigninCase,
    private val googleLegacySigninCase: GoogleLegacySigninCase,
+    private val mailLinkCase: MailLinkCase,
    accountRepository: AccountRepository,
) : ViewModelBase() {
+    fun onChangeMailLinkEmail(newValue: String) {
+        _uiState.value = _uiState.value.copy(mailLinkEmail = newValue)
+    }

+    fun onClickMailLinkSignin() {
+        val validatedMessage = _uiState.value.validateMailLink()
+        if (validatedMessage != 0) {
+            SnackbarManager.showMessage(validatedMessage)
+            return
+        }
+
+        launchCatching("onClickMailLinkSignin") {
+            mailLinkCase.sendMailLink(_uiState.value.mailLinkEmail)
+        }
+    }

+    fun onReceiveMailLink(mailLink: String?) {
+        launchCatching("onReceiveMailLink") {
+            mailLinkCase.signinMailLink(mailLink)
+        }
+    }
}
```
| 関数 | 役割 |
|:--|:--|
| onChangeMailLinkEmail | メールリンク認証用のメールアドレス入力値の保持をしています |
| onClickMailLinkSignin | validationとメール送信を行います |
| onReceiveMailLink | メールリンク認証のURLを引数にSignInします |

### Screen
```diff_kotlin:SigninScreen.kt
@Composable
fun SigninScreen(
    mailLink: String?,
    modifier: Modifier = Modifier,
    viewModel: SigninViewModel = hiltViewModel(),
) {
    viewModel.accountState.collectAsState()
    val uiState by viewModel.uiState

    LaunchedEffect(Unit) {
+        viewModel.onReceiveMailLink(mailLink)
    }
}

+ /**
+  * メールリンク認証
+  */
+ @Composable
+ private fun MailLinkSigninButton(uiState: SigninUiState, viewModel: SigninViewModel) {
+     val focusManager = LocalFocusManager.current
+ 
+     Card(modifier = Modifier.padding(spaceSizeLarge)) {
+         Spacer(modifier = Modifier.height(spaceSizeMedium))
+ 
+         Text(
+             text = stringResource(R.string.sign_mode_mail_link_auth),
+             modifier = Modifier.padding(horizontal = spaceSizeMedium),
+             style = MaterialTheme.typography.titleSmall,
+         )
+ 
+         Spacer(modifier = Modifier.height(spaceSizeMedium))
+ 
+         EmailField(value = uiState.mailLinkEmail, onNewValue = viewModel::onChangeMailLinkEmail, modifier = Modifier.fieldModifier())
+ 
+         Spacer(modifier = Modifier.height(spaceSizeMedium))
+ 
+         BasicButton(text = R.string.sign_in, modifier = Modifier.basicButton(), onClick = {
+             focusManager.clearFocus()
+             viewModel.onClickMailLinkSignin()
+         })
+     }
+ }
```
LaunchedEffectで、メールリンク認証を呼び出しています。
MailLinkSigninButtonは、メールアドレスの入力欄の表示とSignInボタンのonClickで認証メールの送信を行っています。

## いざ実行！
長くなってしまってすみません！ようやく完成です。
（しかもちょっと公開できるレベルのデバイスがなくてスクショないです。。。）
[コードはこちら](https://github.com/jozuko/ComposeFirebaseSample/tree/ee7b8673f293ec4ec42c1b42d3c6b8dce6efbe18)にあります。