From 978bf8a22d00e0e572d3bef5bae5bca95d0a3aa5 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Mon, 20 Jul 2026 22:14:57 -0700 Subject: [PATCH] Floor batch-edit depth at zero for unbalanced endBatchEdit Some IMEs (Huawei Celia, SwiftKey) call endBatchEdit without a matching beginBatchEdit, driving the InputConnection batch depth negative. Once negative, the drain condition batchDepth == 0 never fires and queued edit commands are silently dropped, swallowing typed characters. Floor the depth at zero, matching PlatformTextEditorExtensions which already floors, and add Android host tests for the InputConnection batch-edit behavior. Fixes #33 --- ComposeTextEditor/build.gradle.kts | 11 ++ .../TextEditorInputConnectionBatchTest.kt | 117 ++++++++++++++++++ .../TextEditorTextInputService.android.kt | 9 +- 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 ComposeTextEditor/src/androidHostTest/kotlin/input/TextEditorInputConnectionBatchTest.kt diff --git a/ComposeTextEditor/build.gradle.kts b/ComposeTextEditor/build.gradle.kts index 873cb78a..66f02148 100644 --- a/ComposeTextEditor/build.gradle.kts +++ b/ComposeTextEditor/build.gradle.kts @@ -21,6 +21,8 @@ kotlin { compilerOptions { jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.get())) } + + withHostTestBuilder {} } @OptIn(ExperimentalWasmDsl::class) wasmJs { @@ -59,6 +61,15 @@ kotlin { } } + val androidHostTest by getting { + dependencies { + implementation(libs.jetbrains.kotlin.test) + implementation(libs.jetbrains.kotlin.test.junit) + implementation(libs.mockk) + implementation(libs.kotlinx.coroutines.test) + } + } + val desktopTest by getting { dependencies { implementation(libs.jetbrains.kotlin.test) diff --git a/ComposeTextEditor/src/androidHostTest/kotlin/input/TextEditorInputConnectionBatchTest.kt b/ComposeTextEditor/src/androidHostTest/kotlin/input/TextEditorInputConnectionBatchTest.kt new file mode 100644 index 00000000..def47360 --- /dev/null +++ b/ComposeTextEditor/src/androidHostTest/kotlin/input/TextEditorInputConnectionBatchTest.kt @@ -0,0 +1,117 @@ +package input + +import androidx.compose.ui.text.AnnotatedString +import com.darkrockstudios.texteditor.input.TextEditorInputConnection +import com.darkrockstudios.texteditor.state.TextEditorState +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Regression tests for batch-edit depth handling in [TextEditorInputConnection]. + * + * Some IMEs (Huawei Celia consistently, SwiftKey intermittently) call + * endBatchEdit() without a matching beginBatchEdit(). If the depth goes + * negative, every subsequent edit is queued but never drained, so typed + * characters silently vanish (GitHub issue #33). + */ +class TextEditorInputConnectionBatchTest { + + private lateinit var state: TextEditorState + private lateinit var connection: TextEditorInputConnection + + @BeforeTest + fun setup() { + state = TextEditorState( + scope = TestScope(), + measurer = mockk(relaxed = true), + initialText = AnnotatedString(""), + ) + connection = TextEditorInputConnection(state) + } + + private fun text() = state.getAllText().text + + @Test + fun `unbalanced endBatchEdit does not swallow subsequent edits`() { + // Celia/SwiftKey behavior: a stray end with no matching begin. + connection.endBatchEdit() + + connection.setComposingText("h", 1) + assertEquals("h", text()) + + connection.setComposingText("he", 1) + assertEquals("he", text()) + + connection.commitText("he ", 1) + assertEquals("he ", text()) + } + + @Test + fun `repeated unbalanced endBatchEdit calls are harmless no-ops`() { + repeat(3) { connection.endBatchEdit() } + + connection.commitText("a", 1) + assertEquals("a", text()) + } + + @Test + fun `nested batch edits defer edits until the outermost end`() { + connection.beginBatchEdit() + connection.beginBatchEdit() + connection.commitText("a", 1) + assertEquals("", text(), "Edit must stay queued while a batch is open") + + connection.endBatchEdit() + assertEquals("", text(), "Inner end must not drain the queue") + + connection.endBatchEdit() + assertEquals("a", text(), "Outermost end must apply queued edits") + } + + @Test + fun `unbalanced endBatchEdit keeps the platform batch flag in sync`() { + connection.endBatchEdit() + assertFalse(state.platformExtensions.isInBatchEdit) + + // A stray end must not offset later begin/end pairs. + connection.beginBatchEdit() + assertTrue(state.platformExtensions.isInBatchEdit) + connection.endBatchEdit() + assertFalse(state.platformExtensions.isInBatchEdit) + } + + @Test + fun `endBatchEdit returns true only while a batch is still in progress`() { + assertFalse(connection.endBatchEdit(), "Unbalanced end: no batch in progress") + + connection.beginBatchEdit() + connection.beginBatchEdit() + assertTrue(connection.endBatchEdit(), "Outer batch still open") + assertFalse(connection.endBatchEdit(), "All batches closed") + } + + @Test + fun `batched composition still lands after a stray endBatchEdit`() { + connection.endBatchEdit() + + // The shape an IME sends per keystroke: batch around composition updates. + connection.beginBatchEdit() + connection.setComposingText("w", 1) + connection.endBatchEdit() + + connection.beginBatchEdit() + connection.setComposingText("wo", 1) + connection.endBatchEdit() + + connection.beginBatchEdit() + connection.commitText("word", 1) + connection.endBatchEdit() + + assertEquals("word", text()) + } +} diff --git a/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt b/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt index b2d40c0e..1332d9e0 100644 --- a/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt +++ b/ComposeTextEditor/src/androidMain/kotlin/com/darkrockstudios/texteditor/input/TextEditorTextInputService.android.kt @@ -9,6 +9,7 @@ import android.text.TextUtils import android.view.KeyEvent import android.view.inputmethod.* import androidx.annotation.RequiresApi +import androidx.annotation.VisibleForTesting import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.PlatformTextInputSession import com.darkrockstudios.texteditor.TextEditorRange @@ -144,7 +145,8 @@ private object PerformImeNewlineCommand : EditCommand { * handle arrows, Home/End, Backspace/Delete, Ctrl+letter shortcuts, and * printable characters in one place. */ -private class TextEditorInputConnection( +@VisibleForTesting +internal class TextEditorInputConnection( private val state: TextEditorState ) : InputConnection { @@ -166,7 +168,10 @@ private class TextEditorInputConnection( } private fun endBatchEditInternal() { - batchDepth-- + // Some IMEs (Huawei Celia, SwiftKey) send endBatchEdit without a matching begin. + // Floor at 0 — matching the platform counter — so a stray end can't drive the + // depth negative and permanently block the drain condition below. + batchDepth = (batchDepth - 1).coerceAtLeast(0) // End the platform batch FIRST, so the drain runs with isInBatchEdit == false. // That lets ImeCursorSync's flow collectors observe each state mutation and // push updateSelection / updateExtractedText to the IMM.