From f734865a31d664956aaa13bcbe70717efc6e097a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:29:32 +0000 Subject: [PATCH] fix(ime): stop the soft keyboard state from getting stuck open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving a chat with the keyboard up via a back gesture left a large IME padding stranded at the bottom — and, because WindowInsets.ime is a single app-wide holder, on every other screen too — until some later inset pass happened to rebalance it. It only reproduced on release builds. Root cause: the chat composers install a BackHandler that flushes the draft and pops the screen. When that pop runs while the keyboard is still visible, the predictive-back window animation races the IME's close animation. On an optimized release build the window animation wins, and the IME WindowInsetsAnimation is cancelled before its terminal (zero) frame reaches Compose, so the shared insets holder stays "animating" and every imePadding() in the app freezes at the keyboard height. Debug and benchmark builds are slow enough that the IME animation completes first, which is why they were unaffected. Fixes: - New KeyboardAwareBackHandler: while the keyboard is on screen it does not consume back, so the system dismisses the keyboard first (its animation completes cleanly); the next back runs the original handler. The top-bar back arrow remains an always-available exit. Adopted in the DM, public-chat and new-group-DM composers. - Rederive keyboardAsState() from WindowInsets.ime instead of the pre-edge-to-edge getWindowVisibleDisplayFrame/OnGlobalLayout heuristic, which under enableEdgeToEdge() could itself latch Opened after the keyboard closed. Now it tracks the same inset that drives imePadding(). - Concord channel chat and the minichat thread view used a bare Material3 Scaffold whose content insets ignore the IME; add imePadding() so the composer rides above the keyboard while typing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012dT2RLbPZzan2hULL2cmc6 --- .../ui/navigation/bottombars/KeyboardState.kt | 87 +++++++++++-------- .../loggedIn/chats/minichat/MinichatScreen.kt | 5 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 4 +- .../send/PrivateMessageEditFieldRow.kt | 4 +- .../concord/ConcordChannelScreen.kt | 6 +- .../chats/publicChannels/send/EditFieldRow.kt | 4 +- 6 files changed, 66 insertions(+), 44 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index 68e9ba6c66..1457927832 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -20,52 +20,67 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import android.graphics.Rect -import android.view.View -import android.view.ViewTreeObserver +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalDensity enum class KeyboardState { Opened, Closed, } +/** + * Whether the soft keyboard is currently on screen, derived from [WindowInsets.ime]. + * + * This intentionally reads the same animated IME inset that drives `Modifier.imePadding()` + * everywhere else in the app, so the two can never disagree. The previous implementation + * measured `View.getWindowVisibleDisplayFrame` from a `ViewTreeObserver` global-layout + * listener — a pre-edge-to-edge heuristic. Under `enableEdgeToEdge()` + * (`decorFitsSystemWindows = false`) the window content no longer resizes for the IME, so + * that listener fired when the keyboard appeared but frequently never fired again when it + * closed, latching the state at [Opened] even though the keyboard was gone (leaving the + * bottom navigation bar hidden and a stranded gap at the bottom). The IME inset always + * animates back to zero on the persistent root view, so this reading can't get stuck. + */ @Composable fun keyboardAsState(): State { - val view = LocalView.current - - val keyboardState = remember(view) { mutableStateOf(isKeyboardOpen(view)) } - - DisposableEffect(view) { - val onGlobalListener = - ViewTreeObserver.OnGlobalLayoutListener { - val newKeyboardValue = isKeyboardOpen(view) - - if (newKeyboardValue != keyboardState.value) { - keyboardState.value = newKeyboardValue - } - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) } - } - - return keyboardState -} - -fun isKeyboardOpen(view: View): KeyboardState { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - - return if (keypadHeight > screenHeight * 0.15) { - KeyboardState.Opened - } else { - KeyboardState.Closed + val density = LocalDensity.current + val imeInsets = WindowInsets.ime + return remember(density, imeInsets) { + derivedStateOf { + if (imeInsets.getBottom(density) > 0) KeyboardState.Opened else KeyboardState.Closed + } } } + +/** + * A [BackHandler] that steps aside while the soft keyboard is on screen. + * + * Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen. + * When that pop happens while the keyboard is still up, it races the predictive-back window + * animation against the IME's close animation. On release builds — fast enough that the window + * animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] + * is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime` + * holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard + * height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the + * screen). + * + * Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the + * system dismisses the keyboard first with its own animation (which completes cleanly). The next + * back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an + * always-available exit, so this can never trap the user even if the inset reading were itself stale. + */ +@Composable +fun KeyboardAwareBackHandler( + enabled: Boolean = true, + onBack: () -> Unit, +) { + val keyboardState by keyboardAsState() + BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 6f15e93574..8263fa2a2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -174,7 +175,9 @@ fun MinichatScreen( ) }, ) { padding -> - Column(Modifier.fillMaxHeight().padding(padding)) { + // imePadding so the reply composer rides above the soft keyboard — the bare Material3 + // Scaffold's content insets cover the system bars but not the IME. + Column(Modifier.fillMaxHeight().imePadding().padding(padding)) { // Every reply here is rooted at [rootNote], which is already pinned at the top — so // suppress the redundant reply-to-root preview each reply would otherwise render. CompositionLocalProvider(LocalSuppressReplyToNoteId provides rootId) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index d87f54987d..ce87e19328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send import android.net.Uri -import androidx.activity.compose.BackHandler import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Box @@ -87,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage @@ -169,7 +169,7 @@ fun NewGroupDMScreen( WatchAndLoadMyEmojiList(accountViewModel) - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { postViewModel.sendDraftSync() postViewModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index fc64efe0ab..390d718d9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -60,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow( onSendNewMessage: () -> Unit, nav: INav, ) { - BackHandler { + KeyboardAwareBackHandler { if (channelScreenModel.message.text.isNotBlank()) { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 160acdf42a..761bd36f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api @@ -185,7 +186,10 @@ fun ConcordChannelScreen( ) }, ) { padding -> - Column(Modifier.fillMaxHeight().padding(padding)) { + // imePadding so the composer rides above the soft keyboard. The bare Material3 Scaffold's + // content insets cover the system bars but not the IME, so without this the message field + // sat behind the keyboard while typing. + Column(Modifier.fillMaxHeight().imePadding().padding(padding)) { Column(Modifier.fillMaxHeight().weight(1f, true)) { RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index 8176dc4d5b..f14cd1d59e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -54,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -78,7 +78,7 @@ fun EditFieldRow( onSendNewMessage: suspend () -> Unit, nav: INav, ) { - BackHandler { + KeyboardAwareBackHandler { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() channelScreenModel.cancel()