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>
8.7 KiB
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
(
OpenMultipleDocumentsinui/compose/ComposeScreen.kt:88) and aFileProviderfor 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.ktqueriesContactsContract.CommonDataKinds.Emailfor 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, routeONBOARDING_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.searchreturns 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 viaVISIBILITY_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.ktNotificationPermissionEffect). If denied,MailNotifier.notifyNewMailno-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, manifestforegroundServiceType="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_SERVICEplus the typed permission are both declared and the service callsServiceCompat.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.