Add FCM token support to regV5.
@@ -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"
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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?
|
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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ====================
|
||||||
|
|||||||