Add FCM token support to regV5.

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

View File

@@ -136,6 +136,7 @@ material-material = "com.google.android.material:material:1.12.0"
# Google # Google
google-libphonenumber = "com.googlecode.libphonenumber:libphonenumber:8.13.50" 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-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-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" google-play-services-wallet = "com.google.android.gms:play-services-wallet:19.4.0"

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Response import okhttp3.Response
import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.CreateSessionError 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.ThirdPartyServiceErrorResponse
import org.signal.registration.NetworkController.UpdateSessionError import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport 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 org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.io.IOException import java.io.IOException
import java.util.Locale 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 import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
class RealNetworkController( class RealNetworkController(
private val context: android.content.Context,
private val pushServiceSocket: PushServiceSocket private val pushServiceSocket: PushServiceSocket
) : NetworkController { ) : NetworkController {
companion object {
private val TAG = Log.tag(RealNetworkController::class)
}
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
override suspend fun createSession( override suspend fun createSession(
@@ -294,7 +302,21 @@ class RealNetworkController(
} }
override suspend fun getFcmToken(): String? { 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 { override fun getCaptchaUrl(): String {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

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

View File

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

View File

@@ -87,6 +87,15 @@ interface NetworkController {
*/ */
suspend fun getFcmToken(): String? 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. * Returns the URL to load in the WebView for captcha verification.
*/ */

View File

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

View File

@@ -6,6 +6,7 @@
package org.signal.registration.screens.phonenumber package org.signal.registration.screens.phonenumber
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
import org.signal.registration.test.TestTags import org.signal.registration.test.TestTags
@@ -52,6 +54,29 @@ fun PhoneNumberScreen(
onEvent: (PhoneNumberEntryScreenEvents) -> Unit, onEvent: (PhoneNumberEntryScreenEvents) -> Unit,
modifier: Modifier = Modifier 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 // TODO: These should come from state once country picker is implemented
var selectedCountry by remember { mutableStateOf("United States") } var selectedCountry by remember { mutableStateOf("United States") }
var selectedCountryEmoji by remember { mutableStateOf("🇺🇸") } 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( Column(
modifier = modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.Start horizontalAlignment = Alignment.Start
@@ -241,3 +254,14 @@ private fun PhoneNumberScreenPreview() {
) )
} }
} }
@DayNightPreviews
@Composable
private fun PhoneNumberScreenSpinnerPreview() {
Previews.Preview {
PhoneNumberScreen(
state = PhoneNumberEntryState(showFullScreenSpinner = true),
onEvent = {}
)
}
}

View File

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

View File

@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.registration.NetworkController import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent import org.signal.registration.RegistrationFlowEvent
@@ -34,6 +35,7 @@ class PhoneNumberEntryViewModel(
companion object { companion object {
private val TAG = Log.tag(PhoneNumberEntryViewModel::class) private val TAG = Log.tag(PhoneNumberEntryViewModel::class)
private const val PUSH_CHALLENGE_TIMEOUT_MS = 5000L
} }
private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance() private val phoneNumberUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance()
@@ -46,18 +48,35 @@ class PhoneNumberEntryViewModel(
fun onEvent(event: PhoneNumberEntryScreenEvents) { fun onEvent(event: PhoneNumberEntryScreenEvents) {
viewModelScope.launch { 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 { suspend fun applyEvent(state: PhoneNumberEntryState, event: PhoneNumberEntryScreenEvents, stateEmitter: (PhoneNumberEntryState) -> Unit, parentEventEmitter: (RegistrationFlowEvent) -> Unit) {
return when (event) { when (event) {
is PhoneNumberEntryScreenEvents.CountryCodeChanged -> transformCountryCodeChanged(state, event.value) is PhoneNumberEntryScreenEvents.CountryCodeChanged -> {
is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> transformPhoneNumberChanged(state, event.value) stateEmitter(applyCountryCodeChanged(state, event.value))
is PhoneNumberEntryScreenEvents.PhoneNumberSubmitted -> transformPhoneNumberSubmitted(state) }
is PhoneNumberEntryScreenEvents.CountryPicker -> state.also { parentEventEmitter.navigateTo(RegistrationRoute.CountryCodePicker) } is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> {
is PhoneNumberEntryScreenEvents.CaptchaCompleted -> transformCaptchaCompleted(state, event.token) stateEmitter(applyPhoneNumberChanged(state, event.value))
is PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent -> state.copy(oneTimeEvent = null) }
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) 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 // Only allow digits, max 3 characters
val sanitized = countryCode.filter { it.isDigit() }.take(3) val sanitized = countryCode.filter { it.isDigit() }.take(3)
if (sanitized == state.countryCode) return state 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 // Extract only digits from the input
val digitsOnly = input.filter { it.isDigit() } val digitsOnly = input.filter { it.isDigit() }
if (digitsOnly == state.nationalNumber) return state if (digitsOnly == state.nationalNumber) return state
@@ -107,8 +126,9 @@ class PhoneNumberEntryViewModel(
return result return result
} }
private suspend fun transformPhoneNumberSubmitted( private suspend fun applyPhoneNumberSubmitted(
inputState: PhoneNumberEntryState inputState: PhoneNumberEntryState,
parentEventEmitter: (RegistrationFlowEvent) -> Unit
): PhoneNumberEntryState { ): PhoneNumberEntryState {
val e164 = "+${inputState.countryCode}${inputState.nationalNumber}" val e164 = "+${inputState.countryCode}${inputState.nationalNumber}"
var state = inputState.copy() var state = inputState.copy()
@@ -140,6 +160,36 @@ class PhoneNumberEntryViewModel(
state = state.copy(sessionMetadata = sessionMetadata) 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")) { if (sessionMetadata.requestedInformation.contains("captcha")) {
parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata)) parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata))
return state return state
@@ -203,7 +253,7 @@ class PhoneNumberEntryViewModel(
return state 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 state = inputState.copy()
var sessionMetadata = state.sessionMetadata ?: return state.copy(oneTimeEvent = OneTimeEvent.UnknownError) var sessionMetadata = state.sessionMetadata ?: return state.copy(oneTimeEvent = OneTimeEvent.UnknownError)

View File

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