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 package io.element.android.features.messages.impl
import android.os.Build
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -634,8 +633,9 @@ class MessagesPresenter(
event.eventId ?: return event.eventId ?: return
room.getPermalinkFor(event.eventId).fold( room.getPermalinkFor(event.eventId).fold(
onSuccess = { permalink -> onSuccess = { permalink ->
clipboardHelper.copyPlainText(permalink) clipboardHelper.copyPlainText(permalink) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_link_copied_to_clipboard))
}
}, },
onFailure = { onFailure = {
Timber.e(it, "Failed to get permalink for event ${event.eventId}") Timber.e(it, "Failed to get permalink for event ${event.eventId}")
@@ -650,16 +650,14 @@ class MessagesPresenter(
is TimelineItemStateContent -> event.content.body is TimelineItemStateContent -> event.content.body
else -> return else -> return
} }
clipboardHelper.copyPlainText(content) clipboardHelper.copyPlainText(content) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied)) snackbarDispatcher.post(SnackbarMessage(R.string.screen_room_timeline_message_copied))
} }
} }
private fun handleCopyCaption(event: TimelineItem.Event) { private fun handleCopyCaption(event: TimelineItem.Event) {
val content = event.content.captionOrNull() ?: return val content = event.content.captionOrNull() ?: return
clipboardHelper.copyPlainText(content) clipboardHelper.copyPlainText(content) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) 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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin 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.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem 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.system.openUrlInExternalApp
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
@@ -53,6 +53,8 @@ class PinnedMessagesListNode(
private val timelineItemPresenterFactories: TimelineItemPresenterFactories, private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val permalinkParser: PermalinkParser, private val permalinkParser: PermalinkParser,
private val emojiPickerRenderer: EmojiPickerRenderer, private val emojiPickerRenderer: EmojiPickerRenderer,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator { ) : Node(buildContext, plugins = plugins), PinnedMessagesListNavigator {
interface Callback : Plugin { interface Callback : Plugin {
fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean) fun handleEventClick(event: TimelineItem.Event, canUseOverlay: Boolean)
@@ -114,7 +116,6 @@ class PinnedMessagesListNode(
LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories, LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current val view = LocalView.current
val state = presenter.present() val state = presenter.present()
PinnedMessagesListView( PinnedMessagesListView(
@@ -132,10 +133,9 @@ class PinnedMessagesListNode(
view.performHapticFeedback( view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS HapticFeedbackConstants.LONG_PRESS
) )
context.copyToClipboard( clipboardHelper.copyPlainText(it.url) {
text = it.url, toastHelper.show(CommonStrings.common_copied_to_clipboard)
toastMessage = toastMessage, }
)
}, },
emojiPickerRenderer = emojiPickerRenderer, emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier modifier = modifier
@@ -29,6 +29,8 @@ sealed interface TimelineEvent {
data object MarkAllAsRead : TimelineEvent data object MarkAllAsRead : TimelineEvent
data class CopyToClipboard(val text: String) : TimelineEvent
/** /**
* Events coming from a timeline item. * 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.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.roomcall.api.RoomCallState 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.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.annotations.SessionCoroutineScope 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.LocalEventSendState
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin 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.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.DisplayFirstTimelineItems
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
@@ -104,6 +107,8 @@ class TimelinePresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val markAsFullyRead: MarkAsFullyRead, private val markAsFullyRead: MarkAsFullyRead,
private val timelineProtectionPresenter: Presenter<TimelineProtectionState>, private val timelineProtectionPresenter: Presenter<TimelineProtectionState>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<TimelineState> { ) : Presenter<TimelineState> {
private val tag = "TimelinePresenter" private val tag = "TimelinePresenter"
@@ -315,6 +320,11 @@ class TimelinePresenter(
is TimelineEvent.ValidateMedia -> { is TimelineEvent.ValidateMedia -> {
timelineProtectionState.eventSink(TimelineProtectionEvent.ValidateContent(event.mediaSources, event.validationState)) 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.graphics.vector.ImageVector
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection 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.model.event.TimelineItemEventContentPreviewParam
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState
import io.element.android.features.messages.impl.timeline.protection.aTimelineProtectionState 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.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.dialogs.AlertDialog import io.element.android.libraries.designsystem.components.dialogs.AlertDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -167,8 +165,6 @@ fun TimelineView(
state.eventSink(TimelineEvent.FocusOnEvent(eventId)) state.eventSink(TimelineEvent.FocusOnEvent(eventId))
} }
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val view = LocalView.current val view = LocalView.current
fun inReplyToClick(eventId: EventId) { fun inReplyToClick(eventId: EventId) {
state.eventSink(TimelineEvent.FocusOnEvent(eventId)) state.eventSink(TimelineEvent.FocusOnEvent(eventId))
@@ -178,10 +174,7 @@ fun TimelineView(
view.performHapticFeedback( view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS HapticFeedbackConstants.LONG_PRESS
) )
context.copyToClipboard( state.eventSink(TimelineEvent.CopyToClipboard(link.url))
text = link.url,
toastMessage = toastMessage,
)
} }
fun prefetchMoreItems() { fun prefetchMoreItems() {
@@ -16,17 +16,22 @@ import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode 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.NodeInputs
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.ui.strings.CommonStrings
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
class EventDebugInfoNode( class EventDebugInfoNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
data class Inputs( data class Inputs(
val eventId: EventId?, val eventId: EventId?,
@@ -39,6 +44,12 @@ class EventDebugInfoNode(
navigateUp() navigateUp()
} }
private fun onCopyText(text: String) {
clipboardHelper.copyPlainText(text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
@Composable @Composable
override fun View(modifier: Modifier) = with(inputs) { override fun View(modifier: Modifier) = with(inputs) {
EventDebugInfoView( EventDebugInfoView(
@@ -46,7 +57,8 @@ class EventDebugInfoNode(
model = timelineItemDebugInfo.model, model = timelineItemDebugInfo.model,
originalJson = timelineItemDebugInfo.originalJson, originalJson = timelineItemDebugInfo.originalJson,
latestEditedJson = timelineItemDebugInfo.latestEditedJson, latestEditedJson = timelineItemDebugInfo.latestEditedJson,
onBackClick = ::onBackClick onBackClick = ::onBackClick,
onCopyText = ::onCopyText,
) )
} }
} }
@@ -8,8 +8,6 @@
package io.element.android.features.messages.impl.timeline.debug 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.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
@@ -34,11 +32,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.getSystemService
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@@ -63,6 +59,7 @@ fun EventDebugInfoView(
originalJson: String?, originalJson: String?,
latestEditedJson: String?, latestEditedJson: String?,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onCopyText: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isTest: Boolean = false, isTest: Boolean = false,
) { ) {
@@ -87,20 +84,30 @@ fun EventDebugInfoView(
item { item {
Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = "Event ID:") Text(text = "Event ID:")
CopyableText(text = eventId?.value ?: "-", modifier = Modifier.fillMaxWidth()) CopyableText(text = eventId?.value ?: "-", onClick = onCopyText, modifier = Modifier.fillMaxWidth())
} }
} }
item { item {
CollapsibleSection(title = "Model:", text = model, initiallyExpanded = sectionsInitiallyExpanded) CollapsibleSection(title = "Model:", text = model, onCopyText = onCopyText, initiallyExpanded = sectionsInitiallyExpanded)
} }
if (originalJson != null) { if (originalJson != null) {
item { item {
CollapsibleSection(title = "Original JSON:", text = prettyJSON(originalJson), initiallyExpanded = sectionsInitiallyExpanded) CollapsibleSection(
title = "Original JSON:",
text = prettyJSON(originalJson),
onCopyText = onCopyText,
initiallyExpanded = sectionsInitiallyExpanded,
)
} }
} }
if (latestEditedJson != null) { if (latestEditedJson != null) {
item { 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( private fun CollapsibleSection(
title: String, title: String,
text: String, text: String,
onCopyText: (String) -> Unit,
initiallyExpanded: Boolean = false, initiallyExpanded: Boolean = false,
) { ) {
var isExpanded by remember { mutableStateOf(initiallyExpanded) } var isExpanded by remember { mutableStateOf(initiallyExpanded) }
@@ -139,7 +147,7 @@ private fun CollapsibleSection(
) )
} }
AnimatedVisibility(visible = isExpanded, enter = expandVertically(), exit = shrinkVertically()) { 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 @Composable
private fun CopyableText( private fun CopyableText(
text: String, text: String,
onClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val clipboardManager = remember { requireNotNull(context.getSystemService<ClipboardManager>()) }
Box( Box(
modifier modifier
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.padding(6.dp) .padding(6.dp)
.clickable { clipboardManager.setPrimaryClip(ClipData.newPlainText("JSON", text)) } .clickable { onClick(text) }
) { ) {
Text( Text(
text = text, text = text,
@@ -174,6 +181,7 @@ internal fun EventDebugInfoViewPreview() = ElementPreview {
model = "Rust(\n\tModel()\n)", model = "Rust(\n\tModel()\n)",
originalJson = "{\"name\": \"original\"}", originalJson = "{\"name\": \"original\"}",
latestEditedJson = "{\"name\": \"edited\"}", 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.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.features.roomcall.api.aStandByCallState 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.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.test.timeline.item.event.aRoomMembershipContent
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore 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.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.awaitLastSequentialItem 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 @Test
fun `present - show shield hide shield`() = runTest { fun `present - show shield hide shield`() = runTest {
val presenter = createTimelinePresenter() val presenter = createTimelinePresenter()
@@ -1897,6 +1934,8 @@ class TimelinePresenterTest {
markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> }, markAsFullyRead: MarkAsFullyRead = FakeMarkAsFullyRead { _, _ -> },
timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() }, timelineProtectionPresenter: Presenter<TimelineProtectionState> = { aTimelineProtectionState() },
resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() }, resolveVerifiedUserSendFailurePresenter: Presenter<ResolveVerifiedUserSendFailureState> = { aResolveVerifiedUserSendFailureState() },
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): TimelinePresenter { ): TimelinePresenter {
return TimelinePresenter( return TimelinePresenter(
timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(), timelineItemsFactoryCreator = aTimelineItemsFactoryCreator(),
@@ -1923,6 +1962,8 @@ class TimelinePresenterTest {
liveLocationShareManager = liveLocationShareManager, liveLocationShareManager = liveLocationShareManager,
markAsFullyRead = markAsFullyRead, markAsFullyRead = markAsFullyRead,
timelineProtectionPresenter = timelineProtectionPresenter, timelineProtectionPresenter = timelineProtectionPresenter,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -81,8 +81,9 @@ class BlockedUsersPresenter(
unblockUserAction.value = AsyncAction.ConfirmingNoParams unblockUserAction.value = AsyncAction.ConfirmingNoParams
} }
is BlockedUsersEvent.CopyToClipboard -> { is BlockedUsersEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.userId.value) clipboardHelper.copyPlainText(event.userId.value) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
} }
BlockedUsersEvent.ConfirmUnblock -> { BlockedUsersEvent.ConfirmUnblock -> {
pendingUserToUnblock?.let { pendingUserToUnblock?.let {
@@ -19,4 +19,5 @@ sealed interface DeveloperSettingsEvent {
data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent
data object OpenPushRules : DeveloperSettingsEvent data object OpenPushRules : DeveloperSettingsEvent
data object DismissPushRulesError : DeveloperSettingsEvent data object DismissPushRulesError : DeveloperSettingsEvent
data class CopyToClipboard(val text: String) : DeveloperSettingsEvent
} }
@@ -26,7 +26,9 @@ import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -61,6 +64,8 @@ class DeveloperSettingsPresenter(
private val markAllRoomsAsRead: MarkAllRoomsAsRead, private val markAllRoomsAsRead: MarkAllRoomsAsRead,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val notificationSettingsService: NotificationSettingsService, private val notificationSettingsService: NotificationSettingsService,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<DeveloperSettingsState> { ) : Presenter<DeveloperSettingsState> {
@AssistedFactory @AssistedFactory
fun interface Factory { fun interface Factory {
@@ -130,6 +135,11 @@ class DeveloperSettingsPresenter(
DeveloperSettingsEvent.DismissPushRulesError -> { DeveloperSettingsEvent.DismissPushRulesError -> {
pushRulesAction.value = AsyncAction.Uninitialized pushRulesAction.value = AsyncAction.Uninitialized
} }
is DeveloperSettingsEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
} }
} }
@@ -15,14 +15,12 @@ import androidx.compose.foundation.progressSemantics
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
@@ -84,7 +82,10 @@ fun DeveloperSettingsView(
AppDeveloperSettingsView( AppDeveloperSettingsView(
state = state.appDeveloperSettingsState, state = state.appDeveloperSettingsState,
) )
SessionCategory(deviceId = state.deviceId) SessionCategory(
deviceId = state.deviceId,
onCopyToClipboard = { state.eventSink(DeveloperSettingsEvent.CopyToClipboard(it)) },
)
NotificationCategory( NotificationCategory(
onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) }, onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) },
onPushHistoryClick = onPushHistoryClick, onPushHistoryClick = onPushHistoryClick,
@@ -182,19 +183,15 @@ fun DeveloperSettingsView(
} }
@Composable @Composable
private fun SessionCategory(deviceId: DeviceId) { private fun SessionCategory(
deviceId: DeviceId,
onCopyToClipboard: (String) -> Unit,
) {
PreferenceCategory(title = "Session") { PreferenceCategory(title = "Session") {
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val context = LocalContext.current
ListItem( ListItem(
content = { Text("DeviceId") }, content = { Text("DeviceId") },
supportingContent = { Text(text = deviceId.value) }, supportingContent = { Text(text = deviceId.value) },
onClick = { onClick = { onCopyToClipboard(deviceId.value) }
context.copyToClipboard(
text = deviceId.value,
toastMessage = toastMessage,
)
}
) )
} }
} }
@@ -17,4 +17,5 @@ sealed interface AppDeveloperSettingsEvent {
data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent
data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent
data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent
data class CopyToClipboard(val text: String) : AppDeveloperSettingsEvent
} }
@@ -22,6 +22,8 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel
import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem import io.element.android.features.preferences.impl.developer.tracing.toLogLevelItem
import io.element.android.features.preferences.impl.model.EnabledFeature import io.element.android.features.preferences.impl.model.EnabledFeature
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -29,6 +31,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.featureflag.ui.model.FeatureUiModel
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -44,6 +47,8 @@ class AppDeveloperSettingsPresenter(
private val rageshakePresenter: Presenter<RageshakePreferencesState>, private val rageshakePresenter: Presenter<RageshakePreferencesState>,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<AppDeveloperSettingsState> { ) : Presenter<AppDeveloperSettingsState> {
@Composable @Composable
override fun present(): AppDeveloperSettingsState { override fun present(): AppDeveloperSettingsState {
@@ -106,6 +111,11 @@ class AppDeveloperSettingsPresenter(
} }
appPreferencesStore.setTracingLogPacks(currentPacks) appPreferencesStore.setTracingLogPacks(currentPacks)
} }
is AppDeveloperSettingsEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
}
} }
} }
@@ -14,7 +14,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -23,7 +22,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView import io.element.android.features.rageshake.api.preferences.RageshakePreferencesView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown
@@ -108,6 +106,7 @@ fun AppDeveloperSettingsView(
GitCategory( GitCategory(
gitBranch = state.gitBranch, gitBranch = state.gitBranch,
gitSha = state.gitSha, gitSha = state.gitSha,
onCopyToClipboard = { state.eventSink(AppDeveloperSettingsEvent.CopyToClipboard(it)) },
) )
} }
} }
@@ -116,29 +115,18 @@ fun AppDeveloperSettingsView(
private fun GitCategory( private fun GitCategory(
gitBranch: String, gitBranch: String,
gitSha: String, gitSha: String,
onCopyToClipboard: (String) -> Unit,
) { ) {
PreferenceCategory(title = "Git") { PreferenceCategory(title = "Git") {
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
val context = LocalContext.current
ListItem( ListItem(
content = { Text("Git branch") }, content = { Text("Git branch") },
supportingContent = { Text(text = gitBranch) }, supportingContent = { Text(text = gitBranch) },
onClick = { onClick = { onCopyToClipboard(gitBranch) }
context.copyToClipboard(
text = gitBranch,
toastMessage = toastMessage,
)
}
) )
ListItem( ListItem(
content = { Text("Git SHA") }, content = { Text("Git SHA") },
supportingContent = { Text(text = gitSha) }, supportingContent = { Text(text = gitSha) },
onClick = { onClick = { onCopyToClipboard(gitSha) }
context.copyToClipboard(
text = gitSha,
toastMessage = toastMessage,
)
}
) )
} }
} }
@@ -19,7 +19,11 @@ import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase
import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead
import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.data.megaBytes import io.element.android.libraries.core.data.megaBytes
@@ -33,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -250,6 +255,38 @@ class DeveloperSettingsPresenterTest {
} }
} }
@Test
fun `present - copy to clipboard on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
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 = createDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(DeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
private fun createDeveloperSettingsPresenter( private fun createDeveloperSettingsPresenter(
navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() }, navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() },
sessionId: SessionId = A_SESSION_ID, sessionId: SessionId = A_SESSION_ID,
@@ -262,6 +299,8 @@ class DeveloperSettingsPresenterTest {
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
buildMeta: BuildMeta = aBuildMeta(), buildMeta: BuildMeta = aBuildMeta(),
notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): DeveloperSettingsPresenter { ): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter( return DeveloperSettingsPresenter(
navigator = navigator, navigator = navigator,
@@ -277,6 +316,8 @@ class DeveloperSettingsPresenterTest {
markAllRoomsAsRead = markAllRoomsAsRead, markAllRoomsAsRead = markAllRoomsAsRead,
buildMeta = buildMeta, buildMeta = buildMeta,
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -12,12 +12,17 @@ package io.element.android.features.preferences.impl.developer.appsettings
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem
import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState
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.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
@@ -151,6 +156,38 @@ class AppDeveloperSettingsPresenterTest {
} }
} }
@Test
fun `present - copy to clipboard on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createAppDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
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 = createAppDeveloperSettingsPresenter(
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.test {
awaitItem().eventSink(AppDeveloperSettingsEvent.CopyToClipboard("text"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("text")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
private fun createAppDeveloperSettingsPresenter( private fun createAppDeveloperSettingsPresenter(
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(
getAvailableFeaturesResult = { _, _ -> getAvailableFeaturesResult = { _, _ ->
@@ -164,6 +201,8 @@ class AppDeveloperSettingsPresenterTest {
} }
), ),
preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): AppDeveloperSettingsPresenter { ): AppDeveloperSettingsPresenter {
return AppDeveloperSettingsPresenter( return AppDeveloperSettingsPresenter(
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
@@ -172,7 +211,9 @@ class AppDeveloperSettingsPresenterTest {
buildMeta = aBuildMeta( buildMeta = aBuildMeta(
gitRevision = "1234567890", gitRevision = "1234567890",
gitBranchName = "feature/awesome-feature" gitBranchName = "feature/awesome-feature"
) ),
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -13,6 +13,7 @@ sealed interface RoomDetailsEvent {
data object MuteNotification : RoomDetailsEvent data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent
data class CopyToClipboard(val text: String) : RoomDetailsEvent data class CopyToClipboard(val text: String) : RoomDetailsEvent
data object CopyRoomId : RoomDetailsEvent
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
data object MarkAsRead : RoomDetailsEvent data object MarkAsRead : RoomDetailsEvent
data object MarkAsUnread : RoomDetailsEvent data object MarkAsUnread : RoomDetailsEvent
@@ -32,6 +32,7 @@ import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermission
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper 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.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
@@ -74,6 +75,7 @@ class RoomDetailsPresenter(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val clipboardHelper: ClipboardHelper, private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val sessionPreferencesStore: SessionPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore,
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
@@ -167,8 +169,14 @@ class RoomDetailsPresenter(
} }
is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite)
is RoomDetailsEvent.CopyToClipboard -> { is RoomDetailsEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) clipboardHelper.copyPlainText(event.text) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
}
is RoomDetailsEvent.CopyRoomId -> {
clipboardHelper.copyPlainText(room.roomId.value) {
toastHelper.show(CommonStrings.common_copied_to_clipboard)
}
} }
is RoomDetailsEvent.MarkAsRead -> scope.markAsRead() is RoomDetailsEvent.MarkAsRead -> scope.markAsRead()
is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread() is RoomDetailsEvent.MarkAsUnread -> scope.markAsUnread()
@@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -48,7 +47,6 @@ import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs
import io.element.android.features.userprofile.shared.blockuser.BlockUserSection import io.element.android.features.userprofile.shared.blockuser.BlockUserSection
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom
import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule
@@ -339,6 +337,7 @@ fun RoomDetailsView(
DebugInfoSection( DebugInfoSection(
roomId = state.roomId, roomId = state.roomId,
roomVersion = state.roomVersion, roomVersion = state.roomVersion,
onCopyRoomIdClick = { state.eventSink(RoomDetailsEvent.CopyRoomId) },
) )
} }
} }
@@ -865,10 +864,9 @@ private fun OtherActionsSection(
private fun DebugInfoSection( private fun DebugInfoSection(
roomId: RoomId, roomId: RoomId,
roomVersion: String?, roomVersion: String?,
onCopyRoomIdClick: () -> Unit,
) { ) {
val context = LocalContext.current
PreferenceCategory { PreferenceCategory {
val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard)
ListItem( ListItem(
content = { content = {
Text("Internal room ID") Text("Internal room ID")
@@ -882,12 +880,7 @@ private fun DebugInfoSection(
}, },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())),
trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())),
onClick = { onClick = onCopyRoomIdClick,
context.copyToClipboard(
text = roomId.value,
toastMessage = toastMessage,
)
},
) )
ListItem( ListItem(
content = { content = {
@@ -118,8 +118,9 @@ class RoomMemberDetailsPresenter(
encryptionService.withdrawVerification(roomMemberId) encryptionService.withdrawVerification(roomMemberId)
} }
is UserProfileEvent.CopyToClipboard -> { is UserProfileEvent.CopyToClipboard -> {
clipboardHelper.copyPlainText(event.text) clipboardHelper.copyPlainText(event.text) {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard))
}
} }
else -> userProfileState.eventSink(event) else -> userProfileState.eventSink(event)
} }
@@ -19,6 +19,8 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
import io.element.android.features.userprofile.shared.aUserProfileState import io.element.android.features.userprofile.shared.aUserProfileState
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper 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.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@@ -49,6 +51,7 @@ import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
@@ -85,6 +88,7 @@ class RoomDetailsPresenterTest {
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
encryptionService: FakeEncryptionService = FakeEncryptionService(), encryptionService: FakeEncryptionService = FakeEncryptionService(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(), clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(), navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(),
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
@@ -115,12 +119,53 @@ class RoomDetailsPresenterTest {
dispatchers = dispatchers, dispatchers = dispatchers,
analyticsService = analyticsService, analyticsService = analyticsService,
clipboardHelper = clipboardHelper, clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
sessionPreferencesStore = sessionPreferencesStore, sessionPreferencesStore = sessionPreferencesStore,
) )
} }
@Test
fun `present - copy room id on old device shows a toast`() = runTest {
val room = aJoinedRoom(
roomPermissions = roomPermissions(),
)
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createRoomDetailsPresenter(
room = room,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.CopyRoomId)
assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value)
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy room id on new device does not show a toast`() = runTest {
val room = aJoinedRoom(
roomPermissions = roomPermissions(),
)
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createRoomDetailsPresenter(
room = room,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) {
awaitItem().eventSink(RoomDetailsEvent.CopyRoomId)
assertThat(clipboardHelper.clipboardContents).isEqualTo(room.roomId.value)
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - initial state is created from initial room info`() = runTest { fun `present - initial state is created from initial room info`() = runTest {
val room = aJoinedRoom( val room = aJoinedRoom(
@@ -11,6 +11,7 @@ package io.element.android.features.securebackup.impl.setup
sealed interface SecureBackupSetupEvent { sealed interface SecureBackupSetupEvent {
data object CreateRecoveryKey : SecureBackupSetupEvent data object CreateRecoveryKey : SecureBackupSetupEvent
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent
data object CopyRecoveryKey : SecureBackupSetupEvent
data object Done : SecureBackupSetupEvent data object Done : SecureBackupSetupEvent
data object DismissDialog : SecureBackupSetupEvent data object DismissDialog : SecureBackupSetupEvent
} }
@@ -22,9 +22,12 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.loggerTagSetup import io.element.android.features.securebackup.impl.loggerTagSetup
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
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.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -38,6 +41,8 @@ class SecureBackupSetupPresenter(
@Assisted private val isChangeRecoveryKeyUserStory: Boolean, @Assisted private val isChangeRecoveryKeyUserStory: Boolean,
private val stateMachine: SecureBackupSetupStateMachine, private val stateMachine: SecureBackupSetupStateMachine,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<SecureBackupSetupState> { ) : Presenter<SecureBackupSetupState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -60,6 +65,13 @@ class SecureBackupSetupPresenter(
} }
SecureBackupSetupEvent.RecoveryKeyHasBeenSaved -> SecureBackupSetupEvent.RecoveryKeyHasBeenSaved ->
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey) stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
SecureBackupSetupEvent.CopyRecoveryKey -> {
val recoveryKey = setupState.recoveryKey() ?: return
clipboardHelper.copyPlainText(recoveryKey, isSensitive = true) {
toastHelper.show(R.string.screen_recovery_key_copied_to_clipboard)
}
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
}
SecureBackupSetupEvent.DismissDialog -> { SecureBackupSetupEvent.DismissDialog -> {
showSaveConfirmationDialog = false showSaveConfirmationDialog = false
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError) stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError)
@@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
@@ -114,17 +113,10 @@ private fun subtitle(state: SecureBackupSetupState): String {
private fun Content( private fun Content(
state: SecureBackupSetupState, state: SecureBackupSetupState,
) { ) {
val context = LocalContext.current
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard)
val clickLambda = if (formattedRecoveryKey != null) { val clickLambda = if (formattedRecoveryKey != null) {
{ {
context.copyToClipboard( state.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
text = formattedRecoveryKey,
toastMessage = toastMessage,
isSensitive = true,
)
state.eventSink.invoke(SecureBackupSetupEvent.RecoveryKeyHasBeenSaved)
} }
} else { } else {
if (!state.recoveryKeyViewState.inProgress) { if (!state.recoveryKeyViewState.inProgress) {
@@ -15,6 +15,8 @@ import io.element.android.features.securebackup.api.SecureBackupSetupEntryPoint
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter import io.element.android.features.securebackup.impl.setup.SecureBackupSetupPresenter
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine import io.element.android.features.securebackup.impl.setup.SecureBackupSetupStateMachine
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.androidutils.toast.FakeToastHelper
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.node.TestParentNode import io.element.android.tests.testutils.node.TestParentNode
@@ -40,6 +42,8 @@ class DefaultSecureBackupSetupEntryPointTest {
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
stateMachine = SecureBackupSetupStateMachine(), stateMachine = SecureBackupSetupStateMachine(),
encryptionService = FakeEncryptionService(), encryptionService = FakeEncryptionService(),
clipboardHelper = FakeClipboardHelper(),
toastHelper = FakeToastHelper(),
) )
}, },
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
@@ -12,8 +12,13 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
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.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.A_RECOVERY_KEY import io.element.android.libraries.matrix.test.A_RECOVERY_KEY
@@ -186,16 +191,70 @@ class SecureBackupSetupPresenterTest {
} }
} }
@Test
fun `present - copy recovery key on old device shows a toast`() = runTest {
val encryptionService = FakeEncryptionService()
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey)
skipItems(1)
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
val createdState = awaitItem()
createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY)
assertThat(clipboardHelper.isSensitive).isTrue()
assertThat(toastHelper.shownToasts).containsExactly(R.string.screen_recovery_key_copied_to_clipboard)
assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
}
}
@Test
fun `present - copy recovery key on new device does not show a toast`() = runTest {
val encryptionService = FakeEncryptionService()
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink.invoke(SecureBackupSetupEvent.CreateRecoveryKey)
skipItems(1)
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
val createdState = awaitItem()
createdState.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey)
assertThat(clipboardHelper.clipboardContents).isEqualTo(A_RECOVERY_KEY)
assertThat(clipboardHelper.isSensitive).isTrue()
assertThat(toastHelper.shownToasts).isEmpty()
assertThat(awaitItem().setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
}
}
private fun createSecureBackupSetupPresenter( private fun createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory: Boolean = false, isChangeRecoveryKeyUserStory: Boolean = false,
encryptionService: EncryptionService = FakeEncryptionService( encryptionService: EncryptionService = FakeEncryptionService(
enableRecoveryLambda = { _, _ -> Result.success("") }, enableRecoveryLambda = { _, _ -> Result.success("") },
), ),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
): SecureBackupSetupPresenter { ): SecureBackupSetupPresenter {
return SecureBackupSetupPresenter( return SecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
stateMachine = SecureBackupSetupStateMachine(), stateMachine = SecureBackupSetupStateMachine(),
encryptionService = encryptionService, encryptionService = encryptionService,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
} }
@@ -15,10 +15,16 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.features.viewfolder.impl.file.ColorationMode import io.element.android.features.viewfolder.impl.file.ColorationMode
import io.element.android.features.viewfolder.impl.file.FileContent import io.element.android.features.viewfolder.impl.file.FileContent
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultTextFileViewer : TextFileViewer { class DefaultTextFileViewer(
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : TextFileViewer {
@Composable @Composable
override fun Render( override fun Render(
lines: ImmutableList<String>, lines: ImmutableList<String>,
@@ -27,6 +33,11 @@ class DefaultTextFileViewer : TextFileViewer {
FileContent( FileContent(
lines = lines, lines = lines,
colorationMode = ColorationMode.None, colorationMode = ColorationMode.None,
onLineClick = { line ->
clipboardHelper.copyPlainText(line) {
toastHelper.show(CommonStrings.common_line_copied_to_clipboard)
}
},
modifier = modifier modifier = modifier
) )
} }
@@ -23,12 +23,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -37,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList
internal fun FileContent( internal fun FileContent(
lines: ImmutableList<String>, lines: ImmutableList<String>,
colorationMode: ColorationMode, colorationMode: ColorationMode,
onLineClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@@ -60,6 +59,7 @@ internal fun FileContent(
lineNumber = index + 1, lineNumber = index + 1,
line = line, line = line,
colorationMode = colorationMode, colorationMode = colorationMode,
onClick = { onLineClick(line) },
) )
} }
} }
@@ -71,18 +71,12 @@ private fun LineRow(
lineNumber: Int, lineNumber: Int,
line: String, line: String,
colorationMode: ColorationMode, colorationMode: ColorationMode,
onClick: () -> Unit,
) { ) {
val context = LocalContext.current
val toastMessage = stringResource(CommonStrings.common_line_copied_to_clipboard)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = { .clickable(onClick = onClick)
context.copyToClipboard(
text = line,
toastMessage = toastMessage,
)
})
) { ) {
Text( Text(
modifier = Modifier modifier = Modifier
@@ -11,4 +11,5 @@ package io.element.android.features.viewfolder.impl.file
sealed interface ViewFileEvent { sealed interface ViewFileEvent {
data object SaveOnDisk : ViewFileEvent data object SaveOnDisk : ViewFileEvent
data object Share : ViewFileEvent data object Share : ViewFileEvent
data class CopyToClipboard(val text: String) : ViewFileEvent
} }
@@ -18,8 +18,11 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.androidutils.toast.ToastHelper
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -30,6 +33,8 @@ class ViewFilePresenter(
private val fileContentReader: FileContentReader, private val fileContentReader: FileContentReader,
private val fileShare: FileShare, private val fileShare: FileShare,
private val fileSave: FileSave, private val fileSave: FileSave,
private val clipboardHelper: ClipboardHelper,
private val toastHelper: ToastHelper,
) : Presenter<ViewFileState> { ) : Presenter<ViewFileState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@@ -48,6 +53,9 @@ class ViewFilePresenter(
when (event) { when (event) {
ViewFileEvent.Share -> coroutineScope.share(path) ViewFileEvent.Share -> coroutineScope.share(path)
ViewFileEvent.SaveOnDisk -> coroutineScope.save(path) ViewFileEvent.SaveOnDisk -> coroutineScope.save(path)
is ViewFileEvent.CopyToClipboard -> clipboardHelper.copyPlainText(event.text) {
toastHelper.show(CommonStrings.common_line_copied_to_clipboard)
}
} }
} }
@@ -80,6 +80,7 @@ fun ViewFileView(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
lines = state.lines.data.toImmutableList(), lines = state.lines.data.toImmutableList(),
colorationMode = state.colorationMode, colorationMode = state.colorationMode,
onLineClick = { state.eventSink(ViewFileEvent.CopyToClipboard(it)) },
) )
is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null) is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null)
} }
@@ -18,8 +18,13 @@ import io.element.android.features.viewfolder.impl.file.FileSave
import io.element.android.features.viewfolder.impl.file.FileShare import io.element.android.features.viewfolder.impl.file.FileShare
import io.element.android.features.viewfolder.impl.file.ViewFileEvent import io.element.android.features.viewfolder.impl.file.ViewFileEvent
import io.element.android.features.viewfolder.impl.file.ViewFilePresenter import io.element.android.features.viewfolder.impl.file.ViewFilePresenter
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.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@@ -126,17 +131,51 @@ class ViewFilePresenterTest {
} }
} }
@Test
fun `present - copy line on old device shows a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = true)
val toastHelper = FakeToastHelper()
val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine")
assertThat(toastHelper.shownToasts).containsExactly(CommonStrings.common_line_copied_to_clipboard)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - copy line on new device does not show a toast`() = runTest {
val clipboardHelper = FakeClipboardHelper(isOldDevice = false)
val toastHelper = FakeToastHelper()
val presenter = createPresenter(clipboardHelper = clipboardHelper, toastHelper = toastHelper)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(ViewFileEvent.CopyToClipboard("aLine"))
assertThat(clipboardHelper.clipboardContents).isEqualTo("aLine")
assertThat(toastHelper.shownToasts).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
private fun createPresenter( private fun createPresenter(
path: String = "aPath", path: String = "aPath",
name: String = "aName", name: String = "aName",
fileContentReader: FileContentReader = FakeFileContentReader(), fileContentReader: FileContentReader = FakeFileContentReader(),
fileShare: FileShare = FakeFileShare(), fileShare: FileShare = FakeFileShare(),
fileSave: FileSave = FakeFileSave(), fileSave: FileSave = FakeFileSave(),
clipboardHelper: ClipboardHelper = FakeClipboardHelper(),
toastHelper: ToastHelper = FakeToastHelper(),
) = ViewFilePresenter( ) = ViewFilePresenter(
path = path, path = path,
name = name, name = name,
fileContentReader = fileContentReader, fileContentReader = fileContentReader,
fileShare = fileShare, fileShare = fileShare,
fileSave = fileSave, fileSave = fileSave,
clipboardHelper = clipboardHelper,
toastHelper = toastHelper,
) )
} }
@@ -9,8 +9,11 @@
package io.element.android.libraries.androidutils.clipboard package io.element.android.libraries.androidutils.clipboard
import android.content.ClipData import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
@@ -24,7 +27,30 @@ class AndroidClipboardHelper(
) : ClipboardHelper { ) : ClipboardHelper {
private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>()) private val clipboardManager = requireNotNull(context.getSystemService<ClipboardManager>())
override fun copyPlainText(text: String) { override fun copyPlainText(
clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) text: String,
isSensitive: Boolean,
onSuccessOnOldDevice: () -> Unit,
) {
val clipData = ClipData.newPlainText("", text)
if (isSensitive) {
clipData.description.extras = PersistableBundle().apply {
putBoolean(EXTRA_IS_SENSITIVE, true)
}
}
clipboardManager.setPrimaryClip(clipData)
// On Android 13+, the system already displays a confirmation when content is copied to the clipboard.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
onSuccessOnOldDevice()
}
}
private companion object {
// The constant only exists from API 33, but the documentation recommends using its value on older versions too.
val EXTRA_IS_SENSITIVE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ClipDescription.EXTRA_IS_SENSITIVE
} else {
"android.content.extra.IS_SENSITIVE"
}
} }
} }
@@ -8,9 +8,18 @@
package io.element.android.libraries.androidutils.clipboard package io.element.android.libraries.androidutils.clipboard
/**
* Wrapper class for handling clipboard operations so it can be used in JVM environments.
*/
interface ClipboardHelper { interface ClipboardHelper {
fun copyPlainText(text: String) /**
* Copy a text to the clipboard.
*
* @param text the text to copy
* @param isSensitive if true, the clip is flagged so the system clipboard preview (Android 13+) and keyboards hide its content.
* @param onSuccessOnOldDevice invoked after the copy only on Android versions older than 13, since newer versions already
* display a confirmation. Can be used to render a Toast or a Snackbar.
*/
fun copyPlainText(
text: String,
isSensitive: Boolean = false,
onSuccessOnOldDevice: () -> Unit,
)
} }
@@ -8,10 +8,21 @@
package io.element.android.libraries.androidutils.clipboard package io.element.android.libraries.androidutils.clipboard
class FakeClipboardHelper : ClipboardHelper { class FakeClipboardHelper(
private val isOldDevice: Boolean = true,
) : ClipboardHelper {
var clipboardContents: Any? = null var clipboardContents: Any? = null
var isSensitive: Boolean? = null
override fun copyPlainText(text: String) { override fun copyPlainText(
text: String,
isSensitive: Boolean,
onSuccessOnOldDevice: () -> Unit,
) {
clipboardContents = text clipboardContents = text
this.isSensitive = isSensitive
if (isOldDevice) {
onSuccessOnOldDevice()
}
} }
} }
@@ -1,43 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.system
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import androidx.core.content.getSystemService
class CopyToClipboardUseCase(
private val context: Context,
) {
/**
* @param isSensitive if true, the clip is flagged so the system clipboard preview (Android 13+) and keyboards hide its content.
*/
fun execute(text: CharSequence, isSensitive: Boolean = false) {
val clipData = ClipData.newPlainText("", text)
if (isSensitive) {
clipData.description.extras = PersistableBundle().apply {
putBoolean(EXTRA_IS_SENSITIVE, true)
}
}
context.getSystemService<ClipboardManager>()?.setPrimaryClip(clipData)
}
private companion object {
// The constant only exists from API 33, but the documentation recommends using its value on older versions too.
val EXTRA_IS_SENSITIVE = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ClipDescription.EXTRA_IS_SENSITIVE
} else {
"android.content.extra.IS_SENSITIVE"
}
}
}
@@ -50,26 +50,6 @@ fun Context.getVersionCodeFromManifest(): Long {
) )
} }
// ==============================================================================================================
// Clipboard helper
// ==============================================================================================================
/**
* Copy a text to the clipboard, and display a Toast when done.
*
* @receiver the context
* @param text the text to copy
* @param toastMessage content of the toast message as a String resource. Null for no toast
*/
fun Context.copyToClipboard(
text: CharSequence,
toastMessage: String? = null,
isSensitive: Boolean = false,
) {
CopyToClipboardUseCase(this).execute(text, isSensitive)
toastMessage?.let { toast(it) }
}
/** /**
* Shows notification settings for the current app. * Shows notification settings for the current app.
* In android O will directly opens the notification settings, in lower version it will show the App settings * In android O will directly opens the notification settings, in lower version it will show the App settings
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.di.annotations.ApplicationContext
@ContributesBinding(AppScope::class)
class AndroidToastHelper(
@ApplicationContext private val context: Context,
) : ToastHelper {
override fun show(resId: Int) {
context.toast(resId)
}
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
class FakeToastHelper : ToastHelper {
val shownToasts = mutableListOf<Int>()
override fun show(resId: Int) {
shownToasts.add(resId)
}
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
import androidx.annotation.StringRes
interface ToastHelper {
fun show(@StringRes resId: Int)
}