The READ_CONTACTS permission (used only for recipient autocomplete) was requested lazily on every compose-screen open via LaunchedEffect(Unit), re-prompting users who had declined without "don't ask again". This moves the request into onboarding and Settings, with a proper rationale, and lets users enable it later after declining — implementing three tightly-coupled tickets as one coherent change.
#127 — move the request to a skippable onboarding step
New ONBOARDING_CONTACTS step (ContactsAccessScreen), mirroring the existing BatteryOptimizationScreen pattern. Clearly skippable ("Not now"); the app is fully usable without it.
Shown once, only when the permission isn't already granted and the user hasn't handled it before (persisted contacts_prompt_handled flag, like the battery step).
ComposeScreen no longer prompts — it just reads the current grant on resume, so a grant made later (e.g. from Settings) takes effect the next time compose opens.
Chains cleanly with the battery step: add-another → (contacts) → (battery) → inbox, each shown only when needed.
The onboarding step and the Settings request both explain, up front, that contacts are used only for on-device recipient autocomplete and are never uploaded.
shouldShowRequestPermissionRationale is handled so a re-request explains itself (extra on-screen line in onboarding; the Settings dialog covers it too).
docs/play-permissions.md (#17) updated to describe the new request flow.
New Settings → Contacts → Recipient autocomplete entry. Its subtitle reflects the current state: on / off / blocked in system settings.
When grantable it requests in-app (after a short rationale dialog); when permanently denied it deep-links to the app's system settings with a brief explanation.
Distinguishing "never asked" from "permanently denied" uses a persisted contacts_permission_requested flag combined with the Activity rationale signal, resolved by a pure, unit-tested ContactPermissionDecision.
Graceful degradation preserved
No regression to the decline path: ContactsRepository.search still wraps its query in runCatching, ComposeViewModel.searchContacts() still guards on contactsAllowed, and the suggestion list still renders only when non-empty. Contacts remains fully optional.
Tests
JVM unit:ContactPermissionDecisionTest (all four state transitions), extended OnboardingViewModelTest for the contacts prompt decision, grant result, refresh, and the persisted flags (Turbine-style state + MockK).
Compose UI (androidTest):ContactsAccessStepTest drives the onboarding step's skip / grant / deny / rationale paths deterministically; ContactAutocompleteRowTest covers the Settings row's on/off/blocked states; SettingsScreenTest asserts the row is wired into the real screen. All instrumented @Tests use Unit-returning block bodies.
Verification
Fast CI gate run locally on JDK 21 (all green): assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, plus compileDebugAndroidTestKotlin. Emulator E2E left to CI.
Notes for the maintainer
Two new git-ignored-style onboarding flags live in the settings DataStore alongside the existing battery_prompt_handled (not user-facing AppSettings); no schema/DB or keystore changes.
ContactsPermissionManager mirrors BatteryOptimizationManager (Context-only); the Activity-scoped shouldShowRequestPermissionRationale is read in the Compose layer, keeping the manager unit-mockable.
## Summary
The `READ_CONTACTS` permission (used only for recipient autocomplete) was requested lazily on **every** compose-screen open via `LaunchedEffect(Unit)`, re-prompting users who had declined without "don't ask again". This moves the request into onboarding and Settings, with a proper rationale, and lets users enable it later after declining — implementing three tightly-coupled tickets as one coherent change.
### #127 — move the request to a skippable onboarding step
- New `ONBOARDING_CONTACTS` step (`ContactsAccessScreen`), mirroring the existing `BatteryOptimizationScreen` pattern. Clearly **skippable** ("Not now"); the app is fully usable without it.
- Shown **once**, only when the permission isn't already granted and the user hasn't handled it before (persisted `contacts_prompt_handled` flag, like the battery step).
- `ComposeScreen` no longer prompts — it just reads the current grant on resume, so a grant made later (e.g. from Settings) takes effect the next time compose opens.
- Chains cleanly with the battery step: add-another → (contacts) → (battery) → inbox, each shown only when needed.
### #128 — rationale before/with the request
- The onboarding step and the Settings request both explain, up front, that contacts are used **only** for on-device recipient autocomplete and are **never uploaded**.
- `shouldShowRequestPermissionRationale` is handled so a re-request explains itself (extra on-screen line in onboarding; the Settings dialog covers it too).
- `docs/play-permissions.md` (#17) updated to describe the new request flow.
### #129 — enable it later after declining
- New **Settings → Contacts → Recipient autocomplete** entry. Its subtitle reflects the current state: **on** / **off** / **blocked in system settings**.
- When grantable it requests **in-app** (after a short rationale dialog); when permanently denied it **deep-links** to the app's system settings with a brief explanation.
- Distinguishing "never asked" from "permanently denied" uses a persisted `contacts_permission_requested` flag combined with the Activity rationale signal, resolved by a pure, unit-tested `ContactPermissionDecision`.
## Graceful degradation preserved
No regression to the decline path: `ContactsRepository.search` still wraps its query in `runCatching`, `ComposeViewModel.searchContacts()` still guards on `contactsAllowed`, and the suggestion list still renders only when non-empty. Contacts remains fully optional.
## Tests
- **JVM unit:** `ContactPermissionDecisionTest` (all four state transitions), extended `OnboardingViewModelTest` for the contacts prompt decision, grant result, refresh, and the persisted flags (Turbine-style state + MockK).
- **Compose UI (androidTest):** `ContactsAccessStepTest` drives the onboarding step's skip / grant / deny / rationale paths deterministically; `ContactAutocompleteRowTest` covers the Settings row's on/off/blocked states; `SettingsScreenTest` asserts the row is wired into the real screen. All instrumented `@Test`s use Unit-returning block bodies.
## Verification
Fast CI gate run locally on JDK 21 (all green): `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, plus `compileDebugAndroidTestKotlin`. Emulator E2E left to CI.
## Notes for the maintainer
- Two new git-ignored-style onboarding flags live in the settings DataStore alongside the existing `battery_prompt_handled` (not user-facing `AppSettings`); no schema/DB or keystore changes.
- `ContactsPermissionManager` mirrors `BatteryOptimizationManager` (Context-only); the Activity-scoped `shouldShowRequestPermissionRationale` is read in the Compose layer, keeping the manager unit-mockable.
Closes #127
Closes #128
Closes #129
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
The
READ_CONTACTSpermission (used only for recipient autocomplete) was requested lazily on every compose-screen open viaLaunchedEffect(Unit), re-prompting users who had declined without "don't ask again". This moves the request into onboarding and Settings, with a proper rationale, and lets users enable it later after declining — implementing three tightly-coupled tickets as one coherent change.#127 — move the request to a skippable onboarding step
ONBOARDING_CONTACTSstep (ContactsAccessScreen), mirroring the existingBatteryOptimizationScreenpattern. Clearly skippable ("Not now"); the app is fully usable without it.contacts_prompt_handledflag, like the battery step).ComposeScreenno longer prompts — it just reads the current grant on resume, so a grant made later (e.g. from Settings) takes effect the next time compose opens.#128 — rationale before/with the request
shouldShowRequestPermissionRationaleis handled so a re-request explains itself (extra on-screen line in onboarding; the Settings dialog covers it too).docs/play-permissions.md(#17) updated to describe the new request flow.#129 — enable it later after declining
contacts_permission_requestedflag combined with the Activity rationale signal, resolved by a pure, unit-testedContactPermissionDecision.Graceful degradation preserved
No regression to the decline path:
ContactsRepository.searchstill wraps its query inrunCatching,ComposeViewModel.searchContacts()still guards oncontactsAllowed, and the suggestion list still renders only when non-empty. Contacts remains fully optional.Tests
ContactPermissionDecisionTest(all four state transitions), extendedOnboardingViewModelTestfor the contacts prompt decision, grant result, refresh, and the persisted flags (Turbine-style state + MockK).ContactsAccessStepTestdrives the onboarding step's skip / grant / deny / rationale paths deterministically;ContactAutocompleteRowTestcovers the Settings row's on/off/blocked states;SettingsScreenTestasserts the row is wired into the real screen. All instrumented@Tests use Unit-returning block bodies.Verification
Fast CI gate run locally on JDK 21 (all green):
assembleDebug,testDebugUnitTest,lintDebug,ktlintCheck,detekt, pluscompileDebugAndroidTestKotlin. Emulator E2E left to CI.Notes for the maintainer
battery_prompt_handled(not user-facingAppSettings); no schema/DB or keystore changes.ContactsPermissionManagermirrorsBatteryOptimizationManager(Context-only); the Activity-scopedshouldShowRequestPermissionRationaleis read in the Compose layer, keeping the manager unit-mockable.Closes #127
Closes #128
Closes #129
🤖 Generated with Claude Code