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.