From f59f5da64cece3e1f13de75d00c3bddc46f41396 Mon Sep 17 00:00:00 2001 From: Bardi Harborow Date: Sun, 4 Oct 2026 20:24:22 +1100 Subject: [PATCH 1/2] Mark the recovery key as sensitive when copying it to the clipboard The recovery key was copied without EXTRA_IS_SENSITIVE, so Android 13+ showed it in the clipboard preview and keyboards could store it in their clipboard history. Add an isSensitive option to CopyToClipboardUseCase and Context.copyToClipboard, and set it when copying the recovery key. --- .../impl/setup/SecureBackupSetupView.kt | 1 + .../system/CopyToClipboardUseCase.kt | 26 ++++++++++++++++--- .../androidutils/system/SystemUtils.kt | 5 ++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt index c22bd44c99..802b9ae070 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -122,6 +122,7 @@ private fun Content( context.copyToClipboard( text = formattedRecoveryKey, toastMessage = toastMessage, + isSensitive = true, ) state.eventSink.invoke(SecureBackupSetupEvent.RecoveryKeyHasBeenSaved) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt index 53a1c33995..e20e80884f 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt @@ -9,15 +9,35 @@ 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, ) { - fun execute(text: CharSequence) { - context.getSystemService() - ?.setPrimaryClip(ClipData.newPlainText("", text)) + /** + * @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()?.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" + } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 861aca032d..076cda5704 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -63,9 +63,10 @@ fun Context.getVersionCodeFromManifest(): Long { */ fun Context.copyToClipboard( text: CharSequence, - toastMessage: String? = null + toastMessage: String? = null, + isSensitive: Boolean = false, ) { - CopyToClipboardUseCase(this).execute(text) + CopyToClipboardUseCase(this).execute(text, isSensitive) toastMessage?.let { toast(it) } } From b9ed09040036b663c9c91a6535dec04563891ac2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 5 Oct 2026 11:40:08 +0200 Subject: [PATCH 2/2] 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) --- .../messages/impl/MessagesPresenter.kt | 12 ++-- .../pinned/list/PinnedMessagesListNode.kt | 14 ++--- .../messages/impl/timeline/TimelineEvent.kt | 2 + .../impl/timeline/TimelinePresenter.kt | 10 ++++ .../messages/impl/timeline/TimelineView.kt | 9 +-- .../impl/timeline/debug/EventDebugInfoNode.kt | 14 ++++- .../impl/timeline/debug/EventDebugInfoView.kt | 34 +++++++---- .../impl/timeline/TimelinePresenterTest.kt | 41 +++++++++++++ .../blockedusers/BlockedUsersPresenter.kt | 5 +- .../impl/developer/DeveloperSettingsEvent.kt | 1 + .../developer/DeveloperSettingsPresenter.kt | 10 ++++ .../impl/developer/DeveloperSettingsView.kt | 21 +++---- .../appsettings/AppDeveloperSettingsEvent.kt | 1 + .../AppDeveloperSettingsPresenter.kt | 10 ++++ .../appsettings/AppDeveloperSettingsView.kt | 20 ++----- .../DeveloperSettingsPresenterTest.kt | 41 +++++++++++++ .../AppDeveloperSettingsPresenterTest.kt | 43 +++++++++++++- .../roomdetails/impl/RoomDetailsEvent.kt | 1 + .../roomdetails/impl/RoomDetailsPresenter.kt | 12 +++- .../roomdetails/impl/RoomDetailsView.kt | 13 +--- .../details/RoomMemberDetailsPresenter.kt | 5 +- .../impl/RoomDetailsPresenterTest.kt | 45 ++++++++++++++ .../impl/setup/SecureBackupSetupEvent.kt | 1 + .../impl/setup/SecureBackupSetupPresenter.kt | 12 ++++ .../impl/setup/SecureBackupSetupView.kt | 10 +--- .../DefaultSecureBackupSetupEntryPointTest.kt | 4 ++ .../setup/SecureBackupSetupPresenterTest.kt | 59 +++++++++++++++++++ .../viewfolder/impl/DefaultTextFileViewer.kt | 13 +++- .../viewfolder/impl/file/FileContent.kt | 14 ++--- .../viewfolder/impl/file/ViewFileEvent.kt | 1 + .../viewfolder/impl/file/ViewFilePresenter.kt | 8 +++ .../viewfolder/impl/file/ViewFileView.kt | 1 + .../test/file/ViewFilePresenterTest.kt | 39 ++++++++++++ .../clipboard/AndroidClipboardHelper.kt | 30 +++++++++- .../androidutils/clipboard/ClipboardHelper.kt | 17 ++++-- .../clipboard/FakeClipboardHelper.kt | 15 ++++- .../system/CopyToClipboardUseCase.kt | 43 -------------- .../androidutils/system/SystemUtils.kt | 20 ------- .../androidutils/toast/AndroidToastHelper.kt | 23 ++++++++ .../androidutils/toast/FakeToastHelper.kt | 16 +++++ .../androidutils/toast/ToastHelper.kt | 14 +++++ 41 files changed, 532 insertions(+), 172 deletions(-) delete mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt create mode 100644 libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 98e595c110..89556bb1e1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -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)) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt index 5c6980c8bb..2391766203 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListNode.kt @@ -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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 66807b74f2..82f13a6dae 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -29,6 +29,8 @@ sealed interface TimelineEvent { data object MarkAllAsRead : TimelineEvent + data class CopyToClipboard(val text: String) : TimelineEvent + /** * Events coming from a timeline item. */ diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 4580f77d59..516abcdfd8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -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, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { 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) + } + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt index c9d9d5f630..0dbe6c315f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt @@ -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() { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt index 6288f3f159..3c80cea7f7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt @@ -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, + 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, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt index 073bb427c9..f836bc5d68 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoView.kt @@ -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()) } 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 = { }, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index aa4198d156..f09e595e7c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -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 = { aTimelineProtectionState() }, resolveVerifiedUserSendFailurePresenter: Presenter = { 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, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt index 8f20694d81..ebb671e6d0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt @@ -81,8 +81,9 @@ class BlockedUsersPresenter( unblockUserAction.value = AsyncAction.ConfirmingNoParams } is BlockedUsersEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.userId.value) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.userId.value) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } } BlockedUsersEvent.ConfirmUnblock -> { pendingUserToUnblock?.let { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt index 34ccba2abe..a4ca35e50d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvent.kt @@ -19,4 +19,5 @@ sealed interface DeveloperSettingsEvent { data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvent data object OpenPushRules : DeveloperSettingsEvent data object DismissPushRulesError : DeveloperSettingsEvent + data class CopyToClipboard(val text: String) : DeveloperSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 74bf595ce4..d531bb1cb1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -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.MarkAllRoomsAsRead 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.toast.ToastHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData 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.SessionId 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.toImmutableMap import kotlinx.coroutines.CoroutineScope @@ -61,6 +64,8 @@ class DeveloperSettingsPresenter( private val markAllRoomsAsRead: MarkAllRoomsAsRead, private val buildMeta: BuildMeta, private val notificationSettingsService: NotificationSettingsService, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory fun interface Factory { @@ -130,6 +135,11 @@ class DeveloperSettingsPresenter( DeveloperSettingsEvent.DismissPushRulesError -> { pushRulesAction.value = AsyncAction.Uninitialized } + is DeveloperSettingsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 992a7e6212..b36521ef0c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -15,14 +15,12 @@ import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R 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.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog @@ -84,7 +82,10 @@ fun DeveloperSettingsView( AppDeveloperSettingsView( state = state.appDeveloperSettingsState, ) - SessionCategory(deviceId = state.deviceId) + SessionCategory( + deviceId = state.deviceId, + onCopyToClipboard = { state.eventSink(DeveloperSettingsEvent.CopyToClipboard(it)) }, + ) NotificationCategory( onPushRulesClick = { state.eventSink(DeveloperSettingsEvent.OpenPushRules) }, onPushHistoryClick = onPushHistoryClick, @@ -182,19 +183,15 @@ fun DeveloperSettingsView( } @Composable -private fun SessionCategory(deviceId: DeviceId) { +private fun SessionCategory( + deviceId: DeviceId, + onCopyToClipboard: (String) -> Unit, +) { PreferenceCategory(title = "Session") { - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) - val context = LocalContext.current ListItem( content = { Text("DeviceId") }, supportingContent = { Text(text = deviceId.value) }, - onClick = { - context.copyToClipboard( - text = deviceId.value, - toastMessage = toastMessage, - ) - } + onClick = { onCopyToClipboard(deviceId.value) } ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt index 9f328fdd9b..b3d777c4e7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsEvent.kt @@ -17,4 +17,5 @@ sealed interface AppDeveloperSettingsEvent { data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AppDeveloperSettingsEvent data class SetTracingLogLevel(val logLevel: LogLevelItem) : AppDeveloperSettingsEvent data class ToggleTracingLogPack(val logPack: TraceLogPack, val enabled: Boolean) : AppDeveloperSettingsEvent + data class CopyToClipboard(val text: String) : AppDeveloperSettingsEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt index 1bf162d3ea..75519079ed 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenter.kt @@ -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.model.EnabledFeature 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.Presenter 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.ui.model.FeatureUiModel 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.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -44,6 +47,8 @@ class AppDeveloperSettingsPresenter( private val rageshakePresenter: Presenter, private val appPreferencesStore: AppPreferencesStore, private val buildMeta: BuildMeta, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @Composable override fun present(): AppDeveloperSettingsState { @@ -106,6 +111,11 @@ class AppDeveloperSettingsPresenter( } appPreferencesStore.setTracingLogPacks(currentPacks) } + is AppDeveloperSettingsEvent.CopyToClipboard -> { + clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_copied_to_clipboard) + } + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt index 7f10745872..4f6f8234fa 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsView.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType 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.developer.tracing.LogLevelItem 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.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown @@ -108,6 +106,7 @@ fun AppDeveloperSettingsView( GitCategory( gitBranch = state.gitBranch, gitSha = state.gitSha, + onCopyToClipboard = { state.eventSink(AppDeveloperSettingsEvent.CopyToClipboard(it)) }, ) } } @@ -116,29 +115,18 @@ fun AppDeveloperSettingsView( private fun GitCategory( gitBranch: String, gitSha: String, + onCopyToClipboard: (String) -> Unit, ) { PreferenceCategory(title = "Git") { - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) - val context = LocalContext.current ListItem( content = { Text("Git branch") }, supportingContent = { Text(text = gitBranch) }, - onClick = { - context.copyToClipboard( - text = gitBranch, - toastMessage = toastMessage, - ) - } + onClick = { onCopyToClipboard(gitBranch) } ) ListItem( content = { Text("Git SHA") }, supportingContent = { Text(text = gitSha) }, - onClick = { - context.copyToClipboard( - text = gitSha, - toastMessage = toastMessage, - ) - } + onClick = { onCopyToClipboard(gitSha) } ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index b76939f72c..c3c08602cd 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -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.FakeMarkAllRoomsAsRead 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.toast.FakeToastHelper +import io.element.android.libraries.androidutils.toast.ToastHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData 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.core.aBuildMeta 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.lambda.lambdaError 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( navigator: DeveloperSettingsNavigator = DeveloperSettingsNavigator { _, _ -> lambdaError() }, sessionId: SessionId = A_SESSION_ID, @@ -262,6 +299,8 @@ class DeveloperSettingsPresenterTest { markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), buildMeta: BuildMeta = aBuildMeta(), notificationSettingsService: NotificationSettingsService = FakeNotificationSettingsService(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( navigator = navigator, @@ -277,6 +316,8 @@ class DeveloperSettingsPresenterTest { markAllRoomsAsRead = markAllRoomsAsRead, buildMeta = buildMeta, notificationSettingsService = notificationSettingsService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt index 9300236293..3ec56a64fd 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/appsettings/AppDeveloperSettingsPresenterTest.kt @@ -12,12 +12,17 @@ package io.element.android.features.preferences.impl.developer.appsettings import com.google.common.truth.Truth.assertThat import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem 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.featureflag.api.Feature import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.core.aBuildMeta 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.lambda.lambdaRecorder 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( featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( getAvailableFeaturesResult = { _, _ -> @@ -164,6 +201,8 @@ class AppDeveloperSettingsPresenterTest { } ), preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): AppDeveloperSettingsPresenter { return AppDeveloperSettingsPresenter( featureFlagService = featureFlagService, @@ -172,7 +211,9 @@ class AppDeveloperSettingsPresenterTest { buildMeta = aBuildMeta( gitRevision = "1234567890", gitBranchName = "feature/awesome-feature" - ) + ), + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt index a82766175d..9d06db371f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsEvent.kt @@ -13,6 +13,7 @@ sealed interface RoomDetailsEvent { data object MuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent data class CopyToClipboard(val text: String) : RoomDetailsEvent + data object CopyRoomId : RoomDetailsEvent data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent data object MarkAsRead : RoomDetailsEvent data object MarkAsUnread : RoomDetailsEvent diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt index 9a602d06c0..e8a8079a54 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenter.kt @@ -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.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.designsystem.utils.snackbar.LocalSnackbarDispatcher @@ -74,6 +75,7 @@ class RoomDetailsPresenter( private val dispatchers: CoroutineDispatchers, private val analyticsService: AnalyticsService, private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, private val appPreferencesStore: AppPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore, private val notificationCleaner: NotificationCleaner, @@ -167,8 +169,14 @@ class RoomDetailsPresenter( } is RoomDetailsEvent.SetFavorite -> scope.setFavorite(event.isFavorite) is RoomDetailsEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.text) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.text) { + 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.MarkAsUnread -> scope.markAsUnread() diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 2d76ca5e78..626977007e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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.shared.blockuser.BlockUserDialogs 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.designsystem.atomic.atoms.MatrixBadgeAtom import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule @@ -339,6 +337,7 @@ fun RoomDetailsView( DebugInfoSection( roomId = state.roomId, roomVersion = state.roomVersion, + onCopyRoomIdClick = { state.eventSink(RoomDetailsEvent.CopyRoomId) }, ) } } @@ -865,10 +864,9 @@ private fun OtherActionsSection( private fun DebugInfoSection( roomId: RoomId, roomVersion: String?, + onCopyRoomIdClick: () -> Unit, ) { - val context = LocalContext.current PreferenceCategory { - val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) ListItem( content = { Text("Internal room ID") @@ -882,12 +880,7 @@ private fun DebugInfoSection( }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Code())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())), - onClick = { - context.copyToClipboard( - text = roomId.value, - toastMessage = toastMessage, - ) - }, + onClick = onCopyRoomIdClick, ) ListItem( content = { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt index 85eca4fbae..6428bd097a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsPresenter.kt @@ -118,8 +118,9 @@ class RoomMemberDetailsPresenter( encryptionService.withdrawVerification(roomMemberId) } is UserProfileEvent.CopyToClipboard -> { - clipboardHelper.copyPlainText(event.text) - snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + clipboardHelper.copyPlainText(event.text) { + snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_copied_to_clipboard)) + } } else -> userProfileState.eventSink(event) } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt index 8d6ef8d518..da79d436e0 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsPresenterTest.kt @@ -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.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.core.coroutine.CoroutineDispatchers 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.push.api.notifications.NotificationCleaner 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.test.FakeAnalyticsService import io.element.android.tests.testutils.EventsRecorder @@ -85,6 +88,7 @@ class RoomDetailsPresenterTest { analyticsService: AnalyticsService = FakeAnalyticsService(), encryptionService: FakeEncryptionService = FakeEncryptionService(), clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), navigator: RoomDetailsNavigator = FakeRoomDetailsNavigator(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), @@ -115,12 +119,53 @@ class RoomDetailsPresenterTest { dispatchers = dispatchers, analyticsService = analyticsService, clipboardHelper = clipboardHelper, + toastHelper = toastHelper, appPreferencesStore = appPreferencesStore, notificationCleaner = notificationCleaner, 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 fun `present - initial state is created from initial room info`() = runTest { val room = aJoinedRoom( diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt index 68221a29e4..a71c235857 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvent.kt @@ -11,6 +11,7 @@ package io.element.android.features.securebackup.impl.setup sealed interface SecureBackupSetupEvent { data object CreateRecoveryKey : SecureBackupSetupEvent data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvent + data object CopyRecoveryKey : SecureBackupSetupEvent data object Done : SecureBackupSetupEvent data object DismissDialog : SecureBackupSetupEvent } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt index 538128a0d5..88338c6af4 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -22,9 +22,12 @@ import com.freeletics.flowredux.compose.rememberStateAndDispatch import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory 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.setup.views.RecoveryKeyUserStory 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.matrix.api.encryption.EnableRecoveryProgress import io.element.android.libraries.matrix.api.encryption.EncryptionService @@ -38,6 +41,8 @@ class SecureBackupSetupPresenter( @Assisted private val isChangeRecoveryKeyUserStory: Boolean, private val stateMachine: SecureBackupSetupStateMachine, private val encryptionService: EncryptionService, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory interface Factory { @@ -60,6 +65,13 @@ class SecureBackupSetupPresenter( } SecureBackupSetupEvent.RecoveryKeyHasBeenSaved -> 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 -> { showSaveConfirmationDialog = false stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.ClearError) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt index 802b9ae070..d7c63e3a39 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R 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.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.components.BigIcon @@ -114,17 +113,10 @@ private fun subtitle(state: SecureBackupSetupState): String { private fun Content( state: SecureBackupSetupState, ) { - val context = LocalContext.current val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey - val toastMessage = stringResource(R.string.screen_recovery_key_copied_to_clipboard) val clickLambda = if (formattedRecoveryKey != null) { { - context.copyToClipboard( - text = formattedRecoveryKey, - toastMessage = toastMessage, - isSensitive = true, - ) - state.eventSink.invoke(SecureBackupSetupEvent.RecoveryKeyHasBeenSaved) + state.eventSink.invoke(SecureBackupSetupEvent.CopyRecoveryKey) } } else { if (!state.recoveryKeyViewState.inProgress) { diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt index e37dab748f..3b029b2ba2 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupSetupEntryPointTest.kt @@ -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.SecureBackupSetupPresenter 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.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.node.TestParentNode @@ -40,6 +42,8 @@ class DefaultSecureBackupSetupEntryPointTest { isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = FakeEncryptionService(), + clipboardHelper = FakeClipboardHelper(), + toastHelper = FakeToastHelper(), ) }, snackbarDispatcher = SnackbarDispatcher(), diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt index 6ab0b257a2..2b79863953 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -12,8 +12,13 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test 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.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.EncryptionService 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( isChangeRecoveryKeyUserStory: Boolean = false, encryptionService: EncryptionService = FakeEncryptionService( enableRecoveryLambda = { _, _ -> Result.success("") }, ), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ): SecureBackupSetupPresenter { return SecureBackupSetupPresenter( isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory, stateMachine = SecureBackupSetupStateMachine(), encryptionService = encryptionService, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt index ae3e6932ac..bcbacf00fa 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/DefaultTextFileViewer.kt @@ -15,10 +15,16 @@ import dev.zacsweers.metro.ContributesBinding 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.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 @ContributesBinding(AppScope::class) -class DefaultTextFileViewer : TextFileViewer { +class DefaultTextFileViewer( + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, +) : TextFileViewer { @Composable override fun Render( lines: ImmutableList, @@ -27,6 +33,11 @@ class DefaultTextFileViewer : TextFileViewer { FileContent( lines = lines, colorationMode = ColorationMode.None, + onLineClick = { line -> + clipboardHelper.copyPlainText(line) { + toastHelper.show(CommonStrings.common_line_copied_to_clipboard) + } + }, modifier = modifier ) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt index c68d630869..a0afd75a28 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileContent.kt @@ -23,12 +23,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp 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.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -37,6 +35,7 @@ import kotlinx.collections.immutable.ImmutableList internal fun FileContent( lines: ImmutableList, colorationMode: ColorationMode, + onLineClick: (String) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -60,6 +59,7 @@ internal fun FileContent( lineNumber = index + 1, line = line, colorationMode = colorationMode, + onClick = { onLineClick(line) }, ) } } @@ -71,18 +71,12 @@ private fun LineRow( lineNumber: Int, line: String, colorationMode: ColorationMode, + onClick: () -> Unit, ) { - val context = LocalContext.current - val toastMessage = stringResource(CommonStrings.common_line_copied_to_clipboard) Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = { - context.copyToClipboard( - text = line, - toastMessage = toastMessage, - ) - }) + .clickable(onClick = onClick) ) { Text( modifier = Modifier diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt index 2ff6faf4ef..7ba5338fdf 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileEvent.kt @@ -11,4 +11,5 @@ package io.element.android.features.viewfolder.impl.file sealed interface ViewFileEvent { data object SaveOnDisk : ViewFileEvent data object Share : ViewFileEvent + data class CopyToClipboard(val text: String) : ViewFileEvent } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt index 571f3eb053..0ffea1dc08 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFilePresenter.kt @@ -18,8 +18,11 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory 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.Presenter +import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -30,6 +33,8 @@ class ViewFilePresenter( private val fileContentReader: FileContentReader, private val fileShare: FileShare, private val fileSave: FileSave, + private val clipboardHelper: ClipboardHelper, + private val toastHelper: ToastHelper, ) : Presenter { @AssistedFactory interface Factory { @@ -48,6 +53,9 @@ class ViewFilePresenter( when (event) { ViewFileEvent.Share -> coroutineScope.share(path) ViewFileEvent.SaveOnDisk -> coroutineScope.save(path) + is ViewFileEvent.CopyToClipboard -> clipboardHelper.copyPlainText(event.text) { + toastHelper.show(CommonStrings.common_line_copied_to_clipboard) + } } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt index abe7840718..ee35614a52 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -80,6 +80,7 @@ fun ViewFileView( modifier = Modifier.weight(1f), lines = state.lines.data.toImmutableList(), colorationMode = state.colorationMode, + onLineClick = { state.eventSink(ViewFileEvent.CopyToClipboard(it)) }, ) is AsyncData.Failure -> AsyncFailure(throwable = state.lines.error, onRetry = null) } diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt index c3be8d84b9..7e47d0064d 100644 --- a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/test/file/ViewFilePresenterTest.kt @@ -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.ViewFileEvent 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.matrix.test.AN_EXCEPTION +import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest 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( path: String = "aPath", name: String = "aName", fileContentReader: FileContentReader = FakeFileContentReader(), fileShare: FileShare = FakeFileShare(), fileSave: FileSave = FakeFileSave(), + clipboardHelper: ClipboardHelper = FakeClipboardHelper(), + toastHelper: ToastHelper = FakeToastHelper(), ) = ViewFilePresenter( path = path, name = name, fileContentReader = fileContentReader, fileShare = fileShare, fileSave = fileSave, + clipboardHelper = clipboardHelper, + toastHelper = toastHelper, ) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt index ce6f61f280..c452902404 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/AndroidClipboardHelper.kt @@ -9,8 +9,11 @@ package io.element.android.libraries.androidutils.clipboard 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 import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -24,7 +27,30 @@ class AndroidClipboardHelper( ) : ClipboardHelper { private val clipboardManager = requireNotNull(context.getSystemService()) - override fun copyPlainText(text: String) { - clipboardManager.setPrimaryClip(ClipData.newPlainText("", text)) + override fun copyPlainText( + 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" + } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt index 1d0ab8e3c4..0add39fe76 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/ClipboardHelper.kt @@ -8,9 +8,18 @@ package io.element.android.libraries.androidutils.clipboard -/** - * Wrapper class for handling clipboard operations so it can be used in JVM environments. - */ 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, + ) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt index 9f91b3a4ce..a46053662e 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/clipboard/FakeClipboardHelper.kt @@ -8,10 +8,21 @@ package io.element.android.libraries.androidutils.clipboard -class FakeClipboardHelper : ClipboardHelper { +class FakeClipboardHelper( + private val isOldDevice: Boolean = true, +) : ClipboardHelper { var clipboardContents: Any? = null + var isSensitive: Boolean? = null - override fun copyPlainText(text: String) { + override fun copyPlainText( + text: String, + isSensitive: Boolean, + onSuccessOnOldDevice: () -> Unit, + ) { clipboardContents = text + this.isSensitive = isSensitive + if (isOldDevice) { + onSuccessOnOldDevice() + } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt deleted file mode 100644 index e20e80884f..0000000000 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/CopyToClipboardUseCase.kt +++ /dev/null @@ -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()?.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" - } - } -} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 076cda5704..234697226f 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -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. * In android O will directly opens the notification settings, in lower version it will show the App settings diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt new file mode 100644 index 0000000000..d2ba027f19 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/AndroidToastHelper.kt @@ -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) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt new file mode 100644 index 0000000000..a4b26d1343 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/FakeToastHelper.kt @@ -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() + + override fun show(resId: Int) { + shownToasts.add(resId) + } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt new file mode 100644 index 0000000000..e48022a455 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/toast/ToastHelper.kt @@ -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) +}