From 665c6db4c12bf63678b0970c15d99cbbaaed30cd Mon Sep 17 00:00:00 2001 From: zefutoff Date: Tue, 6 Oct 2026 22:03:33 +0200 Subject: [PATCH 1/2] test: add WebView compatibility fixture --- app/src/debug/AndroidManifest.xml | 4 ++ .../WebViewCompatibilityTestActivity.kt | 72 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 app/src/debug/kotlin/fr/grammalecteandroid/unofficial/WebViewCompatibilityTestActivity.kt diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 43d1f86..da567db 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -4,5 +4,9 @@ + + diff --git a/app/src/debug/kotlin/fr/grammalecteandroid/unofficial/WebViewCompatibilityTestActivity.kt b/app/src/debug/kotlin/fr/grammalecteandroid/unofficial/WebViewCompatibilityTestActivity.kt new file mode 100644 index 0000000..e211990 --- /dev/null +++ b/app/src/debug/kotlin/fr/grammalecteandroid/unofficial/WebViewCompatibilityTestActivity.kt @@ -0,0 +1,72 @@ +package fr.grammalecteandroid.unofficial + +import android.app.Activity +import android.os.Bundle +import android.webkit.WebView + +class WebViewCompatibilityTestActivity : Activity() { + private lateinit var webView: WebView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + webView = + WebView(this).apply { + settings.javaScriptEnabled = false + + loadDataWithBaseURL( + "https://compatibility.invalid/", + HTML, + "text/html", + "UTF-8", + null, + ) + } + + setContentView(webView) + } + + override fun onDestroy() { + webView.destroy() + super.onDestroy() + } + + private companion object { + val HTML = + """ + + + + + + + + + + + + """.trimIndent() + } +} From 42cfc072091c889617bac5e006ec8e7d87059d71 Mon Sep 17 00:00:00 2001 From: zefutoff Date: Tue, 6 Oct 2026 22:40:52 +0200 Subject: [PATCH 2/2] fix: harden IME selection handling for WebView --- CHANGELOG.md | 2 + .../ImeInputConnectionEndToEndTest.kt | 18 +- .../unofficial/GrammalecteImeService.kt | 182 +++++++++++++++--- docs/testing.md | 8 +- 4 files changed, 180 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 121a9b4..4151dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - French spell-checker subtype configuration on Android devices whose primary system locale is not French. - CI JavaScript bridge test stubs to match the real Grammalecte initialization contract. - Android API 26 compatibility in the IME by avoiding the API 28-only `mainExecutor`. +- IME selected-text reads no longer block the UI thread on slow `InputConnection` implementations such as Android WebView. +- IME replacement now tracks and restores the analyzed selection range before committing corrected text, while rejecting stale selections. ### Validated manually diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeInputConnectionEndToEndTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeInputConnectionEndToEndTest.kt index 4850994..cf2589a 100644 --- a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeInputConnectionEndToEndTest.kt +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeInputConnectionEndToEndTest.kt @@ -123,13 +123,23 @@ class ImeInputConnectionEndToEndTest { requireNotNull( waitForNode( - NO_SELECTION_LABEL, + IME_TITLE_LABEL, ), ) { - "Expected Grammalecte IME to start without a selection" + "Expected Grammalecte IME to be visible before late selection" } scenario.onActivity { activity -> + assertEquals( + INITIAL_TEXT.length, + activity.editor.selectionStart, + ) + + assertEquals( + INITIAL_TEXT.length, + activity.editor.selectionEnd, + ) + activity.editor.setSelection( 0, INITIAL_TEXT.length, @@ -338,8 +348,8 @@ class ImeInputConnectionEndToEndTest { const val EXPECTED_TEXT = "Je suis allé au magasin hier." - const val NO_SELECTION_LABEL = - "Aucune sélection." + const val IME_TITLE_LABEL = + "Grammalecte" const val CORRECT_AND_RETURN_LABEL = "Corriger tout et revenir" diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt index 9300473..b09fbe5 100644 --- a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/GrammalecteImeService.kt @@ -25,10 +25,15 @@ class GrammalecteImeService : InputMethodService() { private val selectionRefreshRunnable = Runnable { if (::selectedTextView.isInitialized) { - loadSelectionAndAnalyze() + requestSelectionAndAnalyze() } } + private val selectionExecutor = + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "GrammalecteImeSelection") + } + private val analysisExecutor = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "GrammalecteImeAnalysis") @@ -45,9 +50,17 @@ class GrammalecteImeService : InputMethodService() { private var originalSelectedText = "" private var latestIssues: List = emptyList() + private var latestSelectionStart = -1 + private var latestSelectionEnd = -1 + private var analyzedSelectionStart = -1 + private var analyzedSelectionEnd = -1 + // The engine is created, used and closed only on analysisExecutor. private var engine: GrammalecteQuickJsEngine? = null + @Volatile + private var selectionRequestGeneration = 0 + @Volatile private var analysisGeneration = 0 @@ -306,7 +319,30 @@ class GrammalecteImeService : InputMethodService() { selectionRefreshRunnable, ) - loadSelectionAndAnalyze() + selectionRequestGeneration++ + + updateLatestSelection( + info?.initialSelStart ?: -1, + info?.initialSelEnd ?: -1, + ) + + val initialSelectedText = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + info + ?.getInitialSelectedText(0) + ?.toString() + } else { + null + } + + if (initialSelectedText != null) { + applySelectedTextAndAnalyze( + initialSelectedText, + ) + } else { + clearSelectionState() + requestSelectionAndAnalyze() + } } override fun onUpdateSelection( @@ -333,42 +369,79 @@ class GrammalecteImeService : InputMethodService() { return } + selectionRequestGeneration++ + + updateLatestSelection( + newSelStart, + newSelEnd, + ) + mainHandler.removeCallbacks( selectionRefreshRunnable, ) + if ( + newSelStart < 0 || + newSelEnd < 0 || + newSelStart == newSelEnd + ) { + if (::selectedTextView.isInitialized) { + clearSelectionState() + } + + return + } + mainHandler.postDelayed( selectionRefreshRunnable, SELECTION_REFRESH_DELAY_MILLIS, ) } - private fun loadSelectionAndAnalyze() { - val selectedText = + private fun requestSelectionAndAnalyze() { + val inputConnection = currentInputConnection - ?.getSelectedText(0) - ?.toString() - .orEmpty() + ?: run { + clearSelectionState() + return + } - if (selectedText.isEmpty()) { - analysisGeneration++ + val requestGeneration = + ++selectionRequestGeneration - originalSelectedText = "" - workingText = "" + selectionExecutor.execute { + val selectedText = + runCatching { + inputConnection + .getSelectedText(0) + ?.toString() + .orEmpty() + }.getOrDefault("") - selectedTextView.text = - "Aucune sélection." + mainHandler.post { + if ( + requestGeneration != selectionRequestGeneration || + !::selectedTextView.isInitialized + ) { + return@post + } - statusView.text = - "Sélectionnez du texte à corriger." + applySelectedTextAndAnalyze( + selectedText, + ) + } + } + } - issuesLayout.removeAllViews() - latestIssues = emptyList() - setApplyButtonsEnabled(false) - setApplyAllButtonsEnabled(false) + private fun applySelectedTextAndAnalyze(selectedText: String) { + if (selectedText.isEmpty()) { + clearSelectionState() return } + analyzedSelectionStart = latestSelectionStart + analyzedSelectionEnd = latestSelectionEnd + originalSelectedText = selectedText workingText = selectedText @@ -380,6 +453,40 @@ class GrammalecteImeService : InputMethodService() { analyzeWorkingText() } + private fun clearSelectionState() { + analysisGeneration++ + + originalSelectedText = "" + workingText = "" + analyzedSelectionStart = -1 + analyzedSelectionEnd = -1 + + selectedTextView.text = + "Aucune sélection." + + statusView.text = + "Sélectionnez du texte à corriger." + + issuesLayout.removeAllViews() + latestIssues = emptyList() + setApplyButtonsEnabled(false) + setApplyAllButtonsEnabled(false) + } + + private fun updateLatestSelection( + start: Int, + end: Int, + ) { + if (start < 0 || end < 0) { + latestSelectionStart = -1 + latestSelectionEnd = -1 + return + } + + latestSelectionStart = minOf(start, end) + latestSelectionEnd = maxOf(start, end) + } + private fun analyzeWorkingText() { val text = workingText val generation = ++analysisGeneration @@ -728,13 +835,21 @@ class GrammalecteImeService : InputMethodService() { currentInputConnection ?: return - val currentSelection = - inputConnection - .getSelectedText(0) - ?.toString() - .orEmpty() + if ( + analyzedSelectionStart < 0 || + analyzedSelectionEnd <= analyzedSelectionStart + ) { + statusView.text = + "La sélection n'est plus disponible." - if (currentSelection != originalSelectedText) { + setApplyButtonsEnabled(false) + return + } + + if ( + latestSelectionStart != analyzedSelectionStart || + latestSelectionEnd != analyzedSelectionEnd + ) { statusView.text = "La sélection a changé. Sélectionnez de nouveau le texte." @@ -742,6 +857,20 @@ class GrammalecteImeService : InputMethodService() { return } + val selectionRestored = + inputConnection.setSelection( + analyzedSelectionStart, + analyzedSelectionEnd, + ) + + if (!selectionRestored) { + statusView.text = + "Impossible de restaurer la sélection." + + setApplyButtonsEnabled(false) + return + } + val committed = inputConnection.commitText( workingText, @@ -819,8 +948,11 @@ class GrammalecteImeService : InputMethodService() { selectionRefreshRunnable, ) + selectionRequestGeneration++ analysisGeneration++ + selectionExecutor.shutdown() + analysisExecutor.execute { engine?.close() engine = null diff --git a/docs/testing.md b/docs/testing.md index e6bc995..9365dfb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -162,11 +162,17 @@ Development testing on a physical Android device has validated real local Gramma 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. + +A debug-only `WebViewCompatibilityTestActivity` provides a local `android.webkit.WebView` fixture containing a synthetic editable `