Moving FingerprintSettings to Kotlin

This change is the first of many, it will

1. Change java -> kotlin
2. Use the MVVM architecture
3. Be feature flagged

This change in particular is focused on transitions to and from various activities.

Enable feature via
adb shell setprop sys.fflag.override.settings_biometrics2_fingerprint true

Bug: 280862076
Test: atest FingerprintSettingsViewModelTest
Change-Id: I8eb5c30e6f2e92c256ae7c257a9d560439ba418f
Merged-In: I8eb5c30e6f2e92c256ae7c257a9d560439ba418f
diff --git a/Android.bp b/Android.bp
index fd97dc3..d2b8fc7 100644
--- a/Android.bp
+++ b/Android.bp
@@ -69,6 +69,7 @@
         "androidx.appcompat_appcompat",
         "androidx.cardview_cardview",
         "androidx.compose.runtime_runtime-livedata",
+        "androidx.activity_activity-ktx",
         "androidx.preference_preference",
         "androidx.recyclerview_recyclerview",
         "androidx.window_window",
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index e195beb..0e82501 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -4906,6 +4906,20 @@
         <activity android:name=".spa.SpaBridgeActivity" android:exported="false"/>
         <activity android:name=".spa.SpaAppBridgeActivity" android:exported="false"/>
 
+        <activity android:name=".Settings$FingerprintSettingsActivityV2"
+            android:label="@string/security_settings_fingerprint_preference_title"
+            android:exported="false"
+            android:icon="@drawable/ic_fingerprint_header">
+            <intent-filter>
+                <action android:name="android.settings.FINGERPRINT_SETTINGS_V2" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+            <meta-data android:name="com.android.settings.FRAGMENT_CLASS"
+                android:value="com.android.settings.biometrics.fingerprint2.ui.fragment.FingerprintSettingsV2Fragment" />
+            <meta-data android:name="com.android.settings.HIGHLIGHT_MENU_KEY"
+                android:value="@string/menu_key_security"/>
+        </activity>
+
         <activity-alias android:name="UsageStatsActivity"
                         android:exported="true"
                         android:label="@string/testing_usage_stats"
diff --git a/res/xml/security_settings_fingerprint_limbo.xml b/res/xml/security_settings_fingerprint_limbo.xml
new file mode 100644
index 0000000..b0c06c7
--- /dev/null
+++ b/res/xml/security_settings_fingerprint_limbo.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Copyright (C) 2023 The Android Open Source Project
+  ~
+  ~ Licensed under the Apache License, Version 2.0 (the "License");
+  ~ you may not use this file except in compliance with the License.
+  ~ You may obtain a copy of the License at
+  ~
+  ~      http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"/>
\ No newline at end of file
diff --git a/src/com/android/settings/Settings.java b/src/com/android/settings/Settings.java
index a8fa527..3efa18f 100644
--- a/src/com/android/settings/Settings.java
+++ b/src/com/android/settings/Settings.java
@@ -73,6 +73,7 @@
     }
 
     public static class FingerprintSettingsActivity extends SettingsActivity { /* empty */ }
+    public static class FingerprintSettingsActivityV2 extends SettingsActivity { /* empty */ }
     public static class CombinedBiometricSettingsActivity extends SettingsActivity { /* empty */ }
     public static class CombinedBiometricProfileSettingsActivity extends SettingsActivity { /* empty */ }
     public static class TetherSettingsActivity extends SettingsActivity {
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintViewBinder.kt b/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintViewBinder.kt
new file mode 100644
index 0000000..d4249ff
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintViewBinder.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.biometrics.fingerprint2.ui.binder
+
+import androidx.lifecycle.LifecycleCoroutineScope
+import com.android.settings.biometrics.fingerprint2.ui.fragment.FingerprintSettingsV2Fragment
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollAdditionalFingerprint
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollFirstFingerprint
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsViewModel
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettings
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettingsWithResult
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.LaunchConfirmDeviceCredential
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.ShowSettings
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.launch
+
+/**
+ * Binds a [FingerprintSettingsViewModel] to a [FingerprintSettingsV2Fragment]
+ */
+object FingerprintViewBinder {
+
+    interface Binding {
+        fun onConfirmDevice(wasSuccessful: Boolean, theGateKeeperPasswordHandle: Long?)
+        fun onEnrollSuccess()
+        fun onEnrollAdditionalFailure()
+        fun onEnrollFirstFailure(reason: String)
+        fun onEnrollFirstFailure(reason: String, resultCode: Int)
+        fun onEnrollFirst(token: ByteArray?, keyChallenge: Long?)
+    }
+
+    /** Initial listener for the first enrollment request */
+    fun bind(
+        viewModel: FingerprintSettingsViewModel,
+        lifecycleScope: LifecycleCoroutineScope,
+        token: ByteArray?,
+        challenge: Long?,
+        launchFullFingerprintEnrollment: (
+            userId: Int,
+            gateKeeperPasswordHandle: Long?,
+            challenge: Long?,
+            challengeToken: ByteArray?
+        ) -> Unit,
+        launchAddFingerprint: (userId: Int, challengeToken: ByteArray?) -> Unit,
+        launchConfirmOrChooseLock: (userId: Int) -> Unit,
+        finish: () -> Unit,
+        setResultExternal: (resultCode: Int) -> Unit,
+    ): Binding {
+
+        lifecycleScope.launch {
+            viewModel.nextStep.filterNotNull().collect { nextStep ->
+                when (nextStep) {
+                    is EnrollFirstFingerprint -> launchFullFingerprintEnrollment(
+                        nextStep.userId,
+                        nextStep.gateKeeperPasswordHandle,
+                        nextStep.challenge,
+                        nextStep.challengeToken
+                    )
+
+                    is EnrollAdditionalFingerprint -> launchAddFingerprint(
+                        nextStep.userId, nextStep.challengeToken
+                    )
+
+                    is LaunchConfirmDeviceCredential -> launchConfirmOrChooseLock(nextStep.userId)
+
+                    is FinishSettings -> {
+                        println("Finishing due to ${nextStep.reason}")
+                        finish()
+                    }
+
+                    is FinishSettingsWithResult -> {
+                        println("Finishing with result ${nextStep.result} due to ${nextStep.reason}")
+                        setResultExternal(nextStep.result)
+                        finish()
+                    }
+
+                    is ShowSettings -> println("show settings")
+                }
+
+                viewModel.onUiCommandExecuted()
+            }
+        }
+
+        viewModel.updateTokenAndChallenge(token, if (challenge == -1L) null else challenge)
+
+        return object : Binding {
+            override fun onConfirmDevice(
+                wasSuccessful: Boolean, theGateKeeperPasswordHandle: Long?
+            ) {
+                viewModel.onConfirmDevice(wasSuccessful, theGateKeeperPasswordHandle)
+            }
+
+            override fun onEnrollSuccess() {
+                viewModel.onEnrollSuccess()
+            }
+
+            override fun onEnrollAdditionalFailure() {
+                viewModel.onEnrollAdditionalFailure()
+            }
+
+            override fun onEnrollFirstFailure(reason: String) {
+                viewModel.onEnrollFirstFailure(reason)
+            }
+
+            override fun onEnrollFirstFailure(reason: String, resultCode: Int) {
+                viewModel.onEnrollFirstFailure(reason, resultCode)
+            }
+
+            override fun onEnrollFirst(token: ByteArray?, keyChallenge: Long?) {
+                viewModel.onEnrollFirst(token, keyChallenge)
+            }
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsV2Fragment.kt b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsV2Fragment.kt
new file mode 100644
index 0000000..9b85564
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsV2Fragment.kt
@@ -0,0 +1,252 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.biometrics.fingerprint2.ui.fragment
+
+import android.app.Activity
+import android.app.settings.SettingsEnums
+import android.content.Context.FINGERPRINT_SERVICE
+import android.content.Intent
+import android.hardware.fingerprint.FingerprintManager
+import android.os.Bundle
+import android.util.FeatureFlagUtils
+import android.util.Log
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import com.android.settings.R
+import com.android.settings.Utils
+import com.android.settings.biometrics.BiometricEnrollBase
+import com.android.settings.biometrics.fingerprint.FingerprintEnrollEnrolling
+import com.android.settings.biometrics.fingerprint.FingerprintEnrollIntroductionInternal
+import com.android.settings.biometrics.fingerprint2.ui.binder.FingerprintViewBinder
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsViewModel
+import com.android.settings.core.SettingsBaseActivity
+import com.android.settings.dashboard.DashboardFragment
+import com.android.settings.password.ChooseLockGeneric
+import com.android.settings.password.ChooseLockSettingsHelper
+import com.android.settingslib.transition.SettingsTransitionHelper
+
+const val TAG = "FingerprintSettingsV2Fragment"
+
+/**
+ * A class responsible for showing FingerprintSettings. Typical activity Flows are
+ * 1. Settings > FingerprintSettings > PIN/PATTERN/PASS -> FingerprintSettings
+ * 2. FingerprintSettings -> FingerprintEnrollment fow
+ *
+ * This page typically allows for
+ * 1. Fingerprint deletion
+ * 2. Fingerprint enrollment
+ * 3. Renaming a fingerprint
+ * 4. Enabling/Disabling a feature
+ */
+class FingerprintSettingsV2Fragment : DashboardFragment() {
+    private lateinit var binding: FingerprintViewBinder.Binding
+
+    private val launchFirstEnrollmentListener =
+        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+
+            val resultCode = result.resultCode
+            val data = result.data
+
+            Log.d(
+                TAG, "onEnrollFirstFingerprint($resultCode, $data)"
+            )
+            if (resultCode != BiometricEnrollBase.RESULT_FINISHED || data == null) {
+                if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) {
+                    binding.onEnrollFirstFailure(
+                        "Received RESULT_TIMEOUT when enrolling", resultCode
+                    )
+                } else {
+                    binding.onEnrollFirstFailure("Incorrect resultCode or data was null")
+                }
+            } else {
+                val token =
+                    data.getByteArrayExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN)
+                val keyChallenge = data.getExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE) as Long?
+                binding.onEnrollFirst(token, keyChallenge)
+            }
+        }
+
+    /** Result listener for launching enrollments **after** a user has reached the settings page. */
+    private val launchAdditionalFingerprintListener =
+        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+            val resultCode = result.resultCode
+            Log.d(
+                TAG, "onEnrollAdditionalFingerprint($resultCode)"
+            )
+
+            if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) {
+                binding.onEnrollAdditionalFailure()
+            } else {
+                binding.onEnrollSuccess()
+            }
+        }
+
+    /** Result listener for ChooseLock activity flow. */
+    private val confirmDeviceResultListener =
+        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+            val resultCode = result.resultCode
+            val data = result.data
+            onConfirmDevice(resultCode, data)
+        }
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        // This is needed to support ChooseLockSettingBuilder...show(). All other activity
+        // calls should use the registerForActivity method call.
+        super.onActivityResult(requestCode, resultCode, data)
+        val wasSuccessful =
+            resultCode == BiometricEnrollBase.RESULT_FINISHED || resultCode == Activity.RESULT_OK
+        val gateKeeperPasswordHandle =
+            data?.getExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE) as Long?
+        binding.onConfirmDevice(wasSuccessful, gateKeeperPasswordHandle)
+    }
+
+
+    override fun onCreate(icicle: Bundle?) {
+        super.onCreate(icicle)
+        if (!FeatureFlagUtils.isEnabled(
+                context, FeatureFlagUtils.SETTINGS_BIOMETRICS2_FINGERPRINT_SETTINGS
+            )
+        ) {
+            Log.d(
+                TAG, "Finishing due to feature not being enabled"
+            )
+            finish()
+            return
+        }
+        val viewModel = ViewModelProvider(
+            this, FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
+                requireContext().applicationContext.userId, requireContext().getSystemService(
+                    FINGERPRINT_SERVICE
+                ) as FingerprintManager
+            )
+        )[FingerprintSettingsViewModel::class.java]
+
+        val token = intent.getByteArrayExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN)
+        val challenge = intent.getLongExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE, -1L)
+
+        binding = FingerprintViewBinder.bind(
+            viewModel,
+            lifecycleScope,
+            token,
+            challenge,
+            ::launchFullFingerprintEnrollment,
+            ::launchAddFingerprint,
+            ::launchConfirmOrChooseLock,
+            ::finish,
+            ::setResultExternal,
+        )
+    }
+
+    override fun getMetricsCategory(): Int {
+        return SettingsEnums.FINGERPRINT
+    }
+
+    override fun getPreferenceScreenResId(): Int {
+        return R.xml.security_settings_fingerprint_limbo
+    }
+
+    override fun getLogTag(): String {
+        return TAG
+    }
+
+    /**
+     * Helper function that will try and launch confirm lock, if that fails we will prompt user
+     * to choose a PIN/PATTERN/PASS.
+     */
+    private fun launchConfirmOrChooseLock(userId: Int) {
+        val intent = Intent()
+        val builder = ChooseLockSettingsHelper.Builder(requireActivity(), this)
+        val launched = builder.setRequestCode(BiometricEnrollBase.CONFIRM_REQUEST)
+            .setTitle(getString(R.string.security_settings_fingerprint_preference_title))
+            .setRequestGatekeeperPasswordHandle(true).setUserId(userId).setForegroundOnly(true)
+            .setReturnCredentials(true).show()
+        if (!launched) {
+            intent.setClassName(
+                Utils.SETTINGS_PACKAGE_NAME, ChooseLockGeneric::class.java.name
+            )
+            intent.putExtra(
+                ChooseLockGeneric.ChooseLockGenericFragment.HIDE_INSECURE_OPTIONS, true
+            )
+            intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_GK_PW_HANDLE, true)
+            intent.putExtra(Intent.EXTRA_USER_ID, userId)
+            confirmDeviceResultListener.launch(intent)
+        }
+    }
+
+    /**
+     * Helper for confirming a PIN/PATTERN/PASS
+     */
+    private fun onConfirmDevice(resultCode: Int, data: Intent?) {
+        val wasSuccessful =
+            resultCode == BiometricEnrollBase.RESULT_FINISHED || resultCode == Activity.RESULT_OK
+        val gateKeeperPasswordHandle =
+            data?.getExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE) as Long?
+        binding.onConfirmDevice(wasSuccessful, gateKeeperPasswordHandle)
+    }
+
+    /**
+     * Helper function to launch fingerprint enrollment(This should be the default behavior
+     * when a user enters their PIN/PATTERN/PASS and no fingerprints are enrolled.
+     */
+    private fun launchFullFingerprintEnrollment(
+        userId: Int,
+        gateKeeperPasswordHandle: Long?,
+        challenge: Long?,
+        challengeToken: ByteArray?,
+    ) {
+        val intent = Intent()
+        intent.setClassName(
+            Utils.SETTINGS_PACKAGE_NAME, FingerprintEnrollIntroductionInternal::class.java.name
+        )
+        intent.putExtra(BiometricEnrollBase.EXTRA_FROM_SETTINGS_SUMMARY, true)
+        intent.putExtra(
+            SettingsBaseActivity.EXTRA_PAGE_TRANSITION_TYPE,
+            SettingsTransitionHelper.TransitionType.TRANSITION_SLIDE
+        )
+
+        intent.putExtra(Intent.EXTRA_USER_ID, userId)
+
+        if (gateKeeperPasswordHandle != null) {
+            intent.putExtra(
+                ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, gateKeeperPasswordHandle
+            )
+        } else {
+            intent.putExtra(
+                ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, challengeToken
+            )
+            intent.putExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE, challenge)
+        }
+        launchFirstEnrollmentListener.launch(intent)
+    }
+
+    private fun setResultExternal(resultCode: Int) {
+        setResult(resultCode)
+    }
+
+    /** Helper to launch an add fingerprint request */
+    private fun launchAddFingerprint(userId: Int, challengeToken: ByteArray?) {
+        val intent = Intent()
+        intent.setClassName(
+            Utils.SETTINGS_PACKAGE_NAME, FingerprintEnrollEnrolling::class.qualifiedName.toString()
+        )
+        intent.putExtra(Intent.EXTRA_USER_ID, userId)
+        intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, challengeToken)
+        launchAdditionalFingerprintListener.launch(intent)
+    }
+
+}
\ No newline at end of file
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsViewModel.kt
new file mode 100644
index 0000000..6cddb24
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsViewModel.kt
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.biometrics.fingerprint2.ui.viewmodel
+
+import android.hardware.fingerprint.FingerprintManager
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * Models the UI state for fingerprint settings.
+ */
+class FingerprintSettingsViewModel(
+    private val userId: Int,
+    gateKeeperPassword: Long?,
+    theChallenge: Long?,
+    theChallengeToken: ByteArray?,
+    private val fingerprintManager: FingerprintManager
+) : ViewModel() {
+
+    private val _nextStep: MutableStateFlow<NextStepViewModel?> = MutableStateFlow(null)
+    /**
+     *  This flow represents the high level state for the FingerprintSettingsV2Fragment. The
+     *  consumer of this flow should call [onUiCommandExecuted] which will set the state to null,
+     *  confirming that the UI has consumed the last command and is ready to consume another
+     *  command.
+     */
+    val nextStep = _nextStep.asStateFlow()
+
+
+    private var gateKeeperPasswordHandle: Long? = gateKeeperPassword
+    private var challenge: Long? = theChallenge
+    private var challengeToken: ByteArray? = theChallengeToken
+
+    /**
+     * Indicates to the view model that a confirm device credential action has been completed
+     * with a [theGateKeeperPasswordHandle] which will be used for [FingerprintManager]
+     * operations such as [FingerprintManager.enroll].
+     */
+    fun onConfirmDevice(wasSuccessful: Boolean, theGateKeeperPasswordHandle: Long?) {
+
+        if (!wasSuccessful) {
+            launchFinishSettings("ConfirmDeviceCredential was unsuccessful")
+            return
+        }
+        if (theGateKeeperPasswordHandle == null) {
+            launchFinishSettings("ConfirmDeviceCredential gatekeeper password was null")
+            return
+        }
+
+        gateKeeperPasswordHandle = theGateKeeperPasswordHandle
+        launchEnrollNextStep()
+    }
+
+    /**
+     * Notifies that enrollment was successful.
+     */
+    fun onEnrollSuccess() {
+        _nextStep.update {
+            ShowSettings(userId)
+        }
+    }
+
+    /**
+     * Notifies that an additional enrollment failed.
+     */
+    fun onEnrollAdditionalFailure() {
+        launchFinishSettings("Failed to enroll additional fingerprint")
+    }
+
+    /**
+     * Notifies that the first enrollment failed.
+     */
+    fun onEnrollFirstFailure(reason: String) {
+        launchFinishSettings(reason)
+    }
+
+    /**
+     * Notifies that first enrollment failed (with resultCode)
+     */
+    fun onEnrollFirstFailure(reason: String, resultCode: Int) {
+        launchFinishSettings(reason, resultCode)
+    }
+
+    /**
+     * Notifies that a users first enrollment succeeded.
+     */
+    fun onEnrollFirst(token: ByteArray?, keyChallenge: Long?) {
+        if (token == null) {
+            launchFinishSettings("Error, empty token")
+            return
+        }
+        if (keyChallenge == null) {
+            launchFinishSettings("Error, empty keyChallenge")
+            return
+        }
+        challengeToken = token
+        challenge = keyChallenge
+
+        _nextStep.update {
+            ShowSettings(userId)
+        }
+    }
+
+
+    /**
+     * Indicates if this settings activity has been called with correct token and challenge
+     * and that we do not need to launch confirm device credential.
+     */
+    fun updateTokenAndChallenge(token: ByteArray?, theChallenge: Long?) {
+        challengeToken = token
+        challenge = theChallenge
+        if (challengeToken == null) {
+            _nextStep.update {
+                LaunchConfirmDeviceCredential(userId)
+            }
+        } else {
+            launchEnrollNextStep()
+        }
+    }
+
+    /**
+     * Indicates a UI command has been consumed by the UI, and the logic can send another
+     * UI command.
+     */
+    fun onUiCommandExecuted() {
+        _nextStep.update {
+            null
+        }
+    }
+
+    private fun launchEnrollNextStep() {
+        if (fingerprintManager.getEnrolledFingerprints(userId).isEmpty()) {
+            _nextStep.update {
+                EnrollFirstFingerprint(userId, gateKeeperPasswordHandle, challenge, challengeToken)
+            }
+        } else {
+            _nextStep.update {
+                ShowSettings(userId)
+            }
+        }
+    }
+
+    private fun launchFinishSettings(reason: String) {
+        _nextStep.update {
+            FinishSettings(reason)
+        }
+    }
+
+    private fun launchFinishSettings(reason: String, errorCode: Int) {
+        _nextStep.update {
+            FinishSettingsWithResult(errorCode, reason)
+        }
+    }
+
+    class FingerprintSettingsViewModelFactory(
+        private val userId: Int,
+        private val fingerprintManager: FingerprintManager,
+    ) : ViewModelProvider.Factory {
+
+        @Suppress("UNCHECKED_CAST")
+        override fun <T : ViewModel> create(
+            modelClass: Class<T>,
+        ): T {
+
+            return FingerprintSettingsViewModel(
+                userId, null, null, null, fingerprintManager
+            ) as T
+        }
+    }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/NextStepViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/NextStepViewModel.kt
new file mode 100644
index 0000000..1046f51
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/NextStepViewModel.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.biometrics.fingerprint2.ui.viewmodel
+
+/**
+ * A class to represent a next step for FingerprintSettings. This is typically to perform an action
+ * such that launches another activity such as EnrollFirstFingerprint() or
+ * LaunchConfirmDeviceCredential().
+ */
+sealed class NextStepViewModel
+
+data class EnrollFirstFingerprint(
+    val userId: Int, val gateKeeperPasswordHandle: Long?,
+    val challenge: Long?,
+    val challengeToken: ByteArray?,
+) : NextStepViewModel()
+
+data class EnrollAdditionalFingerprint(
+    val userId: Int,
+    val challengeToken: ByteArray?,
+) : NextStepViewModel()
+
+data class FinishSettings(
+    val reason: String
+) : NextStepViewModel()
+
+data class FinishSettingsWithResult(
+    val result: Int, val reason: String
+) : NextStepViewModel()
+
+data class ShowSettings(val userId: Int) : NextStepViewModel()
+
+data class LaunchConfirmDeviceCredential(val userId: Int) : NextStepViewModel()
+
diff --git a/src/com/android/settings/core/gateway/SettingsGateway.java b/src/com/android/settings/core/gateway/SettingsGateway.java
index b5d12a5..3100706 100644
--- a/src/com/android/settings/core/gateway/SettingsGateway.java
+++ b/src/com/android/settings/core/gateway/SettingsGateway.java
@@ -72,6 +72,7 @@
 import com.android.settings.biometrics.combination.CombinedBiometricSettings;
 import com.android.settings.biometrics.face.FaceSettings;
 import com.android.settings.biometrics.fingerprint.FingerprintSettings;
+import com.android.settings.biometrics.fingerprint2.ui.fragment.FingerprintSettingsV2Fragment;
 import com.android.settings.bluetooth.BluetoothBroadcastDialog;
 import com.android.settings.bluetooth.BluetoothDeviceDetailsFragment;
 import com.android.settings.bluetooth.BluetoothFindBroadcastsFragment;
@@ -266,6 +267,7 @@
             AssistGestureSettings.class.getName(),
             FaceSettings.class.getName(),
             FingerprintSettings.FingerprintSettingsFragment.class.getName(),
+            FingerprintSettingsV2Fragment.class.getName(),
             CombinedBiometricSettings.class.getName(),
             CombinedBiometricProfileSettings.class.getName(),
             SwipeToNotificationSettings.class.getName(),
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index 4e2b3be..3a3ca99 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -32,6 +32,7 @@
         "platform-test-annotations",
         "truth-prebuilt",
         "ub-uiautomator",
+        "kotlinx_coroutines_test",
         // Don't add SettingsLib libraries here - you can use them directly as they are in the
         // instrumented Settings app.
     ],
@@ -40,8 +41,11 @@
         javacflags: ["-Xep:CheckReturnValue:WARN"]
     },
 
-    // Include all test java files.
-    srcs: ["src/**/*.java"],
+    // Include all test java/kotlin files.
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
 
     platform_apis: true,
     test_suites: ["device-tests"],
diff --git a/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsViewModelTest.kt b/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsViewModelTest.kt
new file mode 100644
index 0000000..7389543
--- /dev/null
+++ b/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsViewModelTest.kt
@@ -0,0 +1,332 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.fingerprint2.viewmodel
+
+import android.hardware.fingerprint.Fingerprint
+import android.hardware.fingerprint.FingerprintManager
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollFirstFingerprint
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettings
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettingsWithResult
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.LaunchConfirmDeviceCredential
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.NextStepViewModel
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.ShowSettings
+import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsViewModel
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.anyInt
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.Mockito.`when` as whenever
+
+@RunWith(MockitoJUnitRunner::class)
+class FingerprintSettingsViewModelTest {
+
+    @JvmField
+    @Rule
+    var rule = MockitoJUnit.rule()
+
+    @Mock
+    private lateinit var fingerprintManager: FingerprintManager
+    private lateinit var underTest: FingerprintSettingsViewModel
+    private val defaultUserId = 0
+
+    @Before
+    fun setup() {
+        // @formatter:off
+        underTest = FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
+            defaultUserId,
+            fingerprintManager,
+        ).create(FingerprintSettingsViewModel::class.java)
+        // @formatter:on
+    }
+
+    @Test
+    fun testNoGateKeeper_launchesConfirmDeviceCredential() = runTest {
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+
+        runCurrent()
+        assertThat(nextStep).isEqualTo(LaunchConfirmDeviceCredential(defaultUserId))
+        job.cancel()
+    }
+
+    @Test
+    fun testConfirmDevice_fails() = runTest {
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(false, null)
+
+        runCurrent()
+
+        assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
+        job.cancel()
+    }
+
+    @Test
+    fun confirmDeviceSuccess_noGateKeeper() = runTest {
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, null)
+
+        runCurrent()
+
+        assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
+        job.cancel()
+    }
+
+    @Test
+    fun confirmDeviceSuccess_launchesEnrollment_ifNoPreviousEnrollments() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+
+        runCurrent()
+
+        assertThat(nextStep).isEqualTo(EnrollFirstFingerprint(defaultUserId, 10L, null, null))
+        job.cancel()
+    }
+
+    @Test
+    fun firstEnrollment_fails() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+        underTest.onEnrollFirstFailure("We failed!!")
+
+        runCurrent()
+
+        assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
+        job.cancel()
+    }
+
+    @Test
+    fun firstEnrollment_failsWithReason() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        val failStr = "We failed!!"
+        val failReason = 101
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+        underTest.onEnrollFirstFailure(failStr, failReason)
+
+        runCurrent()
+
+        assertThat(nextStep).isEqualTo(FinishSettingsWithResult(failReason, failStr))
+        job.cancel()
+    }
+
+    @Test
+    fun firstEnrollmentSucceeds_noToken() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+        underTest.onEnrollFirst(null, null)
+
+        runCurrent()
+
+        assertThat(nextStep).isEqualTo(FinishSettings("Error, empty token"))
+        job.cancel()
+    }
+
+    @Test
+    fun firstEnrollmentSucceeds_noKeyChallenge() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        val byteArray = ByteArray(1) {
+            3
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+        underTest.onEnrollFirst(byteArray, null)
+
+        runCurrent()
+
+        assertThat(nextStep).isEqualTo(FinishSettings("Error, empty keyChallenge"))
+        job.cancel()
+    }
+
+    @Test
+    fun firstEnrollment_succeeds() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        val byteArray = ByteArray(1) {
+            3
+        }
+        val keyChallenge = 89L
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+        underTest.onEnrollFirst(byteArray, keyChallenge)
+
+        runCurrent()
+
+        assertThat(nextStep).isEqualTo(ShowSettings(defaultUserId))
+        job.cancel()
+    }
+
+    @Test
+    fun confirmDeviceCredential_withEnrolledFingerprint_showsSettings() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(
+            listOf(
+                Fingerprint(
+                    "a", 1, 2, 3L
+                )
+            )
+        )
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+
+        runCurrent()
+
+        assertThat(nextStep).isEqualTo(ShowSettings(defaultUserId))
+        job.cancel()
+    }
+
+    @Test
+    fun enrollAdditionalFingerprints_fails() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(
+            listOf(
+                Fingerprint(
+                    "a", 1, 2, 3L
+                )
+            )
+        )
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+        underTest.onEnrollAdditionalFailure()
+
+        runCurrent()
+
+        assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
+        job.cancel()
+    }
+
+    @Test
+    fun enrollAdditional_success() = runTest {
+        whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(
+            listOf(
+                Fingerprint(
+                    "a", 1, 2, 3L
+                )
+            )
+        )
+
+        var nextStep: NextStepViewModel? = null
+        val job = launch {
+            underTest.nextStep.collect {
+                nextStep = it
+            }
+        }
+
+        underTest.updateTokenAndChallenge(null, null)
+        underTest.onConfirmDevice(true, 10L)
+        underTest.onEnrollSuccess()
+
+        runCurrent()
+
+        assertThat(nextStep).isEqualTo(ShowSettings(defaultUserId))
+        job.cancel()
+    }
+}
\ No newline at end of file