From 598aa61bd1a510dcd359cb6637d3c872353cde01 Mon Sep 17 00:00:00 2001 From: zefutoff Date: Sun, 4 Oct 2026 21:49:26 +0200 Subject: [PATCH] test: cover Android framework integrations --- CHANGELOG.md | 1 + ROADMAP.md | 4 + .../unofficial/ImeManifestTest.kt | 77 +++++ .../unofficial/ProcessTextManifestTest.kt | 64 ++++ .../SpellCheckerServiceEndToEndTest.kt | 300 ++++++++++++++++++ docs/testing.md | 13 +- 6 files changed, 453 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeManifestTest.kt create mode 100644 app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ProcessTextManifestTest.kt create mode 100644 app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SpellCheckerServiceEndToEndTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 907ed76..f623113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Dark theme support and redesigned application/IME interfaces. - 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. - Opt-in real-engine performance characterization for cold start, sentence analysis and paragraph analysis. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 810a39a..61aa638 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,6 +39,7 @@ Exit criterion: an installable APK performs local French spelling and grammar co - [x] Validate native Android text fields - [x] Validate SMS editing - [x] Identify applications that bypass the Android spell-checker framework +- [x] Add an end-to-end Android framework test for `SpellCheckerService` - [ ] Build and publish a reproducible compatibility matrix - [ ] Test additional messaging and mail applications - [ ] Test additional Chromium/WebView editors @@ -53,6 +54,7 @@ Exit criterion: an installable APK performs local French spelling and grammar co - [x] Copy corrected text when the caller exposes a read-only selection - [x] Validate the workflow in Firefox - [x] Identify applications that ignore returned `PROCESS_TEXT` replacement +- [x] Add automated `ACTION_PROCESS_TEXT` exposure coverage ### IME / InputConnection fallback @@ -68,6 +70,8 @@ Exit criterion: an installable APK performs local French spelling and grammar co - [x] Add an easy way to return to the previous keyboard - [ ] Review QuickJS execution serialization inside the IME - [ ] Add automated IME integration tests where practical + - [x] Cover IME discovery, permission and metadata + - [ ] 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/ImeManifestTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeManifestTest.kt new file mode 100644 index 0000000..6089009 --- /dev/null +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ImeManifestTest.kt @@ -0,0 +1,77 @@ +package fr.grammalecteandroid.unofficial + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ImeManifestTest { + @Suppress("DEPRECATION") + @Test + fun imeServiceIsDiscoverableAndProtected() { + val context = + ApplicationProvider.getApplicationContext() + + val services = + context.packageManager.queryIntentServices( + Intent(INPUT_METHOD_ACTION) + .setPackage(context.packageName), + PackageManager.GET_META_DATA, + ) + + val service = + services.single { + it.serviceInfo.name == + GrammalecteImeService::class.java.name + } + + assertEquals( + Manifest.permission.BIND_INPUT_METHOD, + service.serviceInfo.permission, + ) + } + + @Suppress("DEPRECATION") + @Test + fun imeServiceDeclaresExpectedMetadata() { + val context = + ApplicationProvider.getApplicationContext() + + val services = + context.packageManager.queryIntentServices( + Intent(INPUT_METHOD_ACTION) + .setPackage(context.packageName), + PackageManager.GET_META_DATA, + ) + + val service = + services.single { + it.serviceInfo.name == + GrammalecteImeService::class.java.name + } + + val metadata = service.serviceInfo.metaData + + assertNotNull( + "Expected android.view.im metadata on GrammalecteImeService", + metadata, + ) + + assertEquals( + R.xml.grammalecte_input_method, + metadata.getInt(INPUT_METHOD_METADATA), + ) + } + + private companion object { + const val INPUT_METHOD_ACTION = "android.view.InputMethod" + const val INPUT_METHOD_METADATA = "android.view.im" + } +} diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ProcessTextManifestTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ProcessTextManifestTest.kt new file mode 100644 index 0000000..ac1fd73 --- /dev/null +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/ProcessTextManifestTest.kt @@ -0,0 +1,64 @@ +package fr.grammalecteandroid.unofficial + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProcessTextManifestTest { + @Suppress("DEPRECATION") + @Test + fun processTextActivityIsDiscoverableAndExported() { + val context = + ApplicationProvider.getApplicationContext() + + val activities = + context.packageManager.queryIntentActivities( + Intent(Intent.ACTION_PROCESS_TEXT).apply { + type = "text/plain" + setPackage(context.packageName) + }, + PackageManager.MATCH_DEFAULT_ONLY, + ) + + val activity = + activities.single { + it.activityInfo.name == + ProcessTextActivity::class.java.name + } + + assertTrue( + "PROCESS_TEXT activity must be exported", + activity.activityInfo.exported, + ) + } + + @Suppress("DEPRECATION") + @Test + fun processTextActivityAcceptsPlainText() { + val context = + ApplicationProvider.getApplicationContext() + + val activities = + context.packageManager.queryIntentActivities( + Intent(Intent.ACTION_PROCESS_TEXT).apply { + type = "text/plain" + setPackage(context.packageName) + }, + PackageManager.MATCH_DEFAULT_ONLY, + ) + + assertTrue( + "Expected Corriger avec Grammalecte for text/plain PROCESS_TEXT", + activities.any { + it.activityInfo.name == + ProcessTextActivity::class.java.name + }, + ) + } +} diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SpellCheckerServiceEndToEndTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SpellCheckerServiceEndToEndTest.kt new file mode 100644 index 0000000..4237289 --- /dev/null +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SpellCheckerServiceEndToEndTest.kt @@ -0,0 +1,300 @@ +package fr.grammalecteandroid.unofficial + +import android.content.ComponentName +import android.content.Context +import android.os.ParcelFileDescriptor +import android.os.SystemClock +import android.view.textservice.SentenceSuggestionsInfo +import android.view.textservice.SpellCheckerSession +import android.view.textservice.SuggestionsInfo +import android.view.textservice.TextInfo +import android.view.textservice.TextServicesManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import fr.grammalecteandroid.spellchecker.GrammalecteSpellCheckerService +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +@RunWith(AndroidJUnit4::class) +class SpellCheckerServiceEndToEndTest { + @Test + fun systemSpellCheckerSessionReturnsRealGrammalecteSuggestion() { + val context = + ApplicationProvider.getApplicationContext() + + val originalSelected = + readSecureSetting(SELECTED_SPELL_CHECKER) + + val originalEnabled = + readSecureSetting(SPELL_CHECKER_ENABLED) + + val component = + ComponentName( + context, + GrammalecteSpellCheckerService::class.java, + ).flattenToString() + + try { + writeSecureSetting( + SELECTED_SPELL_CHECKER, + component, + ) + writeSecureSetting( + SPELL_CHECKER_ENABLED, + "1", + ) + + val latch = CountDownLatch(1) + + val received = + AtomicReference>() + + val listener = + object : + SpellCheckerSession.SpellCheckerSessionListener { + override fun onGetSuggestions(results: Array) = Unit + + override fun onGetSentenceSuggestions(results: Array) { + received.set(results) + latch.countDown() + } + } + + val manager = + context.getSystemService( + TextServicesManager::class.java, + ) + + val session = + createGrammalecteSession( + manager = manager, + listener = listener, + expectedPackageName = context.packageName, + ) + + try { + val text = + "Je suis aller au magazin hier." + + InstrumentationRegistry + .getInstrumentation() + .runOnMainSync { + session.getSentenceSuggestions( + arrayOf(TextInfo(text)), + SUGGESTION_LIMIT, + ) + } + + assertTrue( + "Timed out waiting for SpellCheckerService", + latch.await( + RESULT_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + ), + ) + + val results = + requireNotNull(received.get()) { + "No sentence suggestions received" + } + + assertTrue( + "Expected one sentence result, got ${results.size}", + results.isNotEmpty(), + ) + + val found = + results.any { result -> + containsCorrection( + text = text, + result = result, + source = "magazin", + expectedSuggestion = "magasin", + ) + } + + assertTrue( + "Expected «magazin» -> «magasin» through Android SpellCheckerService", + found, + ) + } finally { + InstrumentationRegistry + .getInstrumentation() + .runOnMainSync { + session.close() + } + } + } finally { + restoreSecureSetting( + SELECTED_SPELL_CHECKER, + originalSelected, + ) + restoreSecureSetting( + SPELL_CHECKER_ENABLED, + originalEnabled, + ) + } + } + + private fun createGrammalecteSession( + manager: TextServicesManager, + listener: SpellCheckerSession.SpellCheckerSessionListener, + expectedPackageName: String, + ): SpellCheckerSession { + repeat(SESSION_SELECTION_ATTEMPTS) { + val selectedSession = + AtomicReference() + + InstrumentationRegistry + .getInstrumentation() + .runOnMainSync { + val session = + manager.newSpellCheckerSession( + null, + Locale.FRANCE, + listener, + false, + ) + + if ( + session != null && + session.spellChecker.packageName == + expectedPackageName + ) { + selectedSession.set(session) + } else { + session?.close() + } + } + + selectedSession.get()?.let { + return it + } + + SystemClock.sleep( + SESSION_SELECTION_DELAY_MILLIS, + ) + } + + error( + "Android did not select the Grammalecte SpellCheckerService", + ) + } + + private fun containsCorrection( + text: String, + result: SentenceSuggestionsInfo, + source: String, + expectedSuggestion: String, + ): Boolean = + (0 until result.suggestionsCount).any { index -> + val offset = + result.getOffsetAt(index) + + val length = + result.getLengthAt(index) + + if ( + offset < 0 || + length <= 0 || + offset + length > text.length + ) { + false + } else { + val fragment = + text.substring( + offset, + offset + length, + ) + + val suggestions = + suggestions( + result.getSuggestionsInfoAt(index), + ) + + fragment == source && + expectedSuggestion in suggestions + } + } + + private fun suggestions(info: SuggestionsInfo): List { + val count = + info.suggestionsCount.coerceAtLeast(0) + + return List(count) { index -> + info.getSuggestionAt(index) + } + } + + private fun readSecureSetting(name: String): String = + shell( + "settings get secure $name", + ) + + private fun writeSecureSetting( + name: String, + value: String, + ) { + shell( + "settings put secure $name $value", + ) + } + + private fun restoreSecureSetting( + name: String, + value: String, + ) { + if ( + value.isBlank() || + value == "null" + ) { + shell( + "settings delete secure $name", + ) + } else { + writeSecureSetting( + name, + value, + ) + } + } + + 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 SELECTED_SPELL_CHECKER = + "selected_spell_checker" + + const val SPELL_CHECKER_ENABLED = + "spell_checker_enabled" + + const val SUGGESTION_LIMIT = 8 + + const val SESSION_SELECTION_ATTEMPTS = 20 + + const val SESSION_SELECTION_DELAY_MILLIS = + 250L + + const val RESULT_TIMEOUT_SECONDS = + 15L + } +} diff --git a/docs/testing.md b/docs/testing.md index cfc7838..bbc08e4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -103,7 +103,11 @@ Current application-level instrumentation verifies that: - the real packaged engine performs grammar correction; - returned UTF-16 ranges remain correct with emoji before an issue; - the spell-checker service is discoverable; -- the spell-checker service is protected by `android.permission.BIND_TEXT_SERVICE`. +- 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 `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. The `spellchecker` instrumentation suite verifies Android result mapping, including: @@ -125,11 +129,8 @@ API 26 is the current minimum supported Android API. Additional device, ROM and Future instrumentation should additionally cover: -- IME discovery and `android.permission.BIND_INPUT_METHOD`; -- IME metadata and lifecycle behavior; -- `ACTION_PROCESS_TEXT` exposure; -- end-to-end `SpellCheckerService` requests where practical; -- IME selected-text replacement where practical. +- IME lifecycle behavior; +- IME selected-text replacement through a real `InputConnection` where practical. ## 6. Build and privacy checks