Merge branch 'main' into fix-reader-inline-images
This commit is contained in:
@@ -91,6 +91,19 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Separate-process trampoline for the app-lock key-invalidation recovery restart. Runs in
|
||||
its own ":restart" process (android:process) so it survives the main process being killed
|
||||
and can reliably relaunch the app from the outside — a same-process "startActivity then
|
||||
exit(0)" restart races ActivityManager and can be dropped (the ProcessPhoenix pattern).
|
||||
Not exported; only ProcessRestarter starts it. Translucent + excluded from recents so it
|
||||
never flashes UI or lingers in the switcher before it finishes and exits its own process. -->
|
||||
<activity
|
||||
android:name=".restart.RestartActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:process=":restart"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
||||
|
||||
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.libremail.reporting.AppLog
|
||||
import org.libremail.reporting.CrashReporter
|
||||
import org.libremail.reporting.DiagnosticsCollector
|
||||
import org.libremail.reporting.RingLogBuffer
|
||||
import org.libremail.restart.ProcessRestarter
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
@@ -60,6 +61,12 @@ class LibreMailApplication :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Android also instantiates this Application in the separate ":restart" trampoline process
|
||||
// (see ProcessRestarter / RestartActivity), which exists only to relaunch the app from outside
|
||||
// a dying main process and is torn down within milliseconds. It must NOT run any of the app's
|
||||
// normal startup work — crash reporting, WorkManager scheduling, IDLE push all belong to the
|
||||
// main process. The main process (no ":restart" suffix) is unaffected, so normal launch is too.
|
||||
if (isRestartTrampolineProcess()) return
|
||||
// Wire up debug reporting first so crashes during the rest of startup are still captured.
|
||||
AppLog.install(ringLogBuffer)
|
||||
crashReporter.install()
|
||||
@@ -98,6 +105,10 @@ class LibreMailApplication :
|
||||
if (pushShouldBeActive) idlePushManager.start()
|
||||
}
|
||||
|
||||
/** True when this Application instance is the one Android spun up in the ":restart" aux process. */
|
||||
private fun isRestartTrampolineProcess(): Boolean =
|
||||
Application.getProcessName() == packageName + ProcessRestarter.PROCESS_SUFFIX
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LibreMail"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.backup
|
||||
|
||||
import org.libremail.data.local.DatabaseFiles
|
||||
import org.libremail.data.settings.AppSettings
|
||||
|
||||
/**
|
||||
@@ -24,13 +25,16 @@ object BackupPolicy {
|
||||
"datastore/libremail_dbkey.preferences_pb",
|
||||
)
|
||||
|
||||
/** `databases`-dir-relative names that must never leave the device (encrypted credentials + mail cache). */
|
||||
val EXCLUDED_DATABASE_PATHS: List<String> = listOf(
|
||||
"libremail.db",
|
||||
"libremail.db-wal",
|
||||
"libremail.db-shm",
|
||||
"libremail.db-journal",
|
||||
)
|
||||
/**
|
||||
* `databases`-dir-relative names that must never leave the device: the encrypted mail cache
|
||||
* ([DatabaseFiles.NAME]) AND the accounts + encrypted-credentials database
|
||||
* ([DatabaseFiles.ACCOUNTS_NAME]), each with its SQLite sidecars. Derived from [DatabaseFiles]
|
||||
* rather than hand-listed, so a newly added database can never silently fall out of the
|
||||
* never-back-up set (issue #103).
|
||||
*/
|
||||
val EXCLUDED_DATABASE_PATHS: List<String> =
|
||||
DatabaseFiles.fileNames(DatabaseFiles.NAME) +
|
||||
DatabaseFiles.fileNames(DatabaseFiles.ACCOUNTS_NAME)
|
||||
|
||||
/**
|
||||
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -2,9 +2,8 @@
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/** Central name and wipe helper for the Room cache database file (and its SQLite sidecars). */
|
||||
/** Central names and wipe helper for the Room database files (and their SQLite sidecars). */
|
||||
object DatabaseFiles {
|
||||
|
||||
const val NAME = "libremail.db"
|
||||
@@ -17,15 +16,27 @@ object DatabaseFiles {
|
||||
const val ACCOUNTS_NAME = "libremail-accounts.db"
|
||||
|
||||
/**
|
||||
* Delete the database and any WAL/SHM/journal sidecars. Call only when no connection is open —
|
||||
* used by the "clear + re-sync" path when the encryption key is invalidated and the encrypted
|
||||
* database can no longer be decrypted.
|
||||
* The statically-nameable SQLite sidecars that accompany a database file. A transient `-mj*`
|
||||
* master journal can also exist, but its suffix is random and so can't be listed by name —
|
||||
* [clear] leans on [Context.deleteDatabase] to sweep that one up.
|
||||
*/
|
||||
private val SIDECAR_SUFFIXES = listOf("-wal", "-shm", "-journal")
|
||||
|
||||
/**
|
||||
* [name] plus each of its statically-nameable sidecars. The single source of truth for which
|
||||
* on-disk files make up a database file; `BackupPolicy` derives its never-back-up set from this
|
||||
* so a new database (or a new sidecar suffix) can never silently fall out of the exclusions.
|
||||
*/
|
||||
fun fileNames(name: String): List<String> = listOf(name) + SIDECAR_SUFFIXES.map { name + it }
|
||||
|
||||
/**
|
||||
* Delete the cache database ([NAME]) and every sidecar — including the `-mj*` master journal a
|
||||
* hand-rolled suffix list would miss — via [Context.deleteDatabase]. NEVER touches
|
||||
* [ACCOUNTS_NAME], so a cache-key invalidation keeps the user signed in (issue #111). Call only
|
||||
* when no connection is open — used by the "clear + re-sync" path when the encryption key is
|
||||
* invalidated and the encrypted database can no longer be decrypted.
|
||||
*/
|
||||
fun clear(context: Context) {
|
||||
val db = context.getDatabasePath(NAME)
|
||||
val dir = db.parentFile ?: return
|
||||
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
|
||||
File(dir, db.name + suffix).delete()
|
||||
}
|
||||
context.deleteDatabase(NAME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** How the cache database ([LibreMailDatabase]) must be opened, decided by [DatabaseProvisioner]. */
|
||||
sealed interface CacheOpenMode {
|
||||
/** Open with SQLCipher, keyed by [passphrase] — the opt-in encrypted cache. */
|
||||
data class Encrypted(val passphrase: String) : CacheOpenMode
|
||||
|
||||
/** Open with the default framework helper — the cache is plaintext on disk. */
|
||||
data object Plaintext : CacheOpenMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the one-time, blocking startup sequence that must complete BEFORE Room opens either database —
|
||||
* exactly once, memoized, and OFF the Hilt injection path (issue #93).
|
||||
*
|
||||
* `DatabaseModule.provideDatabase` used to do this work inline, with `runBlocking`, while Hilt
|
||||
* constructed the singleton [LibreMailDatabase]: a DataStore read, a Keystore op, a possible SQLCipher
|
||||
* re-key conversion, and (since #111) the cross-database [AccountDataMigrator]. All of it ran
|
||||
* synchronously on whichever thread first injected the database — which can be the main thread — so the
|
||||
* first DB access could jank or ANR (worst with the encrypted cache on). This class moves that work
|
||||
* behind [prepareCache]; the Hilt providers wire it into a [DeferredOpenHelperFactory] so it runs
|
||||
* lazily, on Room's background open, never at inject time.
|
||||
*
|
||||
* The sequence, its ordering, and its crash-safety are unchanged from the old `provideDatabase` — only
|
||||
* WHERE and WHEN it runs moved:
|
||||
* 1. If a screen-lock change flagged the encrypted cache for wiping, wipe it and reset its seals
|
||||
* (before Room opens the file, so no open connection is deleted underneath it).
|
||||
* 2. Run [AccountDataMigrator] — the one-time move of accounts/credentials/settings/signatures into
|
||||
* the non-auth [AccountDatabase] (issue #111). MUST precede opening the cache (whose
|
||||
* [MIGRATION_15_16] drops the moved tables) AND opening [AccountDatabase] (which reads the copied
|
||||
* rows). Both databases' open paths gate on [prepareCache], so the migrate-before-open guarantee
|
||||
* holds regardless of which database Room opens first.
|
||||
* 3. Resolve the encryption gate: convert the on-disk cache to the form the `encryptCache` setting
|
||||
* asks for, and report how the cache must be opened.
|
||||
*
|
||||
* [prepareCache] is memoized on success and guarded by a [Mutex], so the first database to open runs
|
||||
* the sequence and any concurrent or later opener awaits the same result. A failure is NOT memoized, so
|
||||
* it retries on the next open — preserving the migrator's "crash-loop rather than lose data" contract
|
||||
* (a throw here means the cache never opens, so [MIGRATION_15_16] never drops the not-yet-copied rows).
|
||||
*/
|
||||
@Singleton
|
||||
class DatabaseProvisioner internal constructor(
|
||||
private val context: Context,
|
||||
private val keyStore: DatabaseKeyStore,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val accountDataMigrator: AccountDataMigrator,
|
||||
private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext context: Context,
|
||||
keyStore: DatabaseKeyStore,
|
||||
settingsRepository: SettingsRepository,
|
||||
accountDataMigrator: AccountDataMigrator,
|
||||
) : this(context, keyStore, settingsRepository, accountDataMigrator, Dispatchers.IO)
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var prepared: CacheOpenMode? = null
|
||||
|
||||
/**
|
||||
* Runs the startup sequence exactly once (on [ioDispatcher]) and returns how the cache must be
|
||||
* opened. Idempotent and safe to call concurrently from both databases' open paths; the blocking
|
||||
* work runs on [ioDispatcher], never on the caller's thread past the suspension point.
|
||||
*/
|
||||
suspend fun prepareCache(): CacheOpenMode {
|
||||
prepared?.let { return it }
|
||||
return mutex.withLock {
|
||||
prepared ?: withContext(ioDispatcher) { runStartupSequence() }.also { prepared = it }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runStartupSequence(): CacheOpenMode {
|
||||
val dbFile = context.getDatabasePath(DatabaseFiles.NAME)
|
||||
|
||||
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
|
||||
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
|
||||
// restarts the app; we wipe the cache HERE — before Room opens it — so the file is never
|
||||
// deleted from under an open connection. Crash-safe order: wipe + reset the seals, and only THEN
|
||||
// clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start. Only
|
||||
// libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file), so the
|
||||
// user stays signed in across the wipe (issue #111).
|
||||
if (keyStore.isClearPending()) {
|
||||
DatabaseFiles.clear(context)
|
||||
keyStore.resetSealedPassphrase()
|
||||
keyStore.clearClearPending()
|
||||
}
|
||||
|
||||
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
|
||||
// (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops
|
||||
// the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first
|
||||
// (nothing left to move) and we never block waiting on a passphrase we can't get.
|
||||
accountDataMigrator.migrateIfNeeded()
|
||||
|
||||
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
|
||||
// before the database is opened — so it never races an open connection; toggling the setting
|
||||
// therefore takes effect on the next app start. The passphrase source is resolved from which
|
||||
// seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a
|
||||
// separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so
|
||||
// resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this
|
||||
// must never run on the main thread while the cache is locked (issue #93).
|
||||
val settings = settingsRepository.settings.first()
|
||||
val appLock = settings.appLock
|
||||
return when {
|
||||
settings.encryptCache -> {
|
||||
val passphrase = keyStore.resolvePassphrase(appLock)
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
CacheOpenMode.Encrypted(passphrase)
|
||||
}
|
||||
|
||||
DatabaseEncryption.isEncrypted(dbFile) -> {
|
||||
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
|
||||
val passphrase = keyStore.resolvePassphrase(appLock)
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
CacheOpenMode.Plaintext
|
||||
}
|
||||
|
||||
else -> CacheOpenMode.Plaintext
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteOpenHelper
|
||||
|
||||
/**
|
||||
* A [SupportSQLiteOpenHelper.Factory] that defers building the REAL open helper — and any blocking work
|
||||
* that choosing and creating it entails — from Room's build/inject path to the FIRST actual database
|
||||
* open (issue #93).
|
||||
*
|
||||
* Room calls [create] and [SupportSQLiteOpenHelper.setWriteAheadLoggingEnabled] while it builds the
|
||||
* database, on whichever thread injected it (possibly the main thread); neither may block. This factory
|
||||
* hands back a thin handle whose delegate is materialised only when the database is first opened
|
||||
* (`writableDatabase` / `readableDatabase`), which Room performs on its background query executor. The
|
||||
* [buildDelegate] lambda is where the caller runs the startup gate (see [DatabaseProvisioner]) and
|
||||
* picks the concrete factory — so all of that runs off the injection path and off the main thread.
|
||||
*/
|
||||
internal class DeferredOpenHelperFactory(
|
||||
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
|
||||
) : SupportSQLiteOpenHelper.Factory {
|
||||
override fun create(configuration: SupportSQLiteOpenHelper.Configuration): SupportSQLiteOpenHelper =
|
||||
DeferredOpenHelper(configuration, buildDelegate)
|
||||
}
|
||||
|
||||
/**
|
||||
* The lazy handle returned by [DeferredOpenHelperFactory]. Everything Room touches before the first
|
||||
* open is cheap; [buildDelegate] (which does the blocking work) runs only when [writableDatabase] or
|
||||
* [readableDatabase] is first read.
|
||||
*/
|
||||
private class DeferredOpenHelper(
|
||||
private val configuration: SupportSQLiteOpenHelper.Configuration,
|
||||
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
|
||||
) : SupportSQLiteOpenHelper {
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
/** Guarded by [lock]. Null until the database is first opened — `create()` must stay non-blocking. */
|
||||
private var delegate: SupportSQLiteOpenHelper? = null
|
||||
|
||||
/**
|
||||
* Guarded by [lock]. Room may set WAL before the first open; we remember the value and apply it when
|
||||
* the delegate is built, rather than building the delegate early (which would run the gate at inject
|
||||
* time). Null means "Room never asked", so the delegate keeps the real factory's own default.
|
||||
*/
|
||||
private var writeAheadLoggingEnabled: Boolean? = null
|
||||
|
||||
override val databaseName: String?
|
||||
get() = configuration.name
|
||||
|
||||
override fun setWriteAheadLoggingEnabled(enabled: Boolean) {
|
||||
synchronized(lock) {
|
||||
writeAheadLoggingEnabled = enabled
|
||||
delegate?.setWriteAheadLoggingEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
override val writableDatabase: SupportSQLiteDatabase
|
||||
get() = delegate().writableDatabase
|
||||
|
||||
override val readableDatabase: SupportSQLiteDatabase
|
||||
get() = delegate().readableDatabase
|
||||
|
||||
override fun close() {
|
||||
// Never opened means nothing to close; do NOT build the delegate just to close it.
|
||||
synchronized(lock) { delegate?.close() }
|
||||
}
|
||||
|
||||
private fun delegate(): SupportSQLiteOpenHelper = synchronized(lock) {
|
||||
delegate ?: buildDelegate(configuration).also { built ->
|
||||
writeAheadLoggingEnabled?.let(built::setWriteAheadLoggingEnabled)
|
||||
delegate = built
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.Operation
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
@@ -48,13 +49,18 @@ class SyncScheduler @Inject constructor(
|
||||
workManager.enqueueUniquePeriodicWork(PERIODIC_WORK, PERIODIC_POLICY, request)
|
||||
}
|
||||
|
||||
/** One-shot sync, e.g. right after an account is added. */
|
||||
fun syncNow() {
|
||||
/**
|
||||
* One-shot sync, e.g. right after an account is added. Returns the enqueue [Operation] so callers
|
||||
* that must guarantee the WorkSpec is durably persisted before killing the process (the app-lock
|
||||
* recovery restart) can await it — WorkManager persists the WorkSpec asynchronously on its serial
|
||||
* task executor, so a fire-and-forget enqueue can be lost to a racing process death.
|
||||
*/
|
||||
fun syncNow(): Operation {
|
||||
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.setConstraints(networkConstraint)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request)
|
||||
return workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,14 +3,17 @@ package org.libremail.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.libremail.data.local.AccountDatabase
|
||||
import org.libremail.data.local.DatabaseFiles.ACCOUNTS_NAME
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.DatabaseProvisioner
|
||||
import org.libremail.data.local.DeferredOpenHelperFactory
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AccountSettingsDao
|
||||
import org.libremail.data.local.dao.CredentialDao
|
||||
@@ -27,17 +30,26 @@ import javax.inject.Singleton
|
||||
object AccountDatabaseModule {
|
||||
|
||||
/**
|
||||
* The plaintext account store. Depends on [LibreMailDatabase] purely for construction ordering:
|
||||
* building the cache runs the one-time [org.libremail.data.local.AccountDataMigrator] (which
|
||||
* populates this file on a dedicated connection) and then drops the moved tables, so by the time
|
||||
* Room opens this file the data is already present and no other connection is touching it.
|
||||
* The plaintext account store. Its OPEN is gated on [DatabaseProvisioner.prepareCache] so the
|
||||
* one-time [org.libremail.data.local.AccountDataMigrator] (which populates this file on a dedicated
|
||||
* connection, then drops the moved tables from the cache) has finished before Room opens this file —
|
||||
* the migrate-before-open ordering the old construction-time dependency on `LibreMailDatabase`
|
||||
* enforced, now moved OFF the injection path (issue #93). This store always opens unkeyed, so it
|
||||
* ignores the returned cache open-mode and only awaits the shared sequence.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAccountDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
@Suppress("UNUSED_PARAMETER") cacheDatabase: LibreMailDatabase,
|
||||
): AccountDatabase = Room.databaseBuilder(context, AccountDatabase::class.java, ACCOUNTS_NAME).build()
|
||||
provisioner: DatabaseProvisioner,
|
||||
): AccountDatabase = Room.databaseBuilder(context, AccountDatabase::class.java, ACCOUNTS_NAME)
|
||||
.openHelperFactory(
|
||||
DeferredOpenHelperFactory { configuration ->
|
||||
runBlocking { provisioner.prepareCache() }
|
||||
FrameworkSQLiteOpenHelperFactory().create(configuration)
|
||||
},
|
||||
)
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
fun provideAccountDao(database: AccountDatabase): AccountDao = database.accountDao()
|
||||
|
||||
@@ -3,17 +3,18 @@ package org.libremail.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
import org.libremail.data.local.AccountDataMigrator
|
||||
import org.libremail.data.local.DatabaseEncryption
|
||||
import org.libremail.data.local.CacheOpenMode
|
||||
import org.libremail.data.local.DatabaseFiles
|
||||
import org.libremail.data.local.DatabaseProvisioner
|
||||
import org.libremail.data.local.DeferredOpenHelperFactory
|
||||
import org.libremail.data.local.LibreMailDatabase
|
||||
import org.libremail.data.local.MIGRATION_10_11
|
||||
import org.libremail.data.local.MIGRATION_11_12
|
||||
@@ -37,8 +38,6 @@ import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.security.DatabaseKeyStore
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -47,13 +46,8 @@ object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
keyStore: DatabaseKeyStore,
|
||||
settingsRepository: SettingsRepository,
|
||||
accountDataMigrator: AccountDataMigrator,
|
||||
): LibreMailDatabase {
|
||||
val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
|
||||
fun provideDatabase(@ApplicationContext context: Context, provisioner: DatabaseProvisioner): LibreMailDatabase =
|
||||
Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
|
||||
.addMigrations(
|
||||
MIGRATION_1_2,
|
||||
MIGRATION_2_3,
|
||||
@@ -72,59 +66,29 @@ object DatabaseModule {
|
||||
MIGRATION_15_16,
|
||||
MIGRATION_16_17,
|
||||
)
|
||||
// No destructive fallback: the migration chain is complete, and silently dropping the
|
||||
// mail/message tables would lose cached data. A missing migration should fail loudly in
|
||||
// testing instead.
|
||||
// No destructive fallback: the migration chain is complete, and silently dropping the
|
||||
// mail/message tables would lose cached data. A missing migration should fail loudly in
|
||||
// testing instead.
|
||||
//
|
||||
// All blocking startup work — the issue-#111 AccountDataMigrator, the encrypted-cache
|
||||
// conversion, and the Keystore passphrase resolution — is deferred OFF this injection path
|
||||
// (issue #93). The factory below runs DatabaseProvisioner.prepareCache() lazily, when Room
|
||||
// first OPENS the cache on its background query executor, never on the (possibly main)
|
||||
// thread that injects this singleton. prepareCache() still performs that sequence before the
|
||||
// file opens and in the same order, so the migrate-before-open guarantee and the encryption
|
||||
// gate are unchanged — only where/when they run moved.
|
||||
.openHelperFactory(
|
||||
DeferredOpenHelperFactory { configuration ->
|
||||
val realFactory = when (val mode = runBlocking { provisioner.prepareCache() }) {
|
||||
is CacheOpenMode.Encrypted ->
|
||||
SupportOpenHelperFactory(mode.passphrase.toByteArray(Charsets.US_ASCII), null, false)
|
||||
|
||||
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
|
||||
// before the database is opened — so it never races an open connection; toggling the setting
|
||||
// therefore takes effect on the next app start. The passphrase is sealed by the Keystore.
|
||||
//
|
||||
// The passphrase source is resolved from which seal actually exists
|
||||
// ([DatabaseKeyStore.resolvePassphrase]), NOT from the app-lock setting (a separate DataStore
|
||||
// that can disagree). When app-lock is ON the sealing key is auth-bound, so resolvePassphrase
|
||||
// waits on PassphraseSession until the user authenticates. This provider must therefore never
|
||||
// be constructed on the main thread while the cache is locked — LibreMailApplication injects
|
||||
// AccountRepository lazily and the sync/push workers fail fast when locked, and the gate
|
||||
// composes no DB-backed screen until Unlocked.
|
||||
val dbFile = context.getDatabasePath(DB_NAME)
|
||||
|
||||
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
|
||||
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
|
||||
// restarts the app; we wipe the cache HERE — at cold start, before Room opens — so the file is
|
||||
// never deleted from under an open connection. Crash-safe order: wipe + reset the seals, and
|
||||
// only THEN clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start.
|
||||
// Only libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file),
|
||||
// so the user stays signed in across the wipe (issue #111).
|
||||
if (runBlocking { keyStore.isClearPending() }) {
|
||||
DatabaseFiles.clear(context)
|
||||
runBlocking {
|
||||
keyStore.resetSealedPassphrase()
|
||||
keyStore.clearClearPending()
|
||||
}
|
||||
}
|
||||
|
||||
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
|
||||
// (issue #111). MUST run before builder.build() below: opening the cache applies MIGRATION_15_16,
|
||||
// which drops the moved tables. It runs AFTER the wipe above so an unrecoverable-key cache is
|
||||
// gone first (nothing left to move) and we never block waiting on a passphrase we can't get.
|
||||
runBlocking { accountDataMigrator.migrateIfNeeded() }
|
||||
|
||||
val settings = runBlocking { settingsRepository.settings.first() }
|
||||
val appLock = settings.appLock
|
||||
if (settings.encryptCache) {
|
||||
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
|
||||
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
|
||||
builder.openHelperFactory(
|
||||
SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false),
|
||||
CacheOpenMode.Plaintext -> FrameworkSQLiteOpenHelperFactory()
|
||||
}
|
||||
realFactory.create(configuration)
|
||||
},
|
||||
)
|
||||
} else if (DatabaseEncryption.isEncrypted(dbFile)) {
|
||||
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
|
||||
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
|
||||
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.restart
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Process
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Relaunches the whole app in a brand-new process by handing off to [RestartActivity], a trampoline
|
||||
* that runs in the separate `:restart` process. Because the trampoline survives the current process
|
||||
* being killed, the relaunch it issues cannot be dropped by ActivityManager scheduling it into the
|
||||
* dying process — the failure mode of a same-process "startActivity then exit(0)" restart.
|
||||
*
|
||||
* Used by the app-lock key-invalidation recovery to bounce the process so the cache is wiped safely at
|
||||
* the next cold start (before Room reopens it).
|
||||
*/
|
||||
@Singleton
|
||||
class ProcessRestarter @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
|
||||
/**
|
||||
* Start the [RestartActivity] trampoline in the `:restart` process, passing it this (main) process
|
||||
* PID so it can kill us and relaunch from the outside. Returns immediately; the actual kill +
|
||||
* relaunch happens in the trampoline process moments later.
|
||||
*/
|
||||
fun restart() {
|
||||
val trampoline = Intent(context, RestartActivity::class.java).apply {
|
||||
// Required because we may be started from a non-Activity (Application) context.
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
putExtra(RestartActivity.EXTRA_ORIGINAL_PID, Process.myPid())
|
||||
}
|
||||
context.startActivity(trampoline)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The `android:process` suffix of [RestartActivity] (must match AndroidManifest.xml). The
|
||||
* Application uses it to skip its normal startup work when it is spun up in this aux process.
|
||||
*/
|
||||
const val PROCESS_SUFFIX = ":restart"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.restart
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Separate-process trampoline that performs an app relaunch from OUTSIDE the process being killed.
|
||||
*
|
||||
* Declared in the manifest with `android:process=":restart"`, so Android runs it in its own process.
|
||||
* That is the whole point: it kills the original (main) process by PID and only THEN starts the main
|
||||
* launcher activity, so the relaunch is scheduled from a process that is NOT the one being torn down.
|
||||
* A same-process "startActivity then Runtime.exit(0)" restart races ActivityManager — the relaunch can
|
||||
* be scheduled into the dying process and silently dropped, so the app just closes. Issuing it from a
|
||||
* surviving process (the ProcessPhoenix pattern) makes the relaunch reliable.
|
||||
*
|
||||
* DEVICE-ONLY: the multi-process kill/relaunch cannot be exercised in JVM unit tests; see
|
||||
* [ProcessRestarter] for the (testable) intent/targeting seam and AppLockViewModelTest for the
|
||||
* ordering guarantees around it.
|
||||
*/
|
||||
class RestartActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Kill the original main process FIRST so the relaunch below spins up a genuinely fresh
|
||||
// process — one whose cold DatabaseModule performs the pending cache wipe before Room opens.
|
||||
// If we relaunched while the old process were still alive, ActivityManager could route the
|
||||
// launch back into it and the wipe-on-cold-start would never run.
|
||||
val originalPid = intent.getIntExtra(EXTRA_ORIGINAL_PID, INVALID_PID)
|
||||
if (originalPid > INVALID_PID && originalPid != Process.myPid()) {
|
||||
Process.killProcess(originalPid)
|
||||
}
|
||||
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
if (launchIntent != null) {
|
||||
startActivity(launchIntent)
|
||||
} else {
|
||||
Log.w(TAG, "no launch intent for $packageName; cannot relaunch after restart")
|
||||
}
|
||||
|
||||
finish()
|
||||
// Tear down this trampoline process too: its only job was to issue the relaunch from outside
|
||||
// the dying main process.
|
||||
Runtime.getRuntime().exit(0)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Extra carrying the PID of the main process to kill, so the relaunch starts a fresh one. */
|
||||
const val EXTRA_ORIGINAL_PID = "org.libremail.restart.ORIGINAL_PID"
|
||||
|
||||
private const val INVALID_PID = -1
|
||||
private const val TAG = "LibreMailRestart"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
package org.libremail.ui.lock
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.UserNotAuthenticatedException
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.Operation
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -30,6 +32,10 @@ import org.libremail.data.security.LockState
|
||||
import org.libremail.data.security.PassphraseSession
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.restart.ProcessRestarter
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import javax.inject.Inject
|
||||
|
||||
/** UI state of the app-lock gate that wraps the whole app. */
|
||||
@@ -73,6 +79,10 @@ class AppLockViewModel @Inject constructor(
|
||||
private val databaseKeyCipher: DatabaseKeyCipher,
|
||||
private val session: PassphraseSession,
|
||||
private val syncScheduler: SyncScheduler,
|
||||
// Issues the key-invalidation recovery relaunch from a separate ":restart" process that survives
|
||||
// this process being killed, so the relaunch can't be dropped by ActivityManager scheduling it
|
||||
// into the dying process (the same-process "startActivity then exit(0)" race).
|
||||
private val processRestarter: ProcessRestarter,
|
||||
// Application-scoped (see SecurityModule): the gate is injected rather than owned by this
|
||||
// Activity-scoped ViewModel so the inactivity grace window survives Activity recreation — Back on
|
||||
// the task root finishes the Activity and clears its ViewModelStore on API 29/30, which would
|
||||
@@ -83,6 +93,12 @@ class AppLockViewModel @Inject constructor(
|
||||
private val _uiState = MutableStateFlow<AppLockUiState>(AppLockUiState.Checking)
|
||||
val uiState: StateFlow<AppLockUiState> = _uiState.asStateFlow()
|
||||
|
||||
// The dispatcher for blocking Keystore/DataStore/WorkManager work pushed off the main thread.
|
||||
// Injectable so the recovery flow (clearCacheAndRestart) runs on the test scheduler and its
|
||||
// ordering — enqueue durably persisted BEFORE the restart — is deterministically verifiable.
|
||||
@VisibleForTesting
|
||||
internal var defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
// Cached so onBackground / onForeground can cover the content synchronously (before the async
|
||||
// settings read) whenever app-lock is on — so no stale mailbox frame renders on resume.
|
||||
@Volatile private var appLockEnabledCached = false
|
||||
@@ -125,7 +141,7 @@ class AppLockViewModel @Inject constructor(
|
||||
// App-lock is on: cover any showing content while we resolve, so no stale mailbox frame
|
||||
// renders before the (async) decision lands.
|
||||
if (_uiState.value == AppLockUiState.Unlocked) _uiState.value = AppLockUiState.Checking
|
||||
val action = withContext(Dispatchers.Default) {
|
||||
val action = withContext(defaultDispatcher) {
|
||||
KeyInvalidationPolicy.decide(
|
||||
appLockEnabled = true,
|
||||
encryptCacheEnabled = settings.encryptCache,
|
||||
@@ -173,7 +189,7 @@ class AppLockViewModel @Inject constructor(
|
||||
/** Called by the host after a successful `BiometricPrompt`. */
|
||||
fun onAuthenticated() {
|
||||
viewModelScope.launch {
|
||||
when (withContext(Dispatchers.Default) { unlockOrArm() }) {
|
||||
when (withContext(defaultDispatcher) { unlockOrArm() }) {
|
||||
UnlockResult.OK -> {
|
||||
gate.onAuthenticated()
|
||||
publish()
|
||||
@@ -258,25 +274,52 @@ class AppLockViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun clearCacheAndRestart(disableAppLock: Boolean) {
|
||||
withContext(Dispatchers.Default) {
|
||||
withContext(defaultDispatcher) {
|
||||
// Record the wipe intent BEFORE flipping app-lock off, so a crash between the two writes
|
||||
// leaves the wipe still pending (recoverable) rather than a disabled gate over a stale key.
|
||||
// Both are DataStore edits that only return once durably committed, so they survive the
|
||||
// restart below without further ceremony.
|
||||
databaseKeyStore.setClearPending()
|
||||
if (disableAppLock) settingsRepository.setAppLock(false)
|
||||
syncScheduler.syncNow() // persisted by WorkManager; survives the restart
|
||||
// Enqueue the post-wipe re-sync and BLOCK until WorkManager has durably persisted its
|
||||
// WorkSpec before we hand off to the restart. syncNow() only *schedules* the insert on
|
||||
// WorkManager's serial task executor; killing the process (via restartProcess) can race
|
||||
// that async insert and drop the re-sync, leaving an empty mailbox after the wipe until the
|
||||
// next periodic sync. Awaiting the enqueue Operation makes "survives the restart" real.
|
||||
awaitSyncEnqueue(syncScheduler.syncNow())
|
||||
}
|
||||
restartProcess()
|
||||
}
|
||||
|
||||
/**
|
||||
* Block until WorkManager confirms the re-sync WorkSpec is durably persisted, bounded by
|
||||
* [SYNC_ENQUEUE_TIMEOUT_SECONDS] so a stuck insert can never wedge recovery. A timeout/failure is
|
||||
* logged and we restart anyway: the periodic sync will still eventually refill the wiped cache, so
|
||||
* a best-effort wait is strictly better than the previous fire-and-forget enqueue. Runs on
|
||||
* [defaultDispatcher] (never the main thread) because [Operation.result]'s get blocks.
|
||||
*/
|
||||
private fun awaitSyncEnqueue(operation: Operation) {
|
||||
try {
|
||||
operation.result.get(SYNC_ENQUEUE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
} catch (e: TimeoutException) {
|
||||
Log.w(TAG, "re-sync enqueue not confirmed within timeout; restarting anyway", e)
|
||||
} catch (e: ExecutionException) {
|
||||
Log.w(TAG, "re-sync enqueue failed; restarting anyway", e)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
Log.w(TAG, "interrupted awaiting re-sync enqueue; restarting anyway", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunch the app in a fresh process so [org.libremail.di.DatabaseModule] wipes the cache before
|
||||
* Room reopens it. DEVICE-ONLY: process restart cannot be exercised in JVM unit tests.
|
||||
* Room reopens it. Delegates to [ProcessRestarter], which issues the relaunch from a separate
|
||||
* process that survives this one being killed — a same-process "startActivity then exit(0)" is
|
||||
* unreliable because ActivityManager may schedule the relaunch into the dying process and drop it.
|
||||
* DEVICE-ONLY end to end: the multi-process kill/relaunch cannot be exercised in JVM unit tests.
|
||||
*/
|
||||
private fun restartProcess() {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
if (intent != null) context.startActivity(intent)
|
||||
Runtime.getRuntime().exit(0)
|
||||
processRestarter.restart()
|
||||
}
|
||||
|
||||
// Monotonic clock so a wall-clock change can't extend the inactivity grace window.
|
||||
@@ -284,5 +327,10 @@ class AppLockViewModel @Inject constructor(
|
||||
|
||||
private companion object {
|
||||
const val TAG = "LibreMailAppLock"
|
||||
|
||||
// Upper bound on waiting for WorkManager to persist the re-sync WorkSpec. The insert is
|
||||
// normally sub-second; this only caps a pathological stall so recovery can't hang before the
|
||||
// restart. On timeout we restart anyway (the periodic sync still refills the cache later).
|
||||
const val SYNC_ENQUEUE_TIMEOUT_SECONDS = 5L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -159,6 +159,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>
|
||||
@@ -296,6 +306,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>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
which applies on API 31+. Backup is still gated by LibreMailBackupAgent (opt-in, OFF by default).
|
||||
|
||||
<include> makes this a strict allowlist: ONLY the libremail_settings DataStore is backed up. The
|
||||
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb) and the encrypted
|
||||
credentials + mail-cache database (libremail.db and its -wal/-shm/-journal side files) are kept
|
||||
off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync and
|
||||
accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
|
||||
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb), the mail-cache
|
||||
database (libremail.db) and the accounts + encrypted-credentials database (libremail-accounts.db,
|
||||
which issue #111 split out of libremail.db) — each with their -wal/-shm/-journal side files — are
|
||||
kept off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync
|
||||
and accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
|
||||
outside an <include>, so exclusion is expressed by omission rather than explicit <exclude> entries.)
|
||||
-->
|
||||
<full-backup-content>
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
- datastore/libremail_dbkey.preferences_pb: the Keystore-sealed SQLCipher passphrase for the
|
||||
encrypted cache. The wrapping Keystore key is non-exportable and device-bound, so the
|
||||
ciphertext is useless anywhere else.
|
||||
- libremail.db (+ -wal/-shm/-journal): encrypted IMAP/OAuth credentials and the cached mail.
|
||||
The cache re-downloads on the next sync; accounts are re-added on a new device.
|
||||
- libremail.db (+ -wal/-shm/-journal): the cached mail; re-downloads on the next sync.
|
||||
- libremail-accounts.db (+ -wal/-shm/-journal): accounts, encrypted IMAP/OAuth credentials,
|
||||
per-account settings and signatures (issue #111 moved these out of libremail.db). The
|
||||
credentials are sealed with a device-bound key, so they would only ever restore as
|
||||
undecryptable ciphertext; accounts are re-added on a new device.
|
||||
(Lint's FullBackupContent rule forbids <exclude> paths outside an <include>, so the exclusions are
|
||||
expressed by simply not listing those paths rather than as explicit <exclude> entries.)
|
||||
-->
|
||||
|
||||
Reference in New Issue
Block a user