diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 814a2d010b..925f7e6de2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -51,6 +51,7 @@ import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -104,6 +105,10 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity.Co import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayComponent import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePayRepository +import org.thoughtcrime.securesms.components.snackbars.LocalSnackbarStateConsumerRegistry +import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey +import org.thoughtcrime.securesms.components.snackbars.SnackbarState +import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.compose.SignalTheme @@ -134,13 +139,13 @@ import org.thoughtcrime.securesms.main.MainNavigationListLocation import org.thoughtcrime.securesms.main.MainNavigationRail import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainSnackbar +import org.thoughtcrime.securesms.main.MainSnackbarHostKey import org.thoughtcrime.securesms.main.MainToolbar import org.thoughtcrime.securesms.main.MainToolbarCallback import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarState import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder -import org.thoughtcrime.securesms.main.SnackbarState import org.thoughtcrime.securesms.main.callNavGraphBuilder import org.thoughtcrime.securesms.main.chatNavGraphBuilder import org.thoughtcrime.securesms.main.navigateToDetailLocation @@ -242,6 +247,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner private val megaphoneActionController = MainMegaphoneActionController() private val mainNavigationCallback = MainNavigationCallback() + private val snackbarRegistry = SnackbarStateConsumerRegistry() + override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) } override val googlePayResultPublisher: Subject = PublishSubject.create() @@ -315,10 +322,12 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner this ) { _, bundle -> if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) { - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( message = getString(R.string.CallQualitySheet__thanks_for_your_feedback), - duration = SnackbarDuration.Short + duration = SnackbarDuration.Short, + hostKey = MainSnackbarHostKey.Chat, + fallbackKey = MainSnackbarHostKey.MainChrome ) ) } @@ -327,7 +336,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner shareDataTimestampViewModel.setTimestampFromActivityCreation(savedInstanceState, intent) setContent { - val snackbar by mainNavigationViewModel.snackbar.collectAsStateWithLifecycle() val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle() val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle() val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle() @@ -367,10 +375,9 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } } - val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) { + val mainBottomChromeState = remember(mainToolbarState.destination, mainToolbarState.mode, megaphone) { MainBottomChromeState( destination = mainToolbarState.destination, - snackbarState = snackbar, mainToolbarMode = mainToolbarState.mode, megaphoneState = MainMegaphoneState( megaphone = megaphone, @@ -547,7 +554,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner snackbarHost = { if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Expanded) { MainSnackbar( - snackbarState = snackbar, + hostKey = SnackbarHostKey.Global, onDismissed = mainBottomChromeCallback::onSnackbarDismissed, modifier = Modifier.navigationBarsPadding() ) @@ -763,34 +770,36 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner private fun MainContainer(content: @Composable BoxWithConstraintsScope.() -> Unit) { val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) { - val backgroundColor = if (!windowSizeClass.isSplitPane()) { - MaterialTheme.colorScheme.surface - } else { - SignalTheme.colors.colorSurface1 - } - - val modifier = when { - windowSizeClass.isSplitPane() -> { - Modifier - .systemBarsPadding() - .displayCutoutPadding() + CompositionLocalProvider(LocalSnackbarStateConsumerRegistry provides mainNavigationViewModel.snackbarRegistry) { + SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(this)) { + val backgroundColor = if (!windowSizeClass.isSplitPane()) { + MaterialTheme.colorScheme.surface + } else { + SignalTheme.colors.colorSurface1 } - else -> - Modifier - .windowInsetsPadding( - WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) - .add(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) - ) - } + val modifier = when { + windowSizeClass.isSplitPane() -> { + Modifier + .systemBarsPadding() + .displayCutoutPadding() + } - BoxWithConstraints( - modifier = Modifier - .background(color = backgroundColor) - .then(modifier) - ) { - content() + else -> + Modifier + .windowInsetsPadding( + WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal) + .add(WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal)) + ) + } + + BoxWithConstraints( + modifier = Modifier + .background(color = backgroundColor) + .then(modifier) + ) { + content() + } } } } @@ -876,24 +885,26 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) { - mainNavigationViewModel.setSnackbar(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created))) + mainNavigationViewModel.snackbarRegistry.emit(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created), hostKey = MainSnackbarHostKey.MainChrome)) mainNavigationViewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL) } if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) { val snackbarString = getString(R.string.ConversationListFragment_username_recovered_toast, SignalStore.account.username) - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( - message = snackbarString + message = snackbarString, + hostKey = MainSnackbarHostKey.MainChrome ) ) } if (resultCode == RESULT_OK && requestCode == VerifyBackupKeyActivity.REQUEST_CODE) { - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( message = getString(R.string.VerifyBackupKey__backup_key_correct), - duration = SnackbarDuration.Short + duration = SnackbarDuration.Short, + hostKey = MainSnackbarHostKey.MainChrome ) ) mainNavigationViewModel.onMegaphoneSnoozed(Megaphones.Event.VERIFY_BACKUP_KEY) @@ -1205,9 +1216,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner mainNavigationViewModel.onMegaphoneVisible(megaphone) } - override fun onSnackbarDismissed() { - mainNavigationViewModel.setSnackbar(null) - } + override fun onSnackbarDismissed() = Unit } inner class MainMegaphoneActionController : MegaphoneActionController { @@ -1220,9 +1229,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner } override fun onMegaphoneToastRequested(string: String) { - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( - message = string + message = string, + hostKey = MainSnackbarHostKey.MainChrome ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 2199c2ad00..b700490f50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity +import org.thoughtcrime.securesms.components.snackbars.SnackbarState import org.thoughtcrime.securesms.conversation.ConversationUpdateTick import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController import org.thoughtcrime.securesms.conversation.v2.ConversationDialogs @@ -50,10 +51,10 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.main.MainNavigationListLocation import org.thoughtcrime.securesms.main.MainNavigationViewModel +import org.thoughtcrime.securesms.main.MainSnackbarHostKey import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder -import org.thoughtcrime.securesms.main.SnackbarState import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.CommunicationActions @@ -346,9 +347,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal override fun onStartAudioCallClicked(recipient: Recipient) { CommunicationActions.startVoiceCall(this, recipient) { - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( - getString(R.string.CommunicationActions__you_are_already_in_a_call) + getString(R.string.CommunicationActions__you_are_already_in_a_call), + hostKey = MainSnackbarHostKey.MainChrome ) ) } @@ -357,9 +359,10 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) { if (canUserBeginCall) { CommunicationActions.startVideoCall(this, recipient) { - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( - getString(R.string.CommunicationActions__you_are_already_in_a_call) + getString(R.string.CommunicationActions__you_are_already_in_a_call), + hostKey = MainSnackbarHostKey.MainChrome ) ) } @@ -452,10 +455,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal } CallLogDeletionResult.Success -> { - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( message = snackbarMessage, - duration = SnackbarDuration.Short + duration = SnackbarDuration.Short, + hostKey = MainSnackbarHostKey.MainChrome ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarExtensions.kt new file mode 100644 index 0000000000..dc311d49b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarExtensions.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.snackbars + +import androidx.compose.material3.SnackbarDuration +import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar + +fun Fragment.makeSnackbar(state: SnackbarState) { + if (view == null) { + return + } + + val snackbar = Snackbar.make( + requireView(), + state.message, + when (state.duration) { + SnackbarDuration.Short -> Snackbar.LENGTH_SHORT + SnackbarDuration.Long -> Snackbar.LENGTH_LONG + SnackbarDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE + } + ) + + state.actionState?.let { actionState -> + snackbar.setAction(actionState.action) { actionState.onActionClick() } + snackbar.setActionTextColor(requireContext().getColor(actionState.color)) + } + + snackbar.show() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarHostKey.kt b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarHostKey.kt new file mode 100644 index 0000000000..2f537289c8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarHostKey.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.snackbars + +/** + * Marker interface for identifying snackbar host locations. + * + * Implement this interface to define distinct snackbar display locations within the app. + * When a [SnackbarState] is emitted, its [SnackbarState.hostKey] is used to route the + * snackbar to the appropriate registered consumer. + */ +interface SnackbarHostKey { + object Global : SnackbarHostKey +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarState.kt new file mode 100644 index 0000000000..f9c6138a3e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarState.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.snackbars + +import androidx.annotation.ColorRes +import androidx.compose.material3.SnackbarDuration +import org.thoughtcrime.securesms.R + +/** + * Represents the state of a snackbar to be displayed. + * + * @property message The text message to display in the snackbar. + * @property actionState Optional action button configuration. + * @property showProgress Whether to show a progress indicator in the snackbar. + * @property duration How long the snackbar should be displayed. + * @property hostKey The target host where this snackbar should be displayed. + * @property fallbackKey Optional host to fallback upon if the host key is not registered. Defaults to the Global key. + */ +data class SnackbarState( + val message: String, + val actionState: ActionState? = null, + val showProgress: Boolean = false, + val duration: SnackbarDuration = SnackbarDuration.Long, + val hostKey: SnackbarHostKey, + val fallbackKey: SnackbarHostKey? = SnackbarHostKey.Global +) { + /** + * Configuration for a snackbar action button. + * + * @property action The text label for the action button. + * @property color The color resource for the action text. + * @property onActionClick Callback invoked when the action is clicked. + */ + data class ActionState( + val action: String, + @ColorRes val color: Int = R.color.core_white, + val onActionClick: () -> Unit + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumer.kt new file mode 100644 index 0000000000..a89a633303 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumer.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.snackbars + +/** + * A consumer that can display snackbar messages. + * + * Implementations are typically UI components that host a snackbar display area. + */ +fun interface SnackbarStateConsumer { + /** + * Consumes the given snackbar state. + * + * @param snackbarState The snackbar to display. + */ + fun consume(snackbarState: SnackbarState) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistry.kt b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistry.kt new file mode 100644 index 0000000000..11df77b495 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistry.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.snackbars + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.core.util.Consumer +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModel +import org.thoughtcrime.securesms.main.MainSnackbarHostKey +import java.io.Closeable + +/** + * CompositionLocal providing access to the [SnackbarStateConsumerRegistry]. + */ +val LocalSnackbarStateConsumerRegistry = staticCompositionLocalOf { + error("No SnackbarStateConsumerRegistry provided") +} + +@Composable +fun rememberSnackbarState( + key: SnackbarHostKey +): State { + val state: MutableState = remember(key) { mutableStateOf(null) } + + val registry = LocalSnackbarStateConsumerRegistry.current + DisposableEffect(registry, key) { + val registration = registry.register(MainSnackbarHostKey.MainChrome) { + state.value = it + } + + onDispose { + registration.close() + } + } + + return state +} + +/** + * Registry for managing snackbar consumers tied to lifecycle-aware components. + * + * Consumers are automatically enabled when their lifecycle resumes, disabled when paused, + * and removed when destroyed. + */ +class SnackbarStateConsumerRegistry : ViewModel() { + + private val entries = mutableSetOf() + + /** + * Registers a snackbar consumer for the given host and returns a [Closeable] to unregister it. + * + * The consumer starts enabled immediately. Call [Closeable.close] to unregister. + * This is useful for Compose components using DisposableEffect. + * + * If a consumer is already registered for the given host, it will be replaced. + * + * @param host The host key identifying this consumer's display location. + * @param consumer The consumer that will handle snackbar display. + * @return A [Closeable] that unregisters the consumer when closed. + */ + fun register(host: SnackbarHostKey, consumer: Consumer): Closeable { + entries.removeAll { it.host == host } + + val entry = Entry( + host = host, + consumer = consumer, + enabled = true + ) + entries.add(entry) + + return Closeable { entries.remove(entry) } + } + + /** + * Registers a snackbar consumer for the given host, bound to a lifecycle. + * + * The consumer will be automatically managed based on the lifecycle: + * - Enabled when the lifecycle is in RESUMED state + * - Disabled when paused + * - Removed when destroyed + * + * If a consumer is already registered for the given host, it will be replaced. + * + * @param host The host key identifying this consumer's display location. + * @param lifecycleOwner The lifecycle owner to bind the consumer to. + * @param consumer The consumer that will handle snackbar display. + * @throws IllegalStateException if the lifecycle is not at least CREATED. + */ + fun register(host: SnackbarHostKey, lifecycleOwner: LifecycleOwner, consumer: Consumer) { + val currentState = lifecycleOwner.lifecycle.currentState + check(currentState.isAtLeast(Lifecycle.State.CREATED)) { + "Cannot register a consumer with a lifecycle in state $currentState" + } + + val closeable = register(host, consumer) + val entry = entries.find { it.host == host }!! + entry.enabled = currentState.isAtLeast(Lifecycle.State.RESUMED) + + lifecycleOwner.lifecycle.addObserver(EntryLifecycleObserver(entry, closeable, lifecycleOwner)) + } + + /** + * Emits a snackbar state to be consumed by a registered consumer. + * + * The snackbar is first offered to the consumer registered for the matching [SnackbarState.hostKey]. + * If no matching consumer is enabled, the [SnackbarState.fallbackKey] is tried next (if present). + * Finally, the snackbar is offered to the first enabled registered consumer. + * + * @param snackbarState The snackbar state to emit. + */ + fun emit(snackbarState: SnackbarState) { + val matchingEntry = entries.find { it.host == snackbarState.hostKey && it.enabled } + if (matchingEntry != null) { + matchingEntry.consumer.accept(snackbarState) + return + } + + val fallbackEntry = snackbarState.fallbackKey?.let { fallback -> + entries.find { it.host == fallback && it.enabled } + } + if (fallbackEntry != null) { + fallbackEntry.consumer.accept(snackbarState) + return + } + + val firstEnabled = entries.find { it.enabled } + firstEnabled?.consumer?.accept(snackbarState) + } + + private class EntryLifecycleObserver( + private val entry: Entry, + private val closeable: Closeable, + private val lifecycleOwner: LifecycleOwner + ) : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + entry.enabled = true + } + + override fun onPause(owner: LifecycleOwner) { + entry.enabled = false + } + + override fun onDestroy(owner: LifecycleOwner) { + closeable.close() + lifecycleOwner.lifecycle.removeObserver(this) + } + } + + private data class Entry( + val host: SnackbarHostKey, + val consumer: Consumer, + var enabled: Boolean + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 6aa63448c2..7b85b24453 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -153,6 +153,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity +import org.thoughtcrime.securesms.components.snackbars.makeSnackbar import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner @@ -274,6 +275,7 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2 import org.thoughtcrime.securesms.longmessage.LongMessageFragment import org.thoughtcrime.securesms.main.MainNavigationListLocation import org.thoughtcrime.securesms.main.MainNavigationViewModel +import org.thoughtcrime.securesms.main.MainSnackbarHostKey import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity @@ -1089,6 +1091,14 @@ class ConversationFragment : } } + mainNavigationViewModel.snackbarRegistry.register( + MainSnackbarHostKey.Chat, + viewLifecycleOwner + ) { state -> + makeSnackbar(state) + true + } + menuProvider?.afterFirstRenderMode = true viewLifecycleOwner.lifecycle.addObserver(LastScrolledPositionUpdater(adapter, layoutManager, viewModel)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java index b7d4ca32a3..8317d4af69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -32,9 +32,10 @@ import androidx.recyclerview.widget.RecyclerView; import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.snackbars.SnackbarState; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.main.MainNavigationListLocation; -import org.thoughtcrime.securesms.main.SnackbarState; +import org.thoughtcrime.securesms.main.MainSnackbarHostKey; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.views.Stub; @@ -136,7 +137,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { - mainNavigationViewModel.setSnackbar(new SnackbarState( + mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState( getResources().getQuantityString(R.plurals.ConversationListFragment_moved_conversations_to_inbox, 1, 1), new SnackbarState.ActionState( getString(R.string.ConversationListFragment_undo), @@ -151,7 +152,9 @@ public class ConversationListArchiveFragment extends ConversationListFragment } ), false, - SnackbarDuration.Long + SnackbarDuration.Long, + MainSnackbarHostKey.MainChrome.INSTANCE, + null )); }) ); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 8679160bb0..63dcfc575d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -107,6 +107,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity; import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord; import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate; import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation; +import org.thoughtcrime.securesms.components.snackbars.SnackbarState; import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView; @@ -134,10 +135,10 @@ import org.thoughtcrime.securesms.keyvalue.AccountValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.main.MainNavigationListLocation; import org.thoughtcrime.securesms.main.MainNavigationViewModel; +import org.thoughtcrime.securesms.main.MainSnackbarHostKey; import org.thoughtcrime.securesms.main.MainToolbarMode; import org.thoughtcrime.securesms.main.MainToolbarViewModel; import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder; -import org.thoughtcrime.securesms.main.SnackbarState; import org.thoughtcrime.securesms.megaphone.Megaphones; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment; @@ -979,7 +980,7 @@ public class ConversationListFragment extends MainFragment implements Conversati .subscribe(() -> { endActionModeIfActive(); - mainNavigationViewModel.setSnackbar(new SnackbarState( + mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState( snackBarTitle, new SnackbarState.ActionState( getString(R.string.ConversationListFragment_undo), @@ -990,7 +991,9 @@ public class ConversationListFragment extends MainFragment implements Conversati } ), showProgress, - SnackbarDuration.Long + SnackbarDuration.Long, + MainSnackbarHostKey.MainChrome.INSTANCE, + null )); })); } @@ -1068,11 +1071,13 @@ public class ConversationListFragment extends MainFragment implements Conversati .toList()); if (toPin.size() + viewModel.getPinnedCount() > MAXIMUM_PINNED_CONVERSATIONS) { - mainNavigationViewModel.setSnackbar(new SnackbarState( + mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState( getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS), null, false, - SnackbarDuration.Long + SnackbarDuration.Long, + MainSnackbarHostKey.MainChrome.INSTANCE, + null )); endActionModeIfActive(); @@ -1419,7 +1424,7 @@ public class ConversationListFragment extends MainFragment implements Conversati .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(pinnedThreadIds -> { - mainNavigationViewModel.setSnackbar(new SnackbarState( + mainNavigationViewModel.getSnackbarRegistry().emit(new SnackbarState( getResources().getQuantityString(R.plurals.ConversationListFragment_conversations_archived, 1, 1), new SnackbarState.ActionState( getString(R.string.ConversationListFragment_undo), @@ -1436,7 +1441,9 @@ public class ConversationListFragment extends MainFragment implements Conversati } ), false, - SnackbarDuration.Long + SnackbarDuration.Long, + MainSnackbarHostKey.MainChrome.INSTANCE, + null )); }) ); diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt index 2481251288..e63a62fd37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt @@ -5,19 +5,18 @@ package org.thoughtcrime.securesms.main -import androidx.annotation.ColorRes import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,26 +24,15 @@ import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Snackbars -import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey +import org.thoughtcrime.securesms.components.snackbars.SnackbarState +import org.thoughtcrime.securesms.components.snackbars.rememberSnackbarState import org.thoughtcrime.securesms.megaphone.Megaphone import org.thoughtcrime.securesms.megaphone.MegaphoneActionController import org.thoughtcrime.securesms.megaphone.Megaphones import org.thoughtcrime.securesms.window.NavigationType import org.thoughtcrime.securesms.window.isSplitPane -data class SnackbarState( - val message: String, - val actionState: ActionState? = null, - val showProgress: Boolean = false, - val duration: SnackbarDuration = SnackbarDuration.Long -) { - data class ActionState( - val action: String, - @ColorRes val color: Int = R.color.core_white, - val onActionClick: () -> Unit - ) -} - interface MainBottomChromeCallback : MainFloatingActionButtonsCallback { fun onMegaphoneVisible(megaphone: Megaphone) fun onSnackbarDismissed() @@ -61,7 +49,6 @@ interface MainBottomChromeCallback : MainFloatingActionButtonsCallback { data class MainBottomChromeState( val destination: MainNavigationListLocation = MainNavigationListLocation.CHATS, val megaphoneState: MainMegaphoneState = MainMegaphoneState(), - val snackbarState: SnackbarState? = null, val mainToolbarMode: MainToolbarMode = MainToolbarMode.FULL ) @@ -117,7 +104,6 @@ fun MainBottomChrome( } MainSnackbar( - snackbarState = state.snackbarState, onDismissed = callback::onSnackbarDismissed, modifier = snackBarModifier ) @@ -126,11 +112,13 @@ fun MainBottomChrome( @Composable fun MainSnackbar( - snackbarState: SnackbarState?, onDismissed: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + hostKey: SnackbarHostKey = MainSnackbarHostKey.MainChrome ) { val hostState = remember { SnackbarHostState() } + val state: SnackbarState? by rememberSnackbarState(hostKey) + val snackbarState = state Snackbars.Host( hostState, @@ -175,13 +163,6 @@ fun MainBottomChromePreview() { state = MainBottomChromeState( megaphoneState = MainMegaphoneState( megaphone = megaphone - ), - snackbarState = SnackbarState( - message = "Test Message", - actionState = SnackbarState.ActionState( - action = "Ok", - onActionClick = {} - ) ) ), callback = MainBottomChromeCallback.Empty, diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index bc748b614a..77c09acb08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.rx3.asObservable import org.thoughtcrime.securesms.calls.log.CallLogRow import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository +import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.megaphone.Megaphone @@ -65,9 +66,6 @@ class MainNavigationViewModel( private val internalMegaphone = MutableStateFlow(Megaphone.NONE) val megaphone: StateFlow = internalMegaphone - private val internalSnackbar = MutableStateFlow(null) - val snackbar: StateFlow = internalSnackbar - private val internalNavigationEvents = MutableSharedFlow() val navigationEvents: Flow = internalNavigationEvents @@ -98,6 +96,8 @@ class MainNavigationViewModel( */ private var lockPaneToSecondary = false + val snackbarRegistry = SnackbarStateConsumerRegistry() + init { performStoreUpdate(MainNavigationRepository.getNumberOfUnreadMessages()) { unreadChats, state -> state.copy(chatsCount = unreadChats.toInt()) @@ -232,10 +232,6 @@ class MainNavigationViewModel( } } - fun setSnackbar(snackbarState: SnackbarState?) { - internalSnackbar.update { snackbarState } - } - fun onMegaphoneSnoozed(event: Megaphones.Event) { megaphoneRepository.markSeen(event) internalMegaphone.update { Megaphone.NONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainSnackbarHostKey.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainSnackbarHostKey.kt new file mode 100644 index 0000000000..c58df044c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainSnackbarHostKey.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.main + +import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey + +sealed interface MainSnackbarHostKey : SnackbarHostKey { + data object Chat : MainSnackbarHostKey + data object MainChrome : MainSnackbarHostKey +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index 403a4241b3..7fc27f6a38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.components.snackbars.SnackbarState import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs @@ -32,10 +33,10 @@ import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.main.MainNavigationListLocation import org.thoughtcrime.securesms.main.MainNavigationViewModel +import org.thoughtcrime.securesms.main.MainSnackbarHostKey import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder -import org.thoughtcrime.securesms.main.SnackbarState import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.StoryTextPostModel @@ -332,9 +333,10 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l private fun handleHideStory(model: StoriesLandingItem.Model) { StoryDialogs.hideStory(requireContext(), model.data.storyRecipient.getShortDisplayName(requireContext())) { viewModel.setHideStory(model.data.storyRecipient, true).subscribe { - mainNavigationViewModel.setSnackbar( + mainNavigationViewModel.snackbarRegistry.emit( SnackbarState( - message = getString(R.string.StoriesLandingFragment__story_hidden) + message = getString(R.string.StoriesLandingFragment__story_hidden), + hostKey = MainSnackbarHostKey.MainChrome ) ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistryTest.kt new file mode 100644 index 0000000000..c2bfa7869f --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/snackbars/SnackbarStateConsumerRegistryTest.kt @@ -0,0 +1,270 @@ +package org.thoughtcrime.securesms.components.snackbars + +import androidx.compose.material3.SnackbarDuration +import androidx.core.util.Consumer +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class SnackbarStateConsumerRegistryTest { + + private lateinit var registry: SnackbarStateConsumerRegistry + + private object TestHostKey1 : SnackbarHostKey + private object TestHostKey2 : SnackbarHostKey + private object TestHostKey3 : SnackbarHostKey + + @Before + fun setUp() { + registry = SnackbarStateConsumerRegistry() + } + + @Test + fun `register returns closeable that unregisters consumer`() { + val consumer: Consumer = mockk(relaxed = true) + val closeable = registry.register(TestHostKey1, consumer) + + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + verify(exactly = 1) { consumer.accept(any()) } + + closeable.close() + + registry.emit(snackbar) + verify(exactly = 1) { consumer.accept(any()) } + } + + @Test + fun `register replaces existing registration for same host`() { + val consumer1: Consumer = mockk(relaxed = true) + val consumer2: Consumer = mockk(relaxed = true) + + registry.register(TestHostKey1, consumer1) + registry.register(TestHostKey1, consumer2) + + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + + verify(exactly = 0) { consumer1.accept(any()) } + verify(exactly = 1) { consumer2.accept(snackbar) } + } + + @Test(expected = IllegalStateException::class) + fun `register with lifecycle throws when lifecycle not created`() { + val consumer: Consumer = mockk(relaxed = true) + val lifecycleOwner: LifecycleOwner = mockk() + val lifecycle: Lifecycle = mockk() + + every { lifecycleOwner.lifecycle } returns lifecycle + every { lifecycle.currentState } returns Lifecycle.State.DESTROYED + + registry.register(TestHostKey1, lifecycleOwner, consumer) + } + + @Test + fun `register with lifecycle starts disabled when not resumed`() { + val consumer: Consumer = mockk(relaxed = true) + val (lifecycleOwner, _) = createLifecycleOwner(Lifecycle.State.CREATED) + + registry.register(TestHostKey1, lifecycleOwner, consumer) + + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + + verify(exactly = 0) { consumer.accept(any()) } + } + + @Test + fun `register with lifecycle starts enabled when resumed`() { + val consumer: Consumer = mockk(relaxed = true) + val (lifecycleOwner, _) = createLifecycleOwner(Lifecycle.State.RESUMED) + + registry.register(TestHostKey1, lifecycleOwner, consumer) + + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + + verify(exactly = 1) { consumer.accept(snackbar) } + } + + @Test + fun `lifecycle observer enables consumer on resume`() { + val consumer: Consumer = mockk(relaxed = true) + val (lifecycleOwner, lifecycleRegistry) = createLifecycleOwner(Lifecycle.State.STARTED) + + registry.register(TestHostKey1, lifecycleOwner, consumer) + + val snackbar1 = createSnackbarState(TestHostKey1) + registry.emit(snackbar1) + verify(exactly = 0) { consumer.accept(any()) } + + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + + val snackbar2 = createSnackbarState(TestHostKey1) + registry.emit(snackbar2) + verify(exactly = 1) { consumer.accept(snackbar2) } + } + + @Test + fun `lifecycle observer disables consumer on pause`() { + val consumer: Consumer = mockk(relaxed = true) + val (lifecycleOwner, lifecycleRegistry) = createLifecycleOwner(Lifecycle.State.RESUMED) + + registry.register(TestHostKey1, lifecycleOwner, consumer) + + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + verify(exactly = 1) { consumer.accept(any()) } + + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + + registry.emit(snackbar) + verify(exactly = 1) { consumer.accept(any()) } + } + + @Test + fun `lifecycle observer unregisters consumer on destroy`() { + val consumer: Consumer = mockk(relaxed = true) + val newConsumer: Consumer = mockk(relaxed = true) + val (lifecycleOwner, lifecycleRegistry) = createLifecycleOwner(Lifecycle.State.RESUMED) + + registry.register(TestHostKey1, lifecycleOwner, consumer) + + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + + registry.register(TestHostKey1, newConsumer) + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + + verify(exactly = 0) { consumer.accept(any()) } + verify(exactly = 1) { newConsumer.accept(snackbar) } + } + + @Test + fun `emit routes to matching host`() { + val consumer1: Consumer = mockk(relaxed = true) + val consumer2: Consumer = mockk(relaxed = true) + + registry.register(TestHostKey1, consumer1) + registry.register(TestHostKey2, consumer2) + + val snackbar = createSnackbarState(TestHostKey2) + registry.emit(snackbar) + + verify(exactly = 0) { consumer1.accept(any()) } + verify(exactly = 1) { consumer2.accept(snackbar) } + } + + @Test + fun `emit routes to fallback when no matching host`() { + val consumer1: Consumer = mockk(relaxed = true) + val consumer2: Consumer = mockk(relaxed = true) + + registry.register(TestHostKey1, consumer1) + registry.register(TestHostKey2, consumer2) + + val snackbar = createSnackbarState(hostKey = TestHostKey3, fallbackKey = TestHostKey1) + registry.emit(snackbar) + + verify(exactly = 1) { consumer1.accept(snackbar) } + verify(exactly = 0) { consumer2.accept(any()) } + } + + @Test + fun `emit routes to first enabled when no match and no fallback match`() { + val consumer1: Consumer = mockk(relaxed = true) + val consumer2: Consumer = mockk(relaxed = true) + + registry.register(TestHostKey1, consumer1) + registry.register(TestHostKey2, consumer2) + + val snackbar = createSnackbarState(hostKey = TestHostKey3, fallbackKey = null) + registry.emit(snackbar) + + verify(exactly = 1) { consumer1.accept(snackbar) } + verify(exactly = 0) { consumer2.accept(any()) } + } + + @Test + fun `emit does nothing when no enabled consumers`() { + val consumer: Consumer = mockk(relaxed = true) + val (lifecycleOwner, _) = createLifecycleOwner(Lifecycle.State.CREATED) + + registry.register(TestHostKey1, lifecycleOwner, consumer) + + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + + verify(exactly = 0) { consumer.accept(any()) } + } + + @Test + fun `emit skips disabled consumers and finds enabled fallback`() { + val consumer1: Consumer = mockk(relaxed = true) + val consumer2: Consumer = mockk(relaxed = true) + + val (lifecycleOwner1, _) = createLifecycleOwner(Lifecycle.State.CREATED) + registry.register(TestHostKey1, lifecycleOwner1, consumer1) + registry.register(TestHostKey2, consumer2) + + val snackbar = createSnackbarState(hostKey = TestHostKey1, fallbackKey = TestHostKey2) + registry.emit(snackbar) + + verify(exactly = 0) { consumer1.accept(any()) } + verify(exactly = 1) { consumer2.accept(snackbar) } + } + + @Test + fun `emit skips disabled matching and fallback, finds first enabled`() { + val consumer1: Consumer = mockk(relaxed = true) + val consumer2: Consumer = mockk(relaxed = true) + val consumer3: Consumer = mockk(relaxed = true) + + val (lifecycleOwner1, _) = createLifecycleOwner(Lifecycle.State.CREATED) + val (lifecycleOwner2, _) = createLifecycleOwner(Lifecycle.State.CREATED) + + registry.register(TestHostKey1, lifecycleOwner1, consumer1) + registry.register(TestHostKey2, lifecycleOwner2, consumer2) + registry.register(TestHostKey3, consumer3) + + val snackbar = createSnackbarState(hostKey = TestHostKey1, fallbackKey = TestHostKey2) + registry.emit(snackbar) + + verify(exactly = 0) { consumer1.accept(any()) } + verify(exactly = 0) { consumer2.accept(any()) } + verify(exactly = 1) { consumer3.accept(snackbar) } + } + + @Test + fun `emit does nothing when no consumers registered`() { + val snackbar = createSnackbarState(TestHostKey1) + registry.emit(snackbar) + } + + private fun createSnackbarState( + hostKey: SnackbarHostKey, + fallbackKey: SnackbarHostKey? = SnackbarHostKey.Global + ): SnackbarState { + return SnackbarState( + message = "Test message", + hostKey = hostKey, + fallbackKey = fallbackKey, + duration = SnackbarDuration.Short + ) + } + + private fun createLifecycleOwner(initialState: Lifecycle.State): Pair { + val lifecycleOwner: LifecycleOwner = mockk() + val lifecycleRegistry = LifecycleRegistry.createUnsafe(lifecycleOwner) + lifecycleRegistry.currentState = initialState + every { lifecycleOwner.lifecycle } returns lifecycleRegistry + return Pair(lifecycleOwner, lifecycleRegistry) + } +}