Skip to content
Open
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
5 changes: 5 additions & 0 deletions app/src/androidTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Keep the runner out of the production IME process. -->
<instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
android:targetProcesses="it.palsoftware.pastiera.ime_editor_test" />
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package it.palsoftware.pastiera.inputmethod.composition

import android.os.SystemClock
import android.view.KeyEvent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/** Hardware key injection through Android's selected IME, never a direct composer call.
* Run against an isolated debug installation; the runner must restore the user's IME afterward.
*/
@RunWith(AndroidJUnit4::class)
class HangulSystemImeInstrumentedTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext

@Before
fun configureTestApp() {
context.contentResolver.call(android.net.Uri.parse("content://${context.packageName}.ime-test-settings"),
"configure", null, null)
val expected = android.content.ComponentName(context,
it.palsoftware.pastiera.inputmethod.PhysicalKeyboardInputMethodService::class.java).flattenToShortString()
assertEquals("Select isolated debug IME first", expected,
android.provider.Settings.Secure.getString(context.contentResolver,"default_input_method"))
}

private lateinit var activeEditor: EditorSession

private inner class EditorSession(local: Boolean = false) {
private val authority = if (local) "ime-test-settings" else "ime-editor-test"
fun command(method: String, arg: String? = null, extras: android.os.Bundle? = null) =
context.contentResolver.call(android.net.Uri.parse("content://${context.packageName}.$authority"),
method, arg, extras)!!
}
private fun editor(kind: String = "native", block: (EditorSession) -> Unit) {
val session = EditorSession(local = kind == "local_compose_string")
activeEditor = session
session.command("launch", if (kind == "local_compose_string") "compose_string" else kind)
try {
awaitCondition { session.command("state").getBoolean("ready") }
session.command("focus")
// Instrumentation restarts the target process, including its selected IME.
// Wait for Android to bind the service again before testing connected input.
var lastRestart = SystemClock.uptimeMillis()
awaitCondition {
val pid = session.command("state").getInt("pid")
val dump = android.os.ParcelFileDescriptor.AutoCloseInputStream(
instrumentation.uiAutomation.executeShellCommand("dumpsys input_method")
).bufferedReader().use { it.readText() }
val bound = dump.contains("mBoundToMethod=true") &&
dump.lineSequence().any { it.contains("mEnabledSession=") && it.contains("pid=$pid ") }
if (!bound && SystemClock.uptimeMillis() - lastRestart > 500) {
// Re-request the empty editor connection if Android retained a stale
// binding while instrumentation replaced the previous process.
session.command("restartConnection")
lastRestart = SystemClock.uptimeMillis()
}
bound
}
SystemClock.sleep(700)
block(session)
} finally {
session.command("close")
awaitCondition { !session.command("state").getBoolean("ready") }
}
}
private fun keys(vararg codes: Int) {
check(activeEditor.command("state").getBoolean("ready")) { "Test editor lost window focus; stopping injection" }
codes.forEach { code ->
val now = SystemClock.uptimeMillis()
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
assertTrue(instrumentation.uiAutomation.injectInputEvent(
KeyEvent(now, now, action, code, 0, 0, android.view.KeyCharacterMap.VIRTUAL_KEYBOARD,
0, 0, android.view.InputDevice.SOURCE_KEYBOARD), true))
}
}
}
private fun type(ascii: String) = keys(*ascii.map { KeyEvent.KEYCODE_A + (it-'a') }.toIntArray())
private fun read(s: EditorSession): String {
val result = s.command("read")
check(result.containsKey("text")) { "Editor has no text response: $result" }
return result.getString("text").orEmpty()
}
private fun awaitCondition(check: () -> Boolean) {
val end = SystemClock.uptimeMillis() + 15000
while (!check()) {
assertTrue("Condition timed out", SystemClock.uptimeMillis() < end)
SystemClock.sleep(25)
}
}
private fun awaitText(expected: String, s: EditorSession) {
val end = SystemClock.uptimeMillis() + 3000
while (read(s) != expected && SystemClock.uptimeMillis() < end) SystemClock.sleep(25)
assertEquals(expected, read(s))
}

@Test
fun nativeCompositionAndCompleteDeletion() = editor { s ->
type("gksrmf")
awaitText("한글", s)
repeat(6) { keys(KeyEvent.KEYCODE_DEL) }
awaitText("", s)
assertEquals(-1, s.command("read").getInt("composingStart"))
}
@Test
fun nativeSpaceAndPunctuationPreserveText() = editor { s ->
type("gksrmf")
keys(KeyEvent.KEYCODE_SPACE)
type("rk")
keys(KeyEvent.KEYCODE_PERIOD)
awaitText("한글 가.", s)
}
@Test
fun nativeLongRunAndBackspace() = editor { s ->
type("rk".repeat(63) + "rhk")
awaitText("가".repeat(63) + "과", s)
keys(KeyEvent.KEYCODE_DEL)
awaitText("가".repeat(63) + "고", s)
}
@Test
fun selectionReplacementAndRestart() = editor { s ->
type("gksrmf")
awaitText("한글", s)
s.command("select", extras = android.os.Bundle().apply { putInt("start",0); putInt("end",2) })
SystemClock.sleep(200)
type("rk")
awaitText("가", s)
s.command("restart")
SystemClock.sleep(200)
type("sk")
awaitText("나", s)
}
@Test
fun composeCompositionAndDeletion() = editor("compose") { s ->
type("gksrmf")
awaitText("한글", s)
repeat(6) { keys(KeyEvent.KEYCODE_DEL) }
awaitText("", s)
}
@Test
fun webViewCompositionAndBoundary() = editor("web") { s ->
type("gksrmf")
awaitText("한글", s)
keys(KeyEvent.KEYCODE_SPACE)
type("rk")
awaitText("한글 가", s)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package it.palsoftware.pastiera.inputmethod.composition

import android.text.Selection
import android.view.View
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.ExtractedText
import android.view.inputmethod.ExtractedTextRequest
import it.palsoftware.pastiera.core.composition.NormalizedCompositionStroke
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry

@RunWith(AndroidJUnit4::class)
class ImeCompositionDeviceRegressionTest {
private fun onMain(action: () -> Unit) {
var failure: Throwable? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync {
try { action() } catch (error: Throwable) { failure = error }
}
failure?.let { throw it }
}

private open class Editor : BaseInputConnection(View(InstrumentationRegistry.getInstrumentation().targetContext), true) {
var rejectComposing = false
var rejectCommit = false
var throwComposing = false
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
if (throwComposing) throw IllegalStateException("Editor disconnected")
return !rejectComposing && super.setComposingText(text, newCursorPosition)
}
override fun commitText(text: CharSequence?, newCursorPosition: Int) =
!rejectCommit && super.commitText(text, newCursorPosition)
override fun getExtractedText(request: ExtractedTextRequest?, flags: Int) = ExtractedText().apply {
text = editable.toString()
startOffset = 0
selectionStart = Selection.getSelectionStart(editable)
selectionEnd = Selection.getSelectionEnd(editable)
}
}
private fun coordinator() = ImeCompositionCoordinator().apply {
startEditorSession()
selectLayout("korean_2set")
}
private fun type(c: ImeCompositionCoordinator, e: BaseInputConnection, text: String) {
text.forEach { assertTrue(c.handleMappedStroke(e, NormalizedCompositionStroke(it))) }
}
private fun acknowledge(c: ImeCompositionCoordinator, e: BaseInputConnection, old: Int): SelectionUpdateDisposition {
val cursor = Selection.getSelectionStart(e.editable)
return c.onUpdateSelection(old, old, cursor, cursor,
BaseInputConnection.getComposingSpanStart(e.editable!!),
BaseInputConnection.getComposingSpanEnd(e.editable!!))
}

@Test fun finalDeletePreservesCommittedPrefixAndEndsOwnership() = onMain {
val e = Editor(); val c = coordinator()
e.commitText("prefix ", 1)
type(c, e, "ㄱㅏㅂㅅ")
listOf("갑", "가", "ㄱ", "").forEach {
assertTrue(c.handleBackspace(e))
assertEquals("prefix $it", e.editable.toString())
}
assertFalse(c.isActive)
assertEquals(-1, BaseInputConnection.getComposingSpanStart(e.editable!!))
assertEquals(7, Selection.getSelectionStart(e.editable))
assertFalse(c.handleBackspace(e))
}

@Test fun replacingSelectionAndCoalescingUpdatesKeepsNewSyllableOwned() = onMain {
val e = Editor(); val c = coordinator()
e.commitText("prefix replace tail", 1)
e.setSelection(7, 14)
type(c, e, "ㄱㅏㄴㅏ")
assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE, c.onUpdateSelection(7,14,9,9,7,9))
type(c, e, "ㄴ")
assertEquals("prefix 가난 tail", e.editable.toString())
}

@Test fun lostRangeWithAndWithoutPendingWritesCannotReplayStaleRun() = onMain {
for (pending in listOf(false, true)) {
val e = Editor(); val c = coordinator()
type(c, e, "ㄱ")
assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE, acknowledge(c,e,0))
type(c, e, "ㅏ")
if (!pending) acknowledge(c,e,1)
e.finishComposingText()
assertEquals(SelectionUpdateDisposition.EXTERNAL_CHANGE, acknowledge(c,e,1))
assertFalse(c.isActive)
type(c, e, "ㄴ")
assertEquals("가ㄴ", e.editable.toString())
assertTrue(c.handleBackspace(e))
assertEquals("가", e.editable.toString())
}
}

@Test fun unexpectedRangeAtSameCursorReleasesOwnership() = onMain {
val e = Editor(); val c = coordinator()
type(c,e,"ㄱㅏㄴㅏ")
acknowledge(c,e,0)
e.setComposingRegion(1,2)
assertEquals(SelectionUpdateDisposition.EXTERNAL_CHANGE, acknowledge(c,e,2))
assertFalse(c.isActive)
}

@Test fun editorWithoutCandidateRangesCanAcknowledgeAbsoluteSelection() = onMain {
val e = Editor(); val c = coordinator()
type(c,e,"ㄱㅏㄴㅏ")
assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,c.onUpdateSelection(0,0,2,2,-1,-1))
type(c,e,"ㄴ")
assertEquals("가난",e.editable.toString())
}

@Test fun rejectedComposingFallsBackOnEveryStrokeAndResetsInNewEditor() = onMain {
val e = Editor().apply { rejectComposing = true }; val c = coordinator()
var consumedShift = 0
"ㄱㅏㄴ".forEach { assertTrue(c.handleMappedStroke(e,NormalizedCompositionStroke(it)) { consumedShift++ }) }
assertEquals("ㄱㅏㄴ",e.editable.toString())
assertEquals(3,consumedShift)
assertFalse(c.isActive)
c.startEditorSession(); c.selectLayout("korean_2set")
val next = Editor()
type(c,next,"ㄱㅏ")
assertEquals("가",next.editable.toString())
assertTrue(c.isActive)
}

@Test fun rejectedAndThrowingWritesDoNotConsumeKeyOrShiftWhenCommitAlsoFails() = onMain {
for (throws in listOf(false,true)) {
val e = Editor().apply { rejectComposing = true; rejectCommit = true; throwComposing = throws }
val c = coordinator()
var shiftConsumed = false
assertFalse(c.handleMappedStroke(e,NormalizedCompositionStroke('ㄱ')) { shiftConsumed = true })
assertFalse(shiftConsumed)
assertFalse(c.isActive)
assertEquals("",e.editable.toString())
}
}

@Test fun failedReplacementCommitsOnlyNewStrokeAndKeepsPreviousText() = onMain {
val e = Editor(); val c = coordinator()
type(c,e,"ㄱㅏ")
e.rejectComposing = true
type(c,e,"ㄴ")
assertEquals("가ㄴ",e.editable.toString())
assertFalse(c.isActive)
}

@Test fun failedDeleteLeavesTextAndAllowsCallerFallback() = onMain {
val e = Editor(); val c = coordinator()
type(c,e,"ㄱ")
e.rejectComposing = true
assertFalse(c.handleBackspace(e))
assertFalse(c.isActive)
assertEquals("ㄱ",e.editable.toString())
}

@Test fun splitBoundariesPreserveCompoundVowelsFinalsAndCarryWithDeletion() = onMain {
val tails = listOf("ㄱㅗㅏ", "ㄱㅏㅂㅅ", "ㄱㅏㅂㅅㅏ", "ㄱㅗㅏㄴ", "ㄱㅏㄴㅏ")
for (padding in 123..128) for (tail in tails) {
val e = Editor(); val c = coordinator()
val prefix = "ㅏ".repeat(padding)
type(c,e,prefix+tail)
val reference = it.palsoftware.pastiera.core.composition.HangulComposer(maxStrokes = 1000)
(prefix+tail).forEach { reference.accept(NormalizedCompositionStroke(it)) }
assertEquals("padding=$padding tail=$tail", reference.snapshot?.renderedText,e.editable.toString())
repeat(tail.length) {
assertTrue(c.handleBackspace(e))
reference.backspace()
assertEquals(reference.snapshot?.renderedText,e.editable.toString())
}
}
}

@Test fun delayedAcknowledgedCallbackDoesNotInterruptNewerComposition() = onMain {
val e = Editor(); val c = coordinator()
type(c,e,"ㄱ")
acknowledge(c,e,0)
type(c,e,"ㅏㄴㅏ")
acknowledge(c,e,1)
assertEquals(SelectionUpdateDisposition.OWN_COMPOSING_UPDATE,
c.onUpdateSelection(0,0,1,1,0,1))
type(c,e,"ㄴ")
assertEquals("가난",e.editable.toString())
}

@Test fun newEditorIgnoresOldCallbacksUntilItOwnsText() = onMain {
val e = Editor(); val c = coordinator()
type(c,e,"ㄱㅏ")
c.startEditorSession(); c.selectLayout("korean_2set")
assertEquals(SelectionUpdateDisposition.NOT_TRACKED,c.onUpdateSelection(0,0,1,1,0,1))
assertFalse(c.isActive)
}
}
13 changes: 13 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="it.palsoftware.pastiera.testing.ImeEditorTestActivity"
android:exported="false" android:process="it.palsoftware.pastiera.ime_editor_test" android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<activity android:name="it.palsoftware.pastiera.testing.ImeLocalEditorTestActivity"
android:exported="false" android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
<provider android:name="it.palsoftware.pastiera.testing.ImeEditorTestProvider"
android:authorities="${applicationId}.ime-editor-test"
android:exported="false" android:process="it.palsoftware.pastiera.ime_editor_test" />
<provider android:name="it.palsoftware.pastiera.testing.ImeTestSettingsProvider"
android:authorities="${applicationId}.ime-test-settings" android:exported="false" />
</application>
</manifest>
Loading