Use action pattern in registration module.

This commit is contained in:
Greyson Parrelli
2026-08-10 16:32:56 -04:00
parent b32d811f19
commit 98d386b44f
21 changed files with 387 additions and 61 deletions
@@ -23,7 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.Navigation
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.Flow
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.getParcelableArrayListExtraCompat
@@ -195,7 +195,7 @@ class ConversationSettingsFragment : ComposeFragment() {
private fun IndividualContent() {
val state by individualViewModel.state.collectAsStateWithLifecycle()
CollectActions(individualViewModel.actions)
CollectActions(individualViewModel.actions) { action -> handleAction(action) }
NotifyWhenLoaded(state.isLoaded)
IndividualSettingsScreen(
@@ -211,7 +211,7 @@ class ConversationSettingsFragment : ComposeFragment() {
private fun NoteToSelfContent() {
val state by individualViewModel.state.collectAsStateWithLifecycle()
CollectActions(individualViewModel.actions)
CollectActions(individualViewModel.actions) { action -> handleAction(action) }
NotifyWhenLoaded(state.isLoaded)
NoteToSelfSettingsScreen(
@@ -227,7 +227,7 @@ class ConversationSettingsFragment : ComposeFragment() {
private fun ReleaseNotesContent() {
val state by individualViewModel.state.collectAsStateWithLifecycle()
CollectActions(individualViewModel.actions)
CollectActions(individualViewModel.actions) { action -> handleAction(action) }
NotifyWhenLoaded(state.isLoaded)
ReleaseNotesSettingsScreen(
@@ -243,7 +243,7 @@ class ConversationSettingsFragment : ComposeFragment() {
private fun GroupContent() {
val state by groupViewModel.state.collectAsStateWithLifecycle()
CollectActions(groupViewModel.actions)
CollectActions(groupViewModel.actions) { action -> handleAction(action) }
NotifyWhenLoaded(state.isLoaded)
GroupSettingsScreen(
@@ -255,13 +255,6 @@ class ConversationSettingsFragment : ComposeFragment() {
)
}
@Composable
private fun CollectActions(actions: Flow<ConversationSettingsAction>) {
LaunchedEffect(actions) {
actions.collect { action -> handleAction(action) }
}
}
@Composable
private fun NotifyWhenLoaded(isLoaded: Boolean) {
LaunchedEffect(isLoaded) {
@@ -0,0 +1,26 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
/**
* Helper for collecting one-off actions emitted from a ViewModel. Ensures lifecycle safety.
*/
@Composable
fun <T> CollectActions(actions: Flow<T>, onAction: (T) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(actions, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
actions.collect(onAction)
}
}
}
@@ -39,6 +39,7 @@ import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import kotlinx.serialization.Serializable
import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.navigation.ResultEffect
import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.core.util.LinkActions
@@ -50,7 +51,7 @@ import org.signal.registration.screens.accountlocked.AccountLockedScreen
import org.signal.registration.screens.accountlocked.AccountLockedScreenEvents
import org.signal.registration.screens.accountlocked.AccountLockedState
import org.signal.registration.screens.addusername.AddUsernameScreen
import org.signal.registration.screens.addusername.AddUsernameScreenEvents
import org.signal.registration.screens.addusername.AddUsernameScreenActions
import org.signal.registration.screens.addusername.AddUsernameViewModel
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupResult
import org.signal.registration.screens.aepentry.EnterAepForLocalBackupViewModel
@@ -79,7 +80,7 @@ import org.signal.registration.screens.devicetransfer.setup.DeviceTransferSetupV
import org.signal.registration.screens.discoverability.PhoneNumberDiscoverabilityScreen
import org.signal.registration.screens.discoverability.PhoneNumberDiscoverabilityViewModel
import org.signal.registration.screens.linkaccount.LinkAccountScreen
import org.signal.registration.screens.linkaccount.LinkAccountScreenEvent
import org.signal.registration.screens.linkaccount.LinkAccountScreenAction
import org.signal.registration.screens.linkaccount.LinkAccountViewModel
import org.signal.registration.screens.localbackuprestore.EnterLocalBackupV1PassphaseScreen
import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreEvents
@@ -87,14 +88,14 @@ import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreResu
import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreScreen
import org.signal.registration.screens.localbackuprestore.LocalBackupRestoreViewModel
import org.signal.registration.screens.messagesync.MessageSyncScreen
import org.signal.registration.screens.messagesync.MessageSyncScreenEvent
import org.signal.registration.screens.messagesync.MessageSyncScreenAction
import org.signal.registration.screens.messagesync.MessageSyncViewModel
import org.signal.registration.screens.permissions.PermissionsScreen
import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents
import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel
import org.signal.registration.screens.phonenumber.PhoneNumberScreen
import org.signal.registration.screens.pincreation.PinCreationScreen
import org.signal.registration.screens.pincreation.PinCreationScreenEvents
import org.signal.registration.screens.pincreation.PinCreationScreenActions
import org.signal.registration.screens.pincreation.PinCreationViewModel
import org.signal.registration.screens.pinentry.PinEntryForRegistrationLockViewModel
import org.signal.registration.screens.pinentry.PinEntryForSmsBypassViewModel
@@ -111,14 +112,14 @@ import org.signal.registration.screens.restoreselection.RegisteredState
import org.signal.registration.screens.signallogininfo.SignalLoginInfoScreen
import org.signal.registration.screens.signallogininfo.SignalLoginInfoViewModel
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreen
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreenEvents
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentScreenActions
import org.signal.registration.screens.signalloginpayment.SignalLoginPaymentViewModel
import org.signal.registration.screens.util.navigateBack
import org.signal.registration.screens.util.navigateTo
import org.signal.registration.screens.verificationcode.VerificationCodeScreen
import org.signal.registration.screens.verificationcode.VerificationCodeViewModel
import org.signal.registration.screens.welcome.WelcomeScreen
import org.signal.registration.screens.welcome.WelcomeScreenEvents
import org.signal.registration.screens.welcome.WelcomeScreenActions
import org.signal.registration.screens.welcome.WelcomeScreenViewModel
import org.signal.registration.util.AccountEntropyPoolParceler
import org.signal.registration.util.RegistrationCredentialManager
@@ -443,15 +444,15 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action ->
when (action) {
WelcomeScreenActions.ViewTermsAndPrivacy -> openUrl(context, termsAndPrivacyUrl)
}
}
WelcomeScreen(
state = state,
onEvent = { event ->
when (event) {
WelcomeScreenEvents.ViewTermsAndPrivacy -> openUrl(context, termsAndPrivacyUrl)
else -> viewModel.onEvent(event)
}
}
onEvent = { viewModel.onEvent(it) }
)
}
@@ -496,15 +497,15 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val url = "https://support.signal.org/hc/en-us/articles/360007320551"
CollectActions(viewModel.actions) { action ->
when (action) {
LinkAccountScreenAction.OpenGetHelpArticle -> openUrl(context, url)
}
}
LinkAccountScreen(
state = state,
onEvent = {
when (it) {
LinkAccountScreenEvent.GetHelpClick -> openUrl(context, url)
else -> viewModel.onEvent(it)
}
}
onEvent = { viewModel.onEvent(it) }
)
}
@@ -520,15 +521,15 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val url = "https://support.signal.org/hc/articles/360007320391"
CollectActions(viewModel.actions) { action ->
when (action) {
MessageSyncScreenAction.OpenLearnMoreArticle -> openUrl(context, url)
}
}
MessageSyncScreen(
state = state,
onEvent = {
when (it) {
MessageSyncScreenEvent.LearnMoreClick -> openUrl(context, url)
else -> viewModel.onEvent(it)
}
}
onEvent = { viewModel.onEvent(it) }
)
}
@@ -634,15 +635,15 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
SignalLoginPaymentScreenActions.OpenLearnMoreArticle -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
}
}
SignalLoginPaymentScreen(
state = state,
onEvent = { event ->
when (event) {
SignalLoginPaymentScreenEvents.LearnMoreClicked -> openUrl(context, SIGNAL_LOGIN_LEARN_MORE_URL)
else -> viewModel.onEvent(event)
}
}
onEvent = { viewModel.onEvent(it) }
)
}
@@ -674,15 +675,15 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
AddUsernameScreenActions.OpenLearnMoreArticle -> openUrl(context, USERNAME_LEARN_MORE_URL)
}
}
AddUsernameScreen(
state = state,
onEvent = { event ->
when (event) {
AddUsernameScreenEvents.LearnMoreClicked -> openUrl(context, USERNAME_LEARN_MORE_URL)
else -> viewModel.onEvent(event)
}
}
onEvent = { viewModel.onEvent(it) }
)
}
@@ -714,16 +715,15 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
)
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
CollectActions(viewModel.actions) { action ->
when (action) {
PinCreationScreenActions.OpenLearnMoreArticle -> openUrl(context, PIN_LEARN_MORE_URL)
}
}
PinCreationScreen(
state = state,
onEvent = { event ->
when (event) {
PinCreationScreenEvents.LearnMore -> openUrl(context, PIN_LEARN_MORE_URL)
else -> viewModel.onEvent(event)
}
}
onEvent = { viewModel.onEvent(it) }
)
}
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.addusername
sealed interface AddUsernameScreenActions {
/** Open the article explaining Signal usernames. */
data object OpenLearnMoreArticle : AddUsernameScreenActions
}
@@ -9,11 +9,14 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
@@ -38,6 +41,9 @@ class AddUsernameViewModel(
private val _state = MutableStateFlow(AddUsernameState())
val state: StateFlow<AddUsernameState> = _state.asStateFlow()
private val _actions = Channel<AddUsernameScreenActions>(Channel.BUFFERED)
val actions: Flow<AddUsernameScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
@@ -62,7 +68,7 @@ class AddUsernameViewModel(
}
is AddUsernameScreenEvents.LearnMoreClicked -> {
// Handled by the navigation layer, which owns URL launching.
_actions.trySend(AddUsernameScreenActions.OpenLearnMoreArticle)
}
is AddUsernameScreenEvents.SkipClicked -> {
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.linkaccount
sealed interface LinkAccountScreenAction {
/** Open the article explaining how to link a device. */
data object OpenGetHelpArticle : LinkAccountScreenAction
}
@@ -10,11 +10,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.EventDrivenViewModel
@@ -49,6 +52,9 @@ class LinkAccountViewModel(
private val _state = MutableStateFlow(LinkAccountScreenState(showCreateAccount = showCreateAccount))
val state: StateFlow<LinkAccountScreenState> = _state.asStateFlow()
private val _actions = Channel<LinkAccountScreenAction>(Channel.BUFFERED)
val actions: Flow<LinkAccountScreenAction> = _actions.receiveAsFlow()
private var provisioningJob: Job? = null
init {
@@ -66,7 +72,10 @@ class LinkAccountViewModel(
@VisibleForTesting
fun applyEvent(state: LinkAccountScreenState, event: LinkAccountScreenEvent, stateEmitter: (LinkAccountScreenState) -> Unit) {
val result = when (event) {
LinkAccountScreenEvent.GetHelpClick -> error("This event is handled in the nav-entry.")
LinkAccountScreenEvent.GetHelpClick -> {
_actions.trySend(LinkAccountScreenAction.OpenGetHelpArticle)
state
}
LinkAccountScreenEvent.CreateAccountClick -> {
// Revisit permission screen if necessary
if (parentState.value.backStack.any { it == RegistrationRoute.PhoneNumberEntry }) {
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.messagesync
sealed interface MessageSyncScreenAction {
/** Open the article explaining message sync between devices. */
data object OpenLearnMoreArticle : MessageSyncScreenAction
}
@@ -11,11 +11,14 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.EventDrivenViewModel
@@ -46,6 +49,9 @@ class MessageSyncViewModel(
private val _state = MutableStateFlow(MessageSyncScreenState())
val state: StateFlow<MessageSyncScreenState> = _state.asStateFlow()
private val _actions = Channel<MessageSyncScreenAction>(Channel.BUFFERED)
val actions: Flow<MessageSyncScreenAction> = _actions.receiveAsFlow()
private var restoreJob: Job? = null
private var finishJob: Job? = null
@@ -112,7 +118,10 @@ class MessageSyncViewModel(
@VisibleForTesting
suspend fun applyEvent(state: MessageSyncScreenState, event: MessageSyncScreenEvent, stateEmitter: (MessageSyncScreenState) -> Unit) {
val result = when (event) {
MessageSyncScreenEvent.LearnMoreClick -> error("This event is handled in the nav-entry.")
MessageSyncScreenEvent.LearnMoreClick -> {
_actions.trySend(MessageSyncScreenAction.OpenLearnMoreArticle)
state
}
MessageSyncScreenEvent.CancelClick -> {
Log.i(TAG, "[MessageSync] User cancelled message sync; awaiting restore cancellation, then restoring from storage service and completing.")
stateEmitter(state.copy(stage = Stage.Finishing))
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.pincreation
sealed interface PinCreationScreenActions {
/** Open the article explaining Signal PINs. */
data object OpenLearnMoreArticle : PinCreationScreenActions
}
@@ -9,11 +9,14 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
@@ -43,6 +46,9 @@ class PinCreationViewModel(
private val _state = MutableStateFlow(PinCreationState())
val state: StateFlow<PinCreationState> = _state.asStateFlow()
private val _actions = Channel<PinCreationScreenActions>(Channel.BUFFERED)
val actions: Flow<PinCreationScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
@@ -101,7 +107,7 @@ class PinCreationViewModel(
}
is PinCreationScreenEvents.LearnMore -> {
// Handled by the navigation layer, which opens the help URL directly.
_actions.trySend(PinCreationScreenActions.OpenLearnMoreArticle)
}
is PinCreationScreenEvents.BackToPinEntry -> {
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginpayment
sealed interface SignalLoginPaymentScreenActions {
/** Open the article explaining Signal Login. */
data object OpenLearnMoreArticle : SignalLoginPaymentScreenActions
}
@@ -9,11 +9,14 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
@@ -32,6 +35,9 @@ class SignalLoginPaymentViewModel(
private val _state = MutableStateFlow(SignalLoginPaymentState())
val state: StateFlow<SignalLoginPaymentState> = _state.asStateFlow()
private val _actions = Channel<SignalLoginPaymentScreenActions>(Channel.BUFFERED)
val actions: Flow<SignalLoginPaymentScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
@@ -61,7 +67,7 @@ class SignalLoginPaymentViewModel(
}
is SignalLoginPaymentScreenEvents.LearnMoreClicked -> {
// Handled by the navigation layer, which owns URL launching.
_actions.trySend(SignalLoginPaymentScreenActions.OpenLearnMoreArticle)
}
is SignalLoginPaymentScreenEvents.OptionSelected -> {
@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.welcome
sealed interface WelcomeScreenActions {
/** Open the terms and privacy policy page. */
data object ViewTermsAndPrivacy : WelcomeScreenActions
}
@@ -9,11 +9,14 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.registration.RegistrationFlowEvent
@@ -41,6 +44,9 @@ class WelcomeScreenViewModel(
private val _state = MutableStateFlow(WelcomeScreenState(isLinkAndSyncAvailable = repository.isLinkAndSyncAvailable, showRestoreOrTransfer = false))
val state: StateFlow<WelcomeScreenState> = _state.asStateFlow()
private val _actions = Channel<WelcomeScreenActions>(Channel.BUFFERED)
val actions: Flow<WelcomeScreenActions> = _actions.receiveAsFlow()
init {
_state
.onEach { Log.d(TAG, "[State] $it") }
@@ -69,7 +75,7 @@ class WelcomeScreenViewModel(
parentEventEmitter.navigateTo(RegistrationRoute.AllowNotifications(RegistrationRoute.LinkAccount()))
}
}
WelcomeScreenEvents.ViewTermsAndPrivacy -> error("This event is handled in the nav-entry.")
WelcomeScreenEvents.ViewTermsAndPrivacy -> _actions.trySend(WelcomeScreenActions.ViewTermsAndPrivacy)
}
}
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.addusername
import assertk.assertThat
import assertk.assertions.containsExactly
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
@OptIn(ExperimentalCoroutinesApi::class)
class AddUsernameViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var mockRepository: RegistrationRepository
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var viewModel: AddUsernameViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
parentEventEmitter = {}
viewModel = AddUsernameViewModel(
repository = mockRepository,
parentEventEmitter = parentEventEmitter
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun TestScope.collectActions(): List<AddUsernameScreenActions> {
val actions = mutableListOf<AddUsernameScreenActions>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
return actions
}
@Test
fun `LearnMoreClicked emits an action to open the learn more article`() = runTest(testDispatcher) {
val actions = collectActions()
viewModel.applyEvent(AddUsernameState(), AddUsernameScreenEvents.LearnMoreClicked, parentEventEmitter) {}
assertThat(actions).containsExactly(AddUsernameScreenActions.OpenLearnMoreArticle)
}
}
@@ -7,6 +7,7 @@ package org.signal.registration.screens.linkaccount
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
@@ -79,6 +80,12 @@ class LinkAccountViewModelTest {
return viewModel
}
private fun TestScope.collectActions(viewModel: LinkAccountViewModel): List<LinkAccountScreenAction> {
val actions = mutableListOf<LinkAccountScreenAction>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
return actions
}
@Test
fun `initial state has qrState Loading`() = runTest(testDispatcher) {
val viewModel = createViewModel()
@@ -187,6 +194,16 @@ class LinkAccountViewModelTest {
assertThat(viewModel.state.value.isRegistering).isFalse()
}
@Test
fun `applyEvent GetHelpClick emits an action to open the help article`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel)
viewModel.applyEvent(LinkAccountScreenState(), LinkAccountScreenEvent.GetHelpClick, stateEmitter)
assertThat(actions).containsExactly(LinkAccountScreenAction.OpenGetHelpArticle)
}
@Test
fun `applyEvent DisplayOverlayClick shows the QR overlay`() = runTest(testDispatcher) {
val viewModel = createViewModel()
@@ -7,6 +7,7 @@ package org.signal.registration.screens.messagesync
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.containsExactly
import assertk.assertions.doesNotContain
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
@@ -80,6 +81,17 @@ class MessageSyncViewModelTest {
assertThat(emittedParentEvents).doesNotContain(RegistrationFlowEvent.NavigateToScreen(RegistrationRoute.FullyComplete))
}
@Test
fun `applyEvent LearnMoreClick emits an action to open the learn more article`() = runTest(testDispatcher) {
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed())
val viewModel = createViewModel()
val actions = collectActions(viewModel)
viewModel.applyEvent(viewModel.state.value, MessageSyncScreenEvent.LearnMoreClick) {}
assertThat(actions).containsExactly(MessageSyncScreenAction.OpenLearnMoreArticle)
}
@Test
fun `applyEvent RetryClick clears the dialog and restarts the restore`() = runTest(testDispatcher) {
every { mockRepository.restoreLinkAndSyncBackup() } returns flowOf(LinkAndSyncProgress.Failed())
@@ -170,4 +182,10 @@ class MessageSyncViewModelTest {
backgroundScope.launch { viewModel.state.collect {} }
return viewModel
}
private fun TestScope.collectActions(viewModel: MessageSyncViewModel): List<MessageSyncScreenAction> {
val actions = mutableListOf<MessageSyncScreenAction>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
return actions
}
}
@@ -7,6 +7,7 @@ package org.signal.registration.screens.pincreation
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.containsExactly
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
@@ -75,6 +76,21 @@ class PinCreationViewModelTest {
return states
}
private fun TestScope.collectActions(): List<PinCreationScreenActions> {
val actions = mutableListOf<PinCreationScreenActions>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
return actions
}
@Test
fun `LearnMore emits an action to open the learn more article`() = runTest(testDispatcher) {
val actions = collectActions()
viewModel.applyEvent(PinCreationState(), PinCreationScreenEvents.LearnMore)
assertThat(actions).containsExactly(PinCreationScreenActions.OpenLearnMoreArticle)
}
// ==================== PIN Confirmation Tests ====================
@Test
@@ -0,0 +1,64 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.signalloginpayment
import assertk.assertThat
import assertk.assertions.containsExactly
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationRepository
@OptIn(ExperimentalCoroutinesApi::class)
class SignalLoginPaymentViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var mockRepository: RegistrationRepository
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var viewModel: SignalLoginPaymentViewModel
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
parentEventEmitter = {}
viewModel = SignalLoginPaymentViewModel(
repository = mockRepository,
parentEventEmitter = parentEventEmitter
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun TestScope.collectActions(): List<SignalLoginPaymentScreenActions> {
val actions = mutableListOf<SignalLoginPaymentScreenActions>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
return actions
}
@Test
fun `LearnMoreClicked emits an action to open the learn more article`() = runTest(testDispatcher) {
val actions = collectActions()
viewModel.applyEvent(SignalLoginPaymentState(), SignalLoginPaymentScreenEvents.LearnMoreClicked, parentEventEmitter) {}
assertThat(actions).containsExactly(SignalLoginPaymentScreenActions.OpenLearnMoreArticle)
}
}
@@ -6,6 +6,7 @@
package org.signal.registration.screens.welcome
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isInstanceOf
@@ -16,8 +17,11 @@ import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
@@ -154,6 +158,22 @@ class WelcomeScreenViewModelTest {
.isInstanceOf<RegistrationRoute.AllowNotifications>()
}
@Test
fun `ViewTermsAndPrivacy emits an action to open the terms and privacy page`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel)
viewModel.applyEvent(WelcomeScreenState(), WelcomeScreenEvents.ViewTermsAndPrivacy, parentEventEmitter, stateEmitter)
assertThat(actions).containsExactly(WelcomeScreenActions.ViewTermsAndPrivacy)
}
private fun TestScope.collectActions(viewModel: WelcomeScreenViewModel): List<WelcomeScreenActions> {
val actions = mutableListOf<WelcomeScreenActions>()
backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } }
return actions
}
private fun createViewModel(
parentState: RegistrationFlowState = RegistrationFlowState(),
hasPermissions: Boolean = true,