From 38d3cf46710344f75d801ee9d141e09ddabf040f Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:32:34 +0000 Subject: [PATCH] multiplatform: remove unused clipboard state polling that froze desktop UI The desktop app polled full clipboard contents every second on the AWT event thread to update ChatModel.clipboardHasText. On X11, each read blocks up to sun.awt.datatransfer.timeout (10s) when the selection owner does not respond (e.g. after KeePassXC's clipboard auto-clear), making every click/scroll wait ~9s. clipboardHasText has had no readers since its only consumer was removed in #4398, so the whole mechanism (desktop poll, Android clip listener, onResume refresh, expect/actual, state field) is deleted. See plans/2026-07-11-fix-desktop-clipboard-freeze.md. --- .../java/chat/simplex/app/MainActivity.kt | 14 ----- .../common/views/helpers/Utils.android.kt | 16 ----- .../kotlin/chat/simplex/common/App.kt | 1 - .../chat/simplex/common/model/ChatModel.kt | 1 - .../simplex/common/views/helpers/Utils.kt | 3 - .../common/views/helpers/Utils.desktop.kt | 15 ----- apps/multiplatform/spec/client/navigation.md | 3 +- apps/multiplatform/spec/state.md | 1 - ...2026-07-11-fix-desktop-clipboard-freeze.md | 61 +++++++++++++++++++ 9 files changed, 62 insertions(+), 53 deletions(-) create mode 100644 plans/2026-07-11-fix-desktop-clipboard-freeze.md diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index bacdfe70af..7415b6015d 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -1,6 +1,5 @@ package chat.simplex.app -import android.content.Context import android.content.Intent import android.net.Uri import android.os.* @@ -8,7 +7,6 @@ import android.view.View import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.ui.platform.ClipboardManager import androidx.fragment.app.FragmentActivity import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.getUserIdFromIntent @@ -21,7 +19,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.* import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { @@ -73,17 +70,6 @@ class MainActivity: FragmentActivity() { override fun onResume() { super.onResume() AppLock.recheckAuthState() - withApi { - delay(1000) - if (!isAppOnForeground) return@withApi - /** - * When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies - * access to clipboard because the app considered in background. - * This will ensure that the app will get the event on resume - * */ - val service = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - chatModel.clipboardHasText.value = service.hasPrimaryClip() - } } override fun onPause() { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 5cf17d304d..c98f8f9f89 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -1,7 +1,5 @@ package chat.simplex.common.views.helpers -import android.content.ClipboardManager -import android.content.Context import android.content.res.Resources import android.graphics.* import android.graphics.Typeface @@ -14,7 +12,6 @@ import android.text.SpannedString import android.text.style.* import android.util.Base64 import android.view.WindowManager -import androidx.compose.runtime.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* @@ -58,19 +55,6 @@ fun keepScreenOn(on: Boolean) { } } -@Composable -actual fun SetupClipboardListener() { - DisposableEffect(Unit) { - val service = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val listener = { chatModel.clipboardHasText.value = service.hasPrimaryClip() } - chatModel.clipboardHasText.value = service.hasPrimaryClip() - service.addPrimaryClipChangedListener(listener) - onDispose { - service.removePrimaryClipChangedListener(listener) - } - } -} - actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 7542a0b8c6..cb91c386ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -163,7 +163,6 @@ fun MainScreen() { userPickerState.value = AnimatedViewState.VISIBLE } } - SetupClipboardListener() if (appPlatform.isAndroid) { AndroidWrapInCallLayout { AndroidScreen(userPickerState) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 0e7725c82b..c12d1033a7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -210,7 +210,6 @@ object ChatModel { val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } - val clipboardHasText = mutableStateOf(false) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index eee97eeca9..70f4a1759b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -110,9 +110,6 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr } } -@Composable -expect fun SetupClipboardListener() - // maximum image file size to be auto-accepted // Spec: spec/services/files.md#MAX_IMAGE_SIZE const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 22005bbfc4..b484c540d5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -1,8 +1,6 @@ package chat.simplex.common.views.helpers -import androidx.compose.runtime.* import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -12,7 +10,6 @@ import chat.simplex.common.model.CIFile import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState -import kotlinx.coroutines.delay import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import java.io.File @@ -116,18 +113,6 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat AnnotatedString(text) } -@Composable -actual fun SetupClipboardListener() { - val clipboard = LocalClipboardManager.current - chatModel.clipboardHasText.value = clipboard.hasText() - LaunchedEffect(Unit) { - while (true) { - delay(1000) - chatModel.clipboardHasText.value = clipboard.hasText() - } - } -} - actual fun getAppFileUri(fileName: String): URI { val rh = chatModel.currentRemoteHost.value return if (rh == null) { diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index c9939ea3c0..34a3b502ee 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -105,8 +105,7 @@ fun MainScreen() When onboarding is complete: 1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). -2. Sets up clipboard listener. -3. Routes to `AndroidScreen` or `DesktopScreen` based on platform. +2. Routes to `AndroidScreen` or `DesktopScreen` based on platform. ### Overlay Layers (bottom of MainScreen) diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 09457c4dd3..229c30d18e 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -155,7 +155,6 @@ Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex | [`notificationPreviewMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L147) | `MutableState` | 147 | Notification content preview level | | [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState` | 156 | Whether to show authentication screen | | [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState` | 158 | Whether to show chat preview text in list | -| [`clipboardHasText`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L185) | `MutableState` | 185 | System clipboard has text content | | [`networkInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L186) | `MutableState` | 186 | Network type and online status | | [`conditions`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L188) | `MutableState` | 188 | Server operator terms/conditions | | [`updatingProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L190) | `MutableState` | 190 | Progress indicator for app updates | diff --git a/plans/2026-07-11-fix-desktop-clipboard-freeze.md b/plans/2026-07-11-fix-desktop-clipboard-freeze.md new file mode 100644 index 0000000000..59d357446e --- /dev/null +++ b/plans/2026-07-11-fix-desktop-clipboard-freeze.md @@ -0,0 +1,61 @@ +# Fix desktop UI freeze caused by clipboard polling; remove dead `clipboardHasText` state + +## Problem + +User report: the Linux desktop app becomes extremely slow — every scroll or button press takes ~9 seconds. The lag disappears while the clipboard is filled and returns when it is emptied, reproducibly triggered by KeePassXC's clipboard auto-clear (10s safety timeout). Manual clearing via desktop tools does not trigger it. + +## Root cause + +`SetupClipboardListener()` (desktop actual, `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt`) ran `chatModel.clipboardHasText.value = clipboard.hasText()` once at composition and then every 1 second in a `LaunchedEffect` — i.e. on the Compose main dispatcher, which on desktop is the AWT Event Dispatch Thread that processes all input and rendering. + +The call chain of `hasText()` (verified in Compose 1.8.2 / skiko 0.9.4 bytecode): + +``` +PlatformClipboardManager.hasText() + → org.jetbrains.skiko.ClipboardManager_hasText() + → ClipboardManager_getText() + → java.awt.datatransfer.Clipboard.getData(DataFlavor.stringFlavor) +``` + +So "does the clipboard have text" was answered by fetching the full clipboard contents over an X11 selection conversion, every second. On X11, `getData` blocks in `sun.awt.X11.XSelection.waitForSelectionNotify()` until the selection owner replies or `sun.awt.datatransfer.timeout` expires — **default 10 000 ms** (OpenJDK `UNIXToolkit.getDatatransferTimeout()`). + +When the X11 CLIPBOARD selection owner does not answer conversion requests — the state KeePassXC's auto-clear leaves behind — each poll blocked the EDT for the full 10 s, then the loop slept 1 s and blocked again. The EDT was therefore blocked ~10 of every ~11 seconds; a random click or scroll waited ~9 s on average, exactly as reported. Any responsive clipboard owner (any normal app copying text) made the polls instant again, which is why filling the clipboard "cured" it. + +## Why deletion (not a workaround) is correct + +`chatModel.clipboardHasText` has had **zero readers since July 2024**: its only ever consumer — a conditional paste icon over the chat list search field — was removed in #4398 (commit `3e623684b`). Everything else was write-only plumbing for that state: + +- desktop 1s poll (`Utils.desktop.kt`) — the freeze source; +- Android `addPrimaryClipChangedListener` (`Utils.android.kt`, added in #3529); +- Android `onResume` re-read of `hasPrimaryClip()` (`MainActivity.kt`, #3758) — a workaround for Android 10+ denying clipboard access to backgrounded apps, needed only to keep the state fresh for that same paste icon; +- the `expect`/`actual` declarations and the `App.kt` call site; +- the `ChatModel.clipboardHasText` field itself. + +Verified before removal: + +- No direct reads anywhere in the repo (all source sets, all modules), on any remote branch, and none in any upstream PR (GitHub code search: only the writers + one spec table row). +- No indirect access: no reflection over `ChatModel`, no `"clipboardHasText"` string literal, no wholesale snapshot observation, no serialization (`ChatModel` is `@Stable`, not `@Serializable`), nothing crosses the mobile↔desktop remote protocol, no Gradle/lint/ProGuard/codegen references, no tests. +- Removing the `App.kt` call cannot affect sibling composition: the composable emits no UI node (Android: bare `DisposableEffect`; desktop: state writes only), and there is no `key()`/`movableContentOf`/positional logic nearby. +- Android side effects: none. `hasPrimaryClip()` does not read clip contents (no Android 12+ access toast); this was the only primary-clip listener (no ordering concerns); the `onResume` coroutine `Job` was never captured or awaited. +- All remaining paste features are unaffected because they read the clipboard directly at the moment of user action, not via this state: "tap to paste link" (`NewChatView.kt`), Ctrl+V in the composer (`PlatformTextField.desktop.kt`), "paste desktop address" (`ConnectDesktopView.kt`), migration link paste (`MigrateToDevice.kt`). + +After this change the desktop app touches the X11 clipboard only on explicit user paste, so no background clipboard state can stall the UI. (A paste attempt while the selection owner is unresponsive can still block once, up to the AWT timeout — inherent to AWT's synchronous X11 clipboard, out of scope.) + +## Change + +Delete the dead mechanism end to end: + +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt` — `expect fun SetupClipboardListener()` +- `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt` — desktop actual (the polling loop) + unused imports +- `common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt` — Android actual (clip listener) + unused imports +- `common/src/commonMain/kotlin/chat/simplex/common/App.kt` — call site +- `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` — `clipboardHasText` field +- `android/src/main/java/chat/simplex/app/MainActivity.kt` — `onResume` clipboard block + unused imports +- `spec/state.md` — stale table row +- `spec/client/navigation.md` — stale "sets up clipboard listener" step in the MainScreen description + +## Verification + +- `:common:compileKotlinDesktop`, `:common:compileReleaseKotlinAndroid`, `:android:compileReleaseKotlin` pass. +- Repo-wide grep for `clipboardHasText`/`SetupClipboardListener` returns no references outside this document. +- Linux AppImage and Android APK built from this branch.