diff --git a/CHANGELOG.md b/CHANGELOG.md index 36677df..eebce43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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. +- First-run setup guide explaining the three correction modes, showing live activation state and providing direct access to the relevant Android settings; the guide is shown once automatically and remains available from the main screen. ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 42801d3..dcb154e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -89,8 +89,8 @@ Exit criterion: correction remains usable in common editable applications even w ## Phase 4 — Product polish - [ ] Application icon and visual identity -- [ ] Improve first-run setup -- [ ] Explain the three correction modes in-app +- [x] Improve first-run setup +- [x] Explain the three correction modes in-app - [ ] Improve accessibility of the application UI - [ ] Review all user-facing French strings - [x] Add compatibility documentation diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SetupGuideInstrumentedTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SetupGuideInstrumentedTest.kt new file mode 100644 index 0000000..f8b6d86 --- /dev/null +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SetupGuideInstrumentedTest.kt @@ -0,0 +1,211 @@ +package fr.grammalecteandroid.unofficial + +import android.app.Activity +import android.content.Context +import android.os.SystemClock +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SetupGuideInstrumentedTest { + @Test + fun firstRunGuideIsShownOnceAndRemainsManuallyAvailable() { + val instrumentation = + InstrumentationRegistry.getInstrumentation() + + val context = + instrumentation.targetContext + + val preferences = + context.getSharedPreferences( + SETUP_PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + + preferences + .edit() + .clear() + .commit() + + try { + ActivityScenario + .launch(MainActivity::class.java) + .use { + assertTrue( + "Expected setup guide on first launch", + waitForResumedActivity( + SetupGuideActivity::class.java, + ), + ) + + finishSetupGuide() + + assertTrue( + "Expected MainActivity after closing setup guide", + waitForResumedActivity( + MainActivity::class.java, + ), + ) + } + + ActivityScenario + .launch(MainActivity::class.java) + .use { scenario -> + SystemClock.sleep( + ACTIVITY_SETTLE_DELAY_MILLIS, + ) + + assertFalse( + "Setup guide must not reopen automatically", + isActivityResumed( + SetupGuideActivity::class.java, + ), + ) + + assertTrue( + "Expected MainActivity on later launches", + isActivityResumed( + MainActivity::class.java, + ), + ) + + scenario.onActivity { activity -> + val guideButton = + findButton( + activity.window.decorView, + GUIDE_BUTTON_LABEL, + ) + + assertNotNull( + "Expected permanent setup guide action", + guideButton, + ) + + requireNotNull( + guideButton, + ).performClick() + } + + assertTrue( + "Expected manual setup guide opening", + waitForResumedActivity( + SetupGuideActivity::class.java, + ), + ) + + finishSetupGuide() + + assertTrue( + "Expected return to MainActivity after manual guide", + waitForResumedActivity( + MainActivity::class.java, + ), + ) + } + } finally { + preferences + .edit() + .clear() + .commit() + } + } + + private fun waitForResumedActivity(expected: Class): Boolean { + repeat(WAIT_ATTEMPTS) { + if (isActivityResumed(expected)) { + return true + } + + SystemClock.sleep( + WAIT_DELAY_MILLIS, + ) + } + + return false + } + + private fun isActivityResumed(expected: Class): Boolean { + val instrumentation = + InstrumentationRegistry.getInstrumentation() + + var found = false + + instrumentation.runOnMainSync { + found = + ActivityLifecycleMonitorRegistry + .getInstance() + .getActivitiesInStage( + Stage.RESUMED, + ).any { activity -> + expected.isInstance(activity) + } + } + + return found + } + + private fun finishSetupGuide() { + val instrumentation = + InstrumentationRegistry.getInstrumentation() + + instrumentation.runOnMainSync { + ActivityLifecycleMonitorRegistry + .getInstance() + .getActivitiesInStage( + Stage.RESUMED, + ).filterIsInstance() + .firstOrNull() + ?.finish() + } + } + + private fun findButton( + view: View, + label: String, + ): Button? { + if ( + view is Button && + view.text.toString() == label + ) { + return view + } + + if (view !is ViewGroup) { + return null + } + + for (index in 0 until view.childCount) { + findButton( + view.getChildAt(index), + label, + )?.let { + return it + } + } + + return null + } + + private companion object { + const val SETUP_PREFERENCES_NAME = + "setup_guide" + + const val GUIDE_BUTTON_LABEL = + "Guide de démarrage" + + const val WAIT_ATTEMPTS = 30 + const val WAIT_DELAY_MILLIS = 100L + const val ACTIVITY_SETTLE_DELAY_MILLIS = 500L + } +} diff --git a/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SetupGuideManifestTest.kt b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SetupGuideManifestTest.kt new file mode 100644 index 0000000..022d928 --- /dev/null +++ b/app/src/androidTest/kotlin/fr/grammalecteandroid/unofficial/SetupGuideManifestTest.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 SetupGuideManifestTest { + @Suppress("DEPRECATION") + @Test + fun setupGuideActivityIsInternal() { + val context = + ApplicationProvider.getApplicationContext() + + val activity = + context.packageManager.getActivityInfo( + ComponentName( + context, + SetupGuideActivity::class.java, + ), + PackageManager.GET_META_DATA, + ) + + assertFalse( + "Setup guide activity must not be exported", + activity.exported, + ) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b101f81..faec563 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,6 +18,10 @@ android:name=".CorrectionStatusActivity" android:exported="false" /> + + diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt index 7cc7bcd..9b88a91 100644 --- a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/MainActivity.kt @@ -122,6 +122,14 @@ class MainActivity : statusView = null, actions = listOf( + "Guide de démarrage" to { + startActivity( + Intent( + this@MainActivity, + SetupGuideActivity::class.java, + ), + ) + }, "Voir l’état de la correction" to { startActivity( Intent( @@ -448,6 +456,26 @@ class MainActivity : } setContentView(scrollView) + + showSetupGuideIfNeeded() + } + + private fun showSetupGuideIfNeeded() { + val preferences = + SetupGuidePreferences(this) + + if (!preferences.shouldShowAutomatically()) { + return + } + + preferences.markShown() + + startActivity( + Intent( + this, + SetupGuideActivity::class.java, + ), + ) } private fun runSpellCheckerTest() { diff --git a/app/src/main/kotlin/fr/grammalecteandroid/unofficial/SetupGuideActivity.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/SetupGuideActivity.kt new file mode 100644 index 0000000..45f58db --- /dev/null +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/SetupGuideActivity.kt @@ -0,0 +1,509 @@ +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.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 SetupGuideActivity : 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), + ) + } + + content.addView( + TextView(this).apply { + text = "Bien démarrer avec Grammalecte" + textSize = 26f + + setTypeface( + typeface, + Typeface.BOLD, + ) + + setTextColor( + getColor(R.color.surface_text), + ) + }, + matchWrapParams(), + ) + + content.addView( + TextView(this).apply { + text = + "Grammalecte peut intervenir de trois façons. " + + "Vous n’avez pas besoin d’activer les trois modes." + + textSize = 15f + + setTextColor( + getColor(R.color.surface_text_secondary), + ) + + setPadding( + 0, + smallSpacing, + 0, + smallSpacing, + ) + }, + matchWrapParams(), + ) + + content.addView( + TextView(this).apply { + text = + "Pour commencer, activez de préférence le correcteur Android. " + + "Les deux autres modes servent de complément lorsque " + + "l’application utilisée ne prend pas correctement en charge " + + "la correction native." + + textSize = 14f + + setTextColor( + getColor(R.color.surface_text_secondary), + ) + + setPadding( + 0, + 0, + 0, + spacing, + ) + }, + matchWrapParams(), + ) + + spellCheckerStatusView = + statusTextView() + + content.addView( + createGuideCard( + number = "1", + title = "Correcteur Android", + description = + "Mode recommandé. Grammalecte devient le correcteur " + + "orthographique Android dans les applications compatibles.", + statusView = spellCheckerStatusView, + actions = + listOf( + "Configurer le correcteur Android" to { + openSpellCheckerSettings() + }, + ), + ), + cardParams(spacing), + ) + + content.addView( + createGuideCard( + number = "2", + title = "Corriger une sélection", + description = + "Aucune activation nécessaire. Sélectionnez du texte dans " + + "une application compatible, ouvrez le menu de sélection, " + + "puis choisissez « Corriger avec Grammalecte ».", + statusView = + TextView(this).apply { + text = "État : disponible sans activation." + textSize = 15f + + setTypeface( + typeface, + Typeface.BOLD, + ) + + setTextColor( + getColor(R.color.surface_text), + ) + }, + actions = emptyList(), + ), + cardParams(spacing), + ) + + imeStatusView = + statusTextView() + + content.addView( + createGuideCard( + number = "3", + title = "Clavier Grammalecte", + description = + "Mode de repli pour corriger directement une sélection " + + "dans les applications qui ne gèrent pas correctement " + + "le correcteur Android ou le retour de texte corrigé.", + 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 = + "Tout le traitement est effectué localement sur l’appareil. " + + "Grammalecte Android ne nécessite pas de connexion Internet." + + textSize = 14f + + setTextColor( + getColor(R.color.surface_text_secondary), + ) + + setPadding( + 0, + smallSpacing, + 0, + spacing, + ) + }, + matchWrapParams(), + ) + + content.addView( + Button(this).apply { + text = "Continuer vers l’application" + isAllCaps = false + + setOnClickListener { + finish() + } + }, + 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 : activé et sélectionné." + + SpellCheckerStatus.NOT_SELECTED -> + "État : disponible, mais non sélectionné." + + SpellCheckerStatus.DISABLED -> + "État : correction 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é." + } + } + + 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 createGuideCard( + number: String, + 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@SetupGuideActivity).apply { + text = "$number. $title" + textSize = 18f + + setTypeface( + typeface, + Typeface.BOLD, + ) + + setTextColor( + getColor(R.color.surface_text), + ) + }, + matchWrapParams(), + ) + + addView( + TextView(this@SetupGuideActivity).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@SetupGuideActivity).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/SetupGuidePreferences.kt b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/SetupGuidePreferences.kt new file mode 100644 index 0000000..ea5d567 --- /dev/null +++ b/app/src/main/kotlin/fr/grammalecteandroid/unofficial/SetupGuidePreferences.kt @@ -0,0 +1,36 @@ +package fr.grammalecteandroid.unofficial + +import android.content.Context + +class SetupGuidePreferences( + context: Context, +) { + private val preferences = + context.applicationContext.getSharedPreferences( + PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + + fun shouldShowAutomatically(): Boolean = + !preferences.getBoolean( + KEY_GUIDE_SHOWN_V1, + false, + ) + + fun markShown() { + preferences + .edit() + .putBoolean( + KEY_GUIDE_SHOWN_V1, + true, + ).apply() + } + + private companion object { + const val PREFERENCES_NAME = + "setup_guide" + + const val KEY_GUIDE_SHOWN_V1 = + "setup_guide_shown_v1" + } +}