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.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import android.widget.Toast 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.PaddingValues
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon 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.Previews
import org.signal.core.ui.Scaffolds import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview 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.R
import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
@ -43,42 +35,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
*/ */
class AddLinkDeviceFragment : ComposeFragment() { class AddLinkDeviceFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(AddLinkDeviceFragment::class)
}
private val viewModel: LinkDeviceViewModel by activityViewModels() 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) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
@ -103,14 +60,7 @@ class AddLinkDeviceFragment : ComposeFragment() {
onRequestPermissions = { askPermissions() }, onRequestPermissions = { askPermissions() },
onShowFrontCamera = { viewModel.showFrontCamera() }, onShowFrontCamera = { viewModel.showFrontCamera() },
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
onQrCodeApproved = { onQrCodeApproved = { viewModel.addDevice() },
viewModel.onQrCodeApproved()
if (biometricAuth.canAuthenticate()) {
biometricAuth.authenticate(requireContext(), true) { biometricDeviceLockLauncher.launch(getString(R.string.BiometricDeviceAuthentication__signal)) }
} else {
viewModel.addDevice()
}
},
onQrCodeDismissed = { viewModel.onQrCodeDismissed() }, onQrCodeDismissed = { viewModel.onQrCodeDismissed() },
onQrCodeRetry = { viewModel.onQrCodeScanned(state.url) }, onQrCodeRetry = { viewModel.onQrCodeScanned(state.url) },
onLinkDeviceSuccess = { onLinkDeviceSuccess = {
@ -134,23 +84,6 @@ class AddLinkDeviceFragment : ComposeFragment() {
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) 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 @Composable
@ -188,7 +121,6 @@ private fun MainScreen(
onQrCodeAccepted = onQrCodeApproved, onQrCodeAccepted = onQrCodeApproved,
onQrCodeDismissed = onQrCodeDismissed, onQrCodeDismissed = onQrCodeDismissed,
onQrCodeRetry = onQrCodeRetry, onQrCodeRetry = onQrCodeRetry,
pendingBiometrics = state.pendingBiometrics,
linkDeviceResult = state.linkDeviceResult, linkDeviceResult = state.linkDeviceResult,
onLinkDeviceSuccess = onLinkDeviceSuccess, onLinkDeviceSuccess = onLinkDeviceSuccess,
onLinkDeviceFailure = onLinkDeviceFailure, onLinkDeviceFailure = onLinkDeviceFailure,

View file

@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.linkdevice
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.Previews
import org.signal.core.ui.Scaffolds import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview 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.R
import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
@ -72,12 +78,40 @@ private const val PLACEHOLDER = "__ICON_PLACEHOLDER__"
*/ */
class LinkDeviceFragment : ComposeFragment() { class LinkDeviceFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(LinkDeviceFragment::class)
}
private val viewModel: LinkDeviceViewModel by activityViewModels() private val viewModel: LinkDeviceViewModel by activityViewModels()
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
viewModel.initialize(requireContext()) 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 @Composable
@ -110,7 +144,13 @@ class LinkDeviceFragment : ComposeFragment() {
navController = navController, navController = navController,
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
onLearnMore = { navController.safeNavigate(R.id.action_linkDeviceFragment_to_linkDeviceLearnMoreBottomSheet) }, 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) }, setDeviceToRemove = { device -> viewModel.setDeviceToRemove(device) },
onRemoveDevice = { device -> viewModel.removeDevice(requireContext(), device) } onRemoveDevice = { device -> viewModel.removeDevice(requireContext(), device) }
) )
@ -122,6 +162,22 @@ class LinkDeviceFragment : ComposeFragment() {
requireActivity().finishAfterTransition() 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 @Composable

View file

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

View file

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

View file

@ -98,8 +98,7 @@ class LinkDeviceViewModel : ViewModel() {
_state.update { _state.update {
val frontCamera = it.showFrontCamera val frontCamera = it.showFrontCamera
it.copy( it.copy(
showFrontCamera = if (frontCamera == null) true else !frontCamera, showFrontCamera = if (frontCamera == null) true else !frontCamera
pendingBiometrics = false
) )
} }
} }
@ -140,16 +139,6 @@ class LinkDeviceViewModel : ViewModel() {
} }
} }
fun onQrCodeApproved() {
_state.update {
it.copy(
qrCodeFound = false,
qrCodeInvalid = false,
pendingBiometrics = true
)
}
}
fun onQrCodeDismissed() { fun onQrCodeDismissed() {
_state.update { _state.update {
it.copy( it.copy(
@ -159,21 +148,14 @@ class LinkDeviceViewModel : ViewModel() {
} }
} }
fun clearBiometrics() {
_state.update {
it.copy(
pendingBiometrics = false
)
}
}
fun addDevice() { fun addDevice() {
val uri = Uri.parse(_state.value.url) val uri = Uri.parse(_state.value.url)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val result = LinkDeviceRepository.addDevice(uri) val result = LinkDeviceRepository.addDevice(uri)
_state.update { _state.update {
it.copy( it.copy(
pendingBiometrics = false, qrCodeFound = false,
qrCodeInvalid = false,
linkDeviceResult = result, linkDeviceResult = result,
url = "" 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--> <!-- 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> <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 --> <!-- 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 --> <!-- 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> <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 --> <!-- 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> <string name="LinkDeviceFragment__loading">Loading…</string>
<!-- Text message shown when the user has no linked devices --> <!-- Text message shown when the user has no linked devices -->
<string name="LinkDeviceFragment__no_linked_devices">No linked devices</string> <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 --> <!-- AddLinkDeviceFragment -->
<!-- Description text shown on the QR code scanner when linking a device --> <!-- 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 var scannerView: ScannerView? = null
private val qrDataPublish: PublishSubject<String> = PublishSubject.create() private val qrDataPublish: PublishSubject<String> = PublishSubject.create()
private var forceLegacy: Boolean = false
val qrData: Observable<String> = qrDataPublish val qrData: Observable<String> = qrDataPublish
@ -38,14 +37,12 @@ class QrScannerView @JvmOverloads constructor(
addView(scannerView) addView(scannerView)
this.scannerView = (scannerView as ScannerView) this.scannerView = (scannerView as ScannerView)
this.forceLegacy = forceLegacy
} }
@JvmOverloads @JvmOverloads
fun start(lifecycleOwner: LifecycleOwner, forceLegacy: Boolean = false) { fun start(lifecycleOwner: LifecycleOwner, forceLegacy: Boolean = false) {
if (scannerView != null) { if (scannerView != null) {
Log.w(TAG, "Attempt to start scanning that has already started") Log.w(TAG, "Attempt to start scanning that has already started")
scannerView?.resume()
return return
} }
@ -64,14 +61,6 @@ class QrScannerView @JvmOverloads constructor(
scannerView?.toggleCamera() scannerView?.toggleCamera()
} }
// Biometrics require use of camera so we disable when needed
fun destroy() {
scannerView?.destroy()
if (!forceLegacy) {
scannerView = null
}
}
companion object { companion object {
private val TAG = Log.tag(QrScannerView::class.java) private val TAG = Log.tag(QrScannerView::class.java)
} }

View file

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

View file

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