Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ComposeTextEditor/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.fromTarget(libs.versions.jvm.get()))
}

withHostTestBuilder {}
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {

Expand All @@ -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.
Expand Down
Loading