Support voice verification in registration v2.

This commit is contained in:
Nicholas Tinsley
2024-05-16 10:28:44 -04:00
committed by Clark Chen
parent eb114de5c8
commit 503faea3a9
9 changed files with 253 additions and 35 deletions

View File

@@ -8,9 +8,12 @@ package org.thoughtcrime.securesms.registration.v2.data
import android.app.backup.BackupManager
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.google.android.gms.auth.api.phone.SmsRetriever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.signal.core.util.Base64
@@ -62,6 +65,7 @@ import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataHeaders
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import java.io.IOException
@@ -262,6 +266,7 @@ object RegistrationRepository {
suspend fun validateSession(context: Context, sessionId: String, e164: String, password: String): RegistrationSessionCheckResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
Log.d(TAG, "Validating registration session with service.")
val registrationSessionResult = api.getRegistrationSessionStatus(sessionId)
return@withContext RegistrationSessionCheckResult.from(registrationSessionResult)
}
@@ -275,12 +280,15 @@ object RegistrationRepository {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val registrationSessionResult = if (fcmToken == null) {
Log.d(TAG, "Creating registration session without FCM token.")
api.createRegistrationSession(null, mcc, mnc)
} else {
Log.d(TAG, "Creating registration session with FCM token.")
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
}
val result = RegistrationSessionCreationResult.from(registrationSessionResult)
if (result is RegistrationSessionCreationResult.Success) {
Log.d(TAG, "Updating registration session and E164 in value store.")
SignalStore.registrationValues().sessionId = result.getMetadata().body.id
SignalStore.registrationValues().sessionE164 = e164
}
@@ -293,9 +301,13 @@ object RegistrationRepository {
*/
suspend fun createOrValidateSession(context: Context, sessionId: String?, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionResult {
if (sessionId != null) {
Log.d(TAG, "Validating existing registration session.")
val sessionValidationResult = validateSession(context, sessionId, e164, password)
when (sessionValidationResult) {
is RegistrationSessionCheckResult.Success -> return sessionValidationResult
is RegistrationSessionCheckResult.Success -> {
Log.d(TAG, "Existing registration session is valid.")
return sessionValidationResult
}
is RegistrationSessionCheckResult.UnknownError -> {
Log.w(TAG, "Encountered error when validating existing session.", sessionValidationResult.getCause())
return sessionValidationResult
@@ -313,19 +325,11 @@ object RegistrationRepository {
/**
* Asks the service to send a verification code through one of our supported channels (SMS, phone call).
*/
suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: Mode = Mode.SMS_WITHOUT_LISTENER): VerificationCodeRequestResult =
suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: Mode): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
// TODO [regv2]: support other verification code [Mode] options
val codeRequestResult = if (mode == Mode.PHONE_CALL) {
// TODO [regv2]
val notImplementedError = NotImplementedError()
Log.w(TAG, "Not yet implemented!", notImplementedError)
NetworkResult.ApplicationError(notImplementedError)
} else {
api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported)
}
val codeRequestResult = api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported, mode.transport)
return@withContext VerificationCodeRequestResult.from(codeRequestResult)
}
@@ -342,7 +346,7 @@ object RegistrationRepository {
/**
* Submits the solved captcha token to the service.
*/
suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String) =
suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val captchaSubmissionResult = api.submitCaptchaToken(sessionId = sessionId, captchaToken = captchaToken)
@@ -408,6 +412,7 @@ object RegistrationRepository {
eventBus.register(subscriber)
try {
Log.d(TAG, "Requesting a registration session with FCM token…")
val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc)
if (sessionCreationResponse !is NetworkResult.Success) {
return@withContext sessionCreationResponse
@@ -450,7 +455,13 @@ object RegistrationRepository {
val usernamePasswords = async { retrieveLocalSvrCredentials() }
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val result = api.getSvrAuthCredential(e164, usernamePasswords.await())
val authTokens = usernamePasswords.await()
if (authTokens.isEmpty()) {
return@withContext BackupAuthCheckResult.SuccessWithoutCredentials()
}
val result = api.getSvrAuthCredential(e164, authTokens)
.runIfSuccessful {
val removedInvalidTokens = SignalStore.svr().removeAuthTokens(it.invalid)
if (removedInvalidTokens) {
@@ -484,8 +495,37 @@ object RegistrationRepository {
.toList()
}
enum class Mode(val isSmsRetrieverSupported: Boolean) {
SMS_WITH_LISTENER(true), SMS_WITHOUT_LISTENER(false), PHONE_CALL(false)
/**
* Starts an SMS listener to auto-enter a verification code.
*
* The listener [lives for 5 minutes](https://developers.google.com/android/reference/com/google/android/gms/auth/api/phone/SmsRetrieverApi).
*
* @return whether or not the Play Services SMS Listener was successfully registered.
*/
suspend fun registerSmsListener(context: Context): Boolean {
Log.d(TAG, "Attempting to start verification code SMS retriever.")
val started = withTimeoutOrNull(5.seconds.inWholeMilliseconds) {
try {
SmsRetriever.getClient(context).startSmsRetriever().await()
Log.d(TAG, "Successfully started verification code SMS retriever.")
return@withTimeoutOrNull true
} catch (ex: Exception) {
Log.w(TAG, "Could not start verification code SMS retriever due to exception.", ex)
return@withTimeoutOrNull false
}
}
if (started == null) {
Log.w(TAG, "Could not start verification code SMS retriever due to timeout.")
}
return started == true
}
enum class Mode(val isSmsRetrieverSupported: Boolean, val transport: PushServiceSocket.VerificationCodeTransport) {
SMS_WITH_LISTENER(true, PushServiceSocket.VerificationCodeTransport.SMS),
SMS_WITHOUT_LISTENER(false, PushServiceSocket.VerificationCodeTransport.SMS),
PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE)
}
private class PushTokenChallengeSubscriber {
@@ -494,6 +534,7 @@ object RegistrationRepository {
@Subscribe
fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) {
Log.d(TAG, "Push challenge received!")
challenge = pushChallengeEvent.challenge
latch.countDown()
}

View File

@@ -28,6 +28,7 @@ data class RegistrationV2State(
val captchaToken: String? = null,
val nextSms: Long = 0L,
val nextCall: Long = 0L,
val smsListenerTimeout: Long = 0L,
val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION,
val networkError: Throwable? = null
)

View File

@@ -55,6 +55,7 @@ import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.internal.push.LockedException
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import java.io.IOException
import kotlin.time.Duration.Companion.minutes
/**
* ViewModel shared across all of registration.
@@ -124,10 +125,12 @@ class RegistrationV2ViewModel : ViewModel() {
}
private suspend fun updateFcmToken(context: Context): String? {
Log.d(TAG, "Fetching FCM token…")
val fcmToken = RegistrationRepository.getFcmToken(context)
store.update {
it.copy(fcmToken = fcmToken)
}
Log.d(TAG, "FCM token fetched.")
return fcmToken
}
@@ -147,8 +150,6 @@ class RegistrationV2ViewModel : ViewModel() {
return
}
// TODO [regv2]: initialize Play Services sms retriever
val mccMncProducer = MccMncProducer(context)
val e164 = state.phoneNumber.toE164()
if (hasRecoveryPassword() && matchesSavedE164(e164)) {
// Re-registration when the local database is intact.
@@ -187,14 +188,81 @@ class RegistrationV2ViewModel : ViewModel() {
return@launch
}
val codeRequestResponse = RegistrationRepository.requestSmsCode(context, validSession.body.id, e164, password)
requestSmsCodeInternal(context, validSession.body.id, e164)
}
}
fun requestSmsCode(context: Context) {
val e164 = getCurrentE164()
if (e164 == null) {
Log.w(TAG, "Phone number was null after confirmation.")
onErrorOccurred()
return
}
viewModelScope.launch {
val validSession = getOrCreateValidSession(context) ?: return@launch
requestSmsCodeInternal(context, validSession.body.id, e164)
}
}
fun requestVerificationCall(context: Context) {
val e164 = getCurrentE164()
if (e164 == null) {
Log.w(TAG, "Phone number was null after confirmation.")
onErrorOccurred()
return
}
viewModelScope.launch {
val validSession = getOrCreateValidSession(context) ?: return@launch
Log.d(TAG, "Requesting voice call code…")
val codeRequestResponse = RegistrationRepository.requestSmsCode(
context = context,
sessionId = validSession.body.id,
e164 = e164,
password = password,
mode = RegistrationRepository.Mode.PHONE_CALL
)
Log.d(TAG, "Voice call code request submitted.")
handleSessionStateResult(context, codeRequestResponse)
}
}
private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String) {
var smsListenerReady = false
Log.d(TAG, "Captcha token submitted.")
if (store.value.smsListenerTimeout < System.currentTimeMillis()) {
smsListenerReady = store.value.isFcmSupported && RegistrationRepository.registerSmsListener(context)
if (smsListenerReady) {
val smsRetrieverTimeout = System.currentTimeMillis() + 5.minutes.inWholeMilliseconds
Log.d(TAG, "Successfully started verification code SMS retriever, which will last until $smsRetrieverTimeout.")
store.update { it.copy(smsListenerTimeout = smsRetrieverTimeout) }
} else {
Log.d(TAG, "Could not start verification code SMS retriever.")
}
}
Log.d(TAG, "Requesting SMS code…")
val codeRequestResponse = RegistrationRepository.requestSmsCode(
context = context,
sessionId = sessionId,
e164 = e164,
password = password,
mode = if (smsListenerReady) RegistrationRepository.Mode.SMS_WITH_LISTENER else RegistrationRepository.Mode.SMS_WITHOUT_LISTENER
)
Log.d(TAG, "SMS code request submitted.")
handleSessionStateResult(context, codeRequestResponse)
}
private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? {
val e164 = getCurrentE164() ?: throw IllegalStateException("E164 required to create session!")
Log.d(TAG, "Validating/creating a registration session.")
val mccMncProducer = MccMncProducer(context)
val existingSessionId = store.value.sessionId
@@ -210,6 +278,7 @@ class RegistrationV2ViewModel : ViewModel() {
)
}
}
Log.d(TAG, "Registration session validated.")
return metadata
}
is RegistrationSessionCreationResult.Success -> {
@@ -222,27 +291,46 @@ class RegistrationV2ViewModel : ViewModel() {
)
}
}
Log.d(TAG, "Registration session created.")
return metadata
}
is RegistrationSessionCheckResult.SessionNotFound -> Log.w(TAG, "This should be impossible to reach at this stage; it should have been handled in RegistrationRepository.", sessionResult.getCause())
is RegistrationSessionCheckResult.UnknownError -> Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
is RegistrationSessionCreationResult.MalformedRequest -> Log.i(TAG, "Malformed request error occurred while creating registration session.", sessionResult.getCause())
is RegistrationSessionCreationResult.RateLimited -> Log.i(TAG, "Rate limit occurred while creating registration session.", sessionResult.getCause())
is RegistrationSessionCreationResult.ServerUnableToParse -> Log.i(TAG, "Server unable to parse request for creating registration session.", sessionResult.getCause())
is RegistrationSessionCreationResult.UnknownError -> Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
is RegistrationSessionCheckResult.SessionNotFound -> {
Log.w(TAG, "This should be impossible to reach at this stage; it should have been handled in RegistrationRepository.", sessionResult.getCause())
handleGenericError(sessionResult.getCause())
}
is RegistrationSessionCheckResult.UnknownError -> {
Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
handleGenericError(sessionResult.getCause())
}
is RegistrationSessionCreationResult.MalformedRequest -> {
Log.i(TAG, "Malformed request error occurred while creating registration session.", sessionResult.getCause())
handleGenericError(sessionResult.getCause())
}
is RegistrationSessionCreationResult.RateLimited -> {
Log.i(TAG, "Rate limit occurred while creating registration session.", sessionResult.getCause())
handleGenericError(sessionResult.getCause())
}
is RegistrationSessionCreationResult.ServerUnableToParse -> {
Log.i(TAG, "Server unable to parse request for creating registration session.", sessionResult.getCause())
handleGenericError(sessionResult.getCause())
}
is RegistrationSessionCreationResult.UnknownError -> {
Log.i(TAG, "Unknown error occurred while checking registration session.", sessionResult.getCause())
handleGenericError(sessionResult.getCause())
}
}
setInProgress(false)
return null
}
fun submitCaptchaToken(context: Context) {
val e164 = getCurrentE164() ?: throw IllegalStateException("TODO")
val sessionId = store.value.sessionId ?: throw IllegalStateException("TODO")
val captchaToken = store.value.captchaToken ?: throw IllegalStateException("TODO")
viewModelScope.launch {
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, sessionId, captchaToken)
val session = getOrCreateValidSession(context) ?: return@launch
Log.d(TAG, "Submitting captcha token…")
val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, session.body.id, captchaToken)
Log.d(TAG, "Captcha token submitted.")
handleSessionStateResult(context, captchaSubmissionResult)
}
}
@@ -258,6 +346,7 @@ class RegistrationV2ViewModel : ViewModel() {
}
is Success -> {
Log.d(TAG, "New registration session status received.")
updateFcmToken(context)
store.update {
it.copy(
@@ -273,10 +362,12 @@ class RegistrationV2ViewModel : ViewModel() {
is AttemptsExhausted -> Log.w(TAG, "TODO")
is ChallengeRequired -> store.update {
// TODO [regv2] handle push challenge required
Log.d(TAG, "[${sessionResult.challenges.joinToString()}] registration challenges received.")
it.copy(
registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED
)
}
is ImpossibleNumber -> Log.w(TAG, "TODO")
is NonNormalizedNumber -> Log.w(TAG, "TODO")
is RateLimited -> Log.w(TAG, "TODO")
@@ -447,6 +538,12 @@ class RegistrationV2ViewModel : ViewModel() {
RegistrationUtil.maybeMarkRegistrationComplete()
}
fun clearNetworkError() {
store.update {
it.copy(networkError = null)
}
}
private fun matchesSavedE164(e164: String?): Boolean {
return if (e164 == null) {
false

View File

@@ -58,6 +58,20 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it)
}
binding.callMeCountDown.apply {
setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in)
setOnClickListener {
sharedViewModel.requestVerificationCall(requireContext())
}
}
binding.resendSmsCountDown.apply {
setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in)
setOnClickListener {
sharedViewModel.requestSmsCode(requireContext())
}
}
binding.keyboard.setOnKeyPressListener { key ->
if (!autopilotCodeEntryActive) {
if (key >= 0) {
@@ -67,6 +81,11 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
}
}
}
sharedViewModel.uiState.observe(viewLifecycleOwner) {
binding.resendSmsCountDown.startCountDownTo(it.nextSms)
binding.callMeCountDown.startCountDownTo(it.nextCall)
}
}
private fun popBackStack() {

View File

@@ -243,12 +243,14 @@ class EnterPhoneNumberV2Fragment : LoggingFragment(R.layout.fragment_registratio
}
private fun presentNetworkError(networkError: Throwable) {
// TODO [regv2]: check specific errors with a when clause
Log.i(TAG, "Unknown error during verification code request", networkError)
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
.setPositiveButton(android.R.string.ok, null)
.show()
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
setPositiveButton(android.R.string.ok) { _, _ -> sharedViewModel.clearNetworkError() }
setOnCancelListener { sharedViewModel.clearNetworkError() }
setOnDismissListener { sharedViewModel.clearNetworkError() }
show()
}
}
private fun onRegistrationButtonClicked() {