Remote authenticator enrollment enrolling layout.

This flow will be included in Device Unlock settings with the
Fingerprint and Face Unlock.

Bug: b/293908453
Test: atest RemoteAuthEnrollEnrollingTest
Change-Id: I56c7fb2481ace359813c27e7538bec0baceffced
This commit is contained in:
Justin McClain
2023-08-10 14:50:26 +00:00
parent 3b56d1fca5
commit 5c739de558
11 changed files with 542 additions and 1 deletions

View File

@@ -0,0 +1,64 @@
<?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.
-->
<com.google.android.setupdesign.GlifLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/setup_wizard_layout"
android:icon="@drawable/ic_lock"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:sucHeaderText="@string/security_settings_remoteauth_enroll_enrolling_title" >
<LinearLayout
style="@style/SudContentFrame"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:clipToPadding="false"
android:clipChildren="false">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/BiometricEnrollIntroTitle"
android:text="@string/security_settings_remoteauth_enroll_enrolling_list_heading" />
<ProgressBar
android:id="@+id/enrolling_list_progress_bar"
android:layout_width="@dimen/remoteauth_enrolling_progress_bar_size"
android:layout_height="@dimen/remoteauth_enrolling_progress_bar_size"
android:visibility="invisible"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/discovered_authenticator_list"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
style="@style/TextAppearance.ErrorText"
android:id="@+id/error_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|bottom"
android:accessibilityLiveRegion="polite"
android:gravity="center"
android:visibility="invisible"/>
</LinearLayout>
</com.google.android.setupdesign.GlifLayout>

View File

@@ -0,0 +1,52 @@
<?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.
-->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/authenticator_item_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="@dimen/remoteauth_enrolling_authenticator_vertical_margin"
android:minHeight="@dimen/remoteauth_touchable_area_minimum_span">
<TextView
android:id="@+id/discovered_authenticator_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/remoteauth_enrolling_authenticator_horizontal_margin"
android:ellipsize="end"
android:maxLines="1"
app:layout_constrainedWidth="true"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/authenticator_radio_button"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/authenticator_radio_button"
android:layout_width="@dimen/remoteauth_icon_small_size"
android:layout_height="@dimen/remoteauth_icon_small_size"
android:background="@drawable/ic_radio_button_unchecked_black_24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/discovered_authenticator_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -183,8 +183,10 @@
<dimen name="remoteauth_carousel_progress_margin">28dp</dimen>
<dimen name="remoteauth_carousel_progress_circle_diameter">8dp</dimen>
<dimen name="remoteauth_carousel_progress_circle_margin">4dp</dimen>
<dimen name="remoteauth_enrolling_authenticator_vertical_margin">12dp</dimen>
<dimen name="remoteauth_enrolling_authenticator_horizontal_margin">16dp</dimen>
<dimen name="remoteauth_enrolling_progress_bar_size">16dp</dimen>
<dimen name="remoteauth_settings_top_margin">22dp</dimen>
<dimen name="remoteauth_settings_device_horizontal_margin">16dp</dimen>

View File

@@ -910,6 +910,15 @@
<string name="security_settings_remoteauth_enroll_introduction_animation_tap_notification">Tap a notification</string>
<!-- Subtitle for animation that explains users can unlock by swiping up on the lock screen [CHAR_LIMIT=NONE] -->
<string name="security_settings_remoteauth_enroll_introduction_animation_swipe_up">Swipe up on the lock screen</string>
<!-- Strings for RemoteAuth enroll enrolling page -->
<!-- Title of the screen that allows users to choose from a list of available watches to enable the Watch Unlock feature [CHAR_LIMIT=45] -->
<string name="security_settings_remoteauth_enroll_enrolling_title">Choose your watch</string>
<!-- Heading text for the list of watches available for the Watch Unlock feature [CHAR_LIMIT=40] -->
<string name="security_settings_remoteauth_enroll_enrolling_list_heading">Available watches</string>
<!-- Button text to cancel enrollment [CHAR LIMIT=30] -->
<string name="security_settings_remoteauth_enroll_enrolling_disagree">Cancel</string>
<!-- Button text to start enrollment [CHAR LIMIT=30] -->
<string name="security_settings_remoteauth_enroll_enrolling_agree">Confirm</string>
<!-- Strings for RemoteAuth enroll finish page -->
<!-- Title of the dialog that shows when a paired watch has been set up successfully and can be used to unlock the phone [CHAR_LIMIT=45] -->
<string name="security_settings_remoteauth_enroll_finish_title">You\u2019re all set!</string>

View File

@@ -0,0 +1,24 @@
/*
* 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.remoteauth.enrolling
/** UI state of a single discovered authenticator. */
data class DiscoveredAuthenticatorUiState(
val name: String,
val isSelected: Boolean,
val onSelect: () -> Unit,
)

View File

@@ -0,0 +1,34 @@
/*
* 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.remoteauth.enrolling
/** The different states of the enrolling flow. */
enum class EnrollmentUiState {
/** No enrollment is happening. */
NONE,
/** Searching for potential authenticators. */
FINDING_DEVICES,
/**
* An enrollment is in progress.
*/
ENROLLING,
/** An enrollment has succeeded. */
SUCCESS,
}

View File

@@ -0,0 +1,127 @@
/*
* 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.remoteauth.enrolling
import android.os.Bundle
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.android.settings.R
import com.android.settings.remoteauth.RemoteAuthEnrollBase
import com.google.android.setupcompat.template.FooterButton
import kotlinx.coroutines.launch
class RemoteAuthEnrollEnrolling :
RemoteAuthEnrollBase(
layoutResId = R.layout.remote_auth_enroll_enrolling,
glifLayoutId = R.id.setup_wizard_layout,
) {
// TODO(b/293906345): Scope viewModel to navigation graph when implementing navigation.
private val viewModel = RemoteAuthEnrollEnrollingViewModel()
private val adapter = RemoteAuthEnrollEnrollingRecyclerViewAdapter()
private val progressBar by lazy {
view!!.findViewById<ProgressBar>(R.id.enrolling_list_progress_bar)
}
private val errorText by lazy { view!!.findViewById<TextView>(R.id.error_text) }
private val recyclerView by lazy {
view!!.findViewById<RecyclerView>(R.id.discovered_authenticator_list)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Set up adapter
recyclerView.layoutManager = LinearLayoutManager(context)
recyclerView.adapter = adapter
// Collect UIState and update UI on changes.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect {
updateUi(it)
}
}
}
}
override fun onStart() {
super.onStart()
// Get list of discovered devices from viewModel.
viewModel.discoverDevices()
}
override fun initializePrimaryFooterButton(): FooterButton {
return FooterButton.Builder(requireContext())
.setText(R.string.security_settings_remoteauth_enroll_enrolling_agree)
.setListener(this::onPrimaryFooterButtonClick)
.setButtonType(FooterButton.ButtonType.NEXT)
.setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Primary)
.build()
}
override fun initializeSecondaryFooterButton(): FooterButton? {
return FooterButton.Builder(requireContext())
.setText(R.string.security_settings_remoteauth_enroll_enrolling_disagree)
.setListener(this::onSecondaryFooterButtonClick)
.setButtonType(FooterButton.ButtonType.SKIP)
.setTheme(com.google.android.setupdesign.R.style.SudGlifButton_Secondary)
.build()
}
private fun onPrimaryFooterButtonClick(view: View) {
viewModel.registerAuthenticator()
}
private fun onSecondaryFooterButtonClick(view: View) {
// TODO(b/293906345): Wire up navigation
}
private fun updateUi(uiState: RemoteAuthEnrollEnrollingUiState) {
progressBar.visibility = View.INVISIBLE
primaryFooterButton.isEnabled = false
// TODO(b/290769765): Add unit tests for all this states.
when (uiState.enrollmentUiState) {
EnrollmentUiState.NONE -> {
adapter.uiStates = uiState.discoveredDeviceUiStates
primaryFooterButton.isEnabled = viewModel.isDeviceSelected()
}
EnrollmentUiState.FINDING_DEVICES -> {
progressBar.visibility = View.VISIBLE
}
EnrollmentUiState.ENROLLING -> {}
EnrollmentUiState.SUCCESS -> {
// TODO(b/293906345): Wire up navigation
}
}
if (uiState.errorMsg != null) {
errorText.visibility = View.VISIBLE
errorText.text = uiState.errorMsg
} else {
errorText.visibility = View.INVISIBLE
errorText.text = ""
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.remoteauth.enrolling
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.android.settings.R
class RemoteAuthEnrollEnrollingRecyclerViewAdapter :
RecyclerView.Adapter<RemoteAuthEnrollEnrollingRecyclerViewAdapter.ViewHolder>() {
var uiStates = listOf<DiscoveredAuthenticatorUiState>()
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.remote_auth_enrolling_authenticator_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
viewHolder.bind(uiStates[position])
}
override fun getItemCount() = uiStates.size
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val titleTextView: TextView = view.findViewById(R.id.discovered_authenticator_name)
private val selectButton: ImageView = view.findViewById(R.id.authenticator_radio_button)
private val checkedDrawable =
view.context.getDrawable(R.drawable.ic_radio_button_checked_black_24dp)
private val uncheckedDrawable =
view.context.getDrawable(R.drawable.ic_radio_button_unchecked_black_24dp)
fun bind(discoveredAuthenticatorUiState: DiscoveredAuthenticatorUiState) {
titleTextView.text = discoveredAuthenticatorUiState.name
selectButton.background = if (discoveredAuthenticatorUiState.isSelected) {
checkedDrawable
} else {
uncheckedDrawable
}
selectButton.setOnClickListener { discoveredAuthenticatorUiState.onSelect() }
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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.remoteauth.enrolling
/** UiState for full enrolling view. */
data class RemoteAuthEnrollEnrollingUiState(
val discoveredDeviceUiStates: List<DiscoveredAuthenticatorUiState> = listOf(),
val enrollmentUiState: EnrollmentUiState = EnrollmentUiState.NONE,
// TODO(b/293906744): Change to error code in teamfood and add errors to strings.xml
val errorMsg: String? = null,
)

View File

@@ -0,0 +1,73 @@
/*
* 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.remoteauth.enrolling
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlin.properties.Delegates
class RemoteAuthEnrollEnrollingViewModel : ViewModel() {
private val _uiState = MutableStateFlow(RemoteAuthEnrollEnrollingUiState())
val uiState: StateFlow<RemoteAuthEnrollEnrollingUiState> = _uiState.asStateFlow()
private var errorMessage: String? = null
set(value) {
field = value
_uiState.update { currentState ->
currentState.copy(
errorMsg = value,
)
}
}
// TODO(b/293906744): Change to RemoteAuthManager.DiscoveredDevice.
private var selectedDevice: Any? by Delegates.observable(null) { _, _, _ -> discoverDevices() }
/** Returns if a device has been selected */
fun isDeviceSelected() = selectedDevice != null
/**
* Starts searching for nearby authenticators that are currently not enrolled. The devices
* and the state of the searching are both returned in uiState.
*/
fun discoverDevices() {
_uiState.update { currentState ->
currentState.copy(enrollmentUiState = EnrollmentUiState.FINDING_DEVICES)
}
// TODO(b/293906744): Map RemoteAuthManager discovered devices to
// DiscoveredAuthenticatorUiState in viewModelScope.
val discoveredDeviceUiStates = listOf<DiscoveredAuthenticatorUiState>()
_uiState.update { currentState ->
currentState.copy(
discoveredDeviceUiStates = discoveredDeviceUiStates,
enrollmentUiState = EnrollmentUiState.NONE
)
}
}
/** Registers the selected discovered device, if one is selected. */
fun registerAuthenticator() {
// TODO(b/293906744): Call RemoteAuthManager.register with selected device and update
// _uiState.
}
}

View File

@@ -0,0 +1,65 @@
/*
* 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.remoteauth.enrolling
import android.os.Bundle
import androidx.fragment.app.testing.launchFragmentInContainer
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import com.android.settings.R
import org.hamcrest.core.IsNot.not
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class RemoteAuthEnrollEnrollingTest {
@Before
fun setup() {
launchFragmentInContainer<RemoteAuthEnrollEnrolling>(Bundle(), R.style.SudThemeGlif)
}
@Test
fun testRemoteAuthenticatorEnrollEnrolling_hasHeader() {
onView(withText(R.string.security_settings_remoteauth_enroll_enrolling_title)).check(
matches(
isDisplayed()
)
)
}
@Test
fun testRemoteAuthenticatorEnrollEnrolling_primaryButtonDisabled() {
onView(withText(R.string.security_settings_remoteauth_enroll_enrolling_agree)).check(
matches(
isNotEnabled()
)
)
}
@Test
fun testRemoteAuthenticatorEnrollEnrolling_progressBarNotDisplayed() {
onView(withId(R.id.enrolling_list_progress_bar)).check(matches(not(isDisplayed())))
}
@Test
fun testRemoteAuthenticatorEnrollEnrolling_errorTextNotDisplayed() {
onView(withId(R.id.error_text)).check(matches(not(isDisplayed())))
}
}