From 98d386b44fa0b7af95274da597364d864e4a7bb0 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Sat, 8 Aug 2026 08:00:59 -0400 Subject: [PATCH] Use action pattern in registration module. --- .../ConversationSettingsFragment.kt | 17 ++-- .../signal/core/ui/compose/CollectActions.kt | 26 ++++++ .../registration/RegistrationNavigation.kt | 86 +++++++++---------- .../addusername/AddUsernameScreenActions.kt | 11 +++ .../addusername/AddUsernameViewModel.kt | 8 +- .../linkaccount/LinkAccountScreenAction.kt | 11 +++ .../linkaccount/LinkAccountViewModel.kt | 11 ++- .../messagesync/MessageSyncScreenAction.kt | 11 +++ .../messagesync/MessageSyncViewModel.kt | 11 ++- .../pincreation/PinCreationScreenActions.kt | 11 +++ .../pincreation/PinCreationViewModel.kt | 8 +- .../SignalLoginPaymentScreenActions.kt | 11 +++ .../SignalLoginPaymentViewModel.kt | 8 +- .../screens/welcome/WelcomeScreenActions.kt | 11 +++ .../screens/welcome/WelcomeScreenViewModel.kt | 8 +- .../addusername/AddUsernameViewModelTest.kt | 64 ++++++++++++++ .../linkaccount/LinkAccountViewModelTest.kt | 17 ++++ .../messagesync/MessageSyncViewModelTest.kt | 18 ++++ .../pincreation/PinCreationViewModelTest.kt | 16 ++++ .../SignalLoginPaymentViewModelTest.kt | 64 ++++++++++++++ .../welcome/WelcomeScreenViewModelTest.kt | 20 +++++ 21 files changed, 387 insertions(+), 61 deletions(-) create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/CollectActions.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenAction.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenAction.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenActions.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenActions.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt create mode 100644 feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index ea9445a206..2ef4e3a446 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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) { - LaunchedEffect(actions) { - actions.collect { action -> handleAction(action) } - } - } - @Composable private fun NotifyWhenLoaded(isLoaded: Boolean) { LaunchedEffect(isLoaded) { diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/CollectActions.kt b/core/ui/src/main/java/org/signal/core/ui/compose/CollectActions.kt new file mode 100644 index 0000000000..2a846e5308 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/CollectActions.kt @@ -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 CollectActions(actions: Flow, onAction: (T) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(actions, lifecycleOwner) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + actions.collect(onAction) + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 786106f31e..2ec4ba44c0 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -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.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.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.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.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.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.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) } ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt new file mode 100644 index 0000000000..e9d210a796 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameScreenActions.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt index 6a5875b297..3dfee72cd3 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/addusername/AddUsernameViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenAction.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenAction.kt new file mode 100644 index 0000000000..069cac9540 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountScreenAction.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt index c1e1e8a32a..569562c9d2 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/linkaccount/LinkAccountViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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 }) { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenAction.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenAction.kt new file mode 100644 index 0000000000..899123b980 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncScreenAction.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt index 919994fc21..75dc6e6e7a 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/messagesync/MessageSyncViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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)) diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenActions.kt new file mode 100644 index 0000000000..6b5b2b5b0f --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationScreenActions.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt index 12429b92d1..79b92b074e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/pincreation/PinCreationViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt new file mode 100644 index 0000000000..0835443c8f --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt index 35ef328db8..d972646640 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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 -> { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenActions.kt new file mode 100644 index 0000000000..8b4c293eaa --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenActions.kt @@ -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 +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenViewModel.kt index 55c2c5fe00..ed0141da6d 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/welcome/WelcomeScreenViewModel.kt @@ -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 = _state.asStateFlow() + private val _actions = Channel(Channel.BUFFERED) + val actions: Flow = _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) } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt new file mode 100644 index 0000000000..221b4ec21b --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/addusername/AddUsernameViewModelTest.kt @@ -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 { + val actions = mutableListOf() + 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) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt index 4818cde5ce..cdd4547787 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/linkaccount/LinkAccountViewModelTest.kt @@ -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 { + val actions = mutableListOf() + 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() diff --git a/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt index d0705b7e6c..765cacef45 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/messagesync/MessageSyncViewModelTest.kt @@ -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 { + val actions = mutableListOf() + backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } } + return actions + } } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt index fdbbaf6240..87be28a651 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/pincreation/PinCreationViewModelTest.kt @@ -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 { + val actions = mutableListOf() + 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 diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt new file mode 100644 index 0000000000..9cb559c309 --- /dev/null +++ b/feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt @@ -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 { + val actions = mutableListOf() + 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) + } +} diff --git a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenViewModelTest.kt index ab93bedc57..d11f00aca1 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/welcome/WelcomeScreenViewModelTest.kt @@ -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() } + @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 { + val actions = mutableListOf() + backgroundScope.launch(testDispatcher) { viewModel.actions.collect { actions.add(it) } } + return actions + } + private fun createViewModel( parentState: RegistrationFlowState = RegistrationFlowState(), hasPermissions: Boolean = true,