From b462ef21490f6822cb8f1b23075340b802f29e29 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Thu, 2 Jul 2026 10:14:07 -0400 Subject: [PATCH] Allow banners to reactively update their enabled state. --- .../thoughtcrime/securesms/banner/Banner.kt | 7 +++ .../securesms/banner/BannerManager.kt | 44 ++++++++++++++----- .../banners/ArchiveRestoreStatusBanner.kt | 10 ++++- .../banners/ArchiveUploadStatusBanner.kt | 10 ++++- .../banner/banners/UnauthorizedBanner.kt | 13 ++++++ .../ConversationListFragment.java | 11 ----- 6 files changed, 68 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/Banner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/Banner.kt index 0a0e8e38f3..cb6e9c60fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/Banner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/Banner.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.banner import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow /** * This class represents a banner across the top of the screen. @@ -28,6 +29,12 @@ abstract class Banner { */ abstract val dataFlow: Flow + /** + * Emits whenever this banner's [enabled] state may have changed. + */ + open val stateUpdates: Flow + get() = emptyFlow() + /** * Composable function to display the content emitted from [dataFlow]. * You likely want to use [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner]. diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt index 759152d24a..9ba5bd4b52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt @@ -15,6 +15,11 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.logging.Log @@ -32,6 +37,22 @@ class BannerManager @JvmOverloads constructor( val TAG = Log.tag(BannerManager::class) } + private fun selectEnabledBanner(): Banner? = banners.firstOrNull { it.enabled } as Banner? + + /** + * Reactively selects which [Banner] should be shown. Re-evaluates whenever any banner signals via + * [Banner.stateUpdates] that its eligibility may have changed. + */ + private val selectedBanner: Flow?> = + if (banners.isEmpty()) { + flowOf(null) + } else { + merge(*banners.map { it.stateUpdates }.toTypedArray()) + .onStart { emit(Unit) } + .map { selectEnabledBanner() } + .distinctUntilChanged() + } + /** * Re-evaluates the [Banner]s, choosing one to render (if any) and updating the view. */ @@ -39,19 +60,20 @@ class BannerManager @JvmOverloads constructor( composeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - val banner: Banner? = banners.firstOrNull { it.enabled } as Banner? - if (banner == null) { + val banner: Banner? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner()) + val selected = banner + if (selected == null) { onNoBannerShownListener() return@setContent } - key(banner) { - val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null) + key(selected) { + val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null) bannerState?.let { model -> SignalTheme { Box { - banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp)) + selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp)) } } onNewBannerShownListener() @@ -66,16 +88,14 @@ class BannerManager @JvmOverloads constructor( */ @Composable fun Banner() { - val banner: Banner? = banners.firstOrNull { it.enabled } as Banner? - if (banner == null) { - return - } + val banner: Banner? by selectedBanner.collectAsStateWithLifecycle(initialValue = selectEnabledBanner()) + val selected = banner ?: return - key(banner) { - val bannerState by banner.dataFlow.collectAsStateWithLifecycle(initialValue = null) + key(selected) { + val bannerState by selected.dataFlow.collectAsStateWithLifecycle(initialValue = null) bannerState?.let { model -> - banner.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp)) + selected.DisplayBanner(model, PaddingValues(horizontal = 12.dp, vertical = 8.dp)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveRestoreStatusBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveRestoreStatusBanner.kt index bfbb142f8a..b1f47e2a52 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveRestoreStatusBanner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveRestoreStatusBanner.kt @@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState @@ -32,6 +34,12 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList } } + override val stateUpdates: Flow + get() = ArchiveRestoreProgress.stateFlow + .map { enabled } + .distinctUntilChanged() + .map { } + @Composable override fun DisplayBanner(model: ArchiveRestoreProgressState, contentPadding: PaddingValues) { ArchiveRestoreStatusBanner( @@ -40,7 +48,6 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList onActionClick = listener::onActionClick, onDismissClick = { ArchiveRestoreProgress.clearFinishedStatus() - listener.onDismissComplete() } ) } @@ -48,6 +55,5 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList interface RestoreProgressBannerListener { fun onBannerClick() fun onActionClick(data: ArchiveRestoreProgressState) - fun onDismissComplete() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt index 9da6c96fce..0b04815d58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.signal.core.util.bytes import org.thoughtcrime.securesms.backup.ArchiveUploadProgress @@ -78,6 +79,12 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen } } + override val stateUpdates: Flow + get() = ArchiveUploadProgress.progress + .map { enabled } + .distinctUntilChanged() + .map { } + @Composable override fun DisplayBanner(model: ArchiveUploadStatusBannerViewState, contentPadding: PaddingValues) { ArchiveUploadStatusBannerView( @@ -92,7 +99,7 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen } ArchiveUploadStatusBannerViewEvents.HideClicked -> { SignalStore.backup.uploadBannerVisible = false - listener.onHidden() + ArchiveUploadProgress.triggerUpdate() } } } @@ -105,6 +112,5 @@ class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListen interface UploadProgressBannerListener { fun onBannerClick() fun onCancelClicked() - fun onHidden() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/UnauthorizedBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/UnauthorizedBanner.kt index c85de26005..855f303710 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/UnauthorizedBanner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/UnauthorizedBanner.kt @@ -12,7 +12,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.rx3.asFlow import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.R @@ -22,6 +25,7 @@ import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner import org.thoughtcrime.securesms.banner.ui.compose.Importance import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor +import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.registration.ui.RegistrationActivity import org.thoughtcrime.securesms.util.TextSecurePreferences @@ -36,6 +40,15 @@ class UnauthorizedBanner(val context: Context) : Banner() { override val dataFlow: Flow get() = flowOf(Unit) + override val stateUpdates: Flow + get() = Recipient.self() + .live() + .observable() + .asFlow() + .map { enabled } + .distinctUntilChanged() + .map { } + @Composable override fun DisplayBanner(model: Unit, contentPadding: PaddingValues) { Banner(contentPadding, SignalStore.account.isLinkedDevice) 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 2584f44384..e8bf4f6cf6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -874,11 +874,6 @@ public class ConversationListFragment extends MainFragment implements Conversati .show(); } } - - @Override - public void onDismissComplete() { - bannerManager.updateContent(bannerView.get()); - } }), new ArchiveUploadStatusBanner(new ArchiveUploadStatusBanner.UploadProgressBannerListener() { @Override @@ -894,15 +889,9 @@ public class ConversationListFragment extends MainFragment implements Conversati .setNegativeButton(R.string.CancelBackupDialog_continue_action, null) .setPositiveButton(R.string.CancelBackupDialog_cancel_action, (d, w) -> { ArchiveUploadProgress.INSTANCE.cancel(); - bannerManager.updateContent(bannerView.get()); }) .show(); } - - @Override - public void onHidden() { - bannerManager.updateContent(bannerView.get()); - } }) );