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

Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<out Activity>): Boolean {
repeat(WAIT_ATTEMPTS) {
if (isActivityResumed(expected)) {
return true
}

SystemClock.sleep(
WAIT_DELAY_MILLIS,
)
}

return false
}

private fun isActivityResumed(expected: Class<out Activity>): 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<SetupGuideActivity>()
.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
}
}
Original file line number Diff line number Diff line change
@@ -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<Context>()

val activity =
context.packageManager.getActivityInfo(
ComponentName(
context,
SetupGuideActivity::class.java,
),
PackageManager.GET_META_DATA,
)

assertFalse(
"Setup guide activity must not be exported",
activity.exported,
)
}
}
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
android:name=".CorrectionStatusActivity"
android:exported="false" />

<activity
android:name=".SetupGuideActivity"
android:exported="false" />

<activity
android:name=".MainActivity"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading