Reimplement conversation action mode to not use system actionmode.

This commit is contained in:
Alex Hart
2025-10-29 09:57:54 -03:00
committed by jeffrey-signal
parent b9e0d9978b
commit e0d56bfadf
7 changed files with 199 additions and 141 deletions

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
/**
* A consistent ActionMode top-bar for dealing with multiselect scenarios.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ActionModeTopBar(
title: String,
onCloseClick: () -> Unit,
toolbarColor: Color? = null,
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarColor ?: MaterialTheme.colorScheme.surface
),
navigationIcon = {
IconButtons.IconButton(onClick = onCloseClick) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24),
contentDescription = stringResource(R.string.CallScreenTopBar__go_back)
)
}
},
title = {
Text(text = title)
},
windowInsets = windowInsets
)
}
@PreviewLightDark
@Composable
fun ActionModeTopBarPreview() {
Previews.Preview {
ActionModeTopBar(
title = "1 selected",
onCloseClick = {}
)
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import android.content.Context
import android.util.AttributeSet
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.AbstractComposeView
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.util.DynamicTheme
/**
* A View wrapper for [ActionModeTopBar] so that we can use the same UI element in View and Compose land.
*/
class ActionModeTopBarView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
var title by mutableStateOf("")
var onCloseClick: () -> Unit by mutableStateOf({})
@Composable
override fun Content() {
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) {
Surface(
color = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface
) {
ActionModeTopBar(
title = title,
toolbarColor = Color.Transparent,
onCloseClick = onCloseClick,
windowInsets = WindowInsets()
)
}
}
}
}

View File

@@ -78,12 +78,8 @@ public final class ConversationReactionOverlay extends FrameLayout {
private boolean downIsOurs;
private int selected = -1;
private int customEmojiIndex;
private int originalStatusBarColor;
private int originalNavigationBarColor;
private View dropdownAnchor;
private View toolbarShade;
private View inputShade;
private View conversationItem;
private View backgroundView;
private ConstraintLayout foregroundView;
@@ -121,8 +117,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
super.onFinishInflate();
dropdownAnchor = findViewById(R.id.dropdown_anchor);
toolbarShade = findViewById(R.id.toolbar_shade);
inputShade = findViewById(R.id.input_shade);
conversationItem = findViewById(R.id.conversation_item);
backgroundView = findViewById(R.id.conversation_reaction_scrubber_background);
foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground);
@@ -214,9 +208,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
@NonNull ConversationMessage conversationMessage,
@NonNull PointF lastSeenDownPoint,
boolean isMessageOnLeft) {
updateToolbarShade();
updateInputShade();
contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage));
conversationItem.setX(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset());
@@ -397,18 +388,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar);
}
private void updateToolbarShade() {
LayoutParams layoutParams = (LayoutParams) toolbarShade.getLayoutParams();
layoutParams.height = 0;
toolbarShade.setLayoutParams(layoutParams);
}
private void updateInputShade() {
LayoutParams layoutParams = (LayoutParams) inputShade.getLayoutParams();
layoutParams.height = 0;
inputShade.setLayoutParams(layoutParams);
}
/**
* Returns true when the device is in a configuration where the navigation bar doesn't take up
* space at the bottom of the screen.
@@ -448,9 +427,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
@Override public void onAnimationEnd(Animator animation) {
animatorSet.removeListener(this);
toolbarShade.setVisibility(INVISIBLE);
inputShade.setVisibility(INVISIBLE);
if (onHideListener != null) {
onHideListener.onHide();
}
@@ -872,22 +848,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
itemYAnim.setDuration(duration);
animators.add(itemYAnim);
if (activity != null) {
ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor);
statusBarAnim.setDuration(duration);
statusBarAnim.addUpdateListener(animation -> {
WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue());
});
animators.add(statusBarAnim);
ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor);
navigationBarAnim.setDuration(duration);
navigationBarAnim.addUpdateListener(animation -> {
WindowUtil.setNavigationBarColor(activity, (int) animation.getAnimatedValue());
});
animators.add(navigationBarAnim);
}
return animators;
}

View File

@@ -45,8 +45,6 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.MainThread
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.core.app.ActivityOptionsCompat
@@ -135,6 +133,7 @@ import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.compose.ActionModeTopBarView
import org.thoughtcrime.securesms.components.compose.DeleteSyncEducationDialog
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
@@ -535,7 +534,6 @@ class ConversationFragment :
private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback
private var animationsAllowed = false
private var actionMode: ActionMode? = null
private var pinnedShortcutReceiver: BroadcastReceiver? = null
private var searchMenuItem: MenuItem? = null
@@ -562,10 +560,6 @@ class ConversationFragment :
private val motionEventRelay: MotionEventRelay by viewModels(ownerProducer = { requireActivity() })
private val actionModeCallback by lazy {
ActionModeCallback()
}
private val container: InputAwareConstraintLayout
get() = requireView() as InputAwareConstraintLayout
@@ -587,6 +581,9 @@ class ConversationFragment :
private val searchNav: ConversationSearchBottomBar
get() = binding.conversationSearchBottomBar.root
private val actionModeTopBarView: ActionModeTopBarView
get() = binding.actionModeTopBar
private val scheduledMessagesStub: Stub<View> by lazy { Stub(binding.scheduledMessagesStub) }
private val reactionDelegate: ConversationReactionDelegate by lazy(LazyThreadSafetyMode.NONE) {
@@ -929,13 +926,52 @@ class ConversationFragment :
}
override fun onFinishForwardAction() {
actionMode?.finish()
finishActionMode()
}
override fun onDismissForwardSheet() = Unit
//endregion
private fun startActionMode() {
actionModeTopBarView.isVisible = true
actionModeTopBarView.onCloseClick = this::finishActionMode
actionModeTopBarView.title = calculateSelectedItemCount()
searchMenuItem?.collapseActionView()
binding.toolbar.isInvisible = true
if (scheduledMessagesStub.isVisible) {
reShowScheduleMessagesBar = true
scheduledMessagesStub.visibility = View.GONE
}
setCorrectActionModeMenuVisibility()
binding.conversationItemRecycler.invalidateItemDecorations()
}
private fun setActionModeTitle(title: String) {
actionModeTopBarView.title = title
}
private fun isActionModeStarted(): Boolean {
return actionModeTopBarView.isVisible
}
private fun finishActionMode() {
actionModeTopBarView.isVisible = false
adapter.clearSelection()
setBottomActionBarVisibility(false)
binding.toolbar.isInvisible = false
if (reShowScheduleMessagesBar) {
scheduledMessagesStub.visibility = View.VISIBLE
reShowScheduleMessagesBar = false
}
binding.conversationItemRecycler.invalidateItemDecorations()
}
private fun createGroupSubtitleString(members: List<Recipient>): String {
return members.joinToString(", ") { r -> if (r.isSelf) getString(R.string.ConversationTitleView_you) else r.getDisplayName(requireContext()) }
}
@@ -2164,13 +2200,11 @@ class ConversationFragment :
private fun setCorrectActionModeMenuVisibility() {
val selectedParts = adapter.selectedItems
if (actionMode != null && selectedParts.isEmpty()) {
actionMode?.finish()
if (isActionModeStarted() && selectedParts.isEmpty()) {
finishActionMode()
return
}
setBottomActionBarVisibility(true)
val recipient = viewModel.recipientSnapshot ?: return
val menuState = MenuState.getMenuState(
recipient,
@@ -2186,7 +2220,7 @@ class ConversationFragment :
ActionItem(R.drawable.symbol_reply_24, resources.getString(R.string.conversation_selection__menu_reply)) {
maybeShowSwipeToReplyTooltip()
handleReplyToMessage(getSelectedConversationMessage())
actionMode?.finish()
finishActionMode()
}
)
}
@@ -2195,7 +2229,7 @@ class ConversationFragment :
items.add(
ActionItem(R.drawable.symbol_edit_24, resources.getString(R.string.conversation_selection__menu_edit)) {
handleEditMessage(getSelectedConversationMessage())
actionMode?.finish()
finishActionMode()
}
)
}
@@ -2212,7 +2246,7 @@ class ConversationFragment :
items.add(
ActionItem(R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save)) {
handleSaveAttachment(getSelectedConversationMessage().messageRecord as MmsMessageRecord)
actionMode?.finish()
finishActionMode()
}
)
}
@@ -2221,7 +2255,7 @@ class ConversationFragment :
items.add(
ActionItem(R.drawable.symbol_copy_android_24, getResources().getString(R.string.conversation_selection__menu_copy)) {
handleCopyMessage(selectedParts)
actionMode?.finish()
finishActionMode()
}
)
}
@@ -2230,7 +2264,7 @@ class ConversationFragment :
items.add(
ActionItem(R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details)) {
handleDisplayDetails(getSelectedConversationMessage())
actionMode?.finish()
finishActionMode()
}
)
}
@@ -2239,12 +2273,15 @@ class ConversationFragment :
items.add(
ActionItem(R.drawable.symbol_trash_24, getResources().getString(R.string.conversation_selection__menu_delete)) {
handleDeleteMessages(selectedParts)
actionMode?.finish()
finishActionMode()
}
)
}
bottomActionBar.setItems(items)
bottomActionBar.doAfterNextLayout {
setBottomActionBarVisibility(true)
}
}
private fun setBottomActionBarVisibility(isVisible: Boolean) {
@@ -2266,12 +2303,13 @@ class ConversationFragment :
bottomActionBar.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (bottomActionBar.height == 0 && bottomActionBar.visible) {
if (bottomActionBar.measuredHeight == 0 && bottomActionBar.visible) {
return false
}
bottomActionBar.viewTreeObserver.removeOnPreDrawListener(this)
val bottomPadding = bottomActionBar.height + ((bottomActionBar.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 18.dp)
val bottomPadding = bottomActionBar.measuredHeight + ((bottomActionBar.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 18.dp)
ViewUtil.setPaddingBottom(binding.conversationItemRecycler, bottomPadding)
binding.conversationItemRecycler.scrollBy(0, -(bottomPadding - additionalScrollOffset))
animationsAllowed = true
@@ -2540,8 +2578,7 @@ class ConversationFragment :
private fun handleEnterMultiselect(conversationMessage: ConversationMessage) {
val parts = conversationMessage.multiselectCollection.toSet()
parts.forEach { adapter.toggleSelection(it) }
binding.conversationItemRecycler.invalidateItemDecorations()
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(actionModeCallback)
startActionMode()
}
private fun handleViewPaymentDetails(conversationMessage: ConversationMessage) {
@@ -2621,7 +2658,7 @@ class ConversationFragment :
override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean {
val recipient = viewModel.recipientSnapshot ?: return false
return actionMode == null &&
return !isActionModeStarted() &&
MenuState.canReplyToMessage(
recipient,
MenuState.isActionMessage(conversationMessage.messageRecord),
@@ -2833,7 +2870,7 @@ class ConversationFragment :
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
if (actionMode == null) {
if (!isActionModeStarted()) {
return
}
@@ -2845,9 +2882,9 @@ class ConversationFragment :
adapter.removeFromSelection(expired)
if (adapter.selectedItems.isEmpty()) {
actionMode?.finish()
finishActionMode()
} else {
actionMode?.setTitle(calculateSelectedItemCount())
setActionModeTitle(calculateSelectedItemCount())
}
}
@@ -3311,22 +3348,22 @@ class ConversationFragment :
}
override fun onItemClick(item: MultiselectPart) {
if (actionMode != null) {
if (isActionModeStarted()) {
adapter.toggleSelection(item)
binding.conversationItemRecycler.invalidateItemDecorations()
if (adapter.selectedItems.isEmpty()) {
actionMode?.finish()
finishActionMode()
} else {
setCorrectActionModeMenuVisibility()
actionMode?.title = calculateSelectedItemCount()
setActionModeTitle(calculateSelectedItemCount())
}
}
}
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
Log.d(TAG, "onItemLongClick")
if (actionMode != null) {
if (isActionModeStarted()) {
return
}
@@ -3466,9 +3503,7 @@ class ConversationFragment :
} else {
clearFocusedItem()
adapter.toggleSelection(item)
binding.conversationItemRecycler.invalidateItemDecorations()
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(actionModeCallback)
startActionMode()
}
}
@@ -3838,40 +3873,6 @@ class ConversationFragment :
}
}
}
inner class ActionModeCallback : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = calculateSelectedItemCount()
searchMenuItem?.collapseActionView()
binding.toolbar.isInvisible = true
if (scheduledMessagesStub.isVisible) {
reShowScheduleMessagesBar = true
scheduledMessagesStub.visibility = View.GONE
}
setCorrectActionModeMenuVisibility()
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = false
override fun onDestroyActionMode(mode: ActionMode) {
adapter.clearSelection()
setBottomActionBarVisibility(false)
binding.toolbar.isInvisible = false
if (reShowScheduleMessagesBar) {
scheduledMessagesStub.visibility = View.VISIBLE
reShowScheduleMessagesBar = false
}
binding.conversationItemRecycler.invalidateItemDecorations()
actionMode = null
}
}
// endregion Conversation Callbacks
//region Activity Results Callbacks

View File

@@ -61,6 +61,7 @@ import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.contentDescription
@@ -76,9 +77,9 @@ import org.signal.core.ui.compose.circularReveal
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.components.compose.ActionModeTopBar
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.dependencies.GooglePlayBillingDependencies.context
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.rememberRecipientField
@@ -226,23 +227,10 @@ private fun ActionModeToolbar(
state: MainToolbarState,
callback: MainToolbarCallback
) {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = state.toolbarColor ?: MaterialTheme.colorScheme.surface
),
navigationIcon = {
IconButtons.IconButton(onClick = {
callback.onCloseActionModeClick()
}) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24),
contentDescription = stringResource(R.string.CallScreenTopBar__go_back)
)
}
},
title = {
Text(text = context.resources.getQuantityString(R.plurals.ConversationListFragment_s_selected, state.actionModeCount, state.actionModeCount))
}
ActionModeTopBar(
title = pluralStringResource(R.plurals.ConversationListFragment_s_selected, state.actionModeCount, state.actionModeCount),
onCloseClick = callback::onCloseActionModeClick,
toolbarColor = state.toolbarColor
)
}

View File

@@ -21,21 +21,6 @@
android:layout_gravity="left"
tools:ignore="RtlHardcoded" />
<FrameLayout
android:id="@+id/toolbar_shade"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:background="@color/reactions_screen_light_shade_color"
android:foreground="@color/reactions_screen_dark_shade_color" />
<FrameLayout
android:id="@+id/input_shade"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_gravity="bottom"
android:background="@color/reactions_screen_light_shade_color"
android:foreground="@color/reactions_screen_dark_shade_color" />
<View
android:id="@+id/conversation_item"
android:layout_width="wrap_content"

View File

@@ -139,6 +139,16 @@
</org.thoughtcrime.securesms.util.views.DarkOverflowToolbar>
<org.thoughtcrime.securesms.components.compose.ActionModeTopBarView
android:id="@+id/action_mode_top_bar"
android:layout_width="0dp"
android:layout_height="@dimen/signal_m3_toolbar_height"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintHeight_min="@dimen/signal_m3_toolbar_height"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@id/status_bar_guideline" />
<FrameLayout
android:id="@+id/conversation_banner_frame"
android:layout_width="0dp"
@@ -348,7 +358,7 @@
<org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
android:id="@+id/conversation_bottom_action_bar"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"