diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index fbb8a82c22..c7d02e093d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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"
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index b778118ed9..f77453f334 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -9226,6 +9226,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
@@ -9233,6 +9238,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
+
+
@@ -9310,6 +9325,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
+
+
@@ -9622,6 +9647,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
diff --git a/registration/app/build.gradle.kts b/registration/app/build.gradle.kts
index ae6ce5ac8e..dfb4223fa8 100644
--- a/registration/app/build.gradle.kts
+++ b/registration/app/build.gradle.kts
@@ -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
+ }
}
diff --git a/registration/app/src/main/AndroidManifest.xml b/registration/app/src/main/AndroidManifest.xml
index c19ba9ca4d..341fa50078 100644
--- a/registration/app/src/main/AndroidManifest.xml
+++ b/registration/app/src/main/AndroidManifest.xml
@@ -1,13 +1,20 @@
+
+
+
+
+
+
+
@@ -16,6 +23,15 @@
+
+
+
+
+
+
+
diff --git a/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt b/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt
index cd01c2a114..44bd88b6c6 100644
--- a/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt
+++ b/registration/app/src/main/java/org/signal/registration/sample/RegistrationApplication.kt
@@ -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(
diff --git a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt
index 25600f221d..54a2b1339f 100644
--- a/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt
+++ b/registration/app/src/main/java/org/signal/registration/sample/dependencies/RealNetworkController.kt
@@ -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 {
diff --git a/registration/app/src/main/java/org/signal/registration/sample/fcm/FcmReceiveService.kt b/registration/app/src/main/java/org/signal/registration/sample/fcm/FcmReceiveService.kt
new file mode 100644
index 0000000000..9b71577864
--- /dev/null
+++ b/registration/app/src/main/java/org/signal/registration/sample/fcm/FcmReceiveService.kt
@@ -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")
+ }
+}
diff --git a/registration/app/src/main/java/org/signal/registration/sample/fcm/FcmUtil.kt b/registration/app/src/main/java/org/signal/registration/sample/fcm/FcmUtil.kt
new file mode 100644
index 0000000000..0d87801ae6
--- /dev/null
+++ b/registration/app/src/main/java/org/signal/registration/sample/fcm/FcmUtil.kt
@@ -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
+ }
+}
diff --git a/registration/app/src/main/java/org/signal/registration/sample/fcm/PushChallengeReceiver.kt b/registration/app/src/main/java/org/signal/registration/sample/fcm/PushChallengeReceiver.kt
new file mode 100644
index 0000000000..6dbceaebd4
--- /dev/null
+++ b/registration/app/src/main/java/org/signal/registration/sample/fcm/PushChallengeReceiver.kt
@@ -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(
+ 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()
+ }
+}
diff --git a/registration/app/src/main/res/drawable/ic_launcher_foreground.xml b/registration/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..e3fd6c62ce
--- /dev/null
+++ b/registration/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..7353dbd1fd
--- /dev/null
+++ b/registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000000..7353dbd1fd
--- /dev/null
+++ b/registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/registration/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/registration/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000000..753a37dc93
Binary files /dev/null and b/registration/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/registration/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/registration/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..1d327e6009
Binary files /dev/null and b/registration/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/registration/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/registration/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000000..e1c17dc956
Binary files /dev/null and b/registration/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/registration/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/registration/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..baf7e071b8
Binary files /dev/null and b/registration/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/registration/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/registration/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..4e4d58fb10
Binary files /dev/null and b/registration/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/registration/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/registration/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..311ec3422f
Binary files /dev/null and b/registration/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/registration/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/registration/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..1416867f03
Binary files /dev/null and b/registration/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/registration/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/registration/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..8413ec2307
Binary files /dev/null and b/registration/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000000..ca929faaa4
Binary files /dev/null and b/registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000000..006d0e0851
Binary files /dev/null and b/registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/registration/app/src/main/res/values/firebase_messaging.xml b/registration/app/src/main/res/values/firebase_messaging.xml
new file mode 100644
index 0000000000..6cd44588a4
--- /dev/null
+++ b/registration/app/src/main/res/values/firebase_messaging.xml
@@ -0,0 +1,10 @@
+
+
+ 1:312334754206:android:a9297b152879f266
+ 312334754206
+ 312334754206-dg1p1mtekis8ivja3ica50vonmrlunh4.apps.googleusercontent.com
+ https://api-project-312334754206.firebaseio.com
+ AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU
+ AIzaSyDrfzNAPBPzX6key51hqo3p5LZXF5Y-yxU
+ api-project-312334754206
+
\ No newline at end of file
diff --git a/registration/app/src/main/res/values/ic_launcher_background.xml b/registration/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000000..beab31f753
--- /dev/null
+++ b/registration/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #000000
+
\ No newline at end of file
diff --git a/registration/lib/src/main/java/org/signal/registration/NetworkController.kt b/registration/lib/src/main/java/org/signal/registration/NetworkController.kt
index e97c7b855e..84af11d31a 100644
--- a/registration/lib/src/main/java/org/signal/registration/NetworkController.kt
+++ b/registration/lib/src/main/java/org/signal/registration/NetworkController.kt
@@ -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.
*/
diff --git a/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt b/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt
index e598eb6746..4b93a0e63e 100644
--- a/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt
+++ b/registration/lib/src/main/java/org/signal/registration/RegistrationRepository.kt
@@ -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 = withContext(Dispatchers.IO) {
+ networkController.updateSession(
+ sessionId = sessionId,
+ pushChallengeToken = pushChallengeToken,
+ captchaToken = null
+ )
+ }
+
suspend fun submitVerificationCode(
sessionId: String,
verificationCode: String
diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt
index 7f1603a0d2..5ca81d97df 100644
--- a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt
+++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryScreen.kt
@@ -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 = {}
+ )
+ }
+}
diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt
index e222878a02..ead5f1cdb5 100644
--- a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt
+++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryState.kt
@@ -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 {
diff --git a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt
index bf6ee703c0..80750ff8c7 100644
--- a/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt
+++ b/registration/lib/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt
@@ -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)
diff --git a/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt
index 9839bbaab6..0aaa9ead06 100644
--- a/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt
+++ b/registration/lib/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt
@@ -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
+ private lateinit var emittedStates: MutableList
+ private lateinit var stateEmitter: (PhoneNumberEntryState) -> Unit
private lateinit var emittedEvents: MutableList
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()
@@ -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()
.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()
+ // Verify spinner states
+ assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
+ assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
+
+ assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf()
}
@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()
+ .prop(RegistrationFlowEvent.NavigateToScreen::route)
+ .isInstanceOf()
+
+ // 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()
+ .prop(RegistrationFlowEvent.NavigateToScreen::route)
+ .isInstanceOf()
+
+ // 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()
+ .prop(RegistrationFlowEvent.NavigateToScreen::route)
+ .isInstanceOf()
+ }
+
+ @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()
+ .prop(RegistrationFlowEvent.NavigateToScreen::route)
+ .isInstanceOf()
+ }
+
+ @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()
+ .prop(RegistrationFlowEvent.NavigateToScreen::route)
+ .isInstanceOf()
+ }
+
+ @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()
+ .prop(RegistrationFlowEvent.NavigateToScreen::route)
+ .isInstanceOf()
}
// ==================== 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()
.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 ====================