[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
+}
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.