feat(contacts): move contacts permission to onboarding + settings #132

Merged
JMR-dev merged 4 commits from feat-contacts-permission-onboarding into main 2026-07-02 15:40:26 +00:00
JMR-dev commented 2026-07-02 14:39:40 +00:00 (Migrated from github.com)

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 @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.

Closes #127
Closes #128
Closes #129

🤖 Generated with Claude Code

## 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)
Sign in to join this conversation.