Use only ClipboardHelper to copy to the clipboard, and do not render feedback on Android 13+

Since Android 13, the system displays its own confirmation when content
is copied to the clipboard, so the application does not render its own
Toast or Snackbar anymore on those versions.

Context.copyToClipboard, CopyToClipboardUseCase and the direct
ClipboardManager usage in EventDebugInfoView are removed.
ClipboardHelper.copyPlainText(text, isSensitive, onSuccessOnOldDevice)
is now the only entry point, and AndroidClipboardHelper is the only
class using ClipboardManager.

Copy actions from the views are moved to their Presenter through new
events, or to the Node when there is no Presenter. A ToastHelper is
introduced so that Toasts are kept where they were used before.
Snackbars are kept where they were already used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-05 11:40:08 +02:00
parent 38e74ff43d
commit b9ed090400
41 changed files with 532 additions and 172 deletions
@@ -8,7 +8,6 @@
package io.element.android.features.messages.impl
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -634,8 +633,9 @@ class MessagesPresenter(
event.eventId ?: return
room.getPermalinkFor(event.eventId).fold(
onSuccess = { permalink ->
clipboardHelper.copyPlainText(permalink)
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
clipboardHelper.copyPlainText(permalink) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
}
},
onFailure = {
Timber.e(it, "Failed to get permalink for event ${event.eventId}")
@@ -650,16 +650,14 @@ class MessagesPresenter(
is TimelineItemStateContent -> event.content.body
else -> return
}
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
clipboardHelper.copyPlainText(content) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
}
}
private fun handleCopyCaption(event: TimelineItem.Event) {
val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
clipboardHelper.copyPlainText(content) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
}
@@ -15,7 +15,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
@@ -26,8 +25,9 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -53,6 +53,8 @@ class PinnedMessagesListNode(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser,
private val emojiPickerRenderer: EmojiPickerRenderer,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
@@ -114,7 +116,6 @@ class PinnedMessagesListNode(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) {
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
val state = presenter.present()
PinnedMessagesListView(
@@ -132,10 +133,9 @@ class PinnedMessagesListNode(
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
text = it.url,
toastMessage = toastMessage,
)
clipboardHelper.copyPlainText(it.url) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
},
emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier
@@ -29,6 +29,8 @@ sealed interface TimelineEvent {
data object MarkAllAsRead : TimelineEvent
data class CopyToClipboard(val text: String) : TimelineEvent
/**
* Events coming from a timeline item.
*/
@@ -44,6 +44,8 @@ import io.element.android.features.messages.impl.voicemessages.timeline.Redacted
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope
@@ -60,6 +62,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
@@ -104,6 +107,8 @@ class TimelinePresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<TimelineState> {
private val tag = "TimelinePresenter"
@@ -315,6 +320,11 @@ class TimelinePresenter(
is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState))
}
is TimelineEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
}
}
@@ -63,7 +63,6 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
@@ -93,7 +92,6 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentPreviewParam
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -167,8 +165,6 @@ fun TimelineView(
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
}
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current
fun inReplyToClick(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId))
@@ -178,10 +174,7 @@ fun TimelineView(
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
context.copyToClipboard(
text = link.url,
toastMessage = toastMessage,
)
state.eventSink(TimelineEvent.CopyToClipboard(link.url))
}
fun prefetchMoreItems() {
@@ -16,17 +16,22 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
@ContributesNode(RoomScope::class)
@AssistedInject
class EventDebugInfoNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val eventId: EventId?,
@@ -39,6 +44,12 @@ class EventDebugInfoNode(
navigateUp()
}
private fun onCopyText(text: String) {
clipboardHelper.copyPlainText(text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
@Composable
override fun View(modifier: Modifier) = with(inputs) {
EventDebugInfoView(
@@ -46,7 +57,8 @@ class EventDebugInfoNode(
model = timelineItemDebugInfo.model,
originalJson = timelineItemDebugInfo.originalJson,
latestEditedJson = timelineItemDebugInfo.latestEditedJson,
onBackClick = ::onBackClick
onBackClick = ::onBackClick,
onCopyText = ::onCopyText,
)
}
}
@@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline.debug
import android.content.ClipData
import android.content.ClipboardManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
@@ -34,11 +32,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -63,6 +59,7 @@ fun EventDebugInfoView(
originalJson: String?,
latestEditedJson: String?,
onBackClick: () -> Unit,
onCopyText: (String) -> Unit,
modifier: Modifier = Modifier,
isTest: Boolean = false,
) {
@@ -87,20 +84,30 @@ fun EventDebugInfoView(
item {
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Event ID:")
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth())
CopyableText(text = eventId?.value ?: "-", onClick = onCopyText, modifier = Modifier.fillMaxWidth())
}
}
item {
CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded)
CollapsibleSection(title = "Model:", text = model, onCopyText = onCopyText, initiallyExpanded = sectionsInitiallyExpanded)
}
if (originalJson != null) {
item {
CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded)
CollapsibleSection(
title = "Original JSON:",
text = prettyJSON(originalJson),
onCopyText = onCopyText,
initiallyExpanded = sectionsInitiallyExpanded,
)
}
}
if (latestEditedJson != null) {
item {
CollapsibleSection(title = "Latest edited JSON:", text = prettyJSON(latestEditedJson), initiallyExpanded = sectionsInitiallyExpanded)
CollapsibleSection(
title = "Latest edited JSON:",
text = prettyJSON(latestEditedJson),
onCopyText = onCopyText,
initiallyExpanded = sectionsInitiallyExpanded,
)
}
}
}
@@ -120,6 +127,7 @@ private fun prettyJSON(maybeJSON: String): String {
private fun CollapsibleSection(
title: String,
text: String,
onCopyText: (String) -> Unit,
initiallyExpanded: Boolean = false,
) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) }
@@ -139,7 +147,7 @@ private fun CollapsibleSection(
)
}
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) {
CopyableText(text = text, modifier = Modifier.fillMaxWidth())
CopyableText(text = text, onClick = onCopyText, modifier = Modifier.fillMaxWidth())
}
}
}
@@ -147,16 +155,15 @@ private fun CollapsibleSection(
@Composable
private fun CopyableText(
text: String,
onClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clipboardManager = remember { requireNotNull(context.getSystemService<ClipboardManager>()) }
Box(
modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(6.dp)
.clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) }
.clickable { onClick(text) }
) {
Text(
text = text,
@@ -174,6 +181,7 @@ internal fun EventDebugInfoViewPreview() = ElementPreview {
model = "Rust(\n\tModel()\n)",
originalJson = "{\"name\": \"original\"}",
latestEditedJson = "{\"name\": \"edited\"}",
onBackClick = { }
onBackClick = { },
onCopyText = { },
)
}
@@ -33,6 +33,10 @@ import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@@ -73,6 +77,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem
@@ -1541,6 +1546,38 @@ class TimelinePresenterTest {
}
}
@Test
fun `present - copy to clipboard on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createTimelinePresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy to clipboard on new device does not show a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createTimelinePresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitFirstItem().eventSink(TimelineEvent.CopyToClipboard("https://element.io"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("https://element.io")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter()
@@ -1897,6 +1934,8 @@ class TimelinePresenterTest {
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): TimelinePresenter {
return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -1923,6 +1962,8 @@ class TimelinePresenterTest {
liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead,
timelineProtectionPresenter = timelineProtectionPresenter,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
}
}