mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-14 09:13:42 +01:00
Use action pattern in registration module.
This commit is contained in:
+5
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-43
@@ -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) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
+7
-1
@@ -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 -> {
|
||||
|
||||
+11
@@ -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
-1
@@ -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 }) {
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
+10
-1
@@ -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))
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
+7
-1
@@ -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 -> {
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
+7
-1
@@ -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 -> {
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
@@ -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()
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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
|
||||
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user