Add FCM token support to regV5.
@@ -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"
|
||||
|
||||
@@ -9226,6 +9226,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="7e70f20ca8710a783bfbdb268b35603105089e55134313c977e5ba6a0fae4938" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-base" version="18.0.1">
|
||||
<artifact name="play-services-base-18.0.1.aar">
|
||||
<sha256 value="2896d76f432be52167295bb9ce45ade25c310aeffc04d28cf8db6a15868e83de" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-base" version="18.5.0">
|
||||
<artifact name="play-services-base-18.5.0.aar">
|
||||
<md5 value="2575eef1b04c8d00ca47f5e802897309" origin="Generated by Gradle"/>
|
||||
@@ -9233,6 +9238,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="59a5c0c2da12311d75d965ce1f419498536b1a167fb28ff7dfc2dfd9cefa4157" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-basement" version="18.0.0">
|
||||
<artifact name="play-services-basement-18.0.0.aar">
|
||||
<sha256 value="55c1777467901a2d399f3252384c4976284aa35fddfd5995466dbeacb49f9dd6" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-basement" version="18.3.0">
|
||||
<artifact name="play-services-basement-18.3.0.aar">
|
||||
<sha256 value="6c11ae3eb2dd7f17373f919c4c557a70e4cf891bc0c9b66926a0a6445d654352" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-basement" version="18.4.0">
|
||||
<artifact name="play-services-basement-18.4.0.aar">
|
||||
<md5 value="b0ad2fdb58e14246d8c5bfa85da212ba" origin="Generated by Gradle"/>
|
||||
@@ -9310,6 +9325,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="dd4314a53f49a378ec146103d36232b96c75454d29526336ccbdf132941764d3" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-tasks" version="18.0.1">
|
||||
<artifact name="play-services-tasks-18.0.1.aar">
|
||||
<sha256 value="f106db48c6ccfa8e1315a7adc44aecd02ff7355eb3fa7dcdcba8c283a8eb1681" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-tasks" version="18.1.0">
|
||||
<artifact name="play-services-tasks-18.1.0.aar">
|
||||
<sha256 value="d60575eae39350e6234858bc9d7d775375707ae82a684e6caf7f3e41a12e25a2" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.android.gms" name="play-services-tasks" version="18.2.0">
|
||||
<artifact name="play-services-tasks-18.2.0.aar">
|
||||
<md5 value="a4f08be53b9f2c1e68be610c6d03380d" origin="Generated by Gradle"/>
|
||||
@@ -9622,6 +9647,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="ec6f39f068b6ff9ac323c68e28b9299f8c0a80ca512dccb1d4a70f40ac3ec054" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.errorprone" name="error_prone_annotations" version="2.26.0">
|
||||
<artifact name="error_prone_annotations-2.26.0.jar">
|
||||
<sha256 value="53cdfc0beb2d766fe03b78f0b11d020554cd419879d20380c38fa1dcf2ba1b50" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.errorprone" name="error_prone_annotations" version="2.27.0">
|
||||
<artifact name="error_prone_annotations-2.27.0.jar">
|
||||
<md5 value="fb183180666ce3d0a24ef7ba02d7193c" origin="Generated by Gradle"/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".RegistrationApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Registration Sample"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- Disable Firebase Analytics -->
|
||||
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
|
||||
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
@@ -16,6 +23,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- FCM Service for receiving push challenges -->
|
||||
<service
|
||||
android:name=".fcm.FcmReceiveService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
registration/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 650 B |
BIN
registration/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
registration/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 476 B |
BIN
registration/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1002 B |
BIN
registration/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 814 B |
|
After Width: | Height: | Size: 2.1 KiB |
BIN
registration/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
registration/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
10
registration/app/src/main/res/values/firebase_messaging.xml
Normal 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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -56,6 +56,21 @@ class RegistrationRepository(val networkController: NetworkController, val stora
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun awaitPushChallengeToken(): String? = withContext(Dispatchers.IO) {
|
||||
networkController.awaitPushChallengeToken()
|
||||
}
|
||||
|
||||
suspend fun submitPushChallengeToken(
|
||||
sessionId: String,
|
||||
pushChallengeToken: String
|
||||
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
|
||||
networkController.updateSession(
|
||||
sessionId = sessionId,
|
||||
pushChallengeToken = pushChallengeToken,
|
||||
captchaToken = null
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun submitVerificationCode(
|
||||
sessionId: String,
|
||||
verificationCode: String
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ package org.signal.registration.screens.phonenumber
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.assertions.prop
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
@@ -30,6 +32,8 @@ class PhoneNumberEntryViewModelTest {
|
||||
private lateinit var viewModel: PhoneNumberEntryViewModel
|
||||
private lateinit var mockRepository: RegistrationRepository
|
||||
private lateinit var parentState: MutableStateFlow<RegistrationFlowState>
|
||||
private lateinit var emittedStates: MutableList<PhoneNumberEntryState>
|
||||
private lateinit var stateEmitter: (PhoneNumberEntryState) -> Unit
|
||||
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
|
||||
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
|
||||
|
||||
@@ -37,6 +41,8 @@ class PhoneNumberEntryViewModelTest {
|
||||
fun setup() {
|
||||
mockRepository = mockk(relaxed = true)
|
||||
parentState = MutableStateFlow(RegistrationFlowState())
|
||||
emittedStates = mutableListOf()
|
||||
stateEmitter = { state -> emittedStates.add(state) }
|
||||
emittedEvents = mutableListOf()
|
||||
parentEventEmitter = { event -> emittedEvents.add(event) }
|
||||
viewModel = PhoneNumberEntryViewModel(mockRepository, parentState, parentEventEmitter)
|
||||
@@ -56,47 +62,58 @@ class PhoneNumberEntryViewModelTest {
|
||||
fun `PhoneNumberChanged extracts digits and formats number`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567")
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(result.nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(result.formattedNumber).isEqualTo("(555) 123-4567")
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(emittedStates.last().formattedNumber).isEqualTo("(555) 123-4567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged with raw digits formats correctly`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567")
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551234567"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(result.nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(result.formattedNumber).isEqualTo("(555) 123-4567")
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(emittedStates.last().formattedNumber).isEqualTo("(555) 123-4567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged formats progressively as digits are added`() = runTest {
|
||||
var state = PhoneNumberEntryState()
|
||||
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("5")
|
||||
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("55")
|
||||
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("555"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("555")
|
||||
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("5551"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("5551")
|
||||
// libphonenumber formats progressively - at 4 digits it's still building the format
|
||||
assertThat(state.formattedNumber).isEqualTo("555-1")
|
||||
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("55512"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("55512")
|
||||
assertThat(state.formattedNumber).isEqualTo("555-12")
|
||||
}
|
||||
@@ -105,68 +122,83 @@ class PhoneNumberEntryViewModelTest {
|
||||
fun `PhoneNumberChanged ignores non-digit characters`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!")
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("(555) abc 123-4567!"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(result.nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberChanged with same digits returns same state`() = runTest {
|
||||
fun `PhoneNumberChanged with same digits does not emit new state`() = runTest {
|
||||
val initialState = PhoneNumberEntryState(nationalNumber = "5551234567", formattedNumber = "(555) 123-4567")
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567")
|
||||
PhoneNumberEntryScreenEvents.PhoneNumberChanged("555-123-4567"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
// Should return the same state object since digits haven't changed
|
||||
assertThat(result).isEqualTo(initialState)
|
||||
// Should emit the same state since digits haven't changed
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last()).isEqualTo(initialState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged updates country code and region`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("44")
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("44"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(result.countryCode).isEqualTo("44")
|
||||
assertThat(result.regionCode).isEqualTo("GB")
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("44")
|
||||
assertThat(emittedStates.last().regionCode).isEqualTo("GB")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged sanitizes input to digits only`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("+44abc")
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("+44abc"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(result.countryCode).isEqualTo("44")
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("44")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged limits to 3 digits`() = runTest {
|
||||
val initialState = PhoneNumberEntryState()
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("12345")
|
||||
PhoneNumberEntryScreenEvents.CountryCodeChanged("12345"),
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(result.countryCode).isEqualTo("123")
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("123")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CountryCodeChanged reformats existing phone number for new region`() = runTest {
|
||||
// Start with a US number
|
||||
var state = PhoneNumberEntryState(
|
||||
val state = PhoneNumberEntryState(
|
||||
regionCode = "US",
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567",
|
||||
@@ -174,12 +206,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
)
|
||||
|
||||
// Change to UK
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("44"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(state.countryCode).isEqualTo("44")
|
||||
assertThat(state.regionCode).isEqualTo("GB")
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().countryCode).isEqualTo("44")
|
||||
assertThat(emittedStates.last().regionCode).isEqualTo("GB")
|
||||
// The digits should be reformatted for UK format
|
||||
assertThat(state.nationalNumber).isEqualTo("5551234567")
|
||||
assertThat(emittedStates.last().nationalNumber).isEqualTo("5551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -188,7 +221,9 @@ class PhoneNumberEntryViewModelTest {
|
||||
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.CountryPicker
|
||||
PhoneNumberEntryScreenEvents.CountryPicker,
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
@@ -203,12 +238,15 @@ class PhoneNumberEntryViewModelTest {
|
||||
oneTimeEvent = PhoneNumberEntryState.OneTimeEvent.NetworkError
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(
|
||||
viewModel.applyEvent(
|
||||
initialState,
|
||||
PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent
|
||||
PhoneNumberEntryScreenEvents.ConsumeOneTimeEvent,
|
||||
stateEmitter,
|
||||
parentEventEmitter
|
||||
)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNull()
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -216,11 +254,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
var state = PhoneNumberEntryState()
|
||||
|
||||
// Set German country code
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.CountryCodeChanged("49"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.regionCode).isEqualTo("DE")
|
||||
|
||||
// Enter a German number
|
||||
state = viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"))
|
||||
viewModel.applyEvent(state, PhoneNumberEntryScreenEvents.PhoneNumberChanged("15123456789"), stateEmitter, parentEventEmitter)
|
||||
state = emittedStates.last()
|
||||
assertThat(state.nationalNumber).isEqualTo("15123456789")
|
||||
}
|
||||
|
||||
@@ -240,9 +280,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.sessionMetadata).isNotNull()
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().sessionMetadata).isNotNull()
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
@@ -262,7 +306,11 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
@@ -283,9 +331,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNotNull()
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(60.seconds)
|
||||
@@ -303,9 +355,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -318,9 +374,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -333,9 +393,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -350,7 +414,11 @@ class PhoneNumberEntryViewModelTest {
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(existingSession)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Should not create a new session, just request verification code
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
@@ -376,9 +444,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull().isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -397,7 +469,11 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.ResetState)
|
||||
@@ -419,9 +495,13 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.CouldNotRequestCodeWithSelectedTransport)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -442,9 +522,207 @@ class PhoneNumberEntryViewModelTest {
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted)
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.ThirdPartyError)
|
||||
}
|
||||
|
||||
// ==================== Push Challenge Tests ====================
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge submits token when received`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
val sessionAfterPushChallenge = createSessionMetadata(requestedInformation = emptyList())
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation to verification code entry
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
|
||||
// Verify push challenge token was submitted
|
||||
io.mockk.coVerify { mockRepository.submitPushChallengeToken(sessionWithPushChallenge.id, "test-push-challenge-token") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when token times out`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns null
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite no push challenge token
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
|
||||
// Verify submit was never called since token was null
|
||||
io.mockk.coVerify(exactly = 0) { mockRepository.submitPushChallengeToken(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when submission fails`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Failure(
|
||||
NetworkController.UpdateSessionError.RejectedUpdate("Invalid token")
|
||||
)
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite push challenge submission failure
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when submission has network error`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost"))
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite network error
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge continues when submission has application error`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.ApplicationError(RuntimeException("Unexpected error"))
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation continues despite application error
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.VerificationCodeEntry>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PhoneNumberSubmitted with push challenge navigates to captcha if still required after submission`() = runTest {
|
||||
val sessionWithPushChallenge = createSessionMetadata(requestedInformation = listOf("pushChallenge", "captcha"))
|
||||
val sessionAfterPushChallenge = createSessionMetadata(requestedInformation = listOf("captcha"))
|
||||
|
||||
coEvery { mockRepository.createSession(any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithPushChallenge)
|
||||
coEvery { mockRepository.awaitPushChallengeToken() } returns "test-push-challenge-token"
|
||||
coEvery { mockRepository.submitPushChallengeToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionAfterPushChallenge)
|
||||
|
||||
val initialState = PhoneNumberEntryState(
|
||||
countryCode = "1",
|
||||
nationalNumber = "5551234567"
|
||||
)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.PhoneNumberSubmitted, stateEmitter, parentEventEmitter)
|
||||
|
||||
// Verify spinner states
|
||||
assertThat(emittedStates.first().showFullScreenSpinner).isTrue()
|
||||
assertThat(emittedStates.last().showFullScreenSpinner).isFalse()
|
||||
|
||||
// Verify navigation to captcha
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
|
||||
.prop(RegistrationFlowEvent.NavigateToScreen::route)
|
||||
.isInstanceOf<RegistrationRoute.Captcha>()
|
||||
}
|
||||
|
||||
// ==================== CaptchaCompleted Tests ====================
|
||||
@@ -459,7 +737,7 @@ class PhoneNumberEntryViewModelTest {
|
||||
coEvery { mockRepository.requestVerificationCode(any(), any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionMetadata)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
@@ -472,9 +750,10 @@ class PhoneNumberEntryViewModelTest {
|
||||
fun `CaptchaCompleted returns error when no session exists`() = runTest {
|
||||
val initialState = PhoneNumberEntryState(sessionMetadata = null)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -485,7 +764,7 @@ class PhoneNumberEntryViewModelTest {
|
||||
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.Success(sessionWithCaptcha)
|
||||
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(emittedEvents).hasSize(1)
|
||||
assertThat(emittedEvents.first())
|
||||
@@ -504,9 +783,10 @@ class PhoneNumberEntryViewModelTest {
|
||||
NetworkController.UpdateSessionError.RateLimited(45.seconds, sessionMetadata)
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isNotNull()
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isNotNull()
|
||||
.isInstanceOf<PhoneNumberEntryState.OneTimeEvent.RateLimited>()
|
||||
.prop(PhoneNumberEntryState.OneTimeEvent.RateLimited::retryAfter)
|
||||
.isEqualTo(45.seconds)
|
||||
@@ -522,9 +802,10 @@ class PhoneNumberEntryViewModelTest {
|
||||
NetworkController.UpdateSessionError.RejectedUpdate("Invalid captcha")
|
||||
)
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.UnknownError)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -535,9 +816,10 @@ class PhoneNumberEntryViewModelTest {
|
||||
coEvery { mockRepository.submitCaptchaToken(any(), any()) } returns
|
||||
NetworkController.RegistrationNetworkResult.NetworkError(java.io.IOException("Connection lost"))
|
||||
|
||||
val result = viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"))
|
||||
viewModel.applyEvent(initialState, PhoneNumberEntryScreenEvents.CaptchaCompleted("captcha-token"), stateEmitter, parentEventEmitter)
|
||||
|
||||
assertThat(result.oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
||||
assertThat(emittedStates).hasSize(1)
|
||||
assertThat(emittedStates.last().oneTimeEvent).isEqualTo(PhoneNumberEntryState.OneTimeEvent.NetworkError)
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||