diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e64b6..36677df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Persistent personal dictionary with in-app word management, shared by all correction modes. - Global reset-to-default control restoring the bundled dictionary, Grammalecte rule defaults and clearing the personal dictionary. - Versioned JSON import/export for non-sensitive correction preferences, using Android's system document picker without storage permissions; the personal dictionary is intentionally excluded. +- Dedicated correction status screen showing the native Android spell-checker and Grammalecte IME activation/selection state, with direct access to the relevant system settings. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index a22df4f..42801d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,7 @@ Exit criterion: correction remains usable in common editable applications even w - [x] Personal dictionary - [x] Reset-to-default controls - [x] Import/export of non-sensitive preferences -- [ ] Clear status screen for native spell-checker and IME activation +- [x] Clear status screen for native spell-checker and IME activation ## Phase 4 — Product polish diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatusManifestTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatusManifestTest.kt new file mode 100644 index 0000000..7125dd2 --- /dev/null +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatusManifestTest.kt @@ -0,0 +1,34 @@ +package fr.grammalecteandroid.unofficial + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertFalse +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CorrectionStatusManifestTest { + @Suppress("DEPRECATION") + @Test + fun correctionStatusActivityIsInternal() { + val context = + ApplicationProvider.getApplicationContext() + + val activity = + context.packageManager.getActivityInfo( + ComponentName( + context, + CorrectionStatusActivity::class.java, + ), + PackageManager.GET_META_DATA, + ) + + assertFalse( + "Correction status activity must not be exported", + activity.exported, + ) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78aa95c..b101f81 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,10 @@ android:name=".PersonalDictionaryActivity" android:exported="false" /> + + diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatus.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatus.kt new file mode 100644 index 0000000..28da18e --- /dev/null +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatus.kt @@ -0,0 +1,138 @@ +package fr.grammalecteandroid.unofficial + +import android.content.ComponentName +import android.content.Context +import android.os.Build +import android.provider.Settings +import android.view.inputmethod.InputMethodManager +import android.view.textservice.SentenceSuggestionsInfo +import android.view.textservice.SpellCheckerSession +import android.view.textservice.SuggestionsInfo +import android.view.textservice.TextServicesManager +import java.util.Locale + +data class CorrectionStatus( + val spellChecker: SpellCheckerStatus, + val ime: ImeStatus, +) + +enum class SpellCheckerStatus { + SELECTED, + NOT_SELECTED, + DISABLED, + UNAVAILABLE, +} + +enum class ImeStatus { + SELECTED, + ENABLED, + DISABLED, +} + +class CorrectionStatusReader( + private val context: Context, +) { + fun read(): CorrectionStatus = + CorrectionStatus( + spellChecker = readSpellCheckerStatus(), + ime = readImeStatus(), + ) + + private fun readSpellCheckerStatus(): SpellCheckerStatus { + val manager = + context.getSystemService( + TextServicesManager::class.java, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!manager.isSpellCheckerEnabled) { + return SpellCheckerStatus.DISABLED + } + + return if ( + manager.currentSpellCheckerInfo + ?.packageName == context.packageName + ) { + SpellCheckerStatus.SELECTED + } else { + SpellCheckerStatus.NOT_SELECTED + } + } + + val session = + manager.newSpellCheckerSession( + null, + Locale.FRANCE, + EMPTY_SPELL_CHECKER_LISTENER, + false, + ) + + return try { + when { + session == null -> + SpellCheckerStatus.UNAVAILABLE + + session.spellChecker.packageName == + context.packageName -> + SpellCheckerStatus.SELECTED + + else -> + SpellCheckerStatus.NOT_SELECTED + } + } finally { + session?.close() + } + } + + private fun readImeStatus(): ImeStatus { + val manager = + context.getSystemService( + InputMethodManager::class.java, + ) + + val component = + ComponentName( + context, + GrammalecteImeService::class.java, + ) + + val enabled = + manager.enabledInputMethodList + .any { inputMethod -> + ComponentName( + inputMethod.packageName, + inputMethod.serviceName, + ) == component + } + + val selectedComponent = + Settings.Secure + .getString( + context.contentResolver, + Settings.Secure.DEFAULT_INPUT_METHOD, + )?.let( + ComponentName::unflattenFromString, + ) + + return when { + selectedComponent == component -> + ImeStatus.SELECTED + + enabled -> + ImeStatus.ENABLED + + else -> + ImeStatus.DISABLED + } + } + + private companion object { + val EMPTY_SPELL_CHECKER_LISTENER = + object : + SpellCheckerSession.SpellCheckerSessionListener { + override fun onGetSuggestions(results: Array?) = Unit + + override fun onGetSentenceSuggestions(results: Array?) = Unit + } + } +} diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatusActivity.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatusActivity.kt new file mode 100644 index 0000000..62be2f2 --- /dev/null +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/CorrectionStatusActivity.kt @@ -0,0 +1,471 @@ +package fr.grammalecteandroid.unofficial + +import android.app.Activity +import android.content.ComponentName +import android.content.Intent +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.Gravity +import android.view.ViewGroup +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager +import android.widget.Button +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +class CorrectionStatusActivity : Activity() { + private lateinit var spellCheckerStatusView: TextView + private lateinit var imeStatusView: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val density = + resources.displayMetrics.density + + val padding = + (20 * density).toInt() + + val spacing = + (12 * density).toInt() + + val smallSpacing = + (6 * density).toInt() + + val content = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + + setPadding( + padding, + padding, + padding, + padding, + ) + + setBackgroundColor( + getColor(R.color.surface_background), + ) + } + + val header = + LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + + header.addView( + TextView(this).apply { + text = "État de la correction" + textSize = 24f + + setTypeface( + typeface, + Typeface.BOLD, + ) + + setTextColor( + getColor(R.color.surface_text), + ) + }, + LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f, + ), + ) + + header.addView( + Button(this).apply { + text = "← Retour" + isAllCaps = false + + setOnClickListener { + finish() + } + }, + ) + + content.addView( + header, + matchWrapParams(), + ) + + content.addView( + TextView(this).apply { + text = + "Cette page affiche l’état réellement détecté par Android. " + + "Les informations sont actualisées lorsque vous revenez " + + "des paramètres système." + + textSize = 14f + + setTextColor( + getColor(R.color.surface_text_secondary), + ) + + setPadding( + 0, + smallSpacing, + 0, + spacing, + ) + }, + matchWrapParams(), + ) + + spellCheckerStatusView = + statusTextView() + + content.addView( + createStatusCard( + title = "Correcteur Android", + description = + "Utilise le service de correction orthographique natif " + + "d’Android dans les applications compatibles.", + statusView = spellCheckerStatusView, + actions = + listOf( + "Configurer le correcteur Android" to { + openSpellCheckerSettings() + }, + ), + ), + cardParams(spacing), + ) + + imeStatusView = + statusTextView() + + content.addView( + createStatusCard( + title = "Clavier Grammalecte", + description = + "Permet de corriger directement une sélection lorsque " + + "l’application n’utilise pas le correcteur Android.", + statusView = imeStatusView, + actions = + listOf( + "Gérer les méthodes de saisie" to { + openInputMethodSettings() + }, + "Choisir le clavier" to { + showInputMethodPicker() + }, + ), + ), + cardParams(spacing), + ) + + content.addView( + TextView(this).apply { + text = + "Les deux modes sont indépendants : vous pouvez utiliser " + + "le correcteur Android seul, le clavier Grammalecte seul, " + + "ou conserver les deux disponibles." + + textSize = 14f + + setTextColor( + getColor(R.color.surface_text_secondary), + ) + + setPadding( + 0, + smallSpacing, + 0, + 0, + ) + }, + matchWrapParams(), + ) + + val scrollView = + ScrollView(this).apply { + isFillViewport = true + + addView( + content, + matchWrapParams(), + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + setOnApplyWindowInsetsListener { view, insets -> + val topInset: Int + val bottomInset: Int + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val systemBars = + insets.getInsets( + WindowInsets.Type.systemBars(), + ) + + topInset = systemBars.top + bottomInset = systemBars.bottom + } else { + @Suppress("DEPRECATION") + topInset = insets.systemWindowInsetTop + + @Suppress("DEPRECATION") + bottomInset = insets.systemWindowInsetBottom + } + + view.setPadding( + 0, + topInset, + 0, + bottomInset, + ) + + insets + } + } + } + + setContentView(scrollView) + } + + override fun onResume() { + super.onResume() + updateStatus() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + + if ( + hasFocus && + this::spellCheckerStatusView.isInitialized && + this::imeStatusView.isInitialized + ) { + updateStatus() + } + } + + private fun updateStatus() { + val status = + CorrectionStatusReader(this) + .read() + + spellCheckerStatusView.text = + when (status.spellChecker) { + SpellCheckerStatus.SELECTED -> + "État : sélectionné comme correcteur système." + + SpellCheckerStatus.NOT_SELECTED -> + "État : disponible, mais non sélectionné." + + SpellCheckerStatus.DISABLED -> + "État : correction orthographique Android désactivée." + + SpellCheckerStatus.UNAVAILABLE -> + "État : aucun correcteur Android actif détecté." + } + + imeStatusView.text = + when (status.ime) { + ImeStatus.SELECTED -> + "État : activé et actuellement sélectionné." + + ImeStatus.ENABLED -> + "État : activé, mais un autre clavier est sélectionné." + + ImeStatus.DISABLED -> + "État : non activé dans les méthodes de saisie." + } + } + + private fun openSpellCheckerSettings() { + val directIntent = + Intent(Intent.ACTION_MAIN).apply { + component = + ComponentName( + "com.android.settings", + "com.android.settings.Settings\$SpellCheckersSettingsActivity", + ) + + addCategory( + Intent.CATEGORY_DEFAULT, + ) + } + + val intent = + if ( + directIntent.resolveActivity( + packageManager, + ) != null + ) { + directIntent + } else { + Intent( + Settings.ACTION_INPUT_METHOD_SETTINGS, + ) + } + + runCatching { + startActivity(intent) + }.onFailure { + startActivity( + Intent( + Settings.ACTION_SETTINGS, + ), + ) + } + } + + private fun openInputMethodSettings() { + runCatching { + startActivity( + Intent( + Settings.ACTION_INPUT_METHOD_SETTINGS, + ), + ) + }.onFailure { + startActivity( + Intent( + Settings.ACTION_SETTINGS, + ), + ) + } + } + + private fun showInputMethodPicker() { + getSystemService( + InputMethodManager::class.java, + ).showInputMethodPicker() + } + + private fun statusTextView(): TextView = + TextView(this).apply { + textSize = 15f + + setTypeface( + typeface, + Typeface.BOLD, + ) + + setTextColor( + getColor(R.color.surface_text), + ) + } + + private fun createStatusCard( + title: String, + description: String, + statusView: TextView, + actions: List Unit>>, + ): LinearLayout { + val density = + resources.displayMetrics.density + + val padding = + (14 * density).toInt() + + val spacing = + (8 * density).toInt() + + return LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + + setPadding( + padding, + padding, + padding, + padding, + ) + + background = + roundedBackground( + 14f, + ) + + addView( + TextView(this@CorrectionStatusActivity).apply { + text = title + textSize = 18f + + setTypeface( + typeface, + Typeface.BOLD, + ) + + setTextColor( + getColor(R.color.surface_text), + ) + }, + matchWrapParams(), + ) + + addView( + TextView(this@CorrectionStatusActivity).apply { + text = description + textSize = 14f + + setTextColor( + getColor(R.color.surface_text_secondary), + ) + + setPadding( + 0, + spacing, + 0, + spacing, + ) + }, + matchWrapParams(), + ) + + addView( + statusView, + matchWrapParams(), + ) + + actions.forEach { (label, action) -> + addView( + Button(this@CorrectionStatusActivity).apply { + text = label + isAllCaps = false + + setOnClickListener { + action() + } + }, + matchWrapParams(), + ) + } + } + } + + private fun roundedBackground(radiusDp: Float): GradientDrawable = + GradientDrawable().apply { + shape = + GradientDrawable.RECTANGLE + + cornerRadius = + radiusDp * + resources.displayMetrics.density + + setColor( + getColor(R.color.surface_card), + ) + } + + private fun cardParams(spacing: Int): LinearLayout.LayoutParams = + LinearLayout + .LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { + bottomMargin = spacing + } + + private fun matchWrapParams(): ViewGroup.LayoutParams = + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ) +} diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt index 9e2a4f6..7cc7bcd 100644 --- a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt @@ -113,6 +113,28 @@ class MainActivity : matchWrapParams(), ) + content.addView( + createCard( + title = "État et activation", + description = + "Vérifiez en un seul endroit l’état du correcteur Android " + + "et du clavier Grammalecte.", + statusView = null, + actions = + listOf( + "Voir l’état de la correction" to { + startActivity( + Intent( + this@MainActivity, + CorrectionStatusActivity::class.java, + ), + ) + }, + ), + ), + cardParams(spacing), + ) + spellCheckerStatusView = TextView(this).apply { textSize = 14f @@ -594,47 +616,44 @@ class MainActivity : } private fun updateSpellCheckerStatus() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - spellCheckerStatusView.text = - "Statut non disponible sur cette version d’Android. " + - "Vérifiez le correcteur dans les paramètres système." - return - } + val status = + CorrectionStatusReader(this) + .read() + .spellChecker - val manager = - getSystemService(TextServicesManager::class.java) + spellCheckerStatusView.text = + when (status) { + SpellCheckerStatus.SELECTED -> + "Activé et sélectionné comme correcteur Android." - val current = - manager.currentSpellCheckerInfo + SpellCheckerStatus.NOT_SELECTED -> + "Disponible, mais non sélectionné comme correcteur Android." - val selected = - current?.packageName == packageName + SpellCheckerStatus.DISABLED -> + "La correction orthographique Android est désactivée." - spellCheckerStatusView.text = - if (selected) { - "Activé comme correcteur Android." - } else { - "Non sélectionné comme correcteur Android." + SpellCheckerStatus.UNAVAILABLE -> + "Aucun correcteur Android actif n’a pu être détecté." } } private fun updateImeStatus() { - val manager = getSystemService(InputMethodManager::class.java) - - val enabled = - manager.enabledInputMethodList.any { inputMethod -> - inputMethod.packageName == packageName && - inputMethod.serviceName == GrammalecteImeService::class.java.name - } + val status = + CorrectionStatusReader(this) + .read() + .ime imeStatusView.text = - getString( - if (enabled) { - R.string.ime_status_enabled - } else { - R.string.ime_status_disabled - }, - ) + when (status) { + ImeStatus.SELECTED -> + "Activé et actuellement sélectionné." + + ImeStatus.ENABLED -> + "Activé, mais un autre clavier est actuellement sélectionné." + + ImeStatus.DISABLED -> + "Non activé dans les méthodes de saisie Android." + } } private fun openInputMethodSettings() {