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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-05 11:40:08 +02:00
parent 38e74ff43d
commit b9ed090400
41 changed files with 532 additions and 172 deletions
@@ -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
@@ -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()
@@ -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 = {
@@ -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)
}
@@ -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(