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 ====================