Files
LibreMail/docs/play-permissions.md
T
JMR-devandClaude Fable 5 d877f50fd9 feat(contacts): move contacts permission to onboarding + settings
Recipient autocomplete's READ_CONTACTS permission was requested lazily on
every compose-screen open (a LaunchedEffect(Unit)), re-prompting users who
had declined. Move the request to a dedicated, skippable onboarding step and
add a Settings entry to turn it on later, each with an in-context rationale.

- #127: new skippable ONBOARDING_CONTACTS step (mirrors the battery step),
  requested once. ComposeScreen no longer prompts; it only reads the current
  grant on resume, so a grant made later (e.g. from Settings) still takes
  effect the next time compose opens.
- #128: the onboarding step and the Settings request show a short rationale
  (contacts are used only for on-device autocomplete, never uploaded) and
  handle shouldShowRequestPermissionRationale so a re-request explains itself.
  docs/play-permissions.md updated to match.
- #129: Settings -> Contacts -> Recipient autocomplete reflects on / off /
  blocked-in-settings; requests in-app when grantable, deep-links to the app's
  system settings when permanently denied.

Graceful degradation is preserved: ContactsRepository.search still runCatch-es,
ComposeViewModel.searchContacts() still guards on contactsAllowed, and the
suggestion list still renders only when non-empty.

Adds a pure ContactPermissionDecision (JVM unit-tested), extends the onboarding
view-model tests, and adds Compose UI tests for the onboarding step
(skip / grant / deny / rationale) and the Settings row states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:37:40 -05:00

119 lines
8.7 KiB
Markdown

<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# Permissions justification — merged manifest audit (issue #17)
Every permission in the **merged release manifest** (source of truth:
`app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml`
after `./gradlew :app:bundleRelease`; attribution from
`app/build/outputs/logs/manifest-merger-release-report.txt`), why it exists, where it is used,
and the text to paste into Play Console where a declaration is required.
## Complete merged-manifest permission list
| Permission | Declared by | Runtime prompt? | Purpose |
|---|---|---|---|
| `INTERNET` | app manifest | No | IMAP/SMTP/OAuth/Graph connections to the user's mail provider |
| `ACCESS_NETWORK_STATE` | app manifest | No | Connectivity checks so sync/WorkManager runs only when online |
| `READ_CONTACTS` | app manifest | **Yes** | On-device recipient autocomplete in the compose screen |
| `POST_NOTIFICATIONS` | app manifest | **Yes** (API 33+) | New-mail notifications + mandatory foreground-service status notification |
| `FOREGROUND_SERVICE` | app manifest | No | Prerequisite for running any foreground service (API 28+) |
| `FOREGROUND_SERVICE_DATA_SYNC` | app manifest | No | Type-specific permission for the IMAP IDLE push service (API 34+) |
| `WAKE_LOCK` | `androidx.work:work-runtime:2.11.2` | No | WorkManager keeps the CPU awake while a scheduled job (mail sync, outbox send) runs |
| `RECEIVE_BOOT_COMPLETED` | `androidx.work:work-runtime:2.11.2` | No | WorkManager reschedules pending jobs (periodic sync, queued outbox mail) after reboot |
| `org.libremail.app.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` | `androidx.core:core:1.17.0` | No | Auto-generated app-signature permission guarding non-exported runtime receivers; not user-facing |
Nothing else. Notably **absent** (worth stating in any review exchange):
- **No `AD_ID`** — no ads or analytics SDKs at all.
- **No storage/media permissions** — attachments use the Storage Access Framework
(`OpenMultipleDocuments` in `ui/compose/ComposeScreen.kt:88`) and a `FileProvider` for viewing
(`AndroidManifest.xml:95`).
- **No `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`** — deliberately avoided because Play restricts
it; the app deep-links to the system app-details screen instead
(`push/BatteryOptimizationManager.kt`, comment cites this issue).
- No location, camera, microphone, SMS, call-log, accessibility, or `QUERY_ALL_PACKAGES`.
## `READ_CONTACTS` (Play "sensitive" permission — scrutinized, no declaration form)
- **Feature:** recipient autocomplete while composing. `contacts/ContactsRepository.kt` queries
`ContactsContract.CommonDataKinds.Email` for at most 8 name/email matches of the typed text.
- **Data handling:** query and results are entirely **on-device** (results live in memory for
the suggestion dropdown). Nothing from the contacts provider is stored, logged, or
transmitted; an address reaches the network only if the user puts it on an email they send.
- **Request flow:** a dedicated, skippable **onboarding step** (`ui/onboarding/ContactsAccessScreen.kt`,
route `ONBOARDING_CONTACTS`) requests it **once**, showing an in-context rationale up front —
contacts are used only for on-device autocomplete and never uploaded (#127, #128). The compose
screen no longer prompts; it only reads the current grant. If declined, recipient autocomplete can
be enabled later from **Settings → Contacts → Recipient autocomplete** (`ui/settings/SettingsScreen.kt`),
which re-requests in-app when possible or deep-links to the app's system settings when the
permission is permanently denied (#129). Denial is handled gracefully throughout —
`ContactsRepository.search` returns empty and composing works normally (manual address entry).
- **Play-Console justification text (if asked in review):**
> LibreMail is an email client. READ_CONTACTS powers recipient autocomplete on the compose
> screen only: the app queries the on-device contacts provider for names/email addresses
> matching what the user typed and shows up to 8 suggestions. Contact data is processed
> entirely on the device — it is never uploaded, stored outside the suggestion list, or shared.
> The permission is requested once, in context, from a skippable onboarding step that explains
> the on-device autocomplete use before asking (and can be enabled later from Settings); the
> feature degrades gracefully if denied.
## `POST_NOTIFICATIONS`
- **Features:** (1) per-account new-mail notifications, generated on-device from synced mail —
`notifications/MailNotifier.kt` (no push/cloud-messaging service; lock-screen content
redacted via `VISIBILITY_PRIVATE`); (2) the persistent low-importance status notification
Android requires while the IMAP IDLE foreground service runs (`push/IdleService.kt:120`).
- **Request flow:** once at first launch, API 33+ only (`MainActivity.kt`
`NotificationPermissionEffect`). If denied, `MailNotifier.notifyNewMail` no-ops (permission
re-checked before every post, `MailNotifier.kt:134`); mail sync itself is unaffected.
- **Play-Console justification text (if asked):**
> Notifies the user of newly received email (per-account channels, generated on the device
> from the user's own mailbox — no push service) and shows the persistent status notification
> Android requires for the optional foreground IMAP IDLE connection. Requested once at first
> launch; all app functions except notifications work if declined.
## `FOREGROUND_SERVICE_DATA_SYNC` (requires the Play Console FGS declaration)
Play Console → App content → **Foreground service permissions** asks for the type's use case
and a demo video. Facts to declare, all verifiable in `push/IdleService.kt`:
- **What runs:** one foreground service (`.push.IdleService`, manifest
`foregroundServiceType="dataSync"`, `AndroidManifest.xml:89`) holding a long-lived IMAP IDLE
(RFC 2177) connection per configured account so the user's own mail server can push new mail
instantly. On server activity it triggers a normal sync into the local cache and a new-mail
notification.
- **Why a foreground service:** IMAP IDLE requires a continuously open TCP connection that
survives while the app is backgrounded; it cannot be modeled as deferrable work. WorkManager
**is** used for everything deferrable (periodic sync, outbox sending) — the service exists
only for the always-connected push case. There is no push-notification alternative (FCM)
because plain IMAP servers cannot address one, and the app deliberately uses no Google cloud
services.
- **User control / lifecycle:** starts only when at least one account exists **and** the "push
mail" setting is enabled (`LibreMailApplication.kt:70`); the setting is a visible toggle in
Settings (`ui/settings/SettingsScreen.kt:147`); the service stops reactively when the toggle
turns off or the last account is removed, and shows a persistent low-importance status
notification while running (`IdleService.startAsForeground`).
- **Declaration text to paste:**
> LibreMail is an email client. The dataSync foreground service maintains a long-lived IMAP
> IDLE (RFC 2177) connection to the user's own mail server so new mail arrives instantly.
> IMAP has no out-of-band push channel (such as FCM), so real-time delivery requires keeping
> this user-visible connection open; all deferrable transfers (periodic sync, sending queued
> mail) already use WorkManager instead. The service runs only while the user has an account
> configured and the "push mail" setting enabled, displays a persistent status notification,
> and stops immediately when the user disables the setting or removes their last account.
- **Demo video (human step):** screen-record: Settings → toggle "push mail" on → the status
notification appears → send the account a mail from elsewhere → the new-mail notification
arrives with the app backgrounded → toggle off → status notification disappears.
- **Note:** the app targets SDK 37, so the API-34 requirement to declare a type for every FGS
is in force; `FOREGROUND_SERVICE` plus the typed permission are both declared and the service
calls `ServiceCompat.startForeground(..., FOREGROUND_SERVICE_TYPE_DATA_SYNC)`
(`IdleService.kt:136`).
## Library-injected permissions (`WAKE_LOCK`, `RECEIVE_BOOT_COMPLETED`)
Injected by `androidx.work:work-runtime:2.11.2` for its own machinery: holding a partial wake
lock while an enqueued job executes, and re-registering scheduled jobs after a reboot. LibreMail
uses WorkManager for periodic mail sync (`data/sync/` workers), reliable outbox sending
(`SendWorker.kt`), and the opt-in debug-report upload (`ReportUploadWorker.kt`, dormant —
endpoint unconfigured). Neither permission needs a Play declaration; keep this attribution handy
for review questions.