diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/Banner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/Banner.kt new file mode 100644 index 0000000000..089a63d6a5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/Banner.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.banner + +import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import org.signal.core.util.logging.Log + +/** + * This class represents a banner across the top of the screen. + * + * Typically, a class will subclass [Banner] and have a nested class that subclasses [BannerFactory]. + * The constructor for an implementation of [Banner] should be very lightweight, as it is may be called frequently. + */ +abstract class Banner { + companion object { + private val TAG = Log.tag(Banner::class) + + /** + * A helper function to create a [Flow] of a [Banner]. + * + * @param bannerFactory a block the produces a [Banner], or null. Returning null will complete the [Flow] without emitting any values. + */ + @JvmStatic + fun createAndEmit(bannerFactory: () -> T?): Flow { + return bannerFactory()?.let { + flow { emit(it) } + } ?: emptyFlow() + } + } + + /** + * Whether or not the [Banner] should be shown (enabled) or hidden (disabled). + */ + abstract var enabled: Boolean + + /** + * Composable function to display content when [enabled] is true. + * + * @see [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner] + */ + @Composable + abstract fun DisplayBanner() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt new file mode 100644 index 0000000000..d0f3ccf556 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/BannerManager.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.banner + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +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.combine +import org.signal.core.util.logging.Log + +/** + * A class that can be instantiated with a list of [Flow]s that produce [Banner]s, then applied to a [ComposeView], typically within a [Fragment]. + * Usually, the [Flow]s will come from [Banner.BannerFactory] instances, but may also be produced by the other properties of the host. + */ +class BannerManager(allFlows: Iterable>) { + + companion object { + val TAG = Log.tag(BannerManager::class) + } + + /** + * Takes the flows and combines them into one so that a new [Flow] value from any of them will trigger an update to the UI. + * + * **NOTE**: This will **not** emit its first value until **all** of the input flows have each emitted *at least one value*. + */ + private val combinedFlow: Flow> = combine(allFlows) { banners: Array -> + banners.filter { it.enabled }.toList() + } + + /** + * Sets the content of the provided [ComposeView] to one that consumes the lists emitted by [combinedFlow] and displays them. + */ + fun setContent(composeView: ComposeView) { + composeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val state = combinedFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + + state.value.firstOrNull()?.let { + Box(modifier = Modifier.padding(8.dp)) { + it.DisplayBanner() + } + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ExpiredBuildBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ExpiredBuildBanner.kt new file mode 100644 index 0000000000..e1990be11c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ExpiredBuildBanner.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.banner.banners + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import kotlinx.coroutines.flow.Flow +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.banner.Banner +import org.thoughtcrime.securesms.banner.ui.compose.Action +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.util.PlayStoreUtil + +/** + * Banner to let the user know their build is about to expire. + * + * This serves as an example for how we can replicate the functionality of the old [org.thoughtcrime.securesms.components.reminder.Reminder] system purely in the new [Banner] system. + */ +class ExpiredBuildBanner(val context: Context) : Banner() { + + override var enabled = true + + @Composable + override fun DisplayBanner() { + DefaultBanner( + title = null, + body = stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today), + importance = Importance.TERMINAL, + isDismissible = false, + actions = listOf( + Action(R.string.ExpiredBuildReminder_update_now) { + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context) + } + ), + onHideListener = {}, + onDismissListener = {} + ) + } + + companion object { + @JvmStatic + fun createFlow(context: Context): Flow = createAndEmit { + if (SignalStore.misc.isClientDeprecated) { + ExpiredBuildBanner(context) + } else { + null + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/ui/compose/DefaultBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/ui/compose/DefaultBanner.kt new file mode 100644 index 0000000000..377fe4b7c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/ui/compose/DefaultBanner.kt @@ -0,0 +1,237 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.banner.ui.compose + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.SignalPreview +import org.signal.core.util.isNotNullOrBlank +import org.thoughtcrime.securesms.R + +/** + * A layout intended to display an in-app notification at the top of their screen, + * and optionally allow them to take some action(s) in response. + */ +@Composable +fun DefaultBanner( + title: String?, + body: String, + importance: Importance, + isDismissible: Boolean, + onDismissListener: () -> Unit, + onHideListener: () -> Unit, + @DrawableRes icon: Int? = null, + actions: List = emptyList(), + showProgress: Boolean = false, + progressText: String = "", + progressPercent: Int = -1 +) { + Box( + modifier = Modifier + .background( + color = when (importance) { + Importance.NORMAL -> MaterialTheme.colorScheme.surface + Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.reminder_background) + } + ) + .border( + width = 1.dp, + color = colorResource(id = R.color.signal_colorOutline_38), + shape = RoundedCornerShape(12.dp) + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .defaultMinSize(minHeight = 74.dp) + ) { + if (icon != null) { + Box( + modifier = Modifier + .padding(horizontal = 12.dp) + .size(48.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = icon), + contentDescription = stringResource(id = R.string.ReminderView_icon_content_description), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.5.dp) + ) + } + } + Column { + Row(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .padding(12.dp) + .weight(1f) + ) { + if (title.isNotNullOrBlank()) { + Text( + text = title, + color = when (importance) { + Importance.NORMAL -> MaterialTheme.colorScheme.onSurface + Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.signal_light_colorOnSurface) + }, + style = MaterialTheme.typography.bodyLarge + ) + } + + Text( + text = body, + color = when (importance) { + Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant + Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.signal_light_colorOnSurface) + }, + style = MaterialTheme.typography.bodyMedium + ) + + if (showProgress) { + if (progressPercent >= 0) { + LinearProgressIndicator( + progress = { progressPercent / 100f }, + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.padding(vertical = 12.dp) + ) + } else { + LinearProgressIndicator( + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.padding(vertical = 12.dp) + ) + } + Text( + text = progressText, + style = MaterialTheme.typography.bodySmall, + color = when (importance) { + Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant + Importance.ERROR, Importance.TERMINAL -> colorResource(id = R.color.signal_light_colorOnSurface) + } + ) + } + } + + if (isDismissible) { + IconButton( + onClick = { + onHideListener() + onDismissListener() + }, + modifier = Modifier.size(48.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_x_24), + contentDescription = stringResource(id = R.string.InviteActivity_cancel) + ) + } + } + } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + for (action in actions) { + TextButton(onClick = action.onClick) { + Text(text = stringResource(id = action.label)) + } + } + } + } + } + } +} + +data class Action(@StringRes val label: Int, val onClick: () -> Unit) + +enum class Importance { + NORMAL, ERROR, TERMINAL +} + +@Composable +@SignalPreview +private fun ForcedUpgradePreview() { + DefaultBanner( + title = null, + body = stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today), + importance = Importance.TERMINAL, + isDismissible = false, + actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}), + onHideListener = { }, + onDismissListener = {} + ) +} + +@Composable +@SignalPreview +private fun FullyLoadedErrorPreview() { + val actions = listOf( + Action(R.string.ExpiredBuildReminder_update_now) { }, + Action(R.string.BubbleOptOutTooltip__turn_off) { } + ) + DefaultBanner( + icon = R.drawable.symbol_error_circle_24, + title = "Error", + body = "Creating more errors.", + importance = Importance.ERROR, + isDismissible = true, + actions = actions, + showProgress = true, + progressText = "4 out of 10 errors created.", + progressPercent = 40, + onHideListener = { }, + onDismissListener = {} + ) +} + +@Composable +@SignalPreview +private fun FullyLoadedTerminalPreview() { + val actions = listOf( + Action(R.string.ExpiredBuildReminder_update_now) { }, + Action(R.string.BubbleOptOutTooltip__turn_off) { } + ) + DefaultBanner( + icon = R.drawable.symbol_error_circle_24, + title = "Terminal", + body = "This is a terminal state.", + importance = Importance.TERMINAL, + isDismissible = true, + actions = actions, + showProgress = true, + progressText = "93% terminated", + progressPercent = 93, + onHideListener = { }, + onDismissListener = {} + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderView.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderView.java index 88e31dcf20..d87b5aa8a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/ReminderView.java @@ -5,7 +5,6 @@ import android.text.TextUtils; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ProgressBar; 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 860eb346c5..fd4e1f2dc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -56,6 +56,7 @@ import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.view.ActionMode; import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.TooltipCompat; +import androidx.compose.ui.platform.ComposeView; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.ContextCompat; import androidx.core.view.ViewCompat; @@ -94,6 +95,9 @@ import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertDelegate; import org.thoughtcrime.securesms.badges.models.Badge; import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment; import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment; +import org.thoughtcrime.securesms.banner.Banner; +import org.thoughtcrime.securesms.banner.BannerManager; +import org.thoughtcrime.securesms.banner.banners.ExpiredBuildBanner; import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog; import org.thoughtcrime.securesms.components.Material3SearchToolbar; import org.thoughtcrime.securesms.components.RatingManager; @@ -171,6 +175,7 @@ import org.thoughtcrime.securesms.util.AppStartup; import org.thoughtcrime.securesms.util.CachedInflater; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.PlayStoreUtil; +import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalProxyUtil; @@ -198,6 +203,7 @@ import java.util.Set; import java.util.stream.Collectors; import kotlin.Unit; +import kotlinx.coroutines.flow.Flow; import static android.app.Activity.RESULT_OK; @@ -223,6 +229,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode private View coordinator; private RecyclerView list; private Stub reminderView; + private Stub bannerView; private PulsingFloatingActionButton fab; private PulsingFloatingActionButton cameraFab; private ConversationListFilterPullView pullView; @@ -287,6 +294,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode list = view.findViewById(R.id.list); bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar); reminderView = new Stub<>(view.findViewById(R.id.reminder)); + bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view)); megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container)); voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player)); fab = view.findViewById(R.id.fab); @@ -414,6 +422,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode initializeListAdapters(); initializeTypingObserver(); initializeVoiceNotePlayer(); + initializeBanners(); RatingManager.showRatingDialogIfNecessary(requireContext()); @@ -871,6 +880,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode }); } + private void initializeBanners() { + if (RemoteConfig.newBannerUi()) { + final List> bannerRepositories = List.of(ExpiredBuildBanner.createFlow(requireContext())); + final BannerManager bannerManager = new BannerManager(bannerRepositories); + bannerManager.setContent(bannerView.get()); + } + } + private @NonNull VoiceNotePlayerView requireVoiceNotePlayerView() { if (voiceNotePlayerView == null) { voiceNotePlayerView = voiceNotePlayerViewStub.get().findViewById(R.id.voice_note_player_view); @@ -1042,6 +1059,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode } private void updateReminders() { + if (RemoteConfig.newBannerUi()) { + return; + } Context context = requireContext(); SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 8b42e12054..6c2692a521 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1091,6 +1091,15 @@ object RemoteConfig { hotSwappable = true ) + /** Whether to use the new Banner system instead of the old Reminder system. */ + @JvmStatic + @get:JvmName("newBannerUi") + val newBannerUi: Boolean by remoteBoolean( + key = "android.newBannerUi", + defaultValue = false, + hotSwappable = true + ) + /** Which phase we're in for the SVR3 migration */ val svr3MigrationPhase: Int by remoteInt( key = "global.svr3.phase", diff --git a/app/src/main/res/layout/conversation_list_banner_view.xml b/app/src/main/res/layout/conversation_list_banner_view.xml new file mode 100644 index 0000000000..0f6602adb5 --- /dev/null +++ b/app/src/main/res/layout/conversation_list_banner_view.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index 7b2d1a4a86..cfca1b0e3f 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -25,12 +25,20 @@ android:layout="@layout/conversation_list_reminder_view" app:layout_constraintTop_toTopOf="parent" /> + + + app:constraint_referenced_ids="reminder,voice_note_player,banner_compose_view" /> Confirm and download later Keep subscription + + + Reminder icon