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
@@ -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
}
@@ -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<SecureBackupSetupState> {
@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)
@@ -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) {
@@ -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(),
@@ -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,
)
}
}