[network/kdeconnect-android] src: clipboard: add option to skip syncing sensitive clipboard content
Albert Vaca Cintora <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 0150bd323db4da9cd3adb79499c0c6d57ac07be1 by Albert Vaca Cintora, on behalf of Young Lord.
Committed on 26/07/2026 at 20:51.
Pushed by albertvaka into branch 'master'.
clipboard: add option to skip syncing sensitive clipboard content
Track clipboard content type (Text / Password) using ClipDescription.EXTRA_IS_SENSITIVE, add a per-device preference to suppress automatic outgoing clipboard sync for password content, and expose settings UI for the clipboard plugin.
M +27 -4 src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardListener.kt
M +27 -2 src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardPlugin.kt
M +3 -0 src/main/res/values/strings.xml
A +21 -0 src/main/res/xml/clipboardplugin_preferences.xml
M +48 -3 src/test/java/org/kde/kdeconnect/plugins/ClipboardPluginTest.kt
https://invent.kde.org/network/kdeconnect-android/-/commit/0150bd323db4da9cd3adb79499c0c6d57ac07be1
diff --git a/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardListener.kt b/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardListener.kt
index 2f32c4f4e..fbf8afb49 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardListener.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardListener.kt
@@ -7,6 +7,8 @@
package org.kde.kdeconnect.plugins.clipboard
import android.Manifest
+import android.content.ClipData
+import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
@@ -23,8 +25,13 @@ import java.util.Date
import java.util.Locale
class ClipboardListener {
+ enum class ClipboardContentType {
+ Text,
+ Password,
+ }
+
interface ClipboardObserver {
- fun clipboardChanged(content: String)
+ fun clipboardChanged(content: String, contentType: ClipboardContentType)
}
private val observers: HashSet<ClipboardObserver> = HashSet()
@@ -32,6 +39,8 @@ class ClipboardListener {
private val context: Context
var currentContent: String? = null
private set
+ var currentContentType: ClipboardContentType = ClipboardContentType.Text
+ private set
var updateTimestamp: Long = 0
private set
@@ -71,17 +80,20 @@ class ClipboardListener {
fun onClipboardChanged() {
try {
- val item = cm.primaryClip!!.getItemAt(0)
+ val clip = cm.primaryClip!!
+ val item = clip.getItemAt(0)
val content = item.coerceToText(context).toString()
+ val contentType = detectContentType(clip)
- if (content == currentContent) {
+ if (content == currentContent && contentType == currentContentType) {
return
}
updateTimestamp = System.currentTimeMillis()
currentContent = content
+ currentContentType = contentType
for (observer in observers) {
- observer.clipboardChanged(content)
+ observer.clipboardChanged(content, contentType)
}
} catch (_: Exception) {
//Probably clipboard was not text
@@ -93,6 +105,7 @@ class ClipboardListener {
if (this::cm.isInitialized) {
updateTimestamp = System.currentTimeMillis()
currentContent = text
+ currentContentType = ClipboardContentType.Text
cm.text = text
}
}
@@ -105,5 +118,15 @@ class ClipboardListener {
// FIXME: The _instance we return won't be completely initialized yet since initialization happens on a new thread (why?)
return _instance ?: ClipboardListener(context).also { _instance = it }
}
+
+ @JvmStatic
+ fun detectContentType(clip: ClipData?): ClipboardContentType {
+ if (clip?.description?.extras
+ ?.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE, false) == true
+ ) {
+ return ClipboardContentType.Password
+ }
+ return ClipboardContentType.Text
+ }
}
}
diff --git a/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardPlugin.kt b/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardPlugin.kt
index 7f8e572fd..892524d4a 100644
--- a/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardPlugin.kt
+++ b/src/main/java/org/kde/kdeconnect/plugins/clipboard/ClipboardPlugin.kt
@@ -19,6 +19,7 @@ import org.kde.kdeconnect.NetworkPacket
import org.kde.kdeconnect.plugins.Plugin
import org.kde.kdeconnect.plugins.PluginFactory.LoadablePlugin
import org.kde.kdeconnect.plugins.clipboard.ClipboardListener.ClipboardObserver
+import org.kde.kdeconnect.ui.PluginSettingsFragment
import org.kde.kdeconnect_tp.R
@LoadablePlugin
@@ -29,6 +30,13 @@ class ClipboardPlugin : Plugin() {
override val description: String
get() = context.resources.getString(R.string.pref_plugin_clipboard_desc)
+ override fun hasSettings(): Boolean = true
+
+ override fun supportsDeviceSpecificSettings(): Boolean = true
+
+ override fun getSettingsFragment(activity: Activity): PluginSettingsFragment =
+ PluginSettingsFragment.newInstance(pluginKey, R.xml.clipboardplugin_preferences)
+
override fun onPacketReceived(np: NetworkPacket): Boolean {
val content = np.getString("content")
when (np.type) {
@@ -52,8 +60,16 @@ class ClipboardPlugin : Plugin() {
}
private val observer: ClipboardObserver = object : ClipboardObserver {
- override fun clipboardChanged(content: String) {
- return [email protected](content)
+ override fun clipboardChanged(content: String, contentType: ClipboardListener.ClipboardContentType) {
+ if (contentType == ClipboardListener.ClipboardContentType.Password &&
+ preferences!!.getBoolean(
+ context.getString(R.string.clipboard_preference_key_skip_sensitive),
+ false,
+ )
+ ) {
+ return
+ }
+ propagateClipboard(content)
}
}
@@ -66,6 +82,14 @@ class ClipboardPlugin : Plugin() {
private fun sendConnectPacket() {
val content = ClipboardListener.instance(context).currentContent ?: return // Send clipboard only if it had been initialized
+ if (ClipboardListener.instance(context).currentContentType == ClipboardListener.ClipboardContentType.Password &&
+ preferences!!.getBoolean(
+ context.getString(R.string.clipboard_preference_key_skip_sensitive),
+ false,
+ )
+ ) {
+ return
+ }
val np = NetworkPacket(PACKET_TYPE_CLIPBOARD_CONNECT)
val timestamp = ClipboardListener.instance(context).updateTimestamp
np["timestamp"] = timestamp
@@ -116,6 +140,7 @@ class ClipboardPlugin : Plugin() {
if (clipboardManager!!.hasPrimaryClip()) {
item = clipboardManager.primaryClip!!.getItemAt(0)
val content = item.coerceToText(this.context).toString()
+ // Don't check if the content is sensitive, just send it
this.propagateClipboard(content)
Toast.makeText(this.context, R.string.pref_plugin_clipboard_sent, Toast.LENGTH_SHORT).show()
}
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index d948b90b0..0cbdf41a6 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -31,6 +31,9 @@ SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted
<string name="pref_plugin_clipboard">Clipboard sync</string>
<string name="pref_plugin_clipboard_desc">Share the clipboard content</string>
<string name="pref_plugin_clipboard_sent">Clipboard Sent</string>
+ <string name="clipboard_preference_key_skip_sensitive" translatable="false">clipboard_skip_sensitive_sync</string>
+ <string name="clipboard_preference_title_skip_sensitive">Skip sensitive clipboard sync</string>
+ <string name="clipboard_preference_summary_skip_sensitive">Don\'t auto-sync passwords or other content marked as sensitive</string>
<string name="pref_plugin_mousepad">Remote input</string>
<string name="pref_plugin_mousepad_desc_nontv">Use your phone or tablet as a touchpad and keyboard</string>
<string name="pref_plugin_mousepad_desc_tv">Use your phone or tablet as a TV remote</string>
diff --git a/src/main/res/xml/clipboardplugin_preferences.xml b/src/main/res/xml/clipboardplugin_preferences.xml
new file mode 100644
index 000000000..e42b207c6
--- /dev/null
+++ b/src/main/res/xml/clipboardplugin_preferences.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+SPDX-FileCopyrightText: 2026 KDE Connect contributors
+
+SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+-->
+
+<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:keep="@xml/clipboardplugin_preferences">
+
+ <SwitchPreference
+ android:defaultValue="false"
+ android:key="@string/clipboard_preference_key_skip_sensitive"
+ android:summary="@string/clipboard_preference_summary_skip_sensitive"
+ android:title="@string/clipboard_preference_title_skip_sensitive" />
+
+</androidx.preference.PreferenceScreen>
diff --git a/src/test/java/org/kde/kdeconnect/plugins/ClipboardPluginTest.kt b/src/test/java/org/kde/kdeconnect/plugins/ClipboardPluginTest.kt
index 086c51325..0c408e592 100644
--- a/src/test/java/org/kde/kdeconnect/plugins/ClipboardPluginTest.kt
+++ b/src/test/java/org/kde/kdeconnect/plugins/ClipboardPluginTest.kt
@@ -1,12 +1,12 @@
package org.kde.kdeconnect.plugins
import android.app.Application
+import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
-import io.mockk.mockkStatic
import io.mockk.spyk
import io.mockk.verify
import org.junit.After
@@ -15,9 +15,11 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.kde.kdeconnect.Device
+import org.kde.kdeconnect.MockSharedPreference
import org.kde.kdeconnect.NetworkPacket
import org.kde.kdeconnect.plugins.clipboard.ClipboardListener
import org.kde.kdeconnect.plugins.clipboard.ClipboardPlugin
+import org.kde.kdeconnect_tp.R
@RunWith(AndroidJUnit4::class)
class ClipboardPluginTest {
@@ -25,11 +27,21 @@ class ClipboardPluginTest {
private lateinit var clipboardListener: ClipboardListener
private lateinit var context: Application
private lateinit var device: Device
+ private lateinit var preferences: MockSharedPreference
private var packet: NetworkPacket? = null
+ private companion object {
+ const val SKIP_SENSITIVE_PREF_KEY = "clipboard_skip_sensitive_sync"
+ const val PREFERENCES_NAME = "testDevice_ClipboardPlugin_preferences"
+ }
+
@Before
fun setup() {
- context = ApplicationProvider.getApplicationContext()
+ preferences = MockSharedPreference()
+ context = spyk(ApplicationProvider.getApplicationContext()) {
+ every { getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) } returns preferences
+ every { getString(R.string.clipboard_preference_key_skip_sensitive) } returns SKIP_SENSITIVE_PREF_KEY
+ }
val realListener = ClipboardListener.instance(context)
clipboardListener = spyk(realListener)
@@ -38,6 +50,7 @@ class ClipboardPluginTest {
every { ClipboardListener.instance(context) } returns clipboardListener
device = mockk {
+ every { deviceId } returns "testDevice"
every { sendPacket(any()) } answers {
packet = arg<NetworkPacket>(0)
}
@@ -51,6 +64,7 @@ class ClipboardPluginTest {
@After
fun cleanup() {
packet = null // Remove old capture packet
+ preferences.edit().clear().apply()
}
// REMOTE -> LOCAL
@@ -114,6 +128,7 @@ class ClipboardPluginTest {
fun testOnCreate() {
val content = "B7n30xe0NNO6Y1J7PXOFj6pGd"
every { clipboardListener.currentContent } returns content
+ every { clipboardListener.currentContentType } returns ClipboardListener.ClipboardContentType.Text
Assert.assertTrue(clipboardPlugin.onCreate())
@@ -123,6 +138,36 @@ class ClipboardPluginTest {
Assert.assertEquals(content, sentPacket.getString("content"))
}
+ @Test
+ fun testSendConnectPacketSkippedWhenClipSensitiveAndPreferenceOn() {
+ preferences.edit().putBoolean(SKIP_SENSITIVE_PREF_KEY, true).apply()
+
+ every { clipboardListener.currentContent } returns "secret"
+ every { clipboardListener.currentContentType } returns ClipboardListener.ClipboardContentType.Password
+ every { clipboardListener.updateTimestamp } returns 12345L
+
+ Assert.assertTrue(clipboardPlugin.onCreate())
+
+ Assert.assertNull(packet)
+ verify(exactly = 0) { device.sendPacket(any()) }
+ }
+
+ @Test
+ fun testSendConnectPacketSentWhenClipSensitiveButPreferenceOff() {
+ preferences.edit().putBoolean(SKIP_SENSITIVE_PREF_KEY, false).apply()
+
+ val content = "still-sent"
+ every { clipboardListener.currentContent } returns content
+ every { clipboardListener.currentContentType } returns ClipboardListener.ClipboardContentType.Password
+ every { clipboardListener.updateTimestamp } returns 999L
+
+ Assert.assertTrue(clipboardPlugin.onCreate())
+
+ val sentPacket = checkNotNull(packet)
+ Assert.assertEquals("kdeconnect.clipboard.connect", sentPacket.type)
+ Assert.assertEquals(content, sentPacket.getString("content"))
+ }
+
@Test
fun testPropagateClipboard() {
val content = "llY3kfZNhPur9ldTWEuHQBHPC"
@@ -134,4 +179,4 @@ class ClipboardPluginTest {
Assert.assertEquals("kdeconnect.clipboard", sentPacket.type)
Assert.assertEquals(content, sentPacket.getString("content"))
}
-}
\ No newline at end of file
+}