Move biometrics check when linking a device.

This commit is contained in:
Michelle Tang 2024-06-24 10:46:29 -07:00 committed by Greyson Parrelli
parent 976f80ff7e
commit e08c2966c3
10 changed files with 67 additions and 135 deletions

View file

@ -2,12 +2,7 @@ package org.thoughtcrime.securesms.linkdevice
import android.Manifest
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
@ -30,9 +25,6 @@ import com.google.accompanist.permissions.rememberPermissionState
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.permissions.Permissions
@ -43,42 +35,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
*/
class AddLinkDeviceFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(AddLinkDeviceFragment::class)
}
private val viewModel: LinkDeviceViewModel by activityViewModels()
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
viewModel.addDevice()
} else {
viewModel.clearBiometrics()
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(requireContext().getString(R.string.BiometricDeviceAuthentication__signal))
.setConfirmationRequired(true)
.build()
biometricAuth = BiometricDeviceAuthentication(
BiometricManager.from(requireActivity()),
BiometricPrompt(requireActivity(), BiometricAuthenticationListener()),
promptInfo
)
}
override fun onPause() {
super.onPause()
viewModel.clearBiometrics()
biometricAuth.cancelAuthentication()
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
@ -103,14 +60,7 @@ class AddLinkDeviceFragment : ComposeFragment() {
onRequestPermissions = { askPermissions() },
onShowFrontCamera = { viewModel.showFrontCamera() },
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
onQrCodeApproved = {
viewModel.onQrCodeApproved()
if (biometricAuth.canAuthenticate()) {
biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.BiometricDeviceAuthentication__signal)) }
} else {
viewModel.addDevice()
}
},
onQrCodeApproved = { viewModel.addDevice() },
onQrCodeDismissed = { viewModel.onQrCodeDismissed() },
onQrCodeRetry = { viewModel.onQrCodeScanned(state.url) },
onLinkDeviceSuccess = {
@ -134,23 +84,6 @@ class AddLinkDeviceFragment : ComposeFragment() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
Log.w(TAG, "Linked device authentication error: $errorCode")
viewModel.clearBiometrics()
onAuthenticationFailed()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "Linked device authentication succeeded")
viewModel.addDevice()
}
override fun onAuthenticationFailed() {
Log.w(TAG, "Linked device unable to authenticate")
}
}
}
@Composable
@ -188,7 +121,6 @@ private fun MainScreen(
onQrCodeAccepted = onQrCodeApproved,
onQrCodeDismissed = onQrCodeDismissed,
onQrCodeRetry = onQrCodeRetry,
pendingBiometrics = state.pendingBiometrics,
linkDeviceResult = state.linkDeviceResult,
onLinkDeviceSuccess = onLinkDeviceSuccess,
onLinkDeviceFailure = onLinkDeviceFailure,

View file

@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.linkdevice
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -59,6 +62,9 @@ import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.DateUtils
@ -72,12 +78,40 @@ private const val PLACEHOLDER = "__ICON_PLACEHOLDER__"
*/
class LinkDeviceFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(LinkDeviceFragment::class)
}
private val viewModel: LinkDeviceViewModel by activityViewModels()
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.initialize(requireContext())
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(requireContext().getString(R.string.LinkDeviceFragment__unlock_to_link))
.setConfirmationRequired(true)
.build()
biometricAuth = BiometricDeviceAuthentication(
BiometricManager.from(requireActivity()),
BiometricPrompt(requireActivity(), BiometricAuthenticationListener()),
promptInfo
)
}
override fun onPause() {
super.onPause()
biometricAuth.cancelAuthentication()
}
@Composable
@ -110,7 +144,13 @@ class LinkDeviceFragment : ComposeFragment() {
navController = navController,
modifier = Modifier.padding(contentPadding),
onLearnMore = { navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceLearnMoreBottomSheet) },
onLinkDevice = { navController.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment) },
onLinkDevice = {
if (biometricAuth.canAuthenticate()) {
biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.LinkDeviceFragment__unlock_to_link)) }
} else {
navController.safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
}
},
setDeviceToRemove = { device -> viewModel.setDeviceToRemove(device) },
onRemoveDevice = { device -> viewModel.removeDevice(requireContext(), device) }
)
@ -122,6 +162,22 @@ class LinkDeviceFragment : ComposeFragment() {
requireActivity().finishAfterTransition()
}
}
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
Log.w(TAG, "Authentication error: $errorCode")
onAuthenticationFailed()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "Authentication succeeded")
findNavController().safeNavigate(R.id.action_linkDeviceFragment_to_addLinkDeviceFragment)
}
override fun onAuthenticationFailed() {
Log.w(TAG, "Unable to authenticate")
}
}
}
@Composable

View file

@ -33,7 +33,6 @@ fun LinkDeviceQrScanScreen(
onQrCodeAccepted: () -> Unit,
onQrCodeDismissed: () -> Unit,
onQrCodeRetry: () -> Unit,
pendingBiometrics: Boolean,
linkDeviceResult: LinkDeviceRepository.LinkDeviceResult,
onLinkDeviceSuccess: () -> Unit,
onLinkDeviceFailure: () -> Unit,
@ -95,13 +94,9 @@ fun LinkDeviceQrScanScreen(
view
},
update = { view: QrScannerView ->
if (pendingBiometrics) {
view.destroy()
} else {
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
if (showFrontCamera != null) {
view.toggleCamera()
}
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
if (showFrontCamera != null) {
view.toggleCamera()
}
},
hasPermission = hasPermission,

View file

@ -17,6 +17,5 @@ data class LinkDeviceSettingsState(
val linkDeviceResult: LinkDeviceRepository.LinkDeviceResult = LinkDeviceRepository.LinkDeviceResult.UNKNOWN,
val showFinishedSheet: Boolean = false,
val seenIntroSheet: Boolean = false,
val pendingBiometrics: Boolean = false,
val pendingNewDevice: Boolean = false
)

View file

@ -98,8 +98,7 @@ class LinkDeviceViewModel : ViewModel() {
_state.update {
val frontCamera = it.showFrontCamera
it.copy(
showFrontCamera = if (frontCamera == null) true else !frontCamera,
pendingBiometrics = false
showFrontCamera = if (frontCamera == null) true else !frontCamera
)
}
}
@ -140,16 +139,6 @@ class LinkDeviceViewModel : ViewModel() {
}
}
fun onQrCodeApproved() {
_state.update {
it.copy(
qrCodeFound = false,
qrCodeInvalid = false,
pendingBiometrics = true
)
}
}
fun onQrCodeDismissed() {
_state.update {
it.copy(
@ -159,21 +148,14 @@ class LinkDeviceViewModel : ViewModel() {
}
}
fun clearBiometrics() {
_state.update {
it.copy(
pendingBiometrics = false
)
}
}
fun addDevice() {
val uri = Uri.parse(_state.value.url)
viewModelScope.launch(Dispatchers.IO) {
val result = LinkDeviceRepository.addDevice(uri)
_state.update {
it.copy(
pendingBiometrics = false,
qrCodeFound = false,
qrCodeInvalid = false,
linkDeviceResult = result,
url = ""
)

View file

@ -869,7 +869,7 @@
<!-- Text explaining that on linked devices, messages will be encrypted where %s will be replaced with an image-->
<string name="LinkDeviceFragment__messages_and_chat_info_are_protected">%1$s Messages and chat info are protected by end-to-end encryption on all devices</string>
<!-- Bottom sheet title explaining how Signal works on a linked device -->
<string name="LinkDeviceFragment__signal_on_desktop_ipad">Signal on Desktop or iPad</string>
<string name="LinkDeviceFragment__signal_on_desktop_ipad">Signal on desktop or iPad</string>
<!-- Bottom sheet description explaining that messages on linked devices are private -->
<string name="LinkDeviceFragment__all_messaging_is_private">All messaging on linked devices is private</string>
<!-- Bottom sheet description explaining that future messages on linked devices will be in sync with your phone but previous messages will not appear -->
@ -891,6 +891,8 @@
<string name="LinkDeviceFragment__loading">Loading…</string>
<!-- Text message shown when the user has no linked devices -->
<string name="LinkDeviceFragment__no_linked_devices">No linked devices</string>
<!-- Title on biometrics prompt explaining what biometrics are being used for -->
<string name="LinkDeviceFragment__unlock_to_link">Unlock to link a device</string>
<!-- AddLinkDeviceFragment -->
<!-- Description text shown on the QR code scanner when linking a device -->

View file

@ -23,7 +23,6 @@ class QrScannerView @JvmOverloads constructor(
private var scannerView: ScannerView? = null
private val qrDataPublish: PublishSubject<String> = PublishSubject.create()
private var forceLegacy: Boolean = false
val qrData: Observable<String> = qrDataPublish
@ -38,14 +37,12 @@ class QrScannerView @JvmOverloads constructor(
addView(scannerView)
this.scannerView = (scannerView as ScannerView)
this.forceLegacy = forceLegacy
}
@JvmOverloads
fun start(lifecycleOwner: LifecycleOwner, forceLegacy: Boolean = false) {
if (scannerView != null) {
Log.w(TAG, "Attempt to start scanning that has already started")
scannerView?.resume()
return
}
@ -64,14 +61,6 @@ class QrScannerView @JvmOverloads constructor(
scannerView?.toggleCamera()
}
// Biometrics require use of camera so we disable when needed
fun destroy() {
scannerView?.destroy()
if (!forceLegacy) {
scannerView = null
}
}
companion object {
private val TAG = Log.tag(QrScannerView::class.java)
}

View file

@ -8,6 +8,4 @@ import androidx.lifecycle.LifecycleOwner
interface ScannerView {
fun start(lifecycleOwner: LifecycleOwner)
fun toggleCamera()
fun resume()
fun destroy()
}

View file

@ -59,16 +59,4 @@ internal class ScannerView19 constructor(
lifecycleObserver.onResume(it)
}
}
override fun resume() {
lifecycleOwner?.let {
lifecycleObserver.onResume(it)
}
}
override fun destroy() {
lifecycleOwner?.let {
lifecycleObserver.onPause(it)
}
}
}

View file

@ -36,7 +36,6 @@ internal class ScannerView21 constructor(
private val lifecycleObserver: DefaultLifecycleObserver = object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
cameraProvider?.unbindAll()
cameraProvider = null
camera = null
analyzerExecutor.shutdown()
@ -79,14 +78,6 @@ internal class ScannerView21 constructor(
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
}
override fun resume() = Unit
override fun destroy() {
lifecyleOwner?.let {
lifecycleObserver.onDestroy(it)
}
}
private fun onCameraProvider(lifecycle: LifecycleOwner, cameraProvider: ProcessCameraProvider?) {
if (cameraProvider == null) {
Log.w(TAG, "Camera provider is null")