From 4b06e14df62ff4605677961c55aaab251322c0cf Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 5 Dec 2025 13:57:13 -0500 Subject: [PATCH] Add FCM token support to regV5. --- gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 30 ++ registration/app/build.gradle.kts | 43 +- registration/app/src/main/AndroidManifest.xml | 18 +- .../sample/RegistrationApplication.kt | 2 +- .../dependencies/RealNetworkController.kt | 24 +- .../sample/fcm/FcmReceiveService.kt | 35 ++ .../signal/registration/sample/fcm/FcmUtil.kt | 50 +++ .../sample/fcm/PushChallengeReceiver.kt | 42 ++ .../res/drawable/ic_launcher_foreground.xml | 20 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 650 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 1508 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 476 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1002 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 814 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 2126 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1180 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 3244 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 1610 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 4636 bytes .../main/res/values/firebase_messaging.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + .../signal/registration/NetworkController.kt | 9 + .../registration/RegistrationRepository.kt | 15 + .../phonenumber/PhoneNumberEntryScreen.kt | 50 ++- .../phonenumber/PhoneNumberEntryState.kt | 1 + .../phonenumber/PhoneNumberEntryViewModel.kt | 78 +++- .../PhoneNumberEntryViewModelTest.kt | 424 +++++++++++++++--- 30 files changed, 764 insertions(+), 102 deletions(-) create mode 100644 registration/app/src/main/java/org/signal/registration/sample/fcm/FcmReceiveService.kt create mode 100644 registration/app/src/main/java/org/signal/registration/sample/fcm/FcmUtil.kt create mode 100644 registration/app/src/main/java/org/signal/registration/sample/fcm/PushChallengeReceiver.kt create mode 100644 registration/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 registration/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 registration/app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 registration/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 registration/app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 registration/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 registration/app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 registration/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 registration/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 registration/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 registration/app/src/main/res/values/firebase_messaging.xml create mode 100644 registration/app/src/main/res/values/ic_launcher_background.xml 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 0000000000000000000000000000000000000000..753a37dc9392ac5f9ac6d58d41c0447e74f4b5ab GIT binary patch literal 650 zcmV;50(JdTNk&G30ssJ4MM6+kP&iC=0ssInN5Byfh1~7`3`zQQ4|ex#Tg&il+mY{L z8=-C6R%HAB0Nb|5GZj^9tIn51z^%4zdGdbWp9FW8HWj*ol{u8u^sJCJ6%LiVR3S4U z1L!(-O#t!dokU4Xk_Ti$Q?e^EK~ST?KD+EG%`W>is1XF&1({%&XK2YY!vxt8i6D3Z zKU#t}GC^`eiC_)-tPfXYXOuY6highMIMs(6H=OCi?SB|$ zWy+K(Qw@b#6_UxeDuYF3eT>vn0g9NGi;zPOs6*-^!uGNt03~b?mXH$jMt6#=CLFfK z7TaAVyi^l_ZiJh*u$;^fzz!*Qtu)VWAt5cOQXgQbUeK5snH_ z2go9Pmd@2A!UhHaWe9PFs8oiKQh=)|gcSfFpo0$lRLUg0lg=y0`PG2IU@GT0VJ(w% z(m--QrD?I#LJO_*obXX4kgOvbv8ibmIgKK6+FLyaG^?QZ+sDO%tB1q#mXcHt}GDlpaq;$&=DFY4K>*S!9w9!X=4L k#;G#Htn$oIWsEM88-k!rKf{bF&oKRz2~SBH5`R(w0PDXPg8%>k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1d327e6009273744d5a7030afd3b00f75775d89d GIT binary patch literal 1508 zcmV7LYxVfw5Mg$S@%j(F)^07AAmW9Y7J2is*pjKnRk7vk)n!rHIZ2S%?fAgGezk zMRWql0t7JD0i>9kq5~#^aK(X&6-Y5TMJv^SyYm%_m|jHRk{bcsf+7|a(On=M@u1H> zWEB|NXBhBj-bWEjis%z^B7pfQVNnSk3&M^AKOn6_(JzGC@L)QkR7FBGANZ93UP6jx zDS8ux1qTkZ1)1wGv{e^ylK`$jhAzy|jUY@s@fp&cTWGWy=4s&{t#p(8a;jI3^UzTA z4aWlj0j<^}+xbm9Y93hE#9QceROx4URNZ>(+sBi$gB1*I)Bpm&gTqn6eZVfte^vw; zL#Ye|;NJNg|GfW5UXHUu37rT6fB^1Bda{!P3JMC!E&2R^s=KhiWP$7sAocaXgC#Sw zx<2M2FDr^3Ap!u;dGl-+TLXaieBRCzHhDwam)A1^pcfw&X}r(zKnU;85G3+X3w!M1 z7MKNOeT{$R2qBm=eeRhU~ zZsgT8;b8evX2~!|*k#0IxP2&{>}22U?Ccr`e*7B@;rru9=H2Fs*hJIUN<#aLpNzQJ zMfvyQK!;Xx=guP)L%WRzj(~=Z zH?4u%f4|p*#U^iPg^c|CW;WQQ4+sHeivxl-afc%S6xd*yRv?5J^Alw=c;8D$f)Otvz##Y7pIKu)VSk=CK;L#=YKXVouwqKKwi6_ zkyfGTPYrq43d+t#lxj$bE>@Q9KtYGkQLG`NZ*(ZYA+Kz%UC1ghw9Dbj!U-s7_ZZR& z6g{h55srznrteYAC!(JXD3db~)J(7x#cU#4W{R2&Zk5!XXcy946zz7Zx)R*0=;~K6 zq?stPcCBs|5IGc719DA~`k9ewCdlW)ab@cCDsX4q`cN`ui!CA!8XB zL(BT<`n7Qo&1uzZnja8F>6IiR`pI;?S~-X(T67xQp0)!~lwL?8q8;ry<8*2v@RBHL z7(R~m6(Uj=#V8{BdK_oCh7y6tf;K(I^vnIu5E*6Cjl?KJWPa~g8ly*>0Pl&Cx~B2{ zA|Kd*h)5}ATS_S+q76Ri7aOmsu0$XuucD!8%s9L~?0akQ+8W=rhsWW@XlkhNkd{|g z*KLGxV_v_iz35~AT4bHAcG_vHbr$*8$L+;_)tG0z5qi{>c}UI4tEuZUV5G6%q;J}o zH;(DY_v6PgW8OFQn`Eq!26U;b$#amOlT+4eh;|KKdi3hmqf0}(AzGE?I4DavK_;)D KATJX*0*?WUzsHmS literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1c17dc956406f79f5f6b5ccfe8f41c9ab36fb51 GIT binary patch literal 476 zcmV<20VDoWNk&H00RRA3MM6+kP&iD;0RR9mFTe{BHOSqz?f9E3c2b+0ZQHhO+kb$q zHB(2sU2X5S;$-Ud;rQOK9PcLrl5N|jo%7FoKelb76-hPSa}SJGeWMu83DBRCg3`Id zks@M9Dg;T0SvFXA%m%ZB$N-XHgllTFor`Nm2r`IU#F7UpeiQ_AG{6^s&iN8emI(?E zrP!wn{^Z<5j}wB@j{`a};WPx@m~i%guDX^|IcVYrNr%)1jdX)|heXJA)HAQ#HIhZv zUWq|$y5~;Fg86O`t8j*-dOTUKyUFH=u2^sV6ZEKU-fbX6A2S70hL z>m>nJE3n>R`Ch3fvL>#9)YDalk#6wrkO-MJyFur)l^`;=|GDVIgwr67=*EO|KMI01 zTHuR6=X{AaJANV=;t{drm0^pDNU z@w5FlIs>YXp~ zu6g39`D%t`m_(AR&iksyh%WfY;Q9Pdb|15j2R;uy#B1PL_kWn%97ebT^ity@j?NLnB5?`)mfmVDE>qRe5=ROf6da>S z>q5uc!UhH#2wi#JdhsyOk`|tRgz0wD3@!$X3#}}|D(fr-Uof1;k5z;&QVGJ~VFKbo zM9Mg!V|KUG@IDq2I#l8=xRZek4_M>3TeTl$v?wc)WCdnGvH&YlXi1Pvvl7Q}CL6La zoyZT3gv}5OQH+`568<8|cbv^aAV=^Qg>GO5j5v;8$dCNRRV+d|BvufHO`HI~$zk$FMU=W+>Q< z*Ljnld65O^ppit0cU>N5IhN;M-e)mTt1g(2b;wh6@m?i~^m>=Q`HN#fpDqvb0v&Y8 zArbPT%3|Ef{VYM9J^7KHK<4K$Zl^PmW(kCD&eVsU$d4Srn1k7Z|FtF~uqp5G5L>e< z=kpb((V56ahGrt_jz9Ubi5-N1F8iF>IZkcW*ETULsQhq>GS>b4CaW+jP zX)U&kqf0*~U;AYIm`+6GXwuz7%6gW4oO{o`-`(9^aStgb;4+$Dgyh6TFdBD@+})p@36KN;j3G%ip53!;+qP{! zZQuQD+qP}nwr$&Yy`5L5YI-O8J)|q5{}a$(tZ`W2d;&Ho5q28UO-jhg9^RJ(y-5)8 z=-BY!-P<1B>tS?w08AlT%`qy{u?E-#2>6^J|5RFmv&g9+fEkvC!k7{-Wh^J)&1?(N zrpO+98~~3%AnOUytJv|Z24D!&@L7p3{Wpedmg3U@9&7DVd>Mkje|Uqa->nePsve0e zGKu5Fb&+Vk8(lxEb9~A4-KsUQxIK>o^Q|$7Df9vP%ZAulP;G}~JrDT(!X&;g9Iv%L zV(^EGk~ib!B`od(z(>0jZ??vOE2${F%PE*SvfCH9;KHto5F7u3m9Yi0ueV`#p~JpS z@U4PP1K?X3VU6<)_7LJ)V0N@=5!_0L@Tpf@eT0gE+0~}?a5)8*IgfS|>Iz0nn>Hwf z8g41h!~3+@wb)W$%<#Tw-2eao7v9diN?>0PNb4)Pa0-eyGfNaZtHd9R_6X837o`!B z*CV7QR8j+~;}Q5ieK0Tm1yj93c2$IkBwFn_&RZJqataP!f!pb#$>nz(=QJJe8l^i$ z6MBkJ;+t?Sv2APNibTVixKwx`{BB{}40x0kyAOvvWHLX1^Fh11LR1|j%Ujqg$nXsK z(MWPVp4@NA{R(at@CZ(@7mRv@^1X$lF6`%Rd2a!5~0Q{_WlXRXNs;; zd>PJA^IVA+y)hgL+DEi1dYp*In&h@J6h;k~GFG`s5aZD20#T^+0_P%EhB%pG7>Br7 zSExwW7MBx?H6ftz9=GP)n{Quxbg%cZ{+>_}BtRm@ET*9K9(_%|=3cXh-`C`OOiMwG s8Ovy#MOao*RqgmulwB4aw@VWf#BnHwiB*^2pG4Qn%0M6cwdjJ3c literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..311ec3422f4fee0e5701ed67567d90798dd9aa8b GIT binary patch literal 2126 zcmV-U2(kB4Nk&FS2mkrgA!T8ZctSfKDB9FlBj`)MGgU05XPb>UcMM!TY|q(4YSH zw?8fPo%g-qX2;vqkPHA2j;+P90ARYUT~BbC2C4S^oz^X0XT9Q2cYD1zaol)gohoS#2oGRuUm^-3UMUKQzO)Sh zPp)BDP;ob+DT>!pQ;671qBIS?k(go~{CpJfvmNE_;?l`Df zr7SA1`Ec=~95rjZv(KJA+f_f6jD%<>A~RzdS0KgZN`c^-PshaPoPu~CPRr*O>@k;f zNgzcxGVFu_-s^b)E++TLh$z5(a`82Qcg3Z=EeGs}BL@7EaJvR$jMYCH26I6i-dHA> z#v$zw;6j}&DRQ)V0G!ul{A!Z$Gn-(-*`#ETJv|a3d3eo9dWMDjDal^YAAyx+&<=$A zu#97nN?D{o1?IaKcPF9;k)yU8m>?!mbUMQV03nc=kwr;EH=`?!m7pCtvF}C8hjd4j2H2D0t^c7vP+CxG16zlnD#K5*1&a z&~5>%e8#tGGj+3(rs>_oU>4#HE5uLe4HV5aI$;d?CF6Dt#u%%#GyvA_ggn3R{K{gA z=B!Hj)uPPAxZg8GKjkByXf3gto1HuBGXAjCgf(%*T9cIo&adMa5E~5VwXviu%}$K? zCByH^#f#U&5z;FTi8Lo!PM3G@-o5+hhF>i{({Fah*`!1X_5+J`Z-|#Vb?VfUOc%^G zF*DQGDCSW02>7M*2FGDBQX=|RFUtWijIwvq^yk2I#fN;L%CG>0(d4-_Y7&KK**%f! z7W_(ZMw?h+$oRi9!vGLY!A*H7Z^0bwiNq%m+JrL?n5xLQTahCGz{@!iB`Jw|0Urzx z=qq7OQ5+H;f;EPOB?+A?$HPa1Z4hZzs!xdS;Q?wEiI4_1WuS>)S;Z)#Jv7LCI2nss zkf%O8gxBu%Jj(P7R!yF89|tP*2K*ao zNXENn@XQBfm0WXxmI7B|dw`O!`)9Mh&WKG??og72nSz}VrKLnASzeu!l(9m(EiV&K z!y%(^?QVlQRn4oAq=aaH1r0M+qha46UHUIl^rJ;Jax;+C;Vu-q5zxabvhY(ziyaZA zUL-{OYFS|A)P0CzFCzNXkR105_W+7Li0CnOWe&E9oT?>!g+d1c`o=P<3SNb6A)?4_o} zLME?6Z=8!rt5L+bSg#HqQi(=YgYHHu)siTBz@Vxe$pJ=_8OA(|qEtx|(Tm2-(8NGC ztE6VqODIZpBoV!8F*PL?(g~+sm&Lq{lvbe>y=F;W+BuR3gx8_lBA!E}H7KGNEv{P! zPv9hxQPE?oykWn?jM^MnyCg7aP;9U53DE!fVxSrqi3UMgLjR0g|XCFY)l$AYEZAbnsx E011rN=>Px# literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1416867f03dea0a9b4a4602c4507cb035379781f GIT binary patch literal 1180 zcmV;N1Y`SBNk&GL1ONb6MM6+kP&iD81ONapkH8}kb%xruZ5a9g!%NB@5fi}Mx*WVb zS4wglNs=Vi_Wy5JB+~oTJ>%h#o)H1ZwzXr=_v*K8+jfj0*r`%kBXFu@E?FtlyR@C& z`S4DFBy8J`E&8%;+cw9xZ5u1wwr$(CZQHhfe9x-?d;j|8t?ksEBKki8`A01PLZ`p_ zKn<@9aqT|H&P)1aV&d@MhYtCUa4WflD zJR!il3N0fE4ce&{E&mnfRi!)k5Fiofbyc8Oxgt$q#BLKP77Z1PnrdVO2-%Cr{+TGD?&sy(2IWg=3aL?dJ)()dx4 z#I*nO$;2xG?uOjicvzW@uRedueYm}vF<)*F^RG)y^%%hAb0s!+#e+lYNd;%;F6L85k>+C)e#AZ5z z=JC0Ni}{>FfRccytcxR{$X1Z_dQNyB#w-101We4j-UKWZ7%vhO^?8Mf1gy`xSpmi=BI3o*B0$%BLSoW^=-DBLRJL5^)NGIw}X@dzOH6HY{iSn4PkX zZxzilTH4jS4M0_2V9|12wxPOxF5|7A$hgu-M$VI^Cna))U0km($S91Y#n!yKUr$e9 zyIzAiKHE?nSXEe9c#442Rsw5=BSPkK?`vtj;^N}ukA#94nHluOeA0y%SnQ&8JEzC zfX4=MXhgtrT`9fmgx}TpX1r%31uO@s-=audR0`{4)h$<~(B@C8}1bn}9Ge7q? z0L2g;UcvVlL0Otb>o^J6EmaWXk8$P#xAYegu-QlkFm8V(SJUtFwF?EFRIFcA%YgyD z^^1zCm}+pWTVHz{2Ee*%_kA~R9%)z64E^#y!}0(A3rG$dkfsqZiL|eHe5;Wl--9-r z$HK>Qmsf!ee-9+3w_mtevK%n<8bD5OIUZ~D6axTaT0!?HHWDoQcoDrwdH}+}X%a1L z99Z;0&2lpI2$+IH=($pbOFSXKJF_12xh2F&u?HkbLeDS$oPE5YVLo1;OU+Ne7iJ)U zP_T$dzN^pe$EgoLIPA%%A0Lm?=gzw(d9mCGFd{)>8g@~MPv(<*x4(K`1E>G$y*~LR zQS3Am#0&)>lABFHLTZ090q!kv&6@=3G1+rdF>>$}izA>O{I_5?7adlHcqJrfJ- uVmtM+LIMDQW-486Z_606-NNc@ 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 literal 0 HcmV?d00001 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 ====================