mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
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:
+5
-7
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+2
@@ -29,6 +29,8 @@ sealed interface TimelineEvent {
|
||||
|
||||
data object MarkAllAsRead : TimelineEvent
|
||||
|
||||
data class CopyToClipboard(val text: String) : TimelineEvent
|
||||
|
||||
/**
|
||||
* Events coming from a timeline item.
|
||||
*/
|
||||
|
||||
+10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-8
@@ -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() {
|
||||
|
||||
+13
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-13
@@ -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 = { },
|
||||
)
|
||||
}
|
||||
|
||||
+41
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user