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/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()
+ }
+}
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 `