Add FCM token support to regV5.

This commit is contained in:
Greyson Parrelli
2025-12-05 13:57:13 -05:00
committed by Michelle Tang
parent da9c5edcc6
commit 4b06e14df6
30 changed files with 764 additions and 102 deletions

View File

@@ -136,6 +136,7 @@ material-material = "com.google.android.material:material:1.12.0"
# Google
google-libphonenumber = "com.googlecode.libphonenumber:libphonenumber:8.13.50"
google-play-services-base = "com.google.android.gms:play-services-base:18.5.0"
google-play-services-maps = "com.google.android.gms:play-services-maps:19.0.0"
google-play-services-auth = "com.google.android.gms:play-services-auth:21.3.0"
google-play-services-wallet = "com.google.android.gms:play-services-wallet:19.4.0"

View File

@@ -9226,6 +9226,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7e70f20ca8710a783bfbdb268b35603105089e55134313c977e5ba6a0fae4938" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-base" version="18.0.1">
<artifact name="play-services-base-18.0.1.aar">
<sha256 value="2896d76f432be52167295bb9ce45ade25c310aeffc04d28cf8db6a15868e83de" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-base" version="18.5.0">
<artifact name="play-services-base-18.5.0.aar">
<md5 value="2575eef1b04c8d00ca47f5e802897309" origin="Generated by Gradle"/>
@@ -9233,6 +9238,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="59a5c0c2da12311d75d965ce1f419498536b1a167fb28ff7dfc2dfd9cefa4157" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-basement" version="18.0.0">
<artifact name="play-services-basement-18.0.0.aar">
<sha256 value="55c1777467901a2d399f3252384c4976284aa35fddfd5995466dbeacb49f9dd6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-basement" version="18.3.0">
<artifact name="play-services-basement-18.3.0.aar">
<sha256 value="6c11ae3eb2dd7f17373f919c4c557a70e4cf891bc0c9b66926a0a6445d654352" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-basement" version="18.4.0">
<artifact name="play-services-basement-18.4.0.aar">
<md5 value="b0ad2fdb58e14246d8c5bfa85da212ba" origin="Generated by Gradle"/>
@@ -9310,6 +9325,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="dd4314a53f49a378ec146103d36232b96c75454d29526336ccbdf132941764d3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-tasks" version="18.0.1">
<artifact name="play-services-tasks-18.0.1.aar">
<sha256 value="f106db48c6ccfa8e1315a7adc44aecd02ff7355eb3fa7dcdcba8c283a8eb1681" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-tasks" version="18.1.0">
<artifact name="play-services-tasks-18.1.0.aar">
<sha256 value="d60575eae39350e6234858bc9d7d775375707ae82a684e6caf7f3e41a12e25a2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-tasks" version="18.2.0">
<artifact name="play-services-tasks-18.2.0.aar">
<md5 value="a4f08be53b9f2c1e68be610c6d03380d" origin="Generated by Gradle"/>
@@ -9622,6 +9647,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="ec6f39f068b6ff9ac323c68e28b9299f8c0a80ca512dccb1d4a70f40ac3ec054" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotations" version="2.26.0">
<artifact name="error_prone_annotations-2.26.0.jar">
<sha256 value="53cdfc0beb2d766fe03b78f0b11d020554cd419879d20380c38fa1dcf2ba1b50" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotations" version="2.27.0">
<artifact name="error_prone_annotations-2.27.0.jar">
<md5 value="fb183180666ce3d0a24ef7ba02d7193c" origin="Generated by Gradle"/>

View File

@@ -1,14 +1,20 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("signal-sample-app")
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
}
val keystoreProperties: Properties? = loadKeystoreProperties("keystore.debug.properties")
android {
namespace = "org.signal.registration.sample"
defaultConfig {
applicationId = "org.signal.registration.sample"
// IMPORTANT: We use the same package name as the signal staging app so that FCM works.
applicationId = "org.thoughtcrime.securesms.staging"
versionCode = 1
versionName = "1.0"
@@ -20,7 +26,21 @@ android {
compose = true
}
keystoreProperties?.let { properties ->
signingConfigs.getByName("debug").apply {
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
storePassword = properties.getProperty("storePassword")
keyAlias = properties.getProperty("keyAlias")
keyPassword = properties.getProperty("keyPassword")
}
}
buildTypes {
getByName("debug") {
if (keystoreProperties != null) {
signingConfig = signingConfigs["debug"]
}
}
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@@ -69,4 +89,25 @@ dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling.core)
// Firebase & Play Services
implementation(libs.firebase.messaging) {
exclude(group = "com.google.firebase", module = "firebase-core")
exclude(group = "com.google.firebase", module = "firebase-analytics")
exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
}
implementation(libs.google.play.services.base)
implementation(libs.kotlinx.coroutines.play.services)
}
fun loadKeystoreProperties(filename: String): Properties? {
val keystorePropertiesFile = file("${project.rootDir}/$filename")
return if (keystorePropertiesFile.exists()) {
val properties = Properties()
properties.load(FileInputStream(keystorePropertiesFile))
properties
} else {
null
}
}

View File

@@ -1,13 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".RegistrationApplication"
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:icon="@mipmap/ic_launcher"
android:label="Registration Sample"
android:theme="@android:style/Theme.Material.NoActionBar"
android:supportsRtl="true">
<!-- Disable Firebase Analytics -->
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
<activity
android:name=".MainActivity"
android:exported="true">
@@ -16,6 +23,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FCM Service for receiving push challenges -->
<service
android:name=".fcm.FcmReceiveService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -35,7 +35,7 @@ class RegistrationApplication : Application() {
Log.initialize(AndroidLogger)
val pushServiceSocket = createPushServiceSocket()
val networkController = RealNetworkController(pushServiceSocket)
val networkController = RealNetworkController(this, pushServiceSocket)
val storageController = RealStorageController(this)
RegistrationDependencies.provide(

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Response
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError
@@ -24,6 +25,8 @@ import org.signal.registration.NetworkController.SubmitVerificationCodeError
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport
import org.signal.registration.sample.fcm.FcmUtil
import org.signal.registration.sample.fcm.PushChallengeReceiver
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.io.IOException
import java.util.Locale
@@ -33,9 +36,14 @@ import org.whispersystems.signalservice.api.account.AccountAttributes as Service
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
class RealNetworkController(
private val context: android.content.Context,
private val pushServiceSocket: PushServiceSocket
) : NetworkController {
companion object {
private val TAG = Log.tag(RealNetworkController::class)
}
private val json = Json { ignoreUnknownKeys = true }
override suspend fun createSession(
@@ -294,7 +302,21 @@ class RealNetworkController(
}
override suspend fun getFcmToken(): String? {
return null
return try {
FcmUtil.getToken(context)
} catch (e: Exception) {
Log.w(TAG, "Failed to get FCM token", e)
null
}
}
override suspend fun awaitPushChallengeToken(): String? {
return try {
PushChallengeReceiver.awaitChallenge()
} catch (e: Exception) {
Log.w(TAG, "Failed to await push challenge token", e)
null
}
}
override fun getCaptchaUrl(): String {

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.fcm
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.signal.core.util.logging.Log
/**
* Firebase Cloud Messaging service for receiving push notifications.
* During registration, this is used to receive push challenge tokens from the server.
*/
class FcmReceiveService : FirebaseMessagingService() {
companion object {
private val TAG = Log.tag(FcmReceiveService::class)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d(TAG, "onMessageReceived: ${remoteMessage.messageId}")
val challenge = remoteMessage.data["challenge"]
if (challenge != null) {
Log.d(TAG, "Received push challenge")
PushChallengeReceiver.onChallengeReceived(challenge)
}
}
override fun onNewToken(token: String) {
Log.d(TAG, "onNewToken")
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.fcm
import android.content.Context
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
import org.signal.core.util.logging.Log
/**
* Utility functions for Firebase Cloud Messaging.
*/
object FcmUtil {
private val TAG = Log.tag(FcmUtil::class)
/**
* Retrieves the FCM registration token if available.
* Returns null if FCM is not available on this device.
*
* @param context Application context needed to initialize Firebase
*/
suspend fun getToken(context: Context): String? {
return try {
FirebaseApp.initializeApp(context)
val token = FirebaseMessaging.getInstance().token.await()
Log.d(TAG, "FCM token retrieved successfully")
token
} catch (e: Exception) {
Log.w(TAG, "Failed to get FCM token", e)
null
}
}
/**
* Checks if Google Play Services is available on this device.
*/
fun isPlayServicesAvailable(context: Context): Boolean {
val availability = GoogleApiAvailability.getInstance()
val resultCode = availability.isGooglePlayServicesAvailable(context)
return resultCode == ConnectionResult.SUCCESS
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.sample.fcm
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import org.signal.core.util.logging.Log
/**
* Singleton that receives push challenge tokens from FCM and makes them
* available to the registration flow.
*/
object PushChallengeReceiver {
private val TAG = Log.tag(PushChallengeReceiver::class)
private val challengeFlow = MutableSharedFlow<String>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
/**
* Called by FcmReceiveService when a push challenge is received.
*/
fun onChallengeReceived(challenge: String) {
Log.d(TAG, "Push challenge received")
challengeFlow.tryEmit(challenge)
}
/**
* Suspends until a push challenge token is received.
* The caller should wrap this in withTimeoutOrNull to handle timeout.
*/
suspend fun awaitChallenge(): String {
Log.d(TAG, "Waiting for push challenge...")
return challengeFlow.first()
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="258"
android:viewportHeight="190.08008">
<group android:scaleX="0.46"
android:scaleY="0.33890247"
android:translateX="69.66"
android:translateY="62.830734">
<group android:translateY="146.88005">
<path android:pathData="M13.390625,-0L13.390625,-105.125L45.796875,-105.125Q55.15625,-105.125,62.203125,-101.296875Q69.265625,-97.484375,73.15625,-90.71875Q77.046875,-83.953125,77.046875,-74.875Q77.046875,-64.21875,71.5625,-56.578125Q66.09375,-48.953125,57.03125,-45.9375L78.484375,-0L64.21875,-0L43.625,-44.640625L26.203125,-44.640625L26.203125,-0L13.390625,-0ZM26.203125,-56.296875L45.796875,-56.296875Q53.859375,-56.296875,58.75,-61.40625Q63.640625,-66.53125,63.640625,-74.875Q63.640625,-83.375,58.75,-88.40625Q53.859375,-93.453125,45.796875,-93.453125L26.203125,-93.453125L26.203125,-56.296875Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M100.40625,0L100.40625,-105.125L160.875,-105.125L160.875,-93.3125L113.21875,-93.3125L113.21875,-60.765625L155.84375,-60.765625L155.84375,-49.25L113.21875,-49.25L113.21875,-11.8125L160.875,-11.8125L160.875,0L100.40625,0Z"
android:fillColor="#FFFFFF"/>
<path android:pathData="M215.78125,1.4375Q201.375,1.4375,193.01562,-6.625Q184.67188,-14.6875,184.67188,-28.796875L184.67188,-76.3125Q184.67188,-90.4375,193.01562,-98.5Q201.375,-106.5625,215.78125,-106.5625Q229.89062,-106.5625,238.23438,-98.421875Q246.59375,-90.28125,246.59375,-76.3125L233.625,-76.3125Q233.625,-85.25,228.9375,-90.0625Q224.26562,-94.890625,215.78125,-94.890625Q207.28125,-94.890625,202.45312,-90.140625Q197.625,-85.390625,197.625,-76.46875L197.625,-28.796875Q197.625,-19.875,202.45312,-14.96875Q207.28125,-10.078125,215.78125,-10.078125Q224.26562,-10.078125,228.9375,-14.96875Q233.625,-19.875,233.625,-28.796875L233.625,-43.203125L212.3125,-43.203125L212.3125,-55.015625L246.59375,-55.015625L246.59375,-28.796875Q246.59375,-14.828125,238.23438,-6.6875Q229.89062,1.4375,215.78125,1.4375Z"
android:fillColor="#FFFFFF"/>
</group>
</group>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="google_app_id" translatable="false">1:312334754206:android:a9297b152879f266</string>
<string name="gcm_defaultSenderId" translatable="false">312334754206</string>
<string name="default_web_client_id" translatable="false">312334754206-dg1p1mtekis8ivja3ica50vonmrlunh4.apps.googleusercontent.com</string>
<string name="firebase_database_url" translatable="false">https://api-project-312334754206.firebaseio.com</string>
<string name="google_api_key" translatable="false">AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU</string>
<string name="project_id" translatable="false">api-project-312334754206</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#000000</color>
</resources>

View File

@@ -87,6 +87,15 @@ interface NetworkController {
*/
suspend fun getFcmToken(): String?
/**
* Waits for a push challenge token to arrive via FCM.
* This is a suspending function that will complete when the token arrives.
* The caller should wrap this in withTimeoutOrNull to handle timeout scenarios.
*
* @return The push challenge token, or null if cancelled/unavailable.
*/
suspend fun awaitPushChallengeToken(): String?
/**
* Returns the URL to load in the WebView for captcha verification.
*/

View File

@@ -56,6 +56,21 @@ class RegistrationRepository(val networkController: NetworkController, val stora
)
}
suspend fun awaitPushChallengeToken(): String? = withContext(Dispatchers.IO) {
networkController.awaitPushChallengeToken()
}
suspend fun submitPushChallengeToken(
sessionId: String,
pushChallengeToken: String
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
networkController.updateSession(
sessionId = sessionId,
pushChallengeToken = pushChallengeToken,
captchaToken = null
)
}
suspend fun submitVerificationCode(
sessionId: String,
verificationCode: String

View File

@@ -6,6 +6,7 @@
package org.signal.registration.screens.phonenumber
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -38,6 +39,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
import org.signal.registration.test.TestTags
@@ -52,6 +54,29 @@ fun PhoneNumberScreen(
onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
LaunchedEffect(state.oneTimeEvent) {
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.NetworkError -> TODO()
is OneTimeEvent.RateLimited -> TODO()
OneTimeEvent.UnknownError -> TODO()
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> TODO()
OneTimeEvent.ThirdPartyError -> TODO()
null -> Unit
}
}
Box(modifier = modifier.fillMaxSize()) {
ScreenContent(state, onEvent)
if (state.showFullScreenSpinner) {
Dialogs.IndeterminateProgressDialog()
}
}
}
@Composable
private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEntryScreenEvents) -> Unit) {
// TODO: These should come from state once country picker is implemented
var selectedCountry by remember { mutableStateOf("United States") }
var selectedCountryEmoji by remember { mutableStateOf("🇺🇸") }
@@ -88,20 +113,8 @@ fun PhoneNumberScreen(
}
}
LaunchedEffect(state.oneTimeEvent) {
onEvent(PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent)
when (state.oneTimeEvent) {
OneTimeEvent.NetworkError -> TODO()
is OneTimeEvent.RateLimited -> TODO()
OneTimeEvent.UnknownError -> TODO()
OneTimeEvent.CouldNotRequestCodeWithSelectedTransport -> TODO()
OneTimeEvent.ThirdPartyError -> TODO()
null -> Unit
}
}
Column(
modifier = modifier
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.Start
@@ -241,3 +254,14 @@ private fun PhoneNumberScreenPreview() {
)
}
}
@DayNightPreviews
@Composable
private fun PhoneNumberScreenSpinnerPreview() {
Previews.Preview {
PhoneNumberScreen(
state = PhoneNumberEntryState(showFullScreenSpinner = true),
onEvent = {}
)
}
}

View File

@@ -14,6 +14,7 @@ data class PhoneNumberEntryState(
val nationalNumber: String = "",
val formattedNumber: String = "",
val sessionMetadata: SessionMetadata? = null,
val showFullScreenSpinner: Boolean = false,
val oneTimeEvent: OneTimeEvent? = null
) {
sealed interface OneTimeEvent {

View File

@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
@@ -34,6 +35,7 @@ class PhoneNumberEntryViewModel(
companion object {
private val TAG = Log.tag(PhoneNumberEntryViewModel::class)
private const val PUSH_CHALLENGE_TIMEOUT_MS = 5000L
}
private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance()
@@ -46,18 +48,35 @@ class PhoneNumberEntryViewModel(
fun onEvent(event: PhoneNumberEntryScreenEvents) {
viewModelScope.launch {
_state.emit(applyEvent(_state.value, event))
val stateEMitter: (PhoneNumberEntryState) -> Unit = { state ->
_state.value = state
}
applyEvent(_state.value, event, stateEMitter, parentEventEmitter)
}
}
suspend fun applyEvent(state: PhoneNumberEntryState, event: PhoneNumberEntryScreenEvents): PhoneNumberEntryState {
return when (event) {
is PhoneNumberEntryScreenEvents.CountryCodeChanged -> transformCountryCodeChanged(state, event.value)
is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> transformPhoneNumberChanged(state, event.value)
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> transformPhoneNumberSubmitted(state)
is PhoneNumberEntryScreenEvents.CountryPicker -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }
is PhoneNumberEntryScreenEvents.CaptchaCompleted -> transformCaptchaCompleted(state, event.token)
is PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent -> state.copy(oneTimeEvent = null)
suspend fun applyEvent(state: PhoneNumberEntryState, event: PhoneNumberEntryScreenEvents, stateEmitter: (PhoneNumberEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) {
when (event) {
is PhoneNumberEntryScreenEvents.CountryCodeChanged -> {
stateEmitter(applyCountryCodeChanged(state, event.value))
}
is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> {
stateEmitter(applyPhoneNumberChanged(state, event.value))
}
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> {
stateEmitter(state.copy(showFullScreenSpinner = true))
val resultState = applyPhoneNumberSubmitted(state, parentEventEmitter)
stateEmitter(resultState.copy(showFullScreenSpinner = false))
}
is PhoneNumberEntryScreenEvents.CountryPicker -> {
state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) }
}
is PhoneNumberEntryScreenEvents.CaptchaCompleted -> {
stateEmitter(applyCaptchaCompleted(state, event.token, parentEventEmitter))
}
is PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent -> {
stateEmitter(state.copy(oneTimeEvent = null))
}
}
}
@@ -65,7 +84,7 @@ class PhoneNumberEntryViewModel(
return state.copy(sessionMetadata = parentState.sessionMetadata)
}
private fun transformCountryCodeChanged(state: PhoneNumberEntryState, countryCode: String): PhoneNumberEntryState {
private fun applyCountryCodeChanged(state: PhoneNumberEntryState, countryCode: String): PhoneNumberEntryState {
// Only allow digits, max 3 characters
val sanitized = countryCode.filter { it.isDigit() }.take(3)
if (sanitized == state.countryCode) return state
@@ -84,7 +103,7 @@ class PhoneNumberEntryViewModel(
)
}
private fun transformPhoneNumberChanged(state: PhoneNumberEntryState, input: String): PhoneNumberEntryState {
private fun applyPhoneNumberChanged(state: PhoneNumberEntryState, input: String): PhoneNumberEntryState {
// Extract only digits from the input
val digitsOnly = input.filter { it.isDigit() }
if (digitsOnly == state.nationalNumber) return state
@@ -107,8 +126,9 @@ class PhoneNumberEntryViewModel(
return result
}
private suspend fun transformPhoneNumberSubmitted(
inputState: PhoneNumberEntryState
private suspend fun applyPhoneNumberSubmitted(
inputState: PhoneNumberEntryState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit
): PhoneNumberEntryState {
val e164 = "+${inputState.countryCode}${inputState.nationalNumber}"
var state = inputState.copy()
@@ -140,6 +160,36 @@ class PhoneNumberEntryViewModel(
state = state.copy(sessionMetadata = sessionMetadata)
if (sessionMetadata.requestedInformation.contains("pushChallenge")) {
Log.d(TAG, "Push challenge requested, waiting for token...")
val pushChallengeToken = withTimeoutOrNull(PUSH_CHALLENGE_TIMEOUT_MS) {
repository.awaitPushChallengeToken()
}
if (pushChallengeToken != null) {
Log.d(TAG, "Received push challenge token, submitting...")
val updateResult = repository.submitPushChallengeToken(sessionMetadata.id, pushChallengeToken)
sessionMetadata = when (updateResult) {
is NetworkController.RegistrationNetworkResult.Success -> updateResult.data
is NetworkController.RegistrationNetworkResult.Failure -> {
Log.w(TAG, "Failed to submit push challenge token: ${updateResult.error}")
sessionMetadata
}
is NetworkController.RegistrationNetworkResult.NetworkError -> {
Log.w(TAG, "Network error submitting push challenge token", updateResult.exception)
sessionMetadata
}
is NetworkController.RegistrationNetworkResult.ApplicationError -> {
Log.w(TAG, "Application error submitting push challenge token", updateResult.exception)
sessionMetadata
}
}
state = state.copy(sessionMetadata = sessionMetadata)
} else {
Log.d(TAG, "Push challenge token not received within timeout")
}
}
if (sessionMetadata.requestedInformation.contains("captcha")) {
parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata))
return state
@@ -203,7 +253,7 @@ class PhoneNumberEntryViewModel(
return state
}
private suspend fun transformCaptchaCompleted(inputState: PhoneNumberEntryState, token: String): PhoneNumberEntryState {
private suspend fun applyCaptchaCompleted(inputState: PhoneNumberEntryState, token: String, parentEventEmitter: (RegistrationFlowEvent) -> Unit): PhoneNumberEntryState {
var state = inputState.copy()
var sessionMetadata = state.sessionMetadata ?: return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)

View File

@@ -8,9 +8,11 @@ package org.signal.registration.screens.phonenumber
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import assertk.assertions.isTrue
import assertk.assertions.prop
import io.mockk.coEvery
import io.mockk.mockk
@@ -30,6 +32,8 @@ class PhoneNumberEntryViewModelTest {
private lateinit var viewModel: PhoneNumberEntryViewModel
private lateinit var mockRepository: RegistrationRepository
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
private lateinit var emittedStates: MutableList<PhoneNumberEntryState>
private lateinit var stateEmitter: (PhoneNumberEntryState) -> Unit
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
@@ -37,6 +41,8 @@ class PhoneNumberEntryViewModelTest {
fun setup() {
mockRepository = mockk(relaxed = true)
parentState = MutableStateFlow(RegistrationFlowState())
emittedStates = mutableListOf()
stateEmitter = { state -> emittedStates.add(state) }
emittedEvents = mutableListOf()
parentEventEmitter = { event -> emittedEvents.add(event) }
viewModel = PhoneNumberEntryViewModel(mockRepository, parentState, parentEventEmitter)
@@ -56,47 +62,58 @@ class PhoneNumberEntryViewModelTest {
fun `PhoneNumberChanged extracts digits and formats number`() = runTest {
val initialState = PhoneNumberEntryState()
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567")
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
stateEmitter,
parentEventEmitter
)
assertThat(result.nationalNumber).isEqualTo("5551234567")
assertThat(result.formattedNumber).isEqualTo("(555) 123-4567")
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
assertThat(emittedStates.last().formattedNumber).isEqualTo("(555) 123-4567")
}
@Test
fun `PhoneNumberChanged with raw digits formats correctly`() = runTest {
val initialState = PhoneNumberEntryState()
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567")
PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567"),
stateEmitter,
parentEventEmitter
)
assertThat(result.nationalNumber).isEqualTo("5551234567")
assertThat(result.formattedNumber).isEqualTo("(555) 123-4567")
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
assertThat(emittedStates.last().formattedNumber).isEqualTo("(555) 123-4567")
}
@Test
fun `PhoneNumberChanged formats progressively as digits are added`() = runTest {
var state = PhoneNumberEntryState()
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"), stateEmitter, parentEventEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("5")
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"), stateEmitter, parentEventEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("55")
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"), stateEmitter, parentEventEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("555")
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"), stateEmitter, parentEventEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("5551")
// libphonenumber formats progressively - at 4 digits it's still building the format
assertThat(state.formattedNumber).isEqualTo("555-1")
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"), stateEmitter, parentEventEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("55512")
assertThat(state.formattedNumber).isEqualTo("555-12")
}
@@ -105,68 +122,83 @@ class PhoneNumberEntryViewModelTest {
fun `PhoneNumberChanged ignores non-digit characters`() = runTest {
val initialState = PhoneNumberEntryState()
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!")
PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!"),
stateEmitter,
parentEventEmitter
)
assertThat(result.nationalNumber).isEqualTo("5551234567")
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
}
@Test
fun `PhoneNumberChanged with same digits returns same state`() = runTest {
fun `PhoneNumberChanged with same digits does not emit new state`() = runTest {
val initialState = PhoneNumberEntryState(nationalNumber = "5551234567", formattedNumber = "(555) 123-4567")
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567")
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
stateEmitter,
parentEventEmitter
)
// Should return the same state object since digits haven't changed
assertThat(result).isEqualTo(initialState)
// Should emit the same state since digits haven't changed
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last()).isEqualTo(initialState)
}
@Test
fun `CountryCodeChanged updates country code and region`() = runTest {
val initialState = PhoneNumberEntryState()
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.CountryCodeChanged("44")
PhoneNumberEntryScreenEvents.CountryCodeChanged("44"),
stateEmitter,
parentEventEmitter
)
assertThat(result.countryCode).isEqualTo("44")
assertThat(result.regionCode).isEqualTo("GB")
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().countryCode).isEqualTo("44")
assertThat(emittedStates.last().regionCode).isEqualTo("GB")
}
@Test
fun `CountryCodeChanged sanitizes input to digits only`() = runTest {
val initialState = PhoneNumberEntryState()
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.CountryCodeChanged("+44abc")
PhoneNumberEntryScreenEvents.CountryCodeChanged("+44abc"),
stateEmitter,
parentEventEmitter
)
assertThat(result.countryCode).isEqualTo("44")
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().countryCode).isEqualTo("44")
}
@Test
fun `CountryCodeChanged limits to 3 digits`() = runTest {
val initialState = PhoneNumberEntryState()
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.CountryCodeChanged("12345")
PhoneNumberEntryScreenEvents.CountryCodeChanged("12345"),
stateEmitter,
parentEventEmitter
)
assertThat(result.countryCode).isEqualTo("123")
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().countryCode).isEqualTo("123")
}
@Test
fun `CountryCodeChanged reformats existing phone number for new region`() = runTest {
// Start with a US number
var state = PhoneNumberEntryState(
val state = PhoneNumberEntryState(
regionCode = "US",
countryCode = "1",
nationalNumber = "5551234567",
@@ -174,12 +206,13 @@ class PhoneNumberEntryViewModelTest {
)
// Change to UK
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"), stateEmitter, parentEventEmitter)
assertThat(state.countryCode).isEqualTo("44")
assertThat(state.regionCode).isEqualTo("GB")
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().countryCode).isEqualTo("44")
assertThat(emittedStates.last().regionCode).isEqualTo("GB")
// The digits should be reformatted for UK format
assertThat(state.nationalNumber).isEqualTo("5551234567")
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
}
@Test
@@ -188,7 +221,9 @@ class PhoneNumberEntryViewModelTest {
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.CountryPicker
PhoneNumberEntryScreenEvents.CountryPicker,
stateEmitter,
parentEventEmitter
)
assertThat(emittedEvents).hasSize(1)
@@ -203,12 +238,15 @@ class PhoneNumberEntryViewModelTest {
oneTimeEvent = PhoneNumberEntryState.OneTimeEvent.NetworkError
)
val result = viewModel.applyEvent(
viewModel.applyEvent(
initialState,
PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent
PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent,
stateEmitter,
parentEventEmitter
)
assertThat(result.oneTimeEvent).isNull()
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isNull()
}
@Test
@@ -216,11 +254,13 @@ class PhoneNumberEntryViewModelTest {
var state = PhoneNumberEntryState()
// Set German country code
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49"), stateEmitter, parentEventEmitter)
state = emittedStates.last()
assertThat(state.regionCode).isEqualTo("DE")
// Enter a German number
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"))
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"), stateEmitter, parentEventEmitter)
state = emittedStates.last()
assertThat(state.nationalNumber).isEqualTo("15123456789")
}
@@ -240,9 +280,13 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.sessionMetadata).isNotNull()
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().sessionMetadata).isNotNull()
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
@@ -262,7 +306,11 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
@@ -283,9 +331,13 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isNotNull()
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(60.seconds)
@@ -303,9 +355,13 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@Test
@@ -318,9 +374,13 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
}
@Test
@@ -333,9 +393,13 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@Test
@@ -350,7 +414,11 @@ class PhoneNumberEntryViewModelTest {
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(existingSession)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
// Should not create a new session, just request verification code
assertThat(emittedEvents).hasSize(1)
@@ -376,9 +444,13 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
}
@Test
@@ -397,7 +469,11 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
@@ -419,9 +495,13 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
}
@Test
@@ -442,9 +522,207 @@ class PhoneNumberEntryViewModelTest {
nationalNumber = "5551234567"
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
}
// ==================== Push Challenge Tests ====================
@Test
fun `PhoneNumberSubmitted with push challenge submits token when received`() = runTest {
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
val sessionAfterPushChallenge = createSessionMetadata(requestedInformation = emptyList())
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
// Verify navigation to verification code entry
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
// Verify push challenge token was submitted
io.mockk.coVerify { mockRepository.submitPushChallengeToken(sessionWithPushChallenge.id, "test-push-challenge-token") }
}
@Test
fun `PhoneNumberSubmitted with push challenge continues when token times out`() = runTest {
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
coEvery { mockRepository.awaitPushChallengeToken() } returns null
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
// Verify navigation continues despite no push challenge token
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
// Verify submit was never called since token was null
io.mockk.coVerify(exactly = 0) { mockRepository.submitPushChallengeToken(any(), any()) }
}
@Test
fun `PhoneNumberSubmitted with push challenge continues when submission fails`() = runTest {
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Failure(
NetworkController.UpdateSessionError.RejectedUpdate("Invalid token")
)
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
// Verify navigation continues despite push challenge submission failure
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with push challenge continues when submission has network error`() = runTest {
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost"))
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
// Verify navigation continues despite network error
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with push challenge continues when submission has application error`() = runTest {
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected error"))
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
// Verify navigation continues despite application error
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
}
@Test
fun `PhoneNumberSubmitted with push challenge navigates to captcha if still required after submission`() = runTest {
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge", "captcha"))
val sessionAfterPushChallenge = createSessionMetadata(requestedInformation = listOf("captcha"))
coEvery { mockRepository.createSession(any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
val initialState = PhoneNumberEntryState(
countryCode = "1",
nationalNumber = "5551234567"
)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
// Verify spinner states
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
// Verify navigation to captcha
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.Captcha>()
}
// ==================== CaptchaCompleted Tests ====================
@@ -459,7 +737,7 @@ class PhoneNumberEntryViewModelTest {
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
@@ -472,9 +750,10 @@ class PhoneNumberEntryViewModelTest {
fun `CaptchaCompleted returns error when no session exists`() = runTest {
val initialState = PhoneNumberEntryState(sessionMetadata = null)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@Test
@@ -485,7 +764,7 @@ class PhoneNumberEntryViewModelTest {
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
NetworkController.RegistrationNetworkResult.Success(sessionWithCaptcha)
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
@@ -504,9 +783,10 @@ class PhoneNumberEntryViewModelTest {
NetworkController.UpdateSessionError.RateLimited(45.seconds, sessionMetadata)
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isNotNull()
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
.isEqualTo(45.seconds)
@@ -522,9 +802,10 @@ class PhoneNumberEntryViewModelTest {
NetworkController.UpdateSessionError.RejectedUpdate("Invalid captcha")
)
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
}
@Test
@@ -535,9 +816,10 @@ class PhoneNumberEntryViewModelTest {
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost"))
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
assertThat(emittedStates).hasSize(1)
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
}
// ==================== Helper Functions ====================