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.
This commit is contained in:
Narasimha-sc
2026-07-11 10:32:34 +00:00
parent 5d54362ca8
commit 38d3cf4671
9 changed files with 62 additions and 53 deletions
@@ -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() {
@@ -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)
}
@@ -163,7 +163,6 @@ fun MainScreen() {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
SetupClipboardListener()
if (appPlatform.isAndroid) {
AndroidWrapInCallLayout {
AndroidScreen(userPickerState)
@@ -210,7 +210,6 @@ object ChatModel {
val filesToDelete = mutableSetOf<File>()
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)
@@ -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
@@ -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) {
+1 -2
View File
@@ -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)
-1
View File
@@ -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<NotificationPreviewMode>` | 147 | Notification content preview level |
| [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState<Boolean>` | 156 | Whether to show authentication screen |
| [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState<Boolean>` | 158 | Whether to show chat preview text in list |
| [`clipboardHasText`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L185) | `MutableState<Boolean>` | 185 | System clipboard has text content |
| [`networkInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L186) | `MutableState<UserNetworkInfo>` | 186 | Network type and online status |
| [`conditions`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L188) | `MutableState<ServerOperatorConditionsDetail>` | 188 | Server operator terms/conditions |
| [`updatingProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L190) | `MutableState<Float?>` | 190 | Progress indicator for app updates |
@@ -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.