Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix lockscreen's 'device locked' crash on Android 12 and 12L devices #6784

Merged
merged 4 commits into from
Aug 9, 2022

Conversation

jmartinesp
Copy link
Member

Type of change

  • Feature
  • Bugfix
  • Technical
  • Other :

Content

Fixes #6768.
Depends on #6769.

Motivation and context

On Android 12 and 12L (APIs 31 and 32) there seems to be a bug that causes KeyguardManager.isDeviceLocked to be true for a few milliseconds even after the lock screen has been dismissed and the app is already displayed.

Since we added setRequiresDeviceUnlocked(true) to the biometric key, it can only be used after the device is unlocked and KeyStore will throw an exception if we try to access the key while it's locked. In this case, it's causing crashes when trying to update LockScreenViewModel's state based on the biometric keys info.

I also tried to simplify the implementation of the lock screen feature by removing some redundant components (LockScreenConfigurationProvider).

Screenshots / GIFs

Tests

  • Make sure you're on Android 12 or 12L, in a physical device if possible (emulators on those APIs sometimes don't work well with biometric auth).
  • Also, make sure the device has biometric authentication enabled.
  • Open the app, set up biometric authentication for it if it wasn't already enabled. You can do this on Settings > Security and privacy > Protect access.
  • With the app visible, lock and unlock the device using the power button then your pin/pattern/fingerprint.

If the app didn't crash, the fix is working.

Tested devices

  • Physical
  • Emulator
  • OS version(s): 13, 12L, 12, 11.

Checklist

@jmartinesp jmartinesp requested a review from a team August 9, 2022 10:18
@jmartinesp jmartinesp self-assigned this Aug 9, 2022
@jmartinesp jmartinesp requested review from onurays and removed request for a team August 9, 2022 10:18
Comment on lines +183 to -186
// Exception found:
// 1. Stop collecting.
// 2. Remove the system key if we were creating it.
// 3. Throw the exception and remove the prompt reference
if (!checkSystemKeyExists) {
lockScreenKeyRepository.deleteSystemKey()
}
prompt = null
throw exception
}
}
}
// Generates the system key on successful authentication
if (buildVersionSdkIntProvider.get() >= Build.VERSION_CODES.M) {
lockScreenKeyRepository.ensureSystemKey()
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we started using CryptoObject, the system key is created when that CryptoObject is instantiated. Instead of ensuring the system key is created on success, make sure it's removed on error if we were on the process of enabling biometric auth.

Comment on lines 179 to 202
/**
* Wait until the device is unlocked. There seems to be a behavior change on Android 12 that makes [KeyguardManager.isDeviceLocked] return `false` even
* after an Activity's `onResume` method. If we mix that with the system keys needing the device to be unlocked before they're used, we get crashes.
* See issue [#6768](https://github.com/vector-im/element-android/issues/6768).
*/
@SuppressLint("NewApi")
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal suspend fun waitUntilKeyguardIsUnlocked() {
if (versionProvider.get() < Build.VERSION_CODES.S) return
withTimeoutOrNull(5.seconds) {
while (keyguardManager.isDeviceLocked) {
delay(50.milliseconds)
}
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual code that fixes the issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think it's worth extracting this to a dedicated class/usecase? would also mean we could avoid the VisibleForTesting

not a blocker for me

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, the @VisibleForTesting annotation is no longer needed, I forgot to revert it to private.

I also thought about making it an external component, but it seemed too specific since it's only used here and it's a terrible hack that shouldn't be used anywhere if there's an alternative.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have tooo strong of an opinion here, up to you!
I would lean towards extracting as ViewModels tend to collect logic, requiring more setup for unit tests

@jmartinesp jmartinesp force-pushed the fix/jorgem/lockscreen-device-locked branch from f801ace to a533a8c Compare August 9, 2022 10:22
@ouchadam ouchadam added the Z-NextRelease For issues and PRs which should be included in the NextRelease. label Aug 9, 2022
@ouchadam
Copy link
Contributor

ouchadam commented Aug 9, 2022

if the PR is set to be based on #6780 we'll get a smaller diff (and it'll automatically update the base branch when the base is merged)

val mockAuthChannel = Channel<Boolean>(capacity = 1)
val biometricUtils = spyk(createBiometricHelper(createDefaultConfiguration(isBiometricsEnabled = true))) {
every { createAuthChannel() } returns mockAuthChannel
every { authenticateWithPromptInternal(any(), any(), any()) } returns mockk()
}
every { lockScreenKeyRepository.deleteSystemKey() } returns Unit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can also use justRun/coJustRun for these Unit cases

if (state.canUseBiometricAuth && state.isBiometricKeyInvalidated) {
lockScreenListener?.onBiometricKeyInvalidated()
} else if (state.showBiometricPromptAutomatically) {
viewModel.stateFlow.distinctUntilChangedBy { it.showBiometricPromptAutomatically }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should expose this as a ViewEvent, the ViewModel.init would register to the state and emit the event in onEach rather than the Fragment handling the logic (would have the benefit of being testable at the ViewModel layer)

what do you think? (could be a separate PR if you agree)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't need to observe the field, just checking it once on init, inside the launched coroutine should be enough: we only want to display the prompt automatically once when the user enters the lockscreen, so triggering the event several times depending on external changes might be weird behaviour.

Copy link
Contributor

@ouchadam ouchadam Aug 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see! in that case flow.first { it.showBiometricPromptAutomatically } might be better than distinct + filter

it's also worth highlighting init is probably a bad place to emit events as it requires the fragments to be able to start observing before the ViewModel has been created, otherwise it creates race conditions 😅

other ViewModels have dedicated actions that correlate to the lifecycle like Resumed/Visible

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean something like this?

init {
    ...
    observeStateChanges()
}

private fun observeStateChanges() {
    viewModelScope.launch {
        if (stateFlow.firstOrNull { it.showBiometricPromptAutomatically } != null) {
            _viewEvents.post(LockScreenViewEvent.ShowBiometricPromptAutomatically)
        }
    }

    viewModelScope.launch {
        if (stateFlow.firstOrNull { it.isBiometricKeyInvalidated } != null) {
            onBiometricKeyInvalidated()
        }
    }
}

Copy link
Contributor

@ouchadam ouchadam Aug 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep ^^^ but if it's possible for the view event to trigger before a fragment has a chance to start observing we'll need another ViewAction when onCreate or onResume happens to trigger the logic instead of init

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added an OnUIReady action to be triggered when the fragment's onViewCreated is called, right after it starts observing events.

@jmartinesp jmartinesp force-pushed the fix/jorgem/lockscreen-device-locked branch from a533a8c to 9888e15 Compare August 9, 2022 12:04
@jmartinesp jmartinesp requested a review from ouchadam August 9, 2022 12:05
@ouchadam ouchadam enabled auto-merge August 9, 2022 14:10
@sonarqubecloud
Copy link

sonarqubecloud bot commented Aug 9, 2022

SonarCloud Quality Gate failed.    Quality Gate failed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 0 Code Smells

52.3% 52.3% Coverage
0.0% 0.0% Duplication

@ouchadam ouchadam merged commit fe61fa8 into develop Aug 9, 2022
@ouchadam ouchadam deleted the fix/jorgem/lockscreen-device-locked branch August 9, 2022 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Pin code Z-NextRelease For issues and PRs which should be included in the NextRelease.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Crash - KeyStoreCrypto.ensureKey - java.security.InvalidKeyException
2 participants