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>
This commit is contained in:
2026-07-02 09:37:40 -05:00
co-authored by Claude Fable 5
parent 86e80a41fc
commit d877f50fd9
20 changed files with 871 additions and 43 deletions
@@ -67,8 +67,9 @@ class ComposeScreenTest {
@Before
fun grantContactsPermission() {
// ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test
// calls setContent) so no system permission dialog appears to block the headless run.
// ComposeScreen no longer requests READ_CONTACTS (the request moved to onboarding/#127); it
// only reads the current grant on resume. Pre-grant it (before setContent) so contactsAllowed
// resolves true and the autocomplete path stays exercised — no system dialog is ever shown.
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.uiAutomation.grantRuntimePermission(
instrumentation.targetContext.packageName,
@@ -30,6 +30,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import org.libremail.ui.navigation.Routes
@@ -70,7 +71,11 @@ class BatteryOptimizationStepTest {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
settingsRepository = SettingsRepository(context)
runBlocking { settingsRepository.setBatteryPromptHandled(handled) }
onboarding = OnboardingViewModel(BatteryOptimizationManager(context), settingsRepository)
onboarding = OnboardingViewModel(
BatteryOptimizationManager(context),
ContactsPermissionManager(context),
settingsRepository,
)
onboarding.onAccountAdded(FIRST_ACCOUNT_ID)
composeTestRule.setContent {
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the onboarding contacts-access step (#127, #128). They drive the presentational
* [ContactsAccessContent] with explicit signals so the three paths — skip, grant (the "done" state),
* and request (with the re-ask rationale) — run deterministically without a live system permission
* dialog (whose grant state would otherwise leak across the shared instrumentation process).
*/
@RunWith(AndroidJUnit4::class)
class ContactsAccessStepTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(
granted: Boolean,
showRationale: Boolean = false,
onAllow: () -> Unit = {},
onSkip: () -> Unit = {},
onContinue: () -> Unit = {},
) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ContactsAccessContent(
granted = granted,
showRationale = showRationale,
onAllow = onAllow,
onSkip = onSkip,
onContinue = onContinue,
)
}
}
}
@Test
fun notGranted_notNow_skipsTheStep() {
var skipped = false
var allowed = false
setContent(granted = false, onAllow = { allowed = true }, onSkip = { skipped = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).performClick()
assertTrue("Not now must invoke the skip callback", skipped)
assertFalse("Skipping must not request the permission", allowed)
}
@Test
fun notGranted_allow_triggersTheRequest() {
var allowed = false
setContent(granted = false, onAllow = { allowed = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).performClick()
assertTrue("Allow must trigger the permission request", allowed)
}
@Test
fun granted_showsDoneState_andContinues() {
var continued = false
setContent(granted = true, onContinue = { continued = true })
// The "done" copy is shown and the request/skip buttons are gone.
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_done_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_not_now)).assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_continue)).performClick()
assertTrue("Continue must invoke the continue callback", continued)
}
@Test
fun reRequest_showsRationale() {
setContent(granted = false, showRationale = true)
// A re-request explains itself (shouldShowRequestPermissionRationale handling, #128).
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_rationale)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_contacts_allow)).assertIsDisplayed()
}
}
@@ -23,6 +23,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.auth.OutlookAuthManager
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Message
import org.libremail.push.BatteryOptimizationManager
@@ -85,6 +86,7 @@ class OnboardingFlowTest {
val appContext = composeTestRule.activity.applicationContext
val onboarding = OnboardingViewModel(
BatteryOptimizationManager(appContext),
ContactsPermissionManager(appContext),
SettingsRepository(appContext),
)
composeTestRule.setContent {
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactPermissionState
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the Settings contacts-autocomplete row (#129). The row is presentational, so each of
* its three states — on / off / blocked-in-settings — is driven directly and asserted deterministically,
* independent of the process's real `READ_CONTACTS` grant.
*/
@RunWith(AndroidJUnit4::class)
class ContactAutocompleteRowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(state: ContactPermissionState, onClick: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ContactAutocompleteRow(state = state, onClick = onClick)
}
}
}
@Test
fun granted_showsOnSubtitle_andIsClickable() {
var clicked = false
setContent(ContactPermissionState.GRANTED) { clicked = true }
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_on)).performClick()
assertTrue("Tapping the row must invoke onClick", clicked)
}
@Test
fun denied_showsOffSubtitle() {
setContent(ContactPermissionState.DENIED)
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_off)).assertIsDisplayed()
}
@Test
fun blocked_showsBlockedSubtitle() {
setContent(ContactPermissionState.BLOCKED)
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete_blocked)).assertIsDisplayed()
}
}
@@ -2,6 +2,7 @@
package org.libremail.ui.settings
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
@@ -15,6 +16,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.security.AppLockManager
import org.libremail.data.security.DatabaseKeyCipher
import org.libremail.data.security.DatabaseKeyStore
@@ -57,6 +59,7 @@ class SettingsScreenTest {
insecureDevice,
keyStore,
BatteryOptimizationManager(context),
ContactsPermissionManager(context),
SyncScheduler(Provider { WorkManager.getInstance(context) }),
)
}
@@ -88,6 +91,16 @@ class SettingsScreenTest {
}
}
@Test
fun contactsAutocompleteRow_isShown() {
// The contacts entry (#129) is wired into the real screen; it reflects the live permission
// state, so we assert only that the row is present (state-specific rendering is covered by
// ContactAutocompleteRowTest).
setContent(settingsViewModel(SettingsRepository(context)))
composeTestRule.onNodeWithText(string(R.string.settings_contacts_autocomplete))
.performScrollTo().assertIsDisplayed()
}
@Test
fun enablingAppLockWithoutSecureDevice_showsRejectionSnackbar() {
val settingsRepository = SettingsRepository(context)
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
/**
* Where the optional contacts-autocomplete permission stands, as the Settings entry (#129) shows it.
* - [GRANTED]: on — recipient autocomplete works.
* - [DENIED]: off but re-requestable in-app (never asked, or denied once without "don't ask again").
* - [BLOCKED]: off and no longer re-requestable — the only way back is the system settings screen.
*/
enum class ContactPermissionState { GRANTED, DENIED, BLOCKED }
/**
* Pure mapping from the three Android permission signals to a [ContactPermissionState]. Kept free of
* Android types so it is exhaustively unit-testable; the live inputs are read by
* [ContactsPermissionManager] (grant), the Activity (`shouldShowRequestPermissionRationale`), and
* [org.libremail.data.settings.SettingsRepository] (whether the system dialog has ever been shown).
*/
object ContactPermissionDecision {
/**
* Resolve the current state:
* - [granted]: `READ_CONTACTS` is held → [ContactPermissionState.GRANTED].
* - [showRationale]: the OS says a rationale should precede a re-request, i.e. the user denied
* once without "don't ask again" → still re-requestable, [ContactPermissionState.DENIED].
* - [alreadyRequested]: the system dialog has been shown before. Combined with `!showRationale`
* (and not granted) this is the permanently-denied case → [ContactPermissionState.BLOCKED].
*
* The remaining case — not granted, no rationale, never requested — is a fresh install that has
* simply never asked, so an in-app request will still surface the dialog: [ContactPermissionState.DENIED].
*/
fun resolve(granted: Boolean, showRationale: Boolean, alreadyRequested: Boolean): ContactPermissionState = when {
granted -> ContactPermissionState.GRANTED
showRationale -> ContactPermissionState.DENIED
alreadyRequested -> ContactPermissionState.BLOCKED
else -> ContactPermissionState.DENIED
}
}
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads this app's `READ_CONTACTS` grant and deep-links to the system screen where it can be changed.
* `READ_CONTACTS` powers recipient autocomplete only (see [ContactsRepository]); the whole feature is
* optional and degrades gracefully when the permission is absent.
*
* Deliberately Context-only so it can back both the onboarding opt-in step and the Settings entry.
* The `shouldShowRequestPermissionRationale` signal needs an Activity, so it is read in the Compose
* layer and combined with [ContactPermissionDecision]; this manager stays free of Activity state.
*/
@Singleton
class ContactsPermissionManager @Inject constructor(@ApplicationContext private val context: Context) {
/** True when `READ_CONTACTS` is currently granted to this app. */
fun hasPermission(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/**
* Intent to this app's system details screen, where **Permissions → Contacts** can be toggled.
* Used to recover the permanently-denied ("Don't allow" / don't-ask-again) case, which can no
* longer be re-requested in-app. Always resolvable since API 9.
*/
fun settingsIntent(): Intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
}
@@ -72,6 +72,8 @@ private object Keys {
val RETENTION_COUNT = intPreferencesKey("retention_count")
val RETENTION_MONTHS = intPreferencesKey("retention_months")
val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled")
val CONTACTS_PROMPT_HANDLED = booleanPreferencesKey("contacts_prompt_handled")
val CONTACTS_PERMISSION_REQUESTED = booleanPreferencesKey("contacts_permission_requested")
}
/**
@@ -118,6 +120,29 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
suspend fun setBatteryPromptHandled(value: Boolean) = put(Keys.BATTERY_PROMPT_HANDLED, value)
/**
* One-time onboarding flag: whether the user has already seen/acted on the "contacts access"
* opt-in step, so onboarding offers it at most once (see #127). Like [isBatteryPromptHandled] this
* is internal onboarding state, not a user-facing preference — the Settings contacts entry (#129)
* is the way to enable autocomplete later.
*/
suspend fun isContactsPromptHandled(): Boolean =
context.settingsDataStore.data.map { it[Keys.CONTACTS_PROMPT_HANDLED] ?: false }.first()
suspend fun setContactsPromptHandled(value: Boolean) = put(Keys.CONTACTS_PROMPT_HANDLED, value)
/**
* Whether the `READ_CONTACTS` system dialog has ever actually been shown (from the onboarding step
* or the Settings entry). It is the only reliable signal — combined with the Activity's
* `shouldShowRequestPermissionRationale` — that separates "never asked yet" from "permanently
* denied", so the Settings entry (#129) can offer an in-app request versus a deep-link to system
* settings. See [ContactPermissionDecision][org.libremail.contacts.ContactPermissionDecision].
*/
val contactsPermissionRequested: Flow<Boolean> =
context.settingsDataStore.data.map { it[Keys.CONTACTS_PERMISSION_REQUESTED] ?: false }
suspend fun setContactsPermissionRequested(value: Boolean) = put(Keys.CONTACTS_PERMISSION_REQUESTED, value)
suspend fun setDynamicColor(value: Boolean) = put(Keys.DYNAMIC_COLOR, value)
suspend fun setNewMailNotifications(value: Boolean) = put(Keys.NEW_MAIL_NOTIFICATIONS, value)
suspend fun setPushIdle(value: Boolean) = put(Keys.PUSH_IDLE, value)
@@ -37,6 +37,7 @@ import org.libremail.ui.mailbox.MailboxScreen
import org.libremail.ui.navigation.Routes
import org.libremail.ui.onboarding.AddAnotherAccountScreen
import org.libremail.ui.onboarding.BatteryOptimizationScreen
import org.libremail.ui.onboarding.ContactsAccessScreen
import org.libremail.ui.onboarding.OnboardingViewModel
import org.libremail.ui.onboarding.OnboardingWelcomeScreen
import org.libremail.ui.outbox.OutboxScreen
@@ -327,12 +328,15 @@ private fun NavGraphBuilder.onboardingGraph(navController: NavHostController) {
}
/**
* The tail of onboarding: the "add another?" prompt and the optional battery opt-in step. Split out of
* [onboardingGraph] so each stays a readable length; both share the graph-scoped [OnboardingViewModel].
* The tail of onboarding: the "add another?" prompt and the optional contacts + battery opt-in steps.
* Split out of [onboardingGraph] so each stays a readable length; all share the graph-scoped
* [OnboardingViewModel]. The optional steps chain — contacts (#127) then battery (#49) — each shown
* only when needed; any that isn't is skipped, and a still-undecided (null) decision fails open.
*/
private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostController) {
composable(Routes.ONBOARDING_ADD_ANOTHER) { entry ->
val onboarding = onboardingViewModel(navController, entry)
val contactsPromptNeeded by onboarding.contactsPromptNeeded.collectAsStateWithLifecycle()
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
AddAnotherAccountScreen(
onAddAnother = {
@@ -341,14 +345,18 @@ private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostC
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
}
},
onFinish = { navController.advanceOnboarding(onboarding, contactsPromptNeeded, batteryPromptNeeded) },
)
}
composable(Routes.ONBOARDING_CONTACTS) { entry ->
val onboarding = onboardingViewModel(navController, entry)
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
ContactsAccessScreen(
viewModel = onboarding,
onFinish = {
// Offer the battery opt-in as a final step when it's needed; otherwise go straight to
// the inbox. A still-undecided (null) decision fails open to finishing.
if (batteryPromptNeeded == true) {
navController.navigate(Routes.ONBOARDING_BATTERY)
} else {
navController.finishOnboarding(onboarding.firstAddedAccountId)
}
onboarding.markContactsPromptHandled()
// Contacts is skipped here (it was the step just shown); only battery may remain.
navController.advanceOnboarding(onboarding, contactsPromptNeeded = false, batteryPromptNeeded)
},
)
}
@@ -364,6 +372,23 @@ private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostC
}
}
/**
* Advances through the optional onboarding tail: the next still-needed opt-in step (contacts, then
* battery), or the inbox once none remain. Each `*PromptNeeded` is the graph-scoped decision; `null`
* (undecided) is treated as "not needed" so a slow read never blocks the end of onboarding.
*/
private fun NavHostController.advanceOnboarding(
onboarding: OnboardingViewModel,
contactsPromptNeeded: Boolean?,
batteryPromptNeeded: Boolean?,
) {
when {
contactsPromptNeeded == true -> navigate(Routes.ONBOARDING_CONTACTS)
batteryPromptNeeded == true -> navigate(Routes.ONBOARDING_BATTERY)
else -> finishOnboarding(onboarding.firstAddedAccountId)
}
}
/** Leaves onboarding for the inbox — the first account added this session, or the unfiltered mailbox. */
private fun NavController.finishOnboarding(firstAccountId: String?) {
val dest = if (firstAccountId != null) Routes.mailboxForAccount(firstAccountId) else Routes.MAILBOX
@@ -67,6 +67,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collect
import org.libremail.R
@@ -81,10 +83,6 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> viewModel.onContactsPermission(granted) }
val attachmentPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
@@ -98,16 +96,15 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
)
}
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
viewModel.onContactsPermission(
true,
)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
// Reflect the current READ_CONTACTS grant without ever prompting: the request now lives in the
// onboarding contacts step (#127) and the Settings entry (#129), so compose only reads state.
// Re-checked on resume so enabling autocomplete later (e.g. from Settings) takes effect the next
// time compose is shown. Denial degrades gracefully — searchContacts() guards on this flag.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.onContactsPermission(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED,
)
}
LaunchedEffect(Unit) { viewModel.finished.collect { onBack() } }
BackHandler { viewModel.onExit() }
@@ -36,6 +36,11 @@ object Routes {
const val ONBOARDING_MANUAL = "onboarding/manual"
const val ONBOARDING_ADD_ANOTHER = "onboarding/add_another"
// Optional onboarding step: invites the user to allow contacts access for on-device recipient
// autocomplete (#127). Shown only when the permission isn't already granted and the user hasn't
// handled it before; skippable, and precedes the battery step in the finish tail.
const val ONBOARDING_CONTACTS = "onboarding/contacts"
// Optional final onboarding step: invites the user to allow unrestricted background/battery usage
// so push (IMAP IDLE) and periodic sync aren't throttled by Doze (#49). Shown only when the app
// isn't already exempt and the user hasn't handled it before; otherwise onboarding skips straight
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.Manifest
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
/**
* Optional onboarding step (shown only when needed, see [OnboardingViewModel.contactsPromptNeeded]):
* invites the user to allow contacts access for on-device recipient autocomplete. The rationale is
* on-screen up front — contacts are used **only** for suggesting recipients while composing and are
* never uploaded (#128) — and the step is clearly skippable (#127): **Not now** proceeds without it.
*
* The `READ_CONTACTS` request fires **once**, from here — the compose screen no longer prompts. After
* a grant the screen shows a "done" state; a later change of heart is handled by the Settings entry
* (#129). On returning from anywhere the grant is re-read so the screen reflects the current state.
*
* @param viewModel the graph-scoped onboarding view model (holds the live grant + the handled flag).
* @param onFinish leaves the step for the next destination; the caller also marks the prompt handled.
*/
@Composable
fun ContactsAccessScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) {
val granted by viewModel.contactsGranted.collectAsStateWithLifecycle()
val activity = LocalActivity.current
var showRationale by remember { mutableStateOf(false) }
fun refreshRationale() {
showRationale = activity != null &&
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.READ_CONTACTS)
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { result ->
viewModel.onContactsPermissionResult(result)
refreshRationale()
}
// Re-check the grant (and whether a rationale is now owed) on resume so a change made elsewhere —
// e.g. the user granted from system settings — is reflected when this step comes back to the fore.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refreshContactsStatus()
refreshRationale()
}
ContactsAccessContent(
granted = granted,
showRationale = showRationale,
onAllow = {
// Persist "the dialog was shown" up front so a permanent denial is later distinguishable
// from "never asked" in Settings, even if the process dies before the result arrives.
viewModel.markContactsPermissionRequested()
launcher.launch(Manifest.permission.READ_CONTACTS)
},
onSkip = onFinish,
onContinue = onFinish,
)
}
/**
* Presentational body of the contacts-access step, split out so its three paths — skip, grant (the
* "done" state), and request (with the [showRationale] re-ask explanation) — are driven deterministically
* in tests without a live system permission dialog.
*/
@Composable
fun ContactsAccessContent(
granted: Boolean,
showRationale: Boolean,
onAllow: () -> Unit,
onSkip: () -> Unit,
onContinue: () -> Unit,
) {
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = if (granted) Icons.Filled.CheckCircle else Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(
if (granted) R.string.onboarding_contacts_done_title else R.string.onboarding_contacts_title,
),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(
if (granted) R.string.onboarding_contacts_done_body else R.string.onboarding_contacts_body,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
if (granted) {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_contacts_continue))
}
} else {
if (showRationale) {
Text(
text = stringResource(R.string.onboarding_contacts_rationale),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
}
Button(
onClick = onAllow,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_contacts_allow))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_contacts_not_now))
}
}
}
}
}
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import org.libremail.push.BatteryPromptDecision
@@ -19,11 +20,13 @@ import javax.inject.Inject
* entry, so it is created when onboarding starts and cleared when the graph is popped.
*
* It remembers the **first** account added this session (so finishing opens that account's inbox, see
* #30) and decides whether to show the "unrestricted battery" opt-in step before finishing (see #49).
* #30) and decides which optional opt-in steps to show before finishing: the "contacts access" step
* for recipient autocomplete (#127) and the "unrestricted battery" step for instant push (#49).
*/
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val batteryOptimizationManager: BatteryOptimizationManager,
private val contactsPermissionManager: ContactsPermissionManager,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
@@ -45,6 +48,20 @@ class OnboardingViewModel @Inject constructor(
/** Live "Unrestricted" status, re-read when the opt-in step resumes (e.g. back from Settings). */
val batteryUnrestricted: StateFlow<Boolean> = _batteryUnrestricted.asStateFlow()
private val _contactsPromptNeeded = MutableStateFlow<Boolean?>(null)
/**
* Whether onboarding should show the optional contacts-access step. `null` until decided; like
* [batteryPromptNeeded] the finish path treats `null` as "skip". Offered only when the permission
* isn't already granted and the user hasn't already handled the step on a previous onboarding run.
*/
val contactsPromptNeeded: StateFlow<Boolean?> = _contactsPromptNeeded.asStateFlow()
private val _contactsGranted = MutableStateFlow(contactsPermissionManager.hasPermission())
/** Live `READ_CONTACTS` grant, re-read when the contacts step resumes and after a request result. */
val contactsGranted: StateFlow<Boolean> = _contactsGranted.asStateFlow()
init {
viewModelScope.launch {
val unrestricted = batteryOptimizationManager.isIgnoringBatteryOptimizations()
@@ -55,6 +72,11 @@ class OnboardingViewModel @Inject constructor(
alreadyHandled = settingsRepository.isBatteryPromptHandled(),
)
}
viewModelScope.launch {
_contactsPromptNeeded.value =
!contactsPermissionManager.hasPermission() &&
!settingsRepository.isContactsPromptHandled()
}
}
/** Records a freshly added account. Only the first one sticks — later adds don't overwrite it. */
@@ -78,4 +100,27 @@ class OnboardingViewModel @Inject constructor(
fun markBatteryPromptHandled() {
viewModelScope.launch { settingsRepository.setBatteryPromptHandled(true) }
}
/** Re-read the live `READ_CONTACTS` grant; call when the contacts step resumes. */
fun refreshContactsStatus() {
_contactsGranted.value = contactsPermissionManager.hasPermission()
}
/** Fold the result of the system contacts-permission dialog back into [contactsGranted]. */
fun onContactsPermissionResult(granted: Boolean) {
_contactsGranted.value = granted
}
/**
* Record that the `READ_CONTACTS` system dialog is about to be (or has been) shown, so a later
* permanent denial is distinguishable from "never asked" in Settings (#129). Call before launching.
*/
fun markContactsPermissionRequested() {
viewModelScope.launch { settingsRepository.setContactsPermissionRequested(true) }
}
/** Record that the user has seen/acted on the contacts opt-in so onboarding won't ask again. */
fun markContactsPromptHandled() {
viewModelScope.launch { settingsRepository.setContactsPromptHandled(true) }
}
}
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.Manifest
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -12,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -20,11 +25,14 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -32,11 +40,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.contacts.ContactPermissionDecision
import org.libremail.contacts.ContactPermissionState
import org.libremail.data.settings.FetchPolicy
import org.libremail.ui.LibreMailBottomBar
import org.libremail.ui.TopDest
@@ -56,9 +67,28 @@ fun SettingsScreen(
val appLockMessage by viewModel.appLockMessage.collectAsStateWithLifecycle()
val batteryUnrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
val context = LocalContext.current
val activity = LocalActivity.current
val resources = LocalResources.current
val snackbarHostState = remember { SnackbarHostState() }
// Contacts-autocomplete entry (#129): its on / off / blocked-in-settings state is derived from the
// live grant, the Activity's rationale signal, and whether the dialog was ever shown — recomputed
// on resume (e.g. back from system settings) and when the "requested" flag flips.
val contactsRequested by viewModel.contactsPermissionRequested.collectAsStateWithLifecycle()
var contactsState by remember { mutableStateOf(ContactPermissionState.DENIED) }
var showContactsRationale by remember { mutableStateOf(false) }
var showContactsBlocked by remember { mutableStateOf(false) }
fun resolveContactsState() = ContactPermissionDecision.resolve(
granted = viewModel.hasContactsPermission(),
showRationale = activity != null &&
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.READ_CONTACTS),
alreadyRequested = contactsRequested,
)
val contactsPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { contactsState = resolveContactsState() }
LaunchedEffect(contactsRequested) { contactsState = resolveContactsState() }
// Surface a rejected app-lock toggle via the canonical snackbar pattern (matches MailboxScreen).
// The ViewModel holds the @StringRes id; resolve it here via LocalResources (so it re-resolves on
// configuration changes) at the display boundary, then clear it.
@@ -69,8 +99,11 @@ fun SettingsScreen(
}
}
// Re-read the battery status on resume so it reflects any change made in system settings.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
// Re-read the battery + contacts state on resume so both reflect changes made in system settings.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refreshBatteryStatus()
contactsState = resolveContactsState()
}
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) },
@@ -108,6 +141,23 @@ fun SettingsScreen(
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_contacts))
ContactAutocompleteRow(
state = contactsState,
onClick = {
when (contactsState) {
// Already on: send to system settings, the only place to turn it back off.
ContactPermissionState.GRANTED ->
runCatching { context.startActivity(viewModel.contactsSettingsIntent()) }
// Re-requestable in-app: explain first (#128), then launch the system dialog.
ContactPermissionState.DENIED -> showContactsRationale = true
// Permanently denied: an in-app request is a no-op, so deep-link to settings.
ContactPermissionState.BLOCKED -> showContactsBlocked = true
}
},
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_appearance))
SwitchRow(
title = stringResource(R.string.settings_dynamic_color),
@@ -211,6 +261,69 @@ fun SettingsScreen(
}
}
}
if (showContactsRationale) {
ContactsPermissionDialog(
title = stringResource(R.string.settings_contacts_dialog_title),
body = stringResource(R.string.settings_contacts_rationale),
confirm = stringResource(R.string.settings_contacts_allow),
onConfirm = {
showContactsRationale = false
// Mark the dialog as shown BEFORE launching, so a permanent denial reads as "blocked".
viewModel.markContactsPermissionRequested()
contactsPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
},
onDismiss = { showContactsRationale = false },
)
}
if (showContactsBlocked) {
ContactsPermissionDialog(
title = stringResource(R.string.settings_contacts_dialog_title),
body = stringResource(R.string.settings_contacts_blocked_body),
confirm = stringResource(R.string.settings_contacts_open_settings),
onConfirm = {
showContactsBlocked = false
runCatching { context.startActivity(viewModel.contactsSettingsIntent()) }
},
onDismiss = { showContactsBlocked = false },
)
}
}
/**
* The contacts-autocomplete row (#129). Its subtitle reflects the current [state]: on, off (tap to
* turn on), or blocked in system settings. Extracted so each state renders deterministically in tests.
*/
@Composable
internal fun ContactAutocompleteRow(state: ContactPermissionState, onClick: () -> Unit) {
val subtitleRes = when (state) {
ContactPermissionState.GRANTED -> R.string.settings_contacts_autocomplete_on
ContactPermissionState.DENIED -> R.string.settings_contacts_autocomplete_off
ContactPermissionState.BLOCKED -> R.string.settings_contacts_autocomplete_blocked
}
ClickRow(
title = stringResource(R.string.settings_contacts_autocomplete),
subtitle = stringResource(subtitleRes),
onClick = onClick,
)
}
/** Shared confirm/cancel dialog for the contacts rationale (before a request) and the blocked case. */
@Composable
private fun ContactsPermissionDialog(
title: String,
body: String,
confirm: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(body) },
confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@Composable
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.R
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.security.AppLockManager
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.AppSettings
@@ -31,6 +32,7 @@ class SettingsViewModel @Inject constructor(
private val appLockManager: AppLockManager,
private val databaseKeyStore: DatabaseKeyStore,
private val batteryOptimizationManager: BatteryOptimizationManager,
private val contactsPermissionManager: ContactsPermissionManager,
private val syncScheduler: SyncScheduler,
) : ViewModel() {
@@ -40,6 +42,14 @@ class SettingsViewModel @Inject constructor(
val settings: StateFlow<AppSettings> = settingsRepository.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings())
/**
* Whether the `READ_CONTACTS` system dialog has ever been shown, so the contacts entry (#129) can
* tell "never asked" (an in-app request still works) from "permanently denied" (Settings only).
* See [ContactPermissionDecision][org.libremail.contacts.ContactPermissionDecision].
*/
val contactsPermissionRequested: StateFlow<Boolean> = settingsRepository.contactsPermissionRequested
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val _advancedExpanded = MutableStateFlow(false)
val advancedExpanded: StateFlow<Boolean> = _advancedExpanded.asStateFlow()
@@ -62,6 +72,15 @@ class SettingsViewModel @Inject constructor(
/** Intent to the system screen where the user flips this app to "Unrestricted". */
fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
/** Whether `READ_CONTACTS` is currently granted (drives the contacts-autocomplete row's state). */
fun hasContactsPermission(): Boolean = contactsPermissionManager.hasPermission()
/** Intent to this app's system details screen, to enable contacts when it's permanently denied. */
fun contactsSettingsIntent(): Intent = contactsPermissionManager.settingsIntent()
/** Persist that the contacts dialog is being shown, so a later denial reads as "blocked", not "off". */
fun markContactsPermissionRequested() = update { settingsRepository.setContactsPermissionRequested(true) }
fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) }
fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) }
fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) }
+22
View File
@@ -151,6 +151,16 @@
<string name="onboarding_battery_done_body">Background usage is unrestricted — new mail will arrive instantly.</string>
<string name="onboarding_battery_continue">Continue to inbox</string>
<!-- Onboarding: optional contacts-access opt-in for recipient autocomplete (#127, #128) -->
<string name="onboarding_contacts_title">Suggest recipients as you type</string>
<string name="onboarding_contacts_body">Allow access to your contacts and LibreMail will suggest matching names and email addresses while you compose. This happens entirely on your device — your contacts are never uploaded or shared. It\'s optional; you can skip it and enter addresses yourself.</string>
<string name="onboarding_contacts_rationale">LibreMail needs the Contacts permission to suggest recipients. It\'s used only for on-device autocomplete — nothing is uploaded.</string>
<string name="onboarding_contacts_allow">Allow contacts access</string>
<string name="onboarding_contacts_not_now">Not now</string>
<string name="onboarding_contacts_done_title">Autocomplete is on</string>
<string name="onboarding_contacts_done_body">LibreMail will suggest recipients from your contacts as you compose — all on this device.</string>
<string name="onboarding_contacts_continue">Continue</string>
<!-- Account setup (vendor picker) -->
<string name="account_setup_outlook">Outlook or Hotmail</string>
<string name="account_setup_other">Other (IMAP/SMTP)</string>
@@ -288,6 +298,18 @@
<string name="settings_adv_battery_unrestricted">Unrestricted — instant background mail is allowed.</string>
<string name="settings_adv_battery_optimized">Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage.</string>
<!-- Contacts / recipient autocomplete (#129) -->
<string name="settings_contacts">Contacts</string>
<string name="settings_contacts_autocomplete">Recipient autocomplete</string>
<string name="settings_contacts_autocomplete_on">On — suggesting recipients from your contacts as you type. Tap to manage.</string>
<string name="settings_contacts_autocomplete_off">Off — tap to suggest recipients from your contacts. On-device only; never uploaded.</string>
<string name="settings_contacts_autocomplete_blocked">Blocked in system settings — tap to open settings and allow Contacts access.</string>
<string name="settings_contacts_dialog_title">Recipient autocomplete</string>
<string name="settings_contacts_rationale">LibreMail suggests recipients from your device contacts as you compose. This happens entirely on your device — your contacts are never uploaded or shared.</string>
<string name="settings_contacts_allow">Allow</string>
<string name="settings_contacts_blocked_body">Contacts access is turned off for LibreMail in Android settings. Open settings and allow Contacts to enable recipient autocomplete.</string>
<string name="settings_contacts_open_settings">Open settings</string>
<!-- Diagnostics / debug reporting -->
<string name="settings_diagnostics">Diagnostics</string>
<string name="settings_report_problem">Report a problem</string>
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
import org.junit.Test
import kotlin.test.assertEquals
class ContactPermissionDecisionTest {
@Test
fun `granted is always ON, regardless of the other signals`() {
for (rationale in listOf(false, true)) {
for (requested in listOf(false, true)) {
val state = ContactPermissionDecision.resolve(
granted = true,
showRationale = rationale,
alreadyRequested = requested,
)
assertEquals(
ContactPermissionState.GRANTED,
state,
"granted=true must always be GRANTED (rationale=$rationale, requested=$requested)",
)
}
}
}
@Test
fun `denied once with a rationale owed is re-requestable (DENIED)`() {
assertEquals(
ContactPermissionState.DENIED,
ContactPermissionDecision.resolve(granted = false, showRationale = true, alreadyRequested = true),
)
}
@Test
fun `never asked yet is re-requestable (DENIED), not blocked`() {
// No rationale AND never requested = a fresh install that simply hasn't asked; a request works.
assertEquals(
ContactPermissionState.DENIED,
ContactPermissionDecision.resolve(granted = false, showRationale = false, alreadyRequested = false),
)
}
@Test
fun `permanently denied is BLOCKED`() {
// Requested before, no rationale now, still not granted = "don't ask again" — Settings only.
assertEquals(
ContactPermissionState.BLOCKED,
ContactPermissionDecision.resolve(granted = false, showRationale = false, alreadyRequested = true),
)
}
}
@@ -16,6 +16,7 @@ import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import kotlin.test.assertEquals
@@ -40,13 +41,25 @@ class OnboardingViewModelTest {
every { isIgnoringBatteryOptimizations() } returns unrestricted
}
private fun settingsRepository(handled: Boolean = false) = mockk<SettingsRepository> {
coEvery { isBatteryPromptHandled() } returns handled
private fun contactsManager(granted: Boolean = false) = mockk<ContactsPermissionManager> {
every { hasPermission() } returns granted
}
private fun settingsRepository(batteryHandled: Boolean = false, contactsHandled: Boolean = false) =
mockk<SettingsRepository> {
coEvery { isBatteryPromptHandled() } returns batteryHandled
coEvery { isContactsPromptHandled() } returns contactsHandled
}
private fun viewModel(
battery: BatteryOptimizationManager = batteryManager(),
contacts: ContactsPermissionManager = contactsManager(),
settings: SettingsRepository = settingsRepository(),
) = OnboardingViewModel(battery, contacts, settings)
@Test
fun `battery prompt is needed when not unrestricted and not handled`() = runTest(testDispatcher) {
val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = false))
val vm = viewModel(battery = batteryManager(unrestricted = false), settings = settingsRepository())
assertEquals(true, vm.batteryPromptNeeded.value)
assertFalse(vm.batteryUnrestricted.value)
@@ -54,7 +67,7 @@ class OnboardingViewModelTest {
@Test
fun `battery prompt is skipped when the app is already unrestricted`() = runTest(testDispatcher) {
val vm = OnboardingViewModel(batteryManager(unrestricted = true), settingsRepository(handled = false))
val vm = viewModel(battery = batteryManager(unrestricted = true))
assertEquals(false, vm.batteryPromptNeeded.value)
assertTrue(vm.batteryUnrestricted.value)
@@ -62,14 +75,46 @@ class OnboardingViewModelTest {
@Test
fun `battery prompt is skipped once it has been handled`() = runTest(testDispatcher) {
val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = true))
val vm = viewModel(
battery = batteryManager(unrestricted = false),
settings = settingsRepository(batteryHandled = true),
)
assertEquals(false, vm.batteryPromptNeeded.value)
}
@Test
fun `contacts prompt is needed when not granted and not handled`() = runTest(testDispatcher) {
val vm = viewModel(
contacts = contactsManager(granted = false),
settings = settingsRepository(contactsHandled = false),
)
assertEquals(true, vm.contactsPromptNeeded.value)
assertFalse(vm.contactsGranted.value)
}
@Test
fun `contacts prompt is skipped when the permission is already granted`() = runTest(testDispatcher) {
val vm = viewModel(contacts = contactsManager(granted = true))
assertEquals(false, vm.contactsPromptNeeded.value)
assertTrue(vm.contactsGranted.value)
}
@Test
fun `contacts prompt is skipped once it has been handled`() = runTest(testDispatcher) {
val vm = viewModel(
contacts = contactsManager(granted = false),
settings = settingsRepository(contactsHandled = true),
)
assertEquals(false, vm.contactsPromptNeeded.value)
}
@Test
fun `only the first added account id is remembered`() = runTest(testDispatcher) {
val vm = OnboardingViewModel(batteryManager(), settingsRepository())
val vm = viewModel()
assertNull(vm.firstAddedAccountId)
vm.onAccountAdded("imap:first@example.com")
@@ -79,16 +124,48 @@ class OnboardingViewModelTest {
}
@Test
fun `marking the prompt handled persists the flag`() = runTest(testDispatcher) {
fun `marking the battery prompt handled persists the flag`() = runTest(testDispatcher) {
val repo = settingsRepository()
coEvery { repo.setBatteryPromptHandled(any()) } just Runs
val vm = OnboardingViewModel(batteryManager(), repo)
val vm = viewModel(settings = repo)
vm.markBatteryPromptHandled()
coVerify { repo.setBatteryPromptHandled(true) }
}
@Test
fun `marking the contacts prompt handled persists the flag`() = runTest(testDispatcher) {
val repo = settingsRepository()
coEvery { repo.setContactsPromptHandled(any()) } just Runs
val vm = viewModel(settings = repo)
vm.markContactsPromptHandled()
coVerify { repo.setContactsPromptHandled(true) }
}
@Test
fun `marking the contacts permission requested persists the flag`() = runTest(testDispatcher) {
val repo = settingsRepository()
coEvery { repo.setContactsPermissionRequested(any()) } just Runs
val vm = viewModel(settings = repo)
vm.markContactsPermissionRequested()
coVerify { repo.setContactsPermissionRequested(true) }
}
@Test
fun `a granted permission result flips contactsGranted on`() = runTest(testDispatcher) {
val vm = viewModel(contacts = contactsManager(granted = false))
assertFalse(vm.contactsGranted.value)
vm.onContactsPermissionResult(true)
assertTrue(vm.contactsGranted.value)
}
@Test
fun `refresh re-reads the live battery status`() = runTest(testDispatcher) {
val manager = mockk<BatteryOptimizationManager> {
@@ -96,11 +173,25 @@ class OnboardingViewModelTest {
// First read (init) is not-unrestricted; the second (refresh) reflects the user's change.
every { isIgnoringBatteryOptimizations() } returnsMany listOf(false, true)
}
val vm = OnboardingViewModel(manager, settingsRepository())
val vm = viewModel(battery = manager)
assertFalse(vm.batteryUnrestricted.value)
vm.refreshBatteryStatus()
assertTrue(vm.batteryUnrestricted.value)
}
@Test
fun `refresh re-reads the live contacts grant`() = runTest(testDispatcher) {
val manager = mockk<ContactsPermissionManager> {
// First reads (init) report not-granted; a later read reflects the user granting it.
every { hasPermission() } returnsMany listOf(false, false, true)
}
val vm = viewModel(contacts = manager)
assertFalse(vm.contactsGranted.value)
vm.refreshContactsStatus()
assertTrue(vm.contactsGranted.value)
}
}
+11 -5
View File
@@ -39,16 +39,22 @@ Nothing else. Notably **absent** (worth stating in any review exchange):
- **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:** first composition of the compose screen (`ui/compose/ComposeScreen.kt:101`);
denial is handled gracefully — `ContactsRepository.search` returns empty and composing works
normally (manual address entry).
- **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 in context (first open of the compose screen) and the feature
> degrades gracefully if denied.
> 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`