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:
@@ -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,
|
||||
|
||||
+6
-1
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user