From ed73cf491f535b47a3e1d6abd0e475dcb6852c37 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:38:51 -0700 Subject: [PATCH 1/3] fix: clear clipboard setting does not clear the clipboard on Android 13+ --- .../manager/clipboard/ClearClipboardWorker.kt | 22 ++++- .../clipboard/ClearClipboardWorkerTest.kt | 99 +++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt index c1b69ddd688..9f7604a3ce3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt @@ -1,11 +1,16 @@ package com.x8bit.bitwarden.data.platform.manager.clipboard +import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.content.Context.CLIPBOARD_SERVICE +import android.os.Build +import androidx.core.os.persistableBundleOf import androidx.work.Worker import androidx.work.WorkerParameters import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.core.util.isBuildVersionAtLeast /** * A worker to clear the clipboard manager. @@ -20,7 +25,22 @@ class ClearClipboardWorker( appContext.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager override fun doWork(): Result { - clipboardManager.clearPrimaryClip() + clipboardManager.setPrimaryClip( + ClipData + .newPlainText("", "") + .apply { + description.extras = persistableBundleOf( + if (isBuildVersionAtLeast(version = Build.VERSION_CODES.TIRAMISU)) { + ClipDescription.EXTRA_IS_SENSITIVE to true + } else { + "android.content.extra.IS_SENSITIVE" to true + }, + ) + }, + ) + if (isBuildVersionAtLeast(version = Build.VERSION_CODES.P)) { + clipboardManager.clearPrimaryClip() + } return Result.success() } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt new file mode 100644 index 00000000000..e1c3d03c804 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt @@ -0,0 +1,99 @@ +package com.x8bit.bitwarden.data.platform.manager.clipboard + +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.Context +import android.os.Build +import androidx.work.ListenableWorker +import com.bitwarden.ui.platform.base.BaseRobolectricTest +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +class ClearClipboardWorkerTest : BaseRobolectricTest() { + + private lateinit var clipboardManager: ClipboardManager + private lateinit var worker: ClearClipboardWorker + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + worker = ClearClipboardWorker( + appContext = context, + workerParams = mockk(relaxed = true), + ) + } + + @Test + fun `doWork should clear a populated clipboard and return success`() { + clipboardManager.setPrimaryClip(ClipData.newPlainText("", "password")) + + val result = worker.doWork() + + assertTrue(clipboardManager.primaryClip?.getItemAt(0)?.text.isNullOrEmpty()) + assertEquals(ListenableWorker.Result.success(), result) + } + + @Test + fun `doWork should return success when the clipboard is already empty`() { + clipboardManager.clearPrimaryClip() + + val result = worker.doWork() + + assertFalse(clipboardManager.hasPrimaryClip()) + assertEquals(ListenableWorker.Result.success(), result) + } + + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + @Test + fun `doWork should overwrite the clipboard with sensitive empty content before clearing`() { + val overwrittenClip = captureEmptyClipboardOverwrite() + + assertTrue( + overwrittenClip + ?.description + ?.extras + ?.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE) == true, + ) + } + + @Config(sdk = [Build.VERSION_CODES.S_V2]) + @Test + fun `doWork should use the legacy sensitive extra before Android 13`() { + val overwrittenClip = captureEmptyClipboardOverwrite() + + assertTrue( + overwrittenClip + ?.description + ?.extras + ?.getBoolean("android.content.extra.IS_SENSITIVE") == true, + ) + } + + private fun captureEmptyClipboardOverwrite(): ClipData? { + var overwrittenClip: ClipData? = null + val listener = ClipboardManager.OnPrimaryClipChangedListener { + val currentClip = clipboardManager.primaryClip + if ( + overwrittenClip == null && + currentClip?.getItemAt(0)?.text.isNullOrEmpty() + ) { + overwrittenClip = currentClip + } + } + clipboardManager.setPrimaryClip(ClipData.newPlainText("", "password")) + clipboardManager.addPrimaryClipChangedListener(listener) + + worker.doWork() + + clipboardManager.removePrimaryClipChangedListener(listener) + return overwrittenClip + } +} From 5e2465ca6e495e7e27e2be3c0194f09cfe59035e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:39:39 -0700 Subject: [PATCH 2/3] refactor: drop redundant version check in ClearClipboardWorker Remove the isBuildVersionAtLeast(P) gate and the OmitFromCoverage annotation now that the worker has test coverage. --- .../data/platform/manager/clipboard/ClearClipboardWorker.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt index 9f7604a3ce3..09af11b3d12 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorker.kt @@ -9,13 +9,11 @@ import android.os.Build import androidx.core.os.persistableBundleOf import androidx.work.Worker import androidx.work.WorkerParameters -import com.bitwarden.annotation.OmitFromCoverage import com.bitwarden.core.util.isBuildVersionAtLeast /** * A worker to clear the clipboard manager. */ -@OmitFromCoverage class ClearClipboardWorker( appContext: Context, workerParams: WorkerParameters, @@ -38,9 +36,7 @@ class ClearClipboardWorker( ) }, ) - if (isBuildVersionAtLeast(version = Build.VERSION_CODES.P)) { - clipboardManager.clearPrimaryClip() - } + clipboardManager.clearPrimaryClip() return Result.success() } } From a0302ac113d5974b79e8d6e50cd0d523214c4476 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:16:51 -0700 Subject: [PATCH 3/3] test: inline ClearClipboardWorkerTest setup Per review, fold the @Before setup into property initializers. getSystemService() returns T?, so the clipboard manager is unwrapped with requireNotNull rather than !!, which detekt's UnsafeCallOnNullableType rejects. Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- .../clipboard/ClearClipboardWorkerTest.kt | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt index e1c3d03c804..190db5de11f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/manager/clipboard/ClearClipboardWorkerTest.kt @@ -3,33 +3,26 @@ package com.x8bit.bitwarden.data.platform.manager.clipboard import android.content.ClipData import android.content.ClipDescription import android.content.ClipboardManager -import android.content.Context import android.os.Build +import androidx.core.content.getSystemService import androidx.work.ListenableWorker import com.bitwarden.ui.platform.base.BaseRobolectricTest import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config class ClearClipboardWorkerTest : BaseRobolectricTest() { - private lateinit var clipboardManager: ClipboardManager - private lateinit var worker: ClearClipboardWorker - - @Before - fun setup() { - val context = RuntimeEnvironment.getApplication() - clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - worker = ClearClipboardWorker( - appContext = context, - workerParams = mockk(relaxed = true), - ) - } + private val context = RuntimeEnvironment.getApplication() + private val clipboardManager = requireNotNull(context.getSystemService()) + private val worker: ClearClipboardWorker = ClearClipboardWorker( + appContext = context, + workerParams = mockk(relaxed = true), + ) @Test fun `doWork should clear a populated clipboard and return success`() {