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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@
<activity
android:name=".ImeIntegrationTestActivity"
android:exported="false" />

<activity
android:name=".WebViewCompatibilityTestActivity"
android:exported="true" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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 =
"""
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1"
>
<style>
body {
box-sizing: border-box;
margin: 0;
padding: 24px;
font-family: sans-serif;
}

textarea {
box-sizing: border-box;
width: 100%;
min-height: 240px;
padding: 16px;
font-size: 20px;
line-height: 1.5;
}
</style>
</head>
<body>
<textarea
lang="fr"
spellcheck="true"
>Je vais au magazin.</textarea>
</body>
</html>
""".trimIndent()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -45,9 +50,17 @@ class GrammalecteImeService : InputMethodService() {
private var originalSelectedText = ""
private var latestIssues: List<GrammarIssue> = 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

Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -728,20 +835,42 @@ 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."

setApplyButtonsEnabled(false)
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,
Expand Down Expand Up @@ -819,8 +948,11 @@ class GrammalecteImeService : InputMethodService() {
selectionRefreshRunnable,
)

selectionRequestGeneration++
analysisGeneration++

selectionExecutor.shutdown()

analysisExecutor.execute {
engine?.close()
engine = null
Expand Down
Loading
Loading