diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt index bbd2bbf..662f1a8 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -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, diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt index a32112a..a0b0852 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt @@ -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 { diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/ContactsAccessStepTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/ContactsAccessStepTest.kt new file mode 100644 index 0000000..529a0f9 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/ContactsAccessStepTest.kt @@ -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() + + 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() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt index 580543f..5308266 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -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 { diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/ContactAutocompleteRowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/ContactAutocompleteRowTest.kt new file mode 100644 index 0000000..9d55899 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/ContactAutocompleteRowTest.kt @@ -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() + + 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() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt index 2d986be..e714e22 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt @@ -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) diff --git a/app/src/main/kotlin/org/libremail/contacts/ContactPermissionState.kt b/app/src/main/kotlin/org/libremail/contacts/ContactPermissionState.kt new file mode 100644 index 0000000..8f6a324 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/contacts/ContactPermissionState.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/libremail/contacts/ContactsPermissionManager.kt b/app/src/main/kotlin/org/libremail/contacts/ContactsPermissionManager.kt new file mode 100644 index 0000000..49a447f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/contacts/ContactsPermissionManager.kt @@ -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), + ) +} diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt index e048c41..85c2158 100644 --- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -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 = + 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) diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 0e7882c..1d33c9f 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -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 diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 54c0ae7..ec36b10 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -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() } diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 169d8a2..3659bb9 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -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 diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/ContactsAccessScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/ContactsAccessScreen.kt new file mode 100644 index 0000000..66d2404 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/ContactsAccessScreen.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt index bc8706b..ab55a2d 100644 --- a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt @@ -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 = _batteryUnrestricted.asStateFlow() + private val _contactsPromptNeeded = MutableStateFlow(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 = _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 = _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) } + } } diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index d238929..da70170 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -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 diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index a838b88..043bb80 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -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 = 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 = settingsRepository.contactsPermissionRequested + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + private val _advancedExpanded = MutableStateFlow(false) val advancedExpanded: StateFlow = _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) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8521a5d..b86f50d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -151,6 +151,16 @@ Background usage is unrestricted — new mail will arrive instantly. Continue to inbox + + Suggest recipients as you type + 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. + LibreMail needs the Contacts permission to suggest recipients. It\'s used only for on-device autocomplete — nothing is uploaded. + Allow contacts access + Not now + Autocomplete is on + LibreMail will suggest recipients from your contacts as you compose — all on this device. + Continue + Outlook or Hotmail Other (IMAP/SMTP) @@ -288,6 +298,18 @@ Unrestricted — instant background mail is allowed. Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage. + + Contacts + Recipient autocomplete + On — suggesting recipients from your contacts as you type. Tap to manage. + Off — tap to suggest recipients from your contacts. On-device only; never uploaded. + Blocked in system settings — tap to open settings and allow Contacts access. + Recipient autocomplete + LibreMail suggests recipients from your device contacts as you compose. This happens entirely on your device — your contacts are never uploaded or shared. + Allow + Contacts access is turned off for LibreMail in Android settings. Open settings and allow Contacts to enable recipient autocomplete. + Open settings + Diagnostics Report a problem diff --git a/app/src/test/kotlin/org/libremail/contacts/ContactPermissionDecisionTest.kt b/app/src/test/kotlin/org/libremail/contacts/ContactPermissionDecisionTest.kt new file mode 100644 index 0000000..a78077e --- /dev/null +++ b/app/src/test/kotlin/org/libremail/contacts/ContactPermissionDecisionTest.kt @@ -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), + ) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt index 4000aa5..dcf8d00 100644 --- a/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt @@ -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 { - coEvery { isBatteryPromptHandled() } returns handled + private fun contactsManager(granted: Boolean = false) = mockk { + every { hasPermission() } returns granted } + private fun settingsRepository(batteryHandled: Boolean = false, contactsHandled: Boolean = false) = + mockk { + 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 { @@ -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 { + // 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) + } } diff --git a/docs/play-permissions.md b/docs/play-permissions.md index a5b4568..833d2d9 100644 --- a/docs/play-permissions.md +++ b/docs/play-permissions.md @@ -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`