diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cbf417..cd73bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Real Grammalecte engine regression tests covering representative French corrections. - Android instrumentation coverage for the packaged Grammalecte engine and spell-checker result mapping. - Android framework integration tests covering IME discovery, `PROCESS_TEXT` exposure and end-to-end `SpellCheckerService` requests. +- End-to-end IME instrumentation covering Android IME selection, a real editable `InputConnection` and selected-text replacement. - Opt-in real-engine performance characterization for cold start, sentence analysis and paragraph analysis. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index a7ebb7c..254a581 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -69,9 +69,9 @@ Exit criterion: an installable APK performs local French spelling and grammar co - [x] Add a basic user-friendly IME activation and selection flow - [x] Add an easy way to return to the previous keyboard - [x] Review QuickJS execution serialization inside the IME -- [ ] Add automated IME integration tests where practical +- [x] Add automated IME integration tests where practical - [x] Cover IME discovery, permission and metadata - - [ ] Cover IME lifecycle and selected-text replacement through `InputConnection` + - [x] Cover IME lifecycle and selected-text replacement through `InputConnection` Accessibility-based replacement remains intentionally out of scope. The IME/InputConnection path is the preferred generic fallback for editable fields. diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeInputConnectionEndToEndTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeInputConnectionEndToEndTest.kt new file mode 100644 index 0000000..f7cd636 --- /dev/null +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeInputConnectionEndToEndTest.kt @@ -0,0 +1,336 @@ +package fr.grammalecteandroid.unofficial + +import android.accessibilityservice.AccessibilityServiceInfo +import android.content.ComponentName +import android.os.ParcelFileDescriptor +import android.os.SystemClock +import android.view.accessibility.AccessibilityNodeInfo +import android.view.inputmethod.InputMethodManager +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ImeInputConnectionEndToEndTest { + @Test + fun imeReplacesSelectedTextThroughRealInputConnection() { + val instrumentation = + InstrumentationRegistry.getInstrumentation() + + val context = + instrumentation.targetContext + + val originalIme = + shell("settings get secure default_input_method") + + val componentName = + ComponentName( + context, + GrammalecteImeService::class.java, + ) + + val component = + componentName.flattenToShortString() + + val grammalecteWasEnabled = + imeListContains( + output = shell("ime list -s"), + expected = componentName, + ) + + try { + assertTrue( + "Grammalecte IME was not registered by Android", + waitForImeListed( + command = "ime list -a -s", + expected = componentName, + ), + ) + + shell("ime enable $component") + + assertTrue( + "Grammalecte IME was not enabled", + waitForImeListed( + command = "ime list -s", + expected = componentName, + ), + ) + + ActivityScenario + .launch(ImeIntegrationTestActivity::class.java) + .use { scenario -> + scenario.onActivity { activity -> + activity.editor.setText(INITIAL_TEXT) + + activity.editor.requestFocus() + + activity.editor.setSelection( + 0, + INITIAL_TEXT.length, + ) + + activity.editor.post { + activity + .getSystemService( + InputMethodManager::class.java, + ).showSoftInput( + activity.editor, + InputMethodManager.SHOW_IMPLICIT, + ) + } + } + + SystemClock.sleep(EDITOR_READY_DELAY_MILLIS) + + shell("ime set $component") + + assertTrue( + "Android did not select Grammalecte as the default IME", + waitForDefaultIme(componentName), + ) + + scenario.onActivity { activity -> + val inputMethodManager = + activity.getSystemService( + InputMethodManager::class.java, + ) + + inputMethodManager.restartInput( + activity.editor, + ) + + inputMethodManager.hideSoftInputFromWindow( + activity.editor.windowToken, + 0, + ) + + activity.editor.postDelayed( + { + inputMethodManager.showSoftInput( + activity.editor, + InputMethodManager.SHOW_IMPLICIT, + ) + }, + IME_RECONNECT_DELAY_MILLIS, + ) + } + + SystemClock.sleep(IME_SWITCH_DELAY_MILLIS) + + val button = + requireNotNull( + waitForNode( + CORRECT_AND_RETURN_LABEL, + ), + ) { + "Expected Grammalecte IME correction action" + } + + assertTrue( + "Unable to click Grammalecte correction action", + button.performAction( + AccessibilityNodeInfo.ACTION_CLICK, + ), + ) + + assertTrue( + "Timed out waiting for corrected EditText content", + waitForText( + scenario = scenario, + expected = EXPECTED_TEXT, + ), + ) + + scenario.onActivity { activity -> + assertEquals( + EXPECTED_TEXT, + activity.editor.text.toString(), + ) + } + } + } finally { + restoreInputMethod( + originalIme = originalIme, + component = component, + grammalecteWasEnabled = grammalecteWasEnabled, + ) + } + } + + private fun waitForNode(expectedText: String): AccessibilityNodeInfo? { + val instrumentation = + InstrumentationRegistry.getInstrumentation() + + val automation = + instrumentation.uiAutomation + + val serviceInfo = + automation.serviceInfo + + serviceInfo.flags = + serviceInfo.flags or + AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS + + automation.serviceInfo = + serviceInfo + + repeat(WAIT_ATTEMPTS) { + automation.windows.forEach { window -> + val root = + window.root + ?: return@forEach + + val node = + root + .findAccessibilityNodeInfosByText( + expectedText, + ).firstOrNull { + it.text?.toString() == + expectedText && + it.isVisibleToUser + } + + if (node != null) { + return node + } + } + + SystemClock.sleep(WAIT_DELAY_MILLIS) + } + + return null + } + + private fun waitForText( + scenario: ActivityScenario, + expected: String, + ): Boolean { + repeat(WAIT_ATTEMPTS) { + var currentText = "" + + scenario.onActivity { activity -> + currentText = + activity.editor.text.toString() + } + + if (currentText == expected) { + return true + } + + SystemClock.sleep(WAIT_DELAY_MILLIS) + } + + return false + } + + private fun waitForImeListed( + command: String, + expected: ComponentName, + ): Boolean { + repeat(WAIT_ATTEMPTS) { + if ( + imeListContains( + output = shell(command), + expected = expected, + ) + ) { + return true + } + + SystemClock.sleep(WAIT_DELAY_MILLIS) + } + + return false + } + + private fun imeListContains( + output: String, + expected: ComponentName, + ): Boolean = + output + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .mapNotNull { + ComponentName.unflattenFromString(it) + }.any { + it == expected + } + + private fun waitForDefaultIme(expected: ComponentName): Boolean { + repeat(WAIT_ATTEMPTS) { + val current = + ComponentName.unflattenFromString( + shell( + "settings get secure default_input_method", + ), + ) + + if (current == expected) { + return true + } + + SystemClock.sleep(WAIT_DELAY_MILLIS) + } + + return false + } + + private fun restoreInputMethod( + originalIme: String, + component: String, + grammalecteWasEnabled: Boolean, + ) { + if ( + originalIme.isNotBlank() && + originalIme != "null" + ) { + shell( + "ime set $originalIme", + ) + } + + if (!grammalecteWasEnabled) { + shell( + "ime disable $component", + ) + } + } + + private fun shell(command: String): String { + val descriptor = + InstrumentationRegistry + .getInstrumentation() + .uiAutomation + .executeShellCommand(command) + + return ParcelFileDescriptor + .AutoCloseInputStream(descriptor) + .bufferedReader() + .use { + it.readText().trim() + } + } + + private companion object { + const val INITIAL_TEXT = + "Je suis aller au magasin hier." + + const val EXPECTED_TEXT = + "Je suis allé au magasin hier." + + const val CORRECT_AND_RETURN_LABEL = + "Corriger tout et revenir" + + const val WAIT_ATTEMPTS = 60 + const val WAIT_DELAY_MILLIS = 250L + const val EDITOR_READY_DELAY_MILLIS = 750L + const val IME_RECONNECT_DELAY_MILLIS = 250L + const val IME_SWITCH_DELAY_MILLIS = 1_500L + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..43d1f86 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/debug/kotlin/fr/grammalecteandroid/unofficial/ImeIntegrationTestActivity.kt b/app/src/debug/kotlin/fr/grammalecteandroid/unofficial/ImeIntegrationTestActivity.kt new file mode 100644 index 0000000..1e7bf2e --- /dev/null +++ b/app/src/debug/kotlin/fr/grammalecteandroid/unofficial/ImeIntegrationTestActivity.kt @@ -0,0 +1,26 @@ +package fr.grammalecteandroid.unofficial + +import android.app.Activity +import android.os.Bundle +import android.text.InputType +import android.widget.EditText + +class ImeIntegrationTestActivity : Activity() { + lateinit var editor: EditText + private set + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + editor = + EditText(this).apply { + inputType = + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_MULTI_LINE + + textSize = 18f + } + + setContentView(editor) + } +} diff --git a/docs/testing.md b/docs/testing.md index bbc08e4..be8823a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -106,6 +106,9 @@ Current application-level instrumentation verifies that: - the spell-checker service is protected by `android.permission.BIND_TEXT_SERVICE`; - the IME is discoverable and protected by `android.permission.BIND_INPUT_METHOD`; - the IME exposes the expected `android.view.im` metadata; +- the IME can be enabled and selected through Android's input-method framework; +- a real editable `EditText` exposes selected text to the IME through a real `InputConnection`; +- the Grammalecte IME can analyze that selection and commit corrected text back into the real editor; - the `ACTION_PROCESS_TEXT` activity is discoverable for `text/plain`; - a real Android `TextServicesManager` session reaches the packaged `SpellCheckerService` and returns a Grammalecte correction end to end. @@ -127,11 +130,6 @@ The instrumentation workflow runs: API 26 is the current minimum supported Android API. Additional device, ROM and application coverage is still planned. -Future instrumentation should additionally cover: - -- IME lifecycle behavior; -- IME selected-text replacement through a real `InputConnection` where practical. - ## 6. Build and privacy checks `make check` is the canonical local quality entry point and is also used by the main CI workflow. @@ -161,6 +159,8 @@ Development testing on a physical Android device has validated real local Gramma - `ACTION_PROCESS_TEXT`; - the Grammalecte IME through `InputConnection`. +The end-to-end IME instrumentation test has also been validated on a physical Samsung Galaxy S21 running Android 15. It uses a debug-only editable test activity, switches to the Grammalecte IME through Android's input-method framework, selects synthetic text, performs a real correction and verifies the resulting editor contents. + The IME replacement path has been manually validated in: - Samsung Notes;