【Android/Kotlin】Login Activityの内容【テンプレートActivity】

#はじめに
執筆者は趣味レベルのAndroidアプリ開発者である為，説明間違いが含まれている可能性があります。ですので，お気づきの点がありましたら，是非コメントお願いいたします。また，アップデートにより，機能やコードが記事執筆時点と異なる場合があります。


#本記事の目的
Android Studioで使用可能なテンプレートActivityの一つである「Login Activity」について，機能や構成を説明します。


#テンプレートの概要（[公式](https://developer.android.com/studio/projects/templates?hl=ja)より引用）

>このテンプレートでは、標準的なログイン画面を作成します。ユーザー インターフェースには、メールアドレスとパスワードの各フィールドとログインボタンがあります。一般に、アプリ モジュール テンプレートではなくアクティビティ テンプレートとして使用されます。

>このテンプレートの内容は次のとおりです。

>- ユーザー インターフェースのメインスレッドとは別にネットワーク操作を処理するための AsyncTask の実装
- ネットワーク操作中に表示する進行状況インジケーター
- 次の推奨されるログイン UI を含む 1 つのレイアウト ファイル:
    - メールアドレスとパスワードの入力フィールド
    - ログインボタン



#実際の画面と動作
![LoginActivity.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/260207/c27c137d-d478-a89c-3692-6461a62bd540.gif)


Emailとパスワード入力画面があります
Emailに関しては空欄・空白はNGであるが，それ以外に制限はなく1文字でもOK
ただ，@を含む場合，ちゃんとメールアドレス形式でないと怒られます。
パスワードに関しては，6文字以上を要求されます

どちらかがエラーだとボタンを押せないようになっています
ボタンを押すと「Welcome！Jane Doe」とトースト表示され，アプリが終了します
（Jane Doeは日本語で言うところの名無しの権兵衛に相当するらしい）


#ソースコードを見てみる

###レイアウトファイル

デモ操作の時点では気づきませんでしたが，プログレスバーがあるようです。
（プログレスバーはロード中にクルクル回るやつです）

また，passwordにimeActionLabelやimeOptionsを設定していますね。
これを設定することで，キーボードの決定ボタン（アクションボタン）の文字やアイコンを変更することができるようです。


```activity_login.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingBottom="@dimen/activity_vertical_margin"
    tools:context=".ui.login.LoginActivity">

    <EditText
        android:id="@+id/username"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="24dp"
        android:layout_marginTop="96dp"
        android:layout_marginEnd="24dp"
        android:hint="@string/prompt_email"
        android:inputType="textEmailAddress"
        android:selectAllOnFocus="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <EditText
        android:id="@+id/password"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="24dp"
        android:layout_marginTop="8dp"
        android:layout_marginEnd="24dp"
        android:hint="@string/prompt_password"
        android:imeActionLabel="@string/action_sign_in_short"
        android:imeOptions="actionDone"
        android:inputType="textPassword"
        android:selectAllOnFocus="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/username" />

    <Button
        android:id="@+id/login"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="start"
        android:layout_marginStart="48dp"
        android:layout_marginTop="16dp"
        android:layout_marginEnd="48dp"
        android:layout_marginBottom="64dp"
        android:enabled="false"
        android:text="@string/action_sign_in"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/password"
        app:layout_constraintVertical_bias="0.2" />

    <ProgressBar
        android:id="@+id/loading"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_marginStart="32dp"
        android:layout_marginTop="64dp"
        android:layout_marginEnd="32dp"
        android:layout_marginBottom="64dp"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@+id/password"
        app:layout_constraintStart_toStartOf="@+id/password"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.3" />
</androidx.constraintlayout.widget.ConstraintLayout>

```

###LoginActivityクラス

MVVMパターンで作られているようで，LiveDataを使用して値を監視・変更しているようです。

`updateUiWithUser`で成功時のトースト表示をしていますね。
逆に`showLoginFailed`で失敗時のトースト表示をしています。
成功でも失敗でも`finish()`でログイン画面を終了させる仕様になっています。

LoginViewModelでテキスト変更時やボタン押下時の処理を実装しているようなので，次はLoginViewModelを見ていきます。

```LoginActivity.kt

class LoginActivity : AppCompatActivity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_login)

        val username = findViewById<EditText>(R.id.username)
        val password = findViewById<EditText>(R.id.password)
        val login = findViewById<Button>(R.id.login)
        val loading = findViewById<ProgressBar>(R.id.loading)

        loginViewModel = ViewModelProviders.of(this, LoginViewModelFactory())
                .get(LoginViewModel::class.java)

        loginViewModel.loginFormState.observe(this@LoginActivity, Observer {
            val loginState = it ?: return@Observer

            // disable login button unless both username / password is valid
            login.isEnabled = loginState.isDataValid

            if (loginState.usernameError != null) {
                username.error = getString(loginState.usernameError)
            }
            if (loginState.passwordError != null) {
                password.error = getString(loginState.passwordError)
            }
        })

        loginViewModel.loginResult.observe(this@LoginActivity, Observer {
            val loginResult = it ?: return@Observer

            loading.visibility = View.GONE
            if (loginResult.error != null) {
                showLoginFailed(loginResult.error)
            }
            if (loginResult.success != null) {
                updateUiWithUser(loginResult.success)
            }
            setResult(Activity.RESULT_OK)

            //Complete and destroy login activity once successful
            finish()
        })

        username.afterTextChanged {
            loginViewModel.loginDataChanged(
                    username.text.toString(),
                    password.text.toString()
            )
        }

        password.apply {
            afterTextChanged {
                loginViewModel.loginDataChanged(
                        username.text.toString(),
                        password.text.toString()
                )
            }

            setOnEditorActionListener { _, actionId, _ ->
                when (actionId) {
                    EditorInfo.IME_ACTION_DONE ->
                        loginViewModel.login(
                                username.text.toString(),
                                password.text.toString()
                        )
                }
                false
            }

            login.setOnClickListener {
                loading.visibility = View.VISIBLE
                loginViewModel.login(username.text.toString(), password.text.toString())
            }
        }
    }

    private fun updateUiWithUser(model: LoggedInUserView) {
        val welcome = getString(R.string.welcome)
        val displayName = model.displayName
        // TODO : initiate successful logged in experience
        Toast.makeText(
                applicationContext,
                "$welcome $displayName",
                Toast.LENGTH_LONG
        ).show()
    }

    private fun showLoginFailed(@StringRes errorString: Int) {
        Toast.makeText(applicationContext, errorString, Toast.LENGTH_SHORT).show()
    }
}

/**
 * Extension function to simplify setting an afterTextChanged action to EditText components.
 */
fun EditText.afterTextChanged(afterTextChanged: (String) -> Unit) {
    this.addTextChangedListener(object : TextWatcher {
        override fun afterTextChanged(editable: Editable?) {
            afterTextChanged.invoke(editable.toString())
        }

        override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {}

        override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {}
    })
}

```

###LoginViewModelクラス


ユーザーネームやパスワードが変更された際の処理は`loginDataChanged`関数で実装されているようです。こちらの中では，`isUserNameValid`や`isPasswordValid`で入力値を検証していますね。
usernameに関しては，@を含む場合，`Patterns.EMAIL_ADDRESS.matcher(username).matches()`のAPIを使用して正しいEmailの形式であるかどうかを検証しています。


ログインボタン（あるいはアクションボタン）を押した際の処理は`login`関数にて実装されています。この中では，`loginRepository.login`に入力値を渡しているようで，この関数内ではその結果によって_loginResultに値を格納し，それによりActivity内でトースト表示がされるといった流れになります。

`loginRepository.login`の中身が知りたいので，次は`LoginRepository`クラスを見ていきます。

```LoginViewModel.kt

class LoginViewModel(private val loginRepository: LoginRepository) : ViewModel() {

    private val _loginForm = MutableLiveData<LoginFormState>()
    val loginFormState: LiveData<LoginFormState> = _loginForm

    private val _loginResult = MutableLiveData<LoginResult>()
    val loginResult: LiveData<LoginResult> = _loginResult

    fun login(username: String, password: String) {
        // can be launched in a separate asynchronous job
        val result = loginRepository.login(username, password)

        if (result is Result.Success) {
            _loginResult.value = LoginResult(success = LoggedInUserView(displayName = result.data.displayName))
        } else {
            _loginResult.value = LoginResult(error = R.string.login_failed)
        }
    }

    fun loginDataChanged(username: String, password: String) {
        if (!isUserNameValid(username)) {
            _loginForm.value = LoginFormState(usernameError = R.string.invalid_username)
        } else if (!isPasswordValid(password)) {
            _loginForm.value = LoginFormState(passwordError = R.string.invalid_password)
        } else {
            _loginForm.value = LoginFormState(isDataValid = true)
        }
    }

    // A placeholder username validation check
    private fun isUserNameValid(username: String): Boolean {
        return if (username.contains('@')) {
            Patterns.EMAIL_ADDRESS.matcher(username).matches()
        } else {
            username.isNotBlank()
        }
    }

    // A placeholder password validation check
    private fun isPasswordValid(password: String): Boolean {
        return password.length > 5
    }
}

```

###LoginRepositoryクラス

こちらの`login`関数では，`dataSource.login`関数での結果を返していますね。
ログアウト用の関数もありますが，こちらの中身もやはりLoginDataSourceクラスのメソッドを呼んでいるようです。

では，続いてLoginDataSourceを見ていきます。

```LoginRepository.kt

class LoginRepository(val dataSource: LoginDataSource) {

    // in-memory cache of the loggedInUser object
    var user: LoggedInUser? = null
        private set

    val isLoggedIn: Boolean
        get() = user != null

    init {
        // If user credentials will be cached in local storage, it is recommended it be encrypted
        // @see https://developer.android.com/training/articles/keystore
        user = null
    }

    fun logout() {
        user = null
        dataSource.logout()
    }

    fun login(username: String, password: String): Result<LoggedInUser> {
        // handle login
        val result = dataSource.login(username, password)

        if (result is Result.Success) {
            setLoggedInUser(result.data)
        }

        return result
    }

    private fun setLoggedInUser(loggedInUser: LoggedInUser) {
        this.user = loggedInUser
        // If user credentials will be cached in local storage, it is recommended it be encrypted
        // @see https://developer.android.com/training/articles/keystore
    }
}

```

###LoginDataSourceクラス

こちらで実際のログイン・ログアウト処理を行っていくようです。
ログアウト関数は中身は実装されおらず，コメントで「ここで認証を取り消してください」と記述されています。

また，ログイン関数ですが，LoggedInUserというデータクラスのインスタンスを生成しています。サンプルでは，userIdにランダム生成されたUUIDを渡し，displayNameは"Jane Doe"を渡し`fakeUser`というインスタンスを作成しています。
（本来であればDBにアクセスするなどして取得したユーザー情報や，入力された情報を使用してインスタンス作成するのだろう）

更に，生成したインスタンスは`Result.Success`に渡して，生成された実体を返却しています。

Resultクラスに関しては，初心者の自分には馴染みのない記述が多かったのでまとめました

- sealed class
    - sealed classは列挙型(enum)の拡張版のようなものであり，enumの各値に可変の値を持たせるような仕組み
- \<T : Any>
    - ジェネリクス
    - わざわざAnyを書いている理由は，null非許容なジェネリクスを作るためらしい。
- \<out T>
    - Javaでの`? extends T`であり，「Tもしくはそのサブクラスを全て表す型」を意味するとのこと。上限付きワイルドカード型というらしい。


```LoginDataSource.kt

class LoginDataSource {

    fun login(username: String, password: String): Result<LoggedInUser> {
        try {
            // TODO: handle loggedInUser authentication
            val fakeUser = LoggedInUser(java.util.UUID.randomUUID().toString(), "Jane Doe")
            return Result.Success(fakeUser)
        } catch (e: Throwable) {
            return Result.Error(IOException("Error logging in", e))
        }
    }

    fun logout() {
        // TODO: revoke authentication
    }
}

```
```LoggedInUser.kt

/**
 * Data class that captures user information for logged in users retrieved from LoginRepository
 */
data class LoggedInUser(
        val userId: String,
        val displayName: String
)

```
```Result.kt

sealed class Result<out T : Any> {

    data class Success<out T : Any>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()

    override fun toString(): String {
        return when (this) {
            is Success<*> -> "Success[data=$data]"
            is Error -> "Error[exception=$exception]"
        }
    }
}

```


#疑問点

初心者目線で疑問に思った部分をまとめます。
疑問を解消して頂ける方がいましたら，どれか1つでも構いませんのでコメント頂けますと幸いです。


- Result\<out T>のoutを記述する必要性
    - 追記：[stackoverflow](https://stackoverflow.com/questions/10956993/out-t-vs-t-in-generics)にてピッタリの回答を発見
 
- LoginRepositoryクラスの役割とは（コメントを見る感じだと，ローカルにユーザー情報を保存する場合はここでやれということなのだろうか。）

- ログイン失敗でもActivityが終了してしまうような仕様の理由