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
@@ -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<String>,
@@ -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
)
}
@@ -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<String>,
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
@@ -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
}
@@ -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<ViewFileState> {
@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)
}
}
}
@@ -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)
}
@@ -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,
)
}