Merge pull request #7857 from element-hq/feature/bma/copyToClipboardNoDoubleToast

Mark the recovery key as sensitive when copying it, and do not show a copy confirmation on Android 13+
This commit is contained in:
Benoit Marty
2026-10-05 14:51:04 +02:00
committed by GitHub
41 changed files with 532 additions and 150 deletions
@@ -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<ClipboardManager>())
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"
}
}
}
@@ -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,
)
}
@@ -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()
}
}
}
@@ -1,23 +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.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
class CopyToClipboardUseCase(
private val context: Context,
) {
fun execute(text: CharSequence) {
context.getSystemService<ClipboardManager>()
?.setPrimaryClip(ClipData.newPlainText("", text))
}
}
@@ -50,25 +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
) {
CopyToClipboardUseCase(this).execute(text)
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
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.di.annotations.ApplicationContext
@ContributesBinding(AppScope::class)
class AndroidToastHelper(
@ApplicationContext private val context: Context,
) : ToastHelper {
override fun show(resId: Int) {
context.toast(resId)
}
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
class FakeToastHelper : ToastHelper {
val shownToasts = mutableListOf<Int>()
override fun show(resId: Int) {
shownToasts.add(resId)
}
}
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.toast
import androidx.annotation.StringRes
interface ToastHelper {
fun show(@StringRes resId: Int)
}