Migrate haptic to view model and out of legacy components.

Fix: 272832355
Bug: 288175645
Test: atest PromptViewModelTest AuthContainerViewTest AuthControllerTest
(cherry picked from https://googleplex-android-review.googlesource.com/q/commit:0b64d441089361fa000c09bfaa2e99b0efcfc012)
Merged-In: Ie7181b448b74e4f7322cfad59eced1ac872a9b85
Change-Id: Ie7181b448b74e4f7322cfad59eced1ac872a9b85
This commit is contained in:
Joe Bolinger
2023-06-24 00:56:38 +00:00
committed by Cherrypicker Worker
parent a5aa965b2b
commit 3b9e125c9f
10 changed files with 99 additions and 72 deletions

View File

@@ -737,7 +737,7 @@ public abstract class AuthBiometricView extends LinearLayout implements AuthBiom
});
mUseCredentialButton.setOnClickListener((view) -> {
startTransitionToCredentialUI();
startTransitionToCredentialUI(false /* isError */);
});
mConfirmButton.setOnClickListener((view) -> {
@@ -768,9 +768,12 @@ public abstract class AuthBiometricView extends LinearLayout implements AuthBiom
/**
* Kicks off the animation process and invokes the callback.
*
* @param isError if this was triggered due to an error and not a user action (unused,
* previously for haptics).
*/
@Override
public void startTransitionToCredentialUI() {
public void startTransitionToCredentialUI(boolean isError) {
updateSize(AuthDialog.SIZE_LARGE);
mCallback.onAction(Callback.ACTION_USE_DEVICE_CREDENTIAL);
}

View File

@@ -38,7 +38,7 @@ interface AuthBiometricViewAdapter {
fun onHelp(@BiometricAuthenticator.Modality modality: Int, help: String)
fun startTransitionToCredentialUI()
fun startTransitionToCredentialUI(isError: Boolean)
fun requestLayout()

View File

@@ -801,9 +801,9 @@ public class AuthContainerView extends LinearLayout
}
@Override
public void animateToCredentialUI() {
public void animateToCredentialUI(boolean isError) {
if (mBiometricView != null) {
mBiometricView.startTransitionToCredentialUI();
mBiometricView.startTransitionToCredentialUI(isError);
} else {
Log.e(TAG, "animateToCredentialUI(): mBiometricView is null");
}

View File

@@ -85,7 +85,6 @@ import com.android.systemui.flags.FeatureFlags;
import com.android.systemui.keyguard.WakefulnessLifecycle;
import com.android.systemui.keyguard.data.repository.BiometricType;
import com.android.systemui.statusbar.CommandQueue;
import com.android.systemui.statusbar.VibratorHelper;
import com.android.systemui.util.concurrency.DelayableExecutor;
import com.android.systemui.util.concurrency.Execution;
@@ -185,18 +184,6 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
private final @Background DelayableExecutor mBackgroundExecutor;
private final DisplayInfo mCachedDisplayInfo = new DisplayInfo();
private final VibratorHelper mVibratorHelper;
private void vibrateSuccess(int modality) {
mVibratorHelper.vibrateAuthSuccess(
getClass().getSimpleName() + ", modality = " + modality + "BP::success");
}
private void vibrateError(int modality) {
mVibratorHelper.vibrateAuthError(
getClass().getSimpleName() + ", modality = " + modality + "BP::error");
}
@VisibleForTesting
final TaskStackListener mTaskStackListener = new TaskStackListener() {
@Override
@@ -776,7 +763,6 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
@NonNull InteractionJankMonitor jankMonitor,
@Main Handler handler,
@Background DelayableExecutor bgExecutor,
@NonNull VibratorHelper vibrator,
@NonNull UdfpsUtils udfpsUtils) {
mContext = context;
mFeatureFlags = featureFlags;
@@ -798,7 +784,6 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
mUdfpsEnrolledForUser = new SparseBooleanArray();
mSfpsEnrolledForUser = new SparseBooleanArray();
mFaceEnrolledForUser = new SparseBooleanArray();
mVibratorHelper = vibrator;
mUdfpsUtils = udfpsUtils;
mApplicationCoroutineScope = applicationCoroutineScope;
@@ -987,8 +972,6 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
public void onBiometricAuthenticated(@Modality int modality) {
if (DEBUG) Log.d(TAG, "onBiometricAuthenticated: ");
vibrateSuccess(modality);
if (mCurrentDialog != null) {
mCurrentDialog.onAuthenticationSucceeded(modality);
} else {
@@ -1085,8 +1068,6 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
Log.d(TAG, String.format("onBiometricError(%d, %d, %d)", modality, error, vendorCode));
}
vibrateError(modality);
final boolean isLockout = (error == BiometricConstants.BIOMETRIC_ERROR_LOCKOUT)
|| (error == BiometricConstants.BIOMETRIC_ERROR_LOCKOUT_PERMANENT);
@@ -1103,7 +1084,7 @@ public class AuthController implements CoreStartable, CommandQueue.Callbacks,
if (mCurrentDialog != null) {
if (mCurrentDialog.isAllowDeviceCredentials() && isLockout) {
if (DEBUG) Log.d(TAG, "onBiometricError, lockout");
mCurrentDialog.animateToCredentialUI();
mCurrentDialog.animateToCredentialUI(true /* isError */);
} else if (isSoftError) {
final String errorMessage = (error == BiometricConstants.BIOMETRIC_PAUSED_REJECTED)
? getNotRecognizedString(modality)

View File

@@ -162,7 +162,7 @@ public interface AuthDialog extends Dumpable {
/**
* Animate to credential UI. Typically called after biometric is locked out.
*/
void animateToCredentialUI();
void animateToCredentialUI(boolean isError);
/**
* @return true if device credential is allowed.

View File

@@ -522,6 +522,7 @@ private class Spaghetti(
viewModel.showTemporaryError(
help,
messageAfterError = modalities.asDefaultHelpMessage(applicationContext),
hapticFeedback = false,
)
}
}
@@ -534,7 +535,7 @@ private class Spaghetti(
else -> false
}
override fun startTransitionToCredentialUI() {
override fun startTransitionToCredentialUI(isError: Boolean) {
applicationScope.launch {
viewModel.onSwitchToCredential()
legacyCallback?.onAction(Callback.ACTION_USE_DEVICE_CREDENTIAL)

View File

@@ -22,6 +22,7 @@ import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteracto
import com.android.systemui.biometrics.domain.model.BiometricModalities
import com.android.systemui.biometrics.domain.model.BiometricModality
import com.android.systemui.biometrics.shared.model.PromptKind
import com.android.systemui.statusbar.VibratorHelper
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
@@ -41,6 +42,7 @@ class PromptViewModel
@Inject
constructor(
private val interactor: PromptSelectorInteractor,
private val vibrator: VibratorHelper,
) {
/** The set of modalities available for this prompt */
val modalities: Flow<BiometricModalities> =
@@ -205,17 +207,19 @@ constructor(
private var messageJob: Job? = null
/**
* Show a temporary error [message] associated with an optional [failedModality].
* Show a temporary error [message] associated with an optional [failedModality] and play
* [hapticFeedback].
*
* An optional [messageAfterError] will be shown via [showAuthenticating] when
* [authenticateAfterError] is set (or via [showHelp] when not set) after the error is
* dismissed.
*
* The error is ignored if the user has already authenticated and it is treated as
* [onSilentError] if [suppressIfErrorShowing] is set and an error message is already showing.
* The error is ignored if the user has already authenticated or if [suppressIfErrorShowing] is
* set and an error message is already showing.
*/
suspend fun showTemporaryError(
message: String,
hapticFeedback: Boolean = true,
messageAfterError: String = "",
authenticateAfterError: Boolean = false,
suppressIfErrorShowing: Boolean = false,
@@ -225,7 +229,9 @@ constructor(
return@coroutineScope
}
if (_message.value.isErrorOrHelp && suppressIfErrorShowing) {
onSilentError(failedModality)
if (_isAuthenticated.value.isNotAuthenticated) {
_canTryAgainNow.value = supportsRetry(failedModality)
}
return@coroutineScope
}
@@ -236,6 +242,10 @@ constructor(
_message.value = PromptMessage.Error(message)
_legacyState.value = AuthBiometricView.STATE_ERROR
if (hapticFeedback) {
vibrator.error(failedModality)
}
messageJob?.cancel()
messageJob = launch {
delay(BiometricPrompt.HIDE_DIALOG_DELAY.toLong())
@@ -247,18 +257,6 @@ constructor(
}
}
/**
* Call instead of [showTemporaryError] if an error from the HAL should be silently ignored to
* enable retry (if the [failedModality] supports retrying).
*
* Ignored if the user has already authenticated.
*/
private fun onSilentError(failedModality: BiometricModality = BiometricModality.None) {
if (_isAuthenticated.value.isNotAuthenticated) {
_canTryAgainNow.value = supportsRetry(failedModality)
}
}
/**
* Call to ensure the fingerprint sensor has started. Either when the dialog is first shown
* (most cases) or when it should be enabled after a first error (coex implicit flow).
@@ -376,6 +374,8 @@ constructor(
AuthBiometricView.STATE_AUTHENTICATED
}
vibrator.success(modality)
messageJob?.cancel()
messageJob = null
@@ -386,18 +386,18 @@ constructor(
private suspend fun needsExplicitConfirmation(modality: BiometricModality): Boolean {
val availableModalities = modalities.first()
val confirmationRequested = interactor.isConfirmationRequired.first()
val confirmationRequired = isConfirmationRequired.first()
if (availableModalities.hasFaceAndFingerprint) {
// coex only needs confirmation when face is successful, unless it happens on the
// first attempt (i.e. without failure) before fingerprint scanning starts
val fingerprintStarted = fingerprintStartMode.first() != FingerprintStartMode.Pending
if (modality == BiometricModality.Face) {
return (fingerprintStartMode.first() != FingerprintStartMode.Pending) ||
confirmationRequested
return fingerprintStarted || confirmationRequired
}
}
if (availableModalities.hasFaceOnly) {
return confirmationRequested
return confirmationRequired
}
// fingerprint only never requires confirmation
return false
@@ -412,7 +412,6 @@ constructor(
fun confirmAuthenticated() {
val authState = _isAuthenticated.value
if (authState.isNotAuthenticated) {
"Cannot show authenticated after authenticated"
Log.w(TAG, "Cannot confirm authenticated when not authenticated")
return
}
@@ -434,6 +433,12 @@ constructor(
_forceLargeSize.value = true
}
private fun VibratorHelper.success(modality: BiometricModality) =
vibrateAuthSuccess("$TAG, modality = $modality BP::success")
private fun VibratorHelper.error(modality: BiometricModality = BiometricModality.None) =
vibrateAuthError("$TAG, modality = $modality BP::error")
companion object {
private const val TAG = "PromptViewModel"
}

View File

@@ -51,6 +51,7 @@ import com.android.systemui.biometrics.ui.viewmodel.PromptViewModel
import com.android.systemui.flags.FakeFeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.keyguard.WakefulnessLifecycle
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
@@ -99,6 +100,8 @@ open class AuthContainerViewTest : SysuiTestCase() {
lateinit var windowToken: IBinder
@Mock
lateinit var interactionJankMonitor: InteractionJankMonitor
@Mock
lateinit var vibrator: VibratorHelper
// TODO(b/278622168): remove with flag
open val useNewBiometricPrompt = false
@@ -325,7 +328,7 @@ open class AuthContainerViewTest : SysuiTestCase() {
authenticators = BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
container.animateToCredentialUI()
container.animateToCredentialUI(false)
waitForIdleSync()
assertThat(container.hasCredentialView()).isTrue()
@@ -514,7 +517,7 @@ open class AuthContainerViewTest : SysuiTestCase() {
{ authBiometricFingerprintViewModel },
{ promptSelectorInteractor },
{ bpCredentialInteractor },
PromptViewModel(promptSelectorInteractor),
PromptViewModel(promptSelectorInteractor, vibrator),
{ credentialViewModel },
Handler(TestableLooper.get(this).looper),
fakeExecutor

View File

@@ -202,9 +202,6 @@ public class AuthControllerTest extends SysuiTestCase {
private TestableAuthController mAuthController;
private FakeFeatureFlags mFeatureFlags = new FakeFeatureFlags();
@Mock
private VibratorHelper mVibratorHelper;
@Before
public void setup() throws RemoteException {
// TODO(b/278622168): remove with flag
@@ -267,7 +264,6 @@ public class AuthControllerTest extends SysuiTestCase {
true /* supportsSelfIllumination */,
true /* resetLockoutRequireHardwareAuthToken */));
when(mFaceManager.getSensorPropertiesInternal()).thenReturn(faceProps);
when(mVibratorHelper.hasVibrator()).thenReturn(true);
mAuthController = new TestableAuthController(mContextSpy);
@@ -550,7 +546,7 @@ public class AuthControllerTest extends SysuiTestCase {
mAuthController.onBiometricError(BiometricAuthenticator.TYPE_FACE, error, vendorCode);
verify(mDialog1, never()).onError(anyInt(), anyString());
verify(mDialog1).animateToCredentialUI();
verify(mDialog1).animateToCredentialUI(eq(true));
}
@Test
@@ -563,7 +559,7 @@ public class AuthControllerTest extends SysuiTestCase {
mAuthController.onBiometricError(BiometricAuthenticator.TYPE_FACE, error, vendorCode);
verify(mDialog1, never()).onError(anyInt(), anyString());
verify(mDialog1).animateToCredentialUI();
verify(mDialog1).animateToCredentialUI(eq(true));
}
@Test
@@ -578,7 +574,7 @@ public class AuthControllerTest extends SysuiTestCase {
mAuthController.onBiometricError(modality, error, vendorCode);
verify(mDialog1).onError(
eq(modality), eq(FaceManager.getErrorString(mContext, error, vendorCode)));
verify(mDialog1, never()).animateToCredentialUI();
verify(mDialog1, never()).animateToCredentialUI(eq(true));
}
@Test
@@ -593,7 +589,7 @@ public class AuthControllerTest extends SysuiTestCase {
mAuthController.onBiometricError(modality, error, vendorCode);
verify(mDialog1).onError(
eq(modality), eq(FaceManager.getErrorString(mContext, error, vendorCode)));
verify(mDialog1, never()).animateToCredentialUI();
verify(mDialog1, never()).animateToCredentialUI(eq(true));
}
@Test
@@ -1012,7 +1008,7 @@ public class AuthControllerTest extends SysuiTestCase {
() -> mBiometricPromptCredentialInteractor, () -> mPromptSelectionInteractor,
() -> mCredentialViewModel, () -> mPromptViewModel,
mInteractionJankMonitor, mHandler,
mBackgroundExecutor, mVibratorHelper, mUdfpsUtils);
mBackgroundExecutor, mUdfpsUtils);
}
@Override

View File

@@ -32,6 +32,8 @@ import com.android.systemui.biometrics.extractAuthenticatorTypes
import com.android.systemui.biometrics.faceSensorPropertiesInternal
import com.android.systemui.biometrics.fingerprintSensorPropertiesInternal
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.mockito.any
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
@@ -45,6 +47,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.junit.MockitoJUnit
private const val USER_ID = 4
@@ -58,6 +63,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
@JvmField @Rule var mockitoRule = MockitoJUnit.rule()
@Mock private lateinit var lockPatternUtils: LockPatternUtils
@Mock private lateinit var vibrator: VibratorHelper
private val testScope = TestScope()
private val promptRepository = FakePromptRepository()
@@ -70,11 +76,11 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
selector = PromptSelectorInteractorImpl(promptRepository, lockPatternUtils)
selector.resetPrompt()
viewModel = PromptViewModel(selector)
viewModel = PromptViewModel(selector, vibrator)
}
@Test
fun `start idle and show authenticating`() =
fun start_idle_and_show_authenticating() =
runGenericTest(doNotStart = true) {
val expectedSize =
if (testCase.shouldStartAsImplicitFlow) PromptSize.SMALL else PromptSize.MEDIUM
@@ -107,7 +113,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `shows authenticated - no errors`() = runGenericTest {
fun shows_authenticated_with_no_errors() = runGenericTest {
// this case can't happen until fingerprint is started
// trigger it now since no error has occurred in this test
val forceError = testCase.isCoex && testCase.authenticatedByFingerprint
@@ -124,6 +130,22 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
)
}
@Test
fun plays_haptic_on_authenticated() = runGenericTest {
viewModel.showAuthenticated(testCase.authenticatedModality, 1000L)
verify(vibrator).vibrateAuthSuccess(any())
verify(vibrator, never()).vibrateAuthError(any())
}
@Test
fun plays_no_haptic_on_confirm() = runGenericTest {
viewModel.confirmAuthenticated()
verify(vibrator, never()).vibrateAuthSuccess(any())
verify(vibrator, never()).vibrateAuthError(any())
}
private suspend fun TestScope.showAuthenticated(
authenticatedModality: BiometricModality,
expectConfirmation: Boolean,
@@ -172,7 +194,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `shows temporary errors`() = runGenericTest {
fun shows_temporary_errors() = runGenericTest {
val checkAtEnd = suspend { assertButtonsVisible(negative = true) }
showTemporaryErrors(restart = false) { checkAtEnd() }
@@ -180,6 +202,22 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
showTemporaryErrors(restart = true) { checkAtEnd() }
}
@Test
fun plays_haptic_on_errors() = runGenericTest {
viewModel.showTemporaryError("so sad", hapticFeedback = true)
verify(vibrator).vibrateAuthError(any())
verify(vibrator, never()).vibrateAuthSuccess(any())
}
@Test
fun plays_haptic_on_errors_unless_skipped() = runGenericTest {
viewModel.showTemporaryError("still sad", hapticFeedback = false)
verify(vibrator, never()).vibrateAuthError(any())
verify(vibrator, never()).vibrateAuthSuccess(any())
}
private suspend fun TestScope.showTemporaryErrors(
restart: Boolean,
helpAfterError: String = "",
@@ -233,7 +271,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `no errors or temporary help after authenticated`() = runGenericTest {
fun no_errors_or_temporary_help_after_authenticated() = runGenericTest {
val authenticating by collectLastValue(viewModel.isAuthenticating)
val authenticated by collectLastValue(viewModel.isAuthenticated)
val message by collectLastValue(viewModel.message)
@@ -277,7 +315,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `authenticated at most once`() = runGenericTest {
fun authenticated_at_most_once() = runGenericTest {
val authenticating by collectLastValue(viewModel.isAuthenticating)
val authenticated by collectLastValue(viewModel.isAuthenticated)
@@ -293,7 +331,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `authenticating cannot restart after authenticated`() = runGenericTest {
fun authenticating_cannot_restart_after_authenticated() = runGenericTest {
val authenticating by collectLastValue(viewModel.isAuthenticating)
val authenticated by collectLastValue(viewModel.isAuthenticated)
@@ -309,7 +347,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `confirm authentication`() = runGenericTest {
fun confirm_authentication() = runGenericTest {
val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
viewModel.showAuthenticated(testCase.authenticatedModality, 0)
@@ -341,7 +379,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `cannot confirm unless authenticated`() = runGenericTest {
fun cannot_confirm_unless_authenticated() = runGenericTest {
val authenticating by collectLastValue(viewModel.isAuthenticating)
val authenticated by collectLastValue(viewModel.isAuthenticated)
@@ -360,7 +398,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `shows help - before authenticated`() = runGenericTest {
fun shows_help_before_authenticated() = runGenericTest {
val helpMessage = "please help yourself to some cookies"
val message by collectLastValue(viewModel.message)
val messageVisible by collectLastValue(viewModel.isIndicatorMessageVisible)
@@ -379,7 +417,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `shows help - after authenticated`() = runGenericTest {
fun shows_help_after_authenticated() = runGenericTest {
val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
val helpMessage = "more cookies please"
val authenticating by collectLastValue(viewModel.isAuthenticating)
@@ -409,7 +447,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `retries after failure`() = runGenericTest {
fun retries_after_failure() = runGenericTest {
val errorMessage = "bad"
val helpMessage = "again?"
val expectTryAgainButton = testCase.isFaceOnly
@@ -455,7 +493,7 @@ internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCa
}
@Test
fun `switch to credential fallback`() = runGenericTest {
fun switch_to_credential_fallback() = runGenericTest {
val size by collectLastValue(viewModel.size)
// TODO(b/251476085): remove Spaghetti, migrate logic, and update this test