Support automatic SMS fill in regV5.

This commit is contained in:
Greyson Parrelli
2026-07-02 12:04:10 -04:00
committed by Alex Hart
parent da8af40e4f
commit 7e26badb4c
12 changed files with 311 additions and 7 deletions
+2 -1
View File
@@ -84,8 +84,9 @@ dependencies {
// Phone number formatting
implementation(libs.google.libphonenumber)
// Phone number hint
// Phone number hint + SMS verification code retriever
implementation(libs.google.play.services.auth)
implementation(libs.kotlinx.coroutines.play.services)
// Credential Manager (password manager retrieval)
implementation(libs.androidx.credentials)
@@ -554,8 +554,10 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
// -- Verification Code Entry Screen
entry<RegistrationRoute.VerificationCodeEntry> {
val context = LocalContext.current.applicationContext
val viewModel: VerificationCodeViewModel = viewModel(
factory = VerificationCodeViewModel.Factory(
context = context,
repository = registrationRepository,
parentState = registrationViewModel.state,
parentEventEmitter = registrationViewModel::onEvent
@@ -8,12 +8,15 @@ package org.signal.registration
import android.app.backup.BackupManager
import android.content.Context
import android.net.Uri
import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.signal.archive.LocalBackupRestoreProgress
@@ -93,6 +96,35 @@ class RegistrationRepository(val context: Context, val networkController: Networ
)
}
/**
* Starts the Play Services SMS retriever so an incoming verification code can be automatically entered.
*
* The listener [lives for 5 minutes](https://developers.google.com/android/reference/com/google/android/gms/auth/api/phone/SmsRetrieverApi).
* Callers should pass the result as `smsAutoRetrieveCodeSupported` when requesting a code so the server formats the
* SMS for retrieval.
*
* @return whether the Play Services SMS retriever was successfully started.
*/
suspend fun registerSmsListener(): 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.")
true
} catch (ex: Exception) {
Log.w(TAG, "Could not start verification code SMS retriever due to exception.", ex)
false
}
}
if (started == null) {
Log.w(TAG, "Could not start verification code SMS retriever due to timeout.")
}
return started == true
}
fun getCaptchaUrl(): String = networkController.getCaptchaUrl()
suspend fun submitCaptchaToken(
@@ -540,7 +540,7 @@ class PhoneNumberEntryViewModel(
val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode(
sessionMetadata.id,
smsAutoRetrieveCodeSupported = false,
smsAutoRetrieveCodeSupported = repository.registerSmsListener(),
transport = NetworkController.VerificationCodeTransport.SMS
)
@@ -650,7 +650,7 @@ class PhoneNumberEntryViewModel(
val verificationCodeResponse = this@PhoneNumberEntryViewModel.repository.requestVerificationCode(
sessionId = sessionMetadata.id,
smsAutoRetrieveCodeSupported = false, // TODO eventually support this
smsAutoRetrieveCodeSupported = repository.registerSmsListener(),
transport = NetworkController.VerificationCodeTransport.SMS
)
@@ -0,0 +1,29 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.verificationcode
import java.util.regex.Pattern
/**
* Extracts a verification code from the body of an SMS delivered by the Play Services SMS retriever.
*/
object VerificationCodeParser {
private val CHALLENGE_PATTERN = Pattern.compile("(.*\\D|^)([0-9]{3,4})-?([0-9]{3,4}).*", Pattern.DOTALL)
fun parse(messageBody: String?): String? {
if (messageBody == null) {
return null
}
val matcher = CHALLENGE_PATTERN.matcher(messageBody)
if (!matcher.matches()) {
return null
}
return matcher.group(matcher.groupCount() - 1) + matcher.group(matcher.groupCount())
}
}
@@ -93,6 +93,18 @@ fun VerificationCodeScreen(
}
}
LaunchedEffect(state.autoFillCode) {
val code = state.autoFillCode ?: return@LaunchedEffect
if (code.length == 6 && code.all { it.isDigit() } && !state.isSubmittingCode) {
code.forEachIndexed { index, digit ->
digits = digits.toMutableList().also { it[index] = digit.toString() }
delay(200)
}
}
onEvent(VerificationCodeScreenEvents.ConsumeAutoFillCode)
}
LaunchedEffect(state.oneTimeEvent) {
val event = state.oneTimeEvent ?: return@LaunchedEffect
@@ -12,6 +12,15 @@ sealed class VerificationCodeScreenEvents {
override fun toString(): String = "CodeEntered(code=${code.censor()})"
}
/**
* A verification code was automatically retrieved from an incoming SMS via the Play Services SMS retriever.
*/
data class CodeAutoFilled(val code: String) : VerificationCodeScreenEvents() {
override fun toString(): String = "CodeAutoFilled(code=${code.censor()})"
}
data object ConsumeAutoFillCode : VerificationCodeScreenEvents()
data object WrongNumber : VerificationCodeScreenEvents()
data object ResendSms : VerificationCodeScreenEvents()
@@ -15,9 +15,10 @@ data class VerificationCodeState(
val isSubmittingCode: Boolean = false,
val rateLimits: SmsAndCallRateLimits = SmsAndCallRateLimits(),
val incorrectCodeAttempts: Int = 0,
val autoFillCode: String? = null,
val oneTimeEvent: OneTimeEvent? = null
) {
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, oneTimeEvent=$oneTimeEvent)"
override fun toString(): String = "VerificationCodeState(sessionMetadata=${sessionMetadata?.let { "present" }}, e164=$e164, isSubmittingCode=$isSubmittingCode, rateLimits=$rateLimits, incorrectCodeAttempts=$incorrectCodeAttempts, autoFillCode=${autoFillCode?.let { "present" }}, oneTimeEvent=$oneTimeEvent)"
sealed interface OneTimeEvent {
data object NetworkError : OneTimeEvent
@@ -5,16 +5,29 @@
package org.signal.registration.screens.verificationcode
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.registration.NetworkController
@@ -34,11 +47,45 @@ class VerificationCodeViewModel(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
smsCodeEvents: Flow<String> = emptyFlow(),
private val clock: () -> Long = { System.currentTimeMillis() }
) : EventDrivenViewModel<VerificationCodeScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(VerificationCodeViewModel::class)
/**
* Cold [Flow] of verification codes automatically retrieved from incoming SMS messages via the Play Services SMS
* retriever. Registers a [BroadcastReceiver] for [SmsRetriever.SMS_RETRIEVED_ACTION] while collected, and
* unregisters it when collection stops.
*/
fun smsCodeFlow(context: Context): Flow<String> = callbackFlow {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != SmsRetriever.SMS_RETRIEVED_ACTION) {
return
}
val status = intent.extras?.get(SmsRetriever.EXTRA_STATUS) as? Status
when (status?.statusCode) {
CommonStatusCodes.SUCCESS -> {
val code = VerificationCodeParser.parse(intent.extras?.getString(SmsRetriever.EXTRA_SMS_MESSAGE))
if (code != null) {
Log.i(TAG, "Received verification code via SMS retriever.")
trySend(code)
} else {
Log.w(TAG, "Could not parse verification code from retrieved SMS.")
}
}
CommonStatusCodes.TIMEOUT -> Log.w(TAG, "Timed out waiting for the verification SMS to arrive.")
else -> Log.w(TAG, "SMS retriever broadcast had an unexpected status code: ${status?.statusCode}")
}
}
}
ContextCompat.registerReceiver(context, receiver, IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION), SmsRetriever.SEND_PERMISSION, null, ContextCompat.RECEIVER_EXPORTED)
awaitClose { context.unregisterReceiver(receiver) }
}
}
private val _localState = MutableStateFlow(VerificationCodeState())
@@ -49,6 +96,14 @@ class VerificationCodeViewModel(
private var nextSmsAvailableAt: Duration = 0.seconds
private var nextCallAvailableAt: Duration = 0.seconds
init {
viewModelScope.launch {
smsCodeEvents.collect { code ->
onEvent(VerificationCodeScreenEvents.CodeAutoFilled(code))
}
}
}
override suspend fun processEvent(event: VerificationCodeScreenEvents) {
applyEvent(state.value, event) { _localState.value = it }
}
@@ -60,6 +115,8 @@ class VerificationCodeViewModel(
stateEmitter(state.copy(isSubmittingCode = true))
applyCodeEntered(state, event.code).copy(isSubmittingCode = false)
}
is VerificationCodeScreenEvents.CodeAutoFilled -> state.copy(autoFillCode = event.code)
is VerificationCodeScreenEvents.ConsumeAutoFillCode -> state.copy(autoFillCode = null)
is VerificationCodeScreenEvents.WrongNumber -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.PhoneNumberEntry) }
is VerificationCodeScreenEvents.ResendSms -> applyResendCode(state, NetworkController.VerificationCodeTransport.SMS)
is VerificationCodeScreenEvents.CallMe -> applyResendCode(state, NetworkController.VerificationCodeTransport.VOICE)
@@ -236,7 +293,7 @@ class VerificationCodeViewModel(
val result = repository.requestVerificationCode(
sessionId = state.sessionMetadata.id,
smsAutoRetrieveCodeSupported = false,
smsAutoRetrieveCodeSupported = repository.registerSmsListener(),
transport = transport
)
@@ -322,13 +379,29 @@ class VerificationCodeViewModel(
)
}
/**
* @param smsCodeEvents The stream of auto-retrieved verification codes. Tests can inject codes directly; production
* should use the [Context]-based constructor, which builds a real SMS retriever flow.
*/
class Factory(
private val repository: RegistrationRepository,
private val parentState: StateFlow<RegistrationFlowState>,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val smsCodeEvents: Flow<String>
) : ViewModelProvider.Factory {
/**
* Builds a real SMS retriever flow from [context]. Prefer the application context.
*/
constructor(
context: Context,
repository: RegistrationRepository,
parentState: StateFlow<RegistrationFlowState>,
parentEventEmitter: (RegistrationFlowEvent) -> Unit
) : this(repository, parentState, parentEventEmitter, smsCodeFlow(context))
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return VerificationCodeViewModel(repository, parentState, parentEventEmitter) as T
return VerificationCodeViewModel(repository, parentState, parentEventEmitter, smsCodeEvents) as T
}
}
}
@@ -0,0 +1,34 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.verificationcode
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import org.junit.Test
class VerificationCodeParserTest {
@Test
fun `parses hyphenated code`() {
assertThat(VerificationCodeParser.parse("<#> Your Signal code: 123-456 abcd1234efg")).isEqualTo("123456")
}
@Test
fun `parses non-hyphenated code`() {
assertThat(VerificationCodeParser.parse("Your Signal code: 123456")).isEqualTo("123456")
}
@Test
fun `returns null for message without a code`() {
assertThat(VerificationCodeParser.parse("Your Signal code is on its way")).isNull()
}
@Test
fun `returns null for null message`() {
assertThat(VerificationCodeParser.parse(null)).isNull()
}
}
@@ -177,6 +177,36 @@ class VerificationCodeScreenTest {
}
}
@Test
fun `autoFillCode populates the fields and emits CodeEntered`() {
// Given
var emittedEvent: VerificationCodeScreenEvents? = null
composeTestRule.setContent {
SignalTheme {
VerificationCodeScreen(
state = VerificationCodeState(autoFillCode = "123456"),
onEvent = { event ->
if (event is VerificationCodeScreenEvents.CodeEntered) {
emittedEvent = event
}
}
)
}
}
// When - the auto-fill effect staggers digits into the fields
composeTestRule.waitUntil(timeoutMillis = 5_000) { emittedEvent != null }
// Then
assert(emittedEvent is VerificationCodeScreenEvents.CodeEntered) {
"Expected CodeEntered event but got $emittedEvent"
}
assert((emittedEvent as VerificationCodeScreenEvents.CodeEntered).code == "123456") {
"Expected code '123456' but got ${(emittedEvent as VerificationCodeScreenEvents.CodeEntered).code}"
}
}
@Test
fun `screen displays all action buttons`() {
// Given
@@ -14,9 +14,19 @@ import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
@@ -29,8 +39,11 @@ import org.signal.registration.RegistrationRepository
import org.signal.registration.RegistrationRoute
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class VerificationCodeViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: VerificationCodeViewModel
private lateinit var mockRepository: RegistrationRepository
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
@@ -41,6 +54,7 @@ class VerificationCodeViewModelTest {
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
// Initialize with valid session data to prevent ResetState emission during ViewModel initialization
parentState = MutableStateFlow(
@@ -56,6 +70,11 @@ class VerificationCodeViewModelTest {
viewModel = VerificationCodeViewModel(mockRepository, parentState, parentEventEmitter)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
// ==================== applyParentState Tests ====================
@Test
@@ -161,6 +180,48 @@ class VerificationCodeViewModelTest {
assertThat(emittedStates.last().oneTimeEvent).isNull()
}
// ==================== applyEvent: SMS Auto-Fill Tests ====================
@Test
fun `CodeAutoFilled stores the code in autoFillCode`() = runTest {
val initialState = VerificationCodeState()
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.CodeAutoFilled("123456"),
stateEmitter
)
assertThat(emittedStates.last().autoFillCode).isEqualTo("123456")
}
@Test
fun `ConsumeAutoFillCode clears autoFillCode`() = runTest {
val initialState = VerificationCodeState(autoFillCode = "123456")
viewModel.applyEvent(
initialState,
VerificationCodeScreenEvents.ConsumeAutoFillCode,
stateEmitter
)
assertThat(emittedStates.last().autoFillCode).isNull()
}
@Test
fun `codes from the SMS retriever flow are pushed into the state`() = runTest(testDispatcher) {
val smsCodes = MutableSharedFlow<String>(extraBufferCapacity = 1)
val vm = VerificationCodeViewModel(mockRepository, parentState, parentEventEmitter, smsCodes)
backgroundScope.launch { vm.state.collect {} }
advanceUntilIdle()
smsCodes.emit("123456")
advanceUntilIdle()
assertThat(vm.state.value.autoFillCode).isEqualTo("123456")
}
// ==================== applyEvent: WrongNumber Tests ====================
@Test
@@ -573,6 +634,26 @@ class VerificationCodeViewModelTest {
assertThat(emittedStates.last().sessionMetadata).isEqualTo(updatedSession)
}
@Test
fun `ResendSms passes registerSmsListener result as smsAutoRetrieveCodeSupported`() = runTest {
val sessionMetadata = createSessionMetadata()
val initialState = VerificationCodeState(sessionMetadata = sessionMetadata)
coEvery { mockRepository.registerSmsListener() } returns true
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
RequestResult.Success(sessionMetadata)
viewModel.applyEvent(initialState, VerificationCodeScreenEvents.ResendSms, stateEmitter)
coVerify {
mockRepository.requestVerificationCode(
sessionId = sessionMetadata.id,
smsAutoRetrieveCodeSupported = true,
transport = NetworkController.VerificationCodeTransport.SMS
)
}
}
@Test
fun `ResendSms with rate limit returns RateLimited event`() = runTest {
val sessionMetadata = createSessionMetadata()