Compare commits

..
47 changed files with 1929 additions and 2797 deletions

No files matched your search

+1
View File
@@ -177,6 +177,7 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-prefs")
implementation("de.jeanlucmakiola.floret:core-backup")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
@@ -67,10 +67,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant
@@ -623,20 +620,6 @@ class MainActivity : AppCompatActivity() {
putExtra(EXTRA_SOURCE_VIEW, source.name)
}
/**
* Open the day view on [date] over the default home view — the daily
* summary's tap (#459). Spoken as the system's own "view time" intent,
* at the day's local noon, so it lands on the path a clock tap takes.
*/
fun openDayIntent(context: Context, date: LocalDate): Intent {
val noon = LocalDateTime(date, LocalTime(12, 0))
.toInstant(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
return Intent(Intent.ACTION_VIEW, "content://$CALENDAR_PROVIDER_HOST/time/$noon".toUri())
.setClass(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/** Open the create-event form prefilled for [date] (home-screen widgets). */
fun openCreateIntent(context: Context, date: LocalDate): Intent =
Intent(context, MainActivity::class.java).apply {
@@ -0,0 +1,198 @@
package de.jeanlucmakiola.calendula.data.backup
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.floret.backup.BackupDocument
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.backup.PrefsSection
import de.jeanlucmakiola.floret.backup.invalidBackup
import de.jeanlucmakiola.floret.backup.optBoolean
import de.jeanlucmakiola.floret.backup.optIntList
import de.jeanlucmakiola.floret.backup.optObject
import de.jeanlucmakiola.floret.backup.optObjectList
import de.jeanlucmakiola.floret.backup.optInt
import de.jeanlucmakiola.floret.backup.optString
import de.jeanlucmakiola.floret.backup.readBackup
import de.jeanlucmakiola.floret.backup.requireString
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.get
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
/**
* A calendar as another device can find it again: provider ids are per
* device, so a calendar is named by its account and display name, and a
* special-dates mirror by the date type it mirrors.
*/
data class CalendarIdentity(
val accountName: String,
val accountType: String,
val displayName: String,
val specialDates: SpecialDateType? = null,
)
/** The per-calendar settings of one calendar; null fields inherit the global default. */
data class CalendarSettings(
val calendar: CalendarIdentity,
val hidden: Boolean = false,
val lastUsed: Boolean = false,
val eventDurationMinutes: Int? = null,
/** Timed reminder override; an empty list is "no reminder". */
val reminders: List<Int>? = null,
val allDayReminders: List<Int>? = null,
)
/** The app language a file carries; [tag] null follows the system. */
data class LanguageChoice(val tag: String?)
/** Everything a settings backup holds. Null [language]/[launcherName]: the file didn't say. */
data class SettingsBackup(
val prefs: Map<Pref<*>, Any>,
val calendars: List<CalendarSettings>,
val language: LanguageChoice? = null,
val launcherName: LauncherName? = null,
)
internal val plainSection = PrefsSection(SettingsBackupSchema.plain)
/**
* The backup of [snapshot]. Per-calendar entries are named through
* [calendars] and [managed]; an id neither knows (a deleted calendar's
* leftover) is dropped.
*/
fun buildSettingsBackup(
snapshot: Preferences,
calendars: List<CalendarSource>,
managed: List<ManagedCalendarRow>,
language: LanguageChoice?,
launcherName: LauncherName?,
): SettingsBackup {
val hidden = snapshot[CalendarPrefKeys.hiddenCalendarIds]
val lastUsed = snapshot[CalendarPrefKeys.lastUsedCalendarId]
val durations = snapshot[SettingsKeys.perCalendarEventDuration]
val reminders = snapshot[SettingsKeys.perCalendarReminderOverride]
val allDay = snapshot[SettingsKeys.perCalendarAllDayReminderOverride]
val ids = hidden + listOfNotNull(lastUsed) + durations.keys + reminders.keys + allDay.keys
val byId = calendars.associateBy { it.id }
val managedType = managed.associate { it.id to it.type }
val entries = ids.sorted().mapNotNull { id ->
val calendar = byId[id] ?: return@mapNotNull null
CalendarSettings(
calendar = CalendarIdentity(
accountName = calendar.accountName,
accountType = calendar.accountType,
displayName = calendar.displayName,
specialDates = managedType[id],
),
hidden = id in hidden,
lastUsed = id == lastUsed,
eventDurationMinutes = durations[id],
reminders = reminders[id],
allDayReminders = allDay[id],
)
}
return SettingsBackup(
prefs = plainSection.snapshot(snapshot),
calendars = entries,
language = language,
launcherName = launcherName,
)
}
fun encodeSettingsBackup(backup: SettingsBackup, exportedAtMillis: Long, appVersion: String?): String {
val body = buildJsonObject {
put(KEY_SETTINGS, plainSection.encode(backup.prefs))
put(KEY_CALENDARS, JsonArray(backup.calendars.map { it.toJson() }))
put(
KEY_APP,
buildJsonObject {
backup.language?.let { put(KEY_LANGUAGE, it.tag?.let(::JsonPrimitive) ?: JsonNull) }
backup.launcherName?.let { put(KEY_LAUNCHER_NAME, JsonPrimitive(it.name)) }
},
)
}
val document = BackupDocument(
version = SettingsBackupSchema.format.version,
exportedAtMillis = exportedAtMillis,
appVersion = appVersion,
body = body,
)
return SettingsBackupSchema.format.encode(document)
}
/** Validates the whole file before anything is applied; see docs/SETTINGS_BACKUP.md. */
fun decodeSettingsBackup(text: String): BackupResult<SettingsBackup> {
val document = when (val decoded = SettingsBackupSchema.format.decode(text)) {
is BackupResult.Failure -> return decoded
is BackupResult.Success -> decoded.value
}
return readBackup {
val body = document.body
val app = body.optObject(KEY_APP)
SettingsBackup(
prefs = plainSection.read(body.optObject(KEY_SETTINGS) ?: invalidBackup("$KEY_SETTINGS: missing")),
calendars = body.optObjectList(KEY_CALENDARS).orEmpty().map { it.toCalendarSettings() },
language = app?.takeIf { KEY_LANGUAGE in it }?.let { LanguageChoice(it.optString(KEY_LANGUAGE)) },
launcherName = app?.optString(KEY_LAUNCHER_NAME)
?.let { name -> LauncherName.entries.firstOrNull { it.name == name } },
)
}
}
private fun CalendarSettings.toJson(): JsonObject = buildJsonObject {
put(KEY_ACCOUNT_NAME, JsonPrimitive(calendar.accountName))
put(KEY_ACCOUNT_TYPE, JsonPrimitive(calendar.accountType))
put(KEY_DISPLAY_NAME, JsonPrimitive(calendar.displayName))
calendar.specialDates?.let { put(KEY_SPECIAL_DATES, JsonPrimitive(it.name)) }
if (hidden) put(KEY_HIDDEN, JsonPrimitive(true))
if (lastUsed) put(KEY_LAST_USED, JsonPrimitive(true))
eventDurationMinutes?.let { put(KEY_EVENT_DURATION, JsonPrimitive(it)) }
reminders?.let { put(KEY_REMINDERS, JsonArray(it.map(::JsonPrimitive))) }
allDayReminders?.let { put(KEY_ALL_DAY_REMINDERS, JsonArray(it.map(::JsonPrimitive))) }
}
/**
* Strict about what it reads. A special-dates type this build doesn't know
* reads as an ordinary calendar, which then simply finds no match.
*/
private fun JsonObject.toCalendarSettings(): CalendarSettings {
return CalendarSettings(
calendar = CalendarIdentity(
accountName = requireString(KEY_ACCOUNT_NAME),
accountType = requireString(KEY_ACCOUNT_TYPE),
displayName = requireString(KEY_DISPLAY_NAME),
specialDates = optString(KEY_SPECIAL_DATES)?.let { name -> SpecialDateType.entries.firstOrNull { it.name == name } },
),
hidden = optBoolean(KEY_HIDDEN) ?: false,
lastUsed = optBoolean(KEY_LAST_USED) ?: false,
eventDurationMinutes = optInt(KEY_EVENT_DURATION),
reminders = optIntList(KEY_REMINDERS)?.cleanReminders(),
allDayReminders = optIntList(KEY_ALL_DAY_REMINDERS)?.cleanReminders(),
)
}
/** As the reminder setters store them: no negative leads, each once, ascending. */
private fun List<Int>.cleanReminders(): List<Int> = filter { it >= 0 }.distinct().sorted()
private const val KEY_SETTINGS = "settings"
private const val KEY_CALENDARS = "calendars"
private const val KEY_APP = "app"
private const val KEY_LANGUAGE = "language"
private const val KEY_LAUNCHER_NAME = "launcher_name"
private const val KEY_ACCOUNT_NAME = "account_name"
private const val KEY_ACCOUNT_TYPE = "account_type"
private const val KEY_DISPLAY_NAME = "display_name"
private const val KEY_SPECIAL_DATES = "special_dates"
private const val KEY_HIDDEN = "hidden"
private const val KEY_LAST_USED = "last_used"
private const val KEY_EVENT_DURATION = "event_duration_minutes"
private const val KEY_REMINDERS = "reminders"
private const val KEY_ALL_DAY_REMINDERS = "all_day_reminders"
@@ -0,0 +1,240 @@
package de.jeanlucmakiola.calendula.data.backup
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.BuildConfig
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.appname.LauncherNameManager
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncResult
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.widget.requestWidgetRedraw
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.rawValue
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Exports and imports Calendula's settings (#481). The file format and what
* it holds are [SettingsBackupSchema]'s; the import is planned purely by
* [planSettingsImport], written in one DataStore edit, and then followed by
* the side effects the settings screens would have run themselves.
*/
@Singleton
class SettingsBackupEngine @Inject constructor(
private val store: DataStore<Preferences>,
private val calendars: CalendarDataSource,
private val contacts: ContactSpecialDatesDataSource,
private val specialDates: SpecialDatesSyncEngine,
private val settingsPrefs: SettingsPrefs,
private val reminderScanner: ReminderScanner,
private val launcherNames: LauncherNameManager,
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun export(): String = withContext(io) {
val supported = supportedLanguages()
val snapshot = store.data.first()
// Without the list the per-calendar settings would silently drop out of the file.
val list = if (snapshot.hasCalendarSettings()) calendars.calendars() else emptyList()
val backup = buildSettingsBackup(
snapshot = snapshot,
calendars = list,
managed = if (list.any { it.isManaged }) calendars.findManagedCalendars() else emptyList(),
language = LanguageChoice(AppLanguage.currentTag(context, supported)),
launcherName = launcherNames.current(),
)
encodeSettingsBackup(backup, System.currentTimeMillis(), BuildConfig.VERSION_NAME)
}
/** Validates [text] in full, then replaces the settings; nothing is written for an invalid file. */
suspend fun import(text: String): BackupResult<SettingsImportSummary> = withContext(io) {
val backup = when (val decoded = decodeSettingsBackup(text)) {
is BackupResult.Failure -> return@withContext decoded
is BackupResult.Success -> decoded.value
}
val current = store.data.first()
val supported = supportedLanguages()
val environment = environment(current, backup, supported)
val plan = planSettingsImport(backup, current, environment)
val existingMirrors = environment.managed.map { it.id }.toSet()
// Once the write starts, the side effects have to follow it, whoever cancels.
withContext(NonCancellable) {
try {
store.edit { plan.applyTo(it) }
} catch (e: Exception) {
Log.w(TAG, "Settings import could not be written", e)
return@withContext BackupResult.Failure(BackupProblem.NOT_APPLIED, e.message.orEmpty())
}
val lateSkips = afterImport(plan, current, supported, existingMirrors)
BackupResult.Success(plan.summary(plan.calendars.orEmpty().size + lateSkips.matched, lateSkips.skipped))
}
}
private class LateMatches(val matched: Int, val skipped: List<SkippedSetting>)
/**
* Brings what reads the settings outside DataStore in line: the mirror,
* reminder alarms, scheduled backups, the launcher alias and the language.
* Each step stands alone, so one failing never undoes the import.
*/
private suspend fun afterImport(
plan: SettingsImportPlan,
before: Preferences,
supported: List<String>,
existingMirrors: Set<Long>,
): LateMatches {
val after = store.data.first()
val wasMirroring = before[SettingsKeys.specialDatesEnabled]
val mirroring = after[SettingsKeys.specialDatesEnabled]
var late = LateMatches(0, plan.pendingSpecialDates.map { it.calendar.skipped() })
step("special dates") {
if (mirroring) {
SpecialDatesScheduler.apply(context, enabled = true)
val result = specialDates.sync()
if (result != SpecialDatesSyncResult.Disabled) {
val stalled = SpecialDatesStalledReason.PermissionRevoked
.takeIf { result == SpecialDatesSyncResult.PermissionMissing }
settingsPrefs.recordSpecialDatesRun(System.currentTimeMillis(), stalled)
}
late = matchSpecialDates(plan, before, existingMirrors, synced = result == SpecialDatesSyncResult.Success)
} else if (wasMirroring) {
SpecialDatesScheduler.apply(context, enabled = false)
specialDates.teardown()
}
}
step("reminders") { reminderScanner.scan() }
step("auto backup") {
BackupScheduler.apply(
context = context,
enabled = after[SettingsKeys.autoBackupEnabled],
intervalMinutes = after[SettingsKeys.autoBackupIntervalMinutes],
hasFolder = after[SettingsKeys.autoBackupFolderUri] != null,
)
}
plan.launcherName?.let { name ->
step("launcher name") { if (launcherNames.current() != name) launcherNames.set(name) }
}
plan.language?.let { choice ->
step("language") {
if (AppLanguage.currentTag(context, supported) != choice.tag) {
withContext(Dispatchers.Main) { AppLanguage.apply(context, choice.tag) }
requestWidgetRedraw(context)
}
}
}
return late
}
/**
* Gives late-created mirror calendars their entries (only after a
* successful [synced] run), then rewrites each mirror whose all-day
* reminders differ from what its events carry: the pre-import override for
* a calendar that already existed, the sync's seed for a new one. A mirror
* the file doesn't override inherits again.
*/
private suspend fun matchSpecialDates(
plan: SettingsImportPlan,
before: Preferences,
existingMirrors: Set<Long>,
synced: Boolean,
): LateMatches {
val managed = calendars.findManagedCalendars()
val skipped = mutableListOf<SkippedSetting>()
var matched = 0
val resolved = if (!synced) {
skipped += plan.pendingSpecialDates.map { it.calendar.skipped() }
emptyList()
} else {
plan.pendingSpecialDates.mapNotNull { entry ->
val id = managed.firstOrNull { it.type == entry.calendar.specialDates }?.id
if (id == null) skipped += entry.calendar.skipped() else matched++
id?.let { it to entry }
}
}
if (resolved.isNotEmpty()) {
store.edit { prefs ->
resolved.forEach { (id, entry) -> prefs.addCalendarSettings(id, entry.copy(allDayReminders = null)) }
}
}
if (plan.calendars != null) {
val wanted = (plan.calendars.values + resolved.map { it.second })
.mapNotNull { entry -> entry.calendar.specialDates?.let { it to entry.allDayReminders } }
.toMap()
val carried = before[SettingsKeys.perCalendarAllDayReminderOverride]
val seeded = store.data.first()[SettingsKeys.perCalendarAllDayReminderOverride]
managed.distinctBy { it.type }.forEach { (id, type) ->
val minutes = wanted[type]
val onEvents = if (id in existingMirrors) carried[id] else seeded[id]
if (onEvents == minutes) return@forEach
val desired = minutes?.let { mapOf(id to it) }.orEmpty()
specialDates.applyReminders(type, desired.reminderOverrideFor(id))
}
}
return LateMatches(matched, skipped)
}
/** The highest byte count [import] is worth reading. */
val maxImportBytes: Int get() = SettingsBackupSchema.format.maxChars
private fun environment(
current: Preferences,
backup: SettingsBackup,
supported: List<String>,
): ImportEnvironment = ImportEnvironment(
// An empty list here is a failed read as far as the import can tell, never "wipe them all".
calendars = if (backup.calendars.isEmpty()) {
emptyList()
} else {
runCatching { calendars.calendars() }.getOrNull()?.takeIf { it.isNotEmpty() }
},
managed = runCatching { calendars.findManagedCalendars() }.getOrDefault(emptyList()),
contactsPermission = contacts.hasPermission(),
customFonts = FontRole.entries.filter { CustomFontStore.exists(context, it) }.toSet(),
hasBackupFolder = current[SettingsKeys.autoBackupFolderUri] != null,
supportedLanguages = supported,
)
private fun Preferences.hasCalendarSettings(): Boolean =
SettingsBackupSchema.perCalendar.any { rawValue(it) != null }
private fun supportedLanguages(): List<String> = AppLanguage.supportedTags(context, R.xml.locales_config)
private suspend fun step(name: String, block: suspend () -> Unit) {
try {
block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Settings import: $name could not be refreshed", e)
}
}
private companion object {
const val TAG = "SettingsBackup"
}
}
@@ -0,0 +1,106 @@
package de.jeanlucmakiola.calendula.data.backup
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.ReminderStateKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.floret.backup.BackupFormat
import de.jeanlucmakiola.floret.prefs.Pref
/**
* Which stored keys a settings backup carries (#481), and how. Every key of
* the settings store is in exactly one of the three lists; a test holds that,
* so a new setting cannot ship without deciding. See docs/SETTINGS_BACKUP.md.
*/
internal object SettingsBackupSchema {
val format = BackupFormat(name = "calendula-settings", version = 1)
/** Carried as stored and restored over the current value; an absent key restores the default. */
val plain: List<Pref<*>> = listOf(
SettingsKeys.themeMode,
SettingsKeys.dynamicColor,
SettingsKeys.weekStart,
SettingsKeys.softenCalendarColors,
SettingsKeys.brandFont,
SettingsKeys.plainFont,
SettingsKeys.timeFormat,
SettingsKeys.showHourGrid,
SettingsKeys.pastEventDisplay,
SettingsKeys.dimCompletedEvents,
SettingsKeys.showWeekNumbers,
SettingsKeys.monthShowStartTimes,
SettingsKeys.monthViewStyle,
SettingsKeys.timelineScale,
SettingsKeys.multiDayCount,
SettingsKeys.timelineHoursEnabled,
SettingsKeys.timelineHoursStart,
SettingsKeys.timelineHoursEnd,
SettingsKeys.todayButtonInToolbar,
SettingsKeys.dragToReschedule,
SettingsKeys.tapOpensEditor,
SettingsKeys.agendaScreenRange,
SettingsKeys.agendaShowRangeBar,
SettingsKeys.agendaShowToday,
SettingsKeys.defaultView,
SettingsKeys.quickSwitchConfig,
SettingsKeys.drawerViewOrder,
SettingsKeys.defaultFormFields,
SettingsKeys.autofocusEventTitle,
SettingsKeys.defaultEventDurationMinutes,
SettingsKeys.remindersEnabled,
SettingsKeys.allowColorOnUnsupportedCalendars,
SettingsKeys.defaultReminderMinutes,
SettingsKeys.defaultAllDayReminderMinutes,
SettingsKeys.allDayReminderTimeMinutes,
SettingsKeys.snoozeMinutes,
SettingsKeys.autoBackupEnabled,
SettingsKeys.autoBackupIntervalMinutes,
SettingsKeys.specialDatesEnabled,
SettingsKeys.specialDatesShowYear,
) + SettingsKeys.specialDateTypeEnabled.values + SettingsKeys.specialDatesTitleTemplate.values
/**
* Keyed by calendar id on disk, so carried per calendar under a portable
* identity instead ([CalendarSettings]) and re-keyed to this device's ids.
*/
val perCalendar: List<Pref<*>> = listOf(
CalendarPrefKeys.hiddenCalendarIds,
CalendarPrefKeys.lastUsedCalendarId,
SettingsKeys.perCalendarEventDuration,
SettingsKeys.perCalendarReminderOverride,
SettingsKeys.perCalendarAllDayReminderOverride,
)
/** Never exported: this install's own state, bookkeeping, or meaningless on another device. */
val excluded: List<Pref<*>> = (
listOf(
// Cache-busting counters for the locally imported font file.
SettingsKeys.brandFontStamp,
SettingsKeys.plainFontStamp,
// Legacy defaults for unconfigured widgets; widget configuration stays out.
SettingsKeys.agendaWidgetRange,
SettingsKeys.widgetSize,
// Usage history, not a setting.
SettingsKeys.recentTimeZones,
// First-run wizard progress of this install.
SettingsKeys.onboardingWizardArmed,
CalendarPrefKeys.visibilityNoticePending,
// A SAF grant, which does not survive a reinstall or another device.
SettingsKeys.autoBackupFolderUri,
// Run status.
SettingsKeys.autoBackupLastRun,
SettingsKeys.autoBackupLastSuccess,
SettingsKeys.autoBackupFailures,
SettingsKeys.specialDatesLastRun,
SettingsKeys.specialDatesStalled,
SettingsKeys.specialDatesLastForegroundSync,
// Switch-offs still owed to the provider on this device.
CalendarPrefKeys.pendingDisabledCalendarIds,
// The reminder delivery watermark.
ReminderStateKeys.lastScanMillis,
) +
SettingsKeys.onboardingDone.values +
// The mirror re-adopts its calendars by their provider marker.
SettingsKeys.specialDatesCalendarId.values
).distinct()
}
@@ -0,0 +1,214 @@
package de.jeanlucmakiola.calendula.data.backup
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.floret.locale.AppLanguage
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.rawValue
import de.jeanlucmakiola.floret.prefs.set
import de.jeanlucmakiola.floret.prefs.setRawValue
/** What this device can take of a backup, read before planning. */
data class ImportEnvironment(
/** Null when the calendar list can't be read: the per-calendar settings are then left alone. */
val calendars: List<CalendarSource>?,
val managed: List<ManagedCalendarRow>,
val contactsPermission: Boolean,
val customFonts: Set<FontRole>,
val hasBackupFolder: Boolean,
val supportedLanguages: List<String>,
)
/** Something in the file this device could not take, for the import summary. */
sealed interface SkippedSetting {
/** No calendar on this device matches the file's, or [ambiguous]ly more than one does. */
data class Calendar(
val displayName: String,
val accountName: String,
val ambiguous: Boolean = false,
) : SkippedSetting
/** The calendars couldn't be read, so the per-calendar settings were left as they are. */
data object CalendarsUnavailable : SkippedSetting
/** A custom font: the font file itself is not part of the backup. */
data class CustomFont(val role: FontRole) : SkippedSetting
/** Contact special dates were on, but this device hasn't granted the contacts permission. */
data object SpecialDates : SkippedSetting
/** Automatic backup was on, but its folder has to be chosen again here. */
data object AutoBackupFolder : SkippedSetting
/** The file's app language isn't one this build ships. */
data class Language(val tag: String) : SkippedSetting
}
data class SettingsImportSummary(
val calendarsMatched: Int,
val skipped: List<SkippedSetting>,
)
/**
* A validated backup laid onto this device: the stored values to write in one
* edit, the side effects the engine runs after it, and the summary.
* [calendars] null leaves the per-calendar keys untouched.
* [pendingSpecialDates] are mirror calendars that don't exist yet; the engine
* matches them once the mirror's sync has created them.
*/
data class SettingsImportPlan(
val prefs: Map<Pref<*>, Any?>,
val calendars: Map<Long, CalendarSettings>?,
val pendingSpecialDates: List<CalendarSettings>,
val language: LanguageChoice?,
val launcherName: LauncherName?,
val skipped: List<SkippedSetting>,
)
/** Plans an import that replaces every backed-up setting; see docs/SETTINGS_BACKUP.md. */
fun planSettingsImport(
backup: SettingsBackup,
current: Preferences,
environment: ImportEnvironment,
): SettingsImportPlan {
val skipped = mutableListOf<SkippedSetting>()
val prefs = backup.prefs.toMutableMap<Pref<*>, Any?>()
fun keepCurrent(pref: Pref<*>) {
prefs[pref] = current.rawValue(pref)
}
FontRole.entries.forEach { role ->
val pref = role.fontPref()
if (prefs[pref] == FONT_CUSTOM_TOKEN && role !in environment.customFonts) {
keepCurrent(pref)
skipped += SkippedSetting.CustomFont(role)
}
}
if (prefs[SettingsKeys.specialDatesEnabled] == true && !environment.contactsPermission) {
keepCurrent(SettingsKeys.specialDatesEnabled)
skipped += SkippedSetting.SpecialDates
}
if (prefs[SettingsKeys.autoBackupEnabled] == true && !environment.hasBackupFolder) {
keepCurrent(SettingsKeys.autoBackupEnabled)
skipped += SkippedSetting.AutoBackupFolder
}
val language = backup.language?.let { choice ->
val tag = choice.tag ?: return@let choice
val resolved = AppLanguage.resolveTag(tag, environment.supportedLanguages)
if (resolved in environment.supportedLanguages) {
LanguageChoice(resolved)
} else {
skipped += SkippedSetting.Language(tag)
null
}
}
// The mirror's calendars are torn down when the imported settings leave it off.
val mirroring = prefs[SettingsKeys.specialDatesEnabled] == true
val available = environment.calendars
val calendars = linkedMapOf<Long, CalendarSettings>()
val pending = mutableListOf<CalendarSettings>()
if (available == null) {
if (backup.calendars.isNotEmpty()) skipped += SkippedSetting.CalendarsUnavailable
} else {
val repeated = backup.calendars.groupingBy { it.calendar }.eachCount().filterValues { it > 1 }.keys
backup.calendars.forEach { entry ->
val type = entry.calendar.specialDates
if (type != null) {
// The mirror only keeps calendars for the types the imported settings switch on.
val typeOn = prefs[SettingsKeys.specialDateTypeEnabled.getValue(type)] != false
if (!mirroring || !typeOn) return@forEach
val id = environment.managed.firstOrNull { it.type == type }?.id
if (id != null) calendars[id] = entry else pending += entry
return@forEach
}
if (entry.calendar in repeated) {
// Two calendars on the exporting phone that this one can't tell apart.
val reported = SkippedSetting.Calendar(entry.calendar.displayName, entry.calendar.accountName, ambiguous = true)
if (reported !in skipped) skipped += reported
return@forEach
}
val candidates = available.filter { !it.isManaged && it.matches(entry.calendar) }
when (candidates.size) {
1 -> calendars[candidates.single().id] = entry
else -> skipped += entry.calendar.skipped(ambiguous = candidates.size > 1)
}
}
}
return SettingsImportPlan(
prefs = prefs,
calendars = calendars.takeIf { available != null },
pendingSpecialDates = pending,
language = language,
launcherName = backup.launcherName,
skipped = skipped,
)
}
/** Writes the plan's stored values, replacing every backed-up setting and all per-calendar ones. */
fun SettingsImportPlan.applyTo(target: MutablePreferences) {
plainSection.apply(target, prefs)
calendars?.let(target::writeCalendarSettings)
}
/**
* Replaces the per-calendar keys with [calendars], by this device's ids. A
* last-used calendar only counts from the first entry claiming it.
*/
fun MutablePreferences.writeCalendarSettings(calendars: Map<Long, CalendarSettings>) {
SettingsBackupSchema.perCalendar.forEach { setRawValue(it, null) }
calendars.forEach { (id, entry) -> addCalendarSettings(id, entry) }
}
/** Adds one late-matched calendar's settings to what [writeCalendarSettings] stored. */
fun MutablePreferences.addCalendarSettings(id: Long, entry: CalendarSettings) {
if (entry.hidden) this[CalendarPrefKeys.hiddenCalendarIds] = this[CalendarPrefKeys.hiddenCalendarIds] + id
if (entry.lastUsed && this[CalendarPrefKeys.lastUsedCalendarId] == null) {
this[CalendarPrefKeys.lastUsedCalendarId] = id
}
entry.eventDurationMinutes?.let {
this[SettingsKeys.perCalendarEventDuration] =
this[SettingsKeys.perCalendarEventDuration] + (id to it.clampDuration())
}
entry.reminders?.let {
this[SettingsKeys.perCalendarReminderOverride] = this[SettingsKeys.perCalendarReminderOverride] + (id to it)
}
entry.allDayReminders?.let {
this[SettingsKeys.perCalendarAllDayReminderOverride] =
this[SettingsKeys.perCalendarAllDayReminderOverride] + (id to it)
}
}
fun CalendarIdentity.skipped(ambiguous: Boolean = false): SkippedSetting =
SkippedSetting.Calendar(displayName, accountName, ambiguous)
fun SettingsImportPlan.summary(calendarsMatched: Int, more: List<SkippedSetting> = emptyList()) =
SettingsImportSummary(
calendarsMatched = calendarsMatched,
skipped = skipped + more,
)
private fun CalendarSource.matches(identity: CalendarIdentity): Boolean =
accountType == identity.accountType &&
accountName == identity.accountName &&
displayName == identity.displayName
/** The same range the per-calendar duration setter clamps to. */
private fun Int.clampDuration(): Int = coerceIn(SettingsPrefs.MIN_EVENT_DURATION, SettingsPrefs.MAX_EVENT_DURATION)
private fun FontRole.fontPref(): Pref<String> = when (this) {
FontRole.BRAND -> SettingsKeys.brandFont
FontRole.PLAIN -> SettingsKeys.plainFont
}
@@ -35,7 +35,6 @@ import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.semanticReminderMinutes
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
import de.jeanlucmakiola.calendula.domain.fixedReminderOffsets
import de.jeanlucmakiola.calendula.domain.toEditForm
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
@@ -920,15 +919,11 @@ class AndroidCalendarDataSource @Inject constructor(
minutes
}
/**
* [form]'s reminders as the distinct raw provider offsets to store, its
* fixed-time ones (#459) measured from the start it is saved with.
*/
/** [form]'s reminders as the distinct raw provider offsets to store. */
private fun encodedReminders(form: EventForm, allDayReminderTimeMinutes: Int): List<Int> =
(
form.reminders.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) } +
form.fixedReminderOffsets(TimeZone.currentSystemDefault())
).distinct()
form.reminders
.map { providerReminderMinutes(form, it, allDayReminderTimeMinutes) }
.distinct()
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
// A globally-unique UID so a later .ics backup/restore can identify
@@ -1031,11 +1026,7 @@ class AndroidCalendarDataSource @Inject constructor(
// Untouched reminder sets are left alone so unrelated edits can't
// disturb provider rows the form never knew about. The diff is on the
// form's semantic minutes; reconcile works in encoded provider minutes.
// A fixed-time reminder also changes when the start's day or time does.
val zone = TimeZone.currentSystemDefault()
if (updated.reminders.toSet() != original.reminders.toSet() ||
updated.fixedReminderOffsets(zone).toSet() != original.fixedReminderOffsets(zone).toSet()
) {
if (updated.reminders.toSet() != original.reminders.toSet()) {
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
}
// Same untouched-set guard for guests: only reconcile when the form's
@@ -1236,9 +1227,8 @@ class AndroidCalendarDataSource @Inject constructor(
)
}
// Whether the provider copied the parent's reminder rows is its
// business — reconciling against the actual rows handles both ways. The
// exception is a one-off, so a fixed-time reminder takes real minutes.
reconcileReminders(exceptionId, encodedReminders(form.toDetachedOccurrence(), allDayReminderTimeMinutes))
// business — reconciling against the actual rows handles both ways.
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
reconcileAttendees(exceptionId, form.attendees)
return exceptionId
}
@@ -28,9 +28,9 @@ class CalendarPrefs @Inject constructor(
) {
private val prefs = PrefStore(store)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.hiddenCalendarIds)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, ids)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(CalendarPrefKeys.hiddenCalendarIds, ids)
/**
* Switch-offs the provider does not know about yet, because writing
@@ -41,10 +41,10 @@ class CalendarPrefs @Inject constructor(
* second visibility model: `CalendarVisibilityReconciler` drains it entry by
* entry as soon as the app may write, and nothing adds to it while it may.
*/
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.pendingDisabledCalendarIds)
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(DISABLED_IDS) { it + ids }
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it + ids }
}
/**
@@ -52,7 +52,7 @@ class CalendarPrefs @Inject constructor(
* them, so a run that fails part-way never re-applies what already landed.
*/
suspend fun removePendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(DISABLED_IDS) { it - ids.toSet() }
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it - ids.toSet() }
}
/**
@@ -60,28 +60,30 @@ class CalendarPrefs @Inject constructor(
* owed. Null until the reconciler has evaluated it (which needs the calendar
* permission), false once it has been shown or was never needed.
*/
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(CalendarPrefKeys.visibilityNoticePending)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, pending)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(CalendarPrefKeys.visibilityNoticePending, pending)
/**
* The calendar the user last created an event in; preselected in the
* event form. Null until the first event is created.
*/
val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
val lastUsedCalendarId: Flow<Long?> = prefs.flow(CalendarPrefKeys.lastUsedCalendarId)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(CalendarPrefKeys.lastUsedCalendarId, id)
private companion object {
val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
val DISABLED_IDS = idSetPref("disabled_calendar_ids")
val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
val LAST_USED_CALENDAR = nullableLongPref("last_used_calendar_id")
}
}
/** Every key [CalendarPrefs] stores; the names are on-disk data. */
internal object CalendarPrefKeys {
val hiddenCalendarIds: Pref<Set<Long>> = idSetPref("hidden_calendar_ids")
val pendingDisabledCalendarIds: Pref<Set<Long>> = idSetPref("disabled_calendar_ids")
val visibilityNoticePending: Pref<Boolean?> = nullableBooleanPref("visibility_notice_pending")
val lastUsedCalendarId: Pref<Long?> = nullableLongPref("last_used_calendar_id")
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
internal fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
private fun idSetPref(name: String): Pref<Set<Long>> = nullableStringPref(name).map(
{ stored -> stored.orEmpty().split(',').mapNotNull { it.trim().toLongOrNull() }.toSet() },
{ ids -> if (ids.isEmpty()) null else ids.sorted().joinToString(",") },
)
@@ -2,10 +2,9 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.floret.prefs.Pref
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import java.time.LocalDate
import javax.inject.Inject
import javax.inject.Singleton
@@ -25,24 +24,13 @@ class ReminderStatePrefs @Inject constructor(
private val prefs = PrefStore(store)
/** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
suspend fun lastScanMillis(): Long? = prefs.get(ReminderStateKeys.lastScanMillis)
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
suspend fun setLastScanMillis(millis: Long) = prefs.set(ReminderStateKeys.lastScanMillis, millis)
/**
* The day the last daily summary listed (#459): recorded at once, so neither
* a pass failing after posting nor a later time picked the same evening
* lists it twice.
*/
suspend fun lastDigestDay(): LocalDate? =
prefs.get(LAST_DIGEST_DAY)?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
}
suspend fun setLastDigestDay(day: LocalDate) = prefs.set(LAST_DIGEST_DAY, day.toString())
suspend fun clearLastDigestDay() = prefs.set(LAST_DIGEST_DAY, null)
private companion object {
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
val LAST_DIGEST_DAY = nullableStringPref("reminder_last_digest_day")
}
/** The key [ReminderStatePrefs] stores. */
internal object ReminderStateKeys {
val lastScanMillis: Pref<Long?> = nullableLongPref("reminder_last_scan_millis")
}
@@ -1,15 +1,10 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.reminders.DEFAULT_DIGEST_TIME_MINUTES
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
@@ -171,8 +166,6 @@ class SettingsPrefs @Inject constructor(
val defaultEventDurationMinutes: Flow<Int> = prefs.flow(SettingsKeys.defaultEventDurationMinutes)
val perCalendarEventDuration: Flow<Map<Long, Int>> = prefs.flow(SettingsKeys.perCalendarEventDuration)
val remindersEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.remindersEnabled)
val mutedReminderCalendarIds: Flow<Set<Long>> = prefs.flow(SettingsKeys.mutedReminderCalendarIds)
val notificationCategoryHintDismissed: Flow<Boolean> = prefs.flow(SettingsKeys.notificationCategoryHintDismissed)
val allowColorOnUnsupportedCalendars: Flow<Boolean> =
prefs.flow(SettingsKeys.allowColorOnUnsupportedCalendars)
val defaultReminderMinutes: Flow<List<Int>> = prefs.flow(SettingsKeys.defaultReminderMinutes)
@@ -183,20 +176,6 @@ class SettingsPrefs @Inject constructor(
prefs.flow(SettingsKeys.perCalendarReminderOverride)
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> =
prefs.flow(SettingsKeys.perCalendarAllDayReminderOverride)
val defaultFixedTimeReminder: Flow<FixedTimeReminder?> = prefs.flow(SettingsKeys.defaultFixedTimeReminder)
val perCalendarFixedTimeReminderOverride: Flow<Map<Long, FixedTimeReminder?>> =
prefs.flow(SettingsKeys.perCalendarFixedTimeReminderOverride)
/**
* Every "N days before at HH:MM" the defaults name, so an offset landing on
* one reads back as that reminder rather than a lead time (#459).
*/
val knownFixedTimeReminders: Flow<Set<FixedTimeReminder>> = prefs.snapshot {
setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) +
it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull() +
it[SettingsKeys.recentFixedTimeReminders]
}
val dailyDigest: Flow<DailyDigestSettings> = prefs.snapshot { it.dailyDigest }
val autoBackupEnabled: Flow<Boolean> = prefs.flow(SettingsKeys.autoBackupEnabled)
val autoBackupIntervalMinutes: Flow<Long> = prefs.flow(SettingsKeys.autoBackupIntervalMinutes)
val autoBackupFolderUri: Flow<String?> = prefs.flow(SettingsKeys.autoBackupFolderUri)
@@ -294,12 +273,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setRemindersEnabled(enabled: Boolean) = prefs.set(SettingsKeys.remindersEnabled, enabled)
suspend fun dismissNotificationCategoryHint() = prefs.set(SettingsKeys.notificationCategoryHintDismissed, true)
suspend fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
prefs.update(SettingsKeys.mutedReminderCalendarIds) { if (enabled) it - calendarId else it + calendarId }
}
suspend fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) =
prefs.set(SettingsKeys.allowColorOnUnsupportedCalendars, enabled)
@@ -351,77 +324,6 @@ class SettingsPrefs @Inject constructor(
suspend fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) =
prefs.applyOverride(SettingsKeys.perCalendarAllDayReminderOverride, calendarId, override)
/** The replaced default stays a known pattern, so events that got it keep reading as it. */
suspend fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) {
prefs.edit {
val old = it[SettingsKeys.defaultFixedTimeReminder]
if (old != reminder) retireFixedTimeReminder(it, old)
it[SettingsKeys.defaultFixedTimeReminder] = reminder
}
}
/** [override] [FixedTimeOverride.Inherit] drops the calendar's entry; a replaced value stays known. */
suspend fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) {
prefs.edit {
val current = it[SettingsKeys.perCalendarFixedTimeReminderOverride]
val old = current[calendarId]
if (old != (override as? FixedTimeOverride.At)?.reminder) retireFixedTimeReminder(it, old)
it[SettingsKeys.perCalendarFixedTimeReminderOverride] = when (override) {
FixedTimeOverride.Inherit -> current - calendarId
FixedTimeOverride.Off -> current + (calendarId to null)
is FixedTimeOverride.At -> current + (calendarId to override.reminder)
}
}
}
/**
* Remember fixed-time reminders saved by hand (#459), so their rows read
* back as them; the defaults are known anyway and take no slot.
*/
suspend fun rememberFixedTimeReminders(reminders: Collection<FixedTimeReminder>) {
prefs.edit {
val defaults = setOfNotNull(it[SettingsKeys.defaultFixedTimeReminder]) +
it[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull()
val fresh = reminders.filterNot { reminder -> reminder in defaults }
if (fresh.isNotEmpty()) {
it[SettingsKeys.recentFixedTimeReminders] = (fresh + it[SettingsKeys.recentFixedTimeReminders]).distinct()
}
}
}
/** Keep a default being replaced known, unless it still is a default elsewhere. */
private fun retireFixedTimeReminder(preferences: MutablePreferences, reminder: FixedTimeReminder?) {
if (reminder == null) return
val defaults = listOfNotNull(preferences[SettingsKeys.defaultFixedTimeReminder]) +
preferences[SettingsKeys.perCalendarFixedTimeReminderOverride].values.filterNotNull()
if (defaults.count { it == reminder } > 1) return
preferences[SettingsKeys.recentFixedTimeReminders] =
(listOf(reminder) + preferences[SettingsKeys.recentFixedTimeReminders]).distinct()
}
suspend fun setDailyDigestEnabled(enabled: Boolean) = editDigest { it[SettingsKeys.dailyDigestEnabled] = enabled }
suspend fun setDailyDigestTimeMinutes(minutesOfDay: Int) =
editDigest { it[SettingsKeys.dailyDigestTimeMinutes] = minutesOfDay }
suspend fun setDailyDigestDay(day: DigestDay) = editDigest { it[SettingsKeys.dailyDigestDay] = day }
/**
* Change when the summary arrives, stamping the moment: a summary whose time
* had already passed today is not owed by the change itself.
*/
private suspend fun editDigest(change: (MutablePreferences) -> Unit) {
prefs.edit {
change(it)
it[SettingsKeys.dailyDigestArmedAt] = System.currentTimeMillis()
}
}
suspend fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) {
prefs.update(SettingsKeys.digestExcludedCalendarIds) { if (included) it - calendarId else it + calendarId }
}
suspend fun setAutoBackupEnabled(enabled: Boolean) = prefs.set(SettingsKeys.autoBackupEnabled, enabled)
suspend fun setAutoBackupIntervalMinutes(minutes: Long) =
@@ -471,7 +373,6 @@ class SettingsPrefs @Inject constructor(
/** 09:00 as minutes from midnight; the default all-day reminder fire time. */
internal const val DEFAULT_ALLDAY_REMINDER_TIME = 540
internal const val MINUTES_PER_DAY = 1_440
internal const val MAX_RECENT_FIXED_TIME_REMINDERS = 12
/** Default snooze delay for the notification "Snooze" action. */
const val DEFAULT_SNOOZE_MINUTES = 10
const val DEFAULT_MULTI_DAY_COUNT = 3
@@ -693,18 +594,6 @@ object SettingsKeys {
*/
val remindersEnabled: Pref<Boolean> = booleanPref("reminders_enabled", true)
/**
* Calendars whose reminders Calendula doesn't post (#272), independent of
* `Calendars.VISIBLE`: the events still show, only the notifications stop.
*/
val mutedReminderCalendarIds: Pref<Set<Long>> = idSetPref("muted_reminder_calendar_ids")
/**
* Whether the one-time hint that some skins (Samsung) start new notification
* categories switched off has been dismissed (#145).
*/
val notificationCategoryHintDismissed: Pref<Boolean> = booleanPref("notification_category_hint_dismissed", false)
/**
* Whether to offer a custom event colour on calendars that publish no colour
* palette; synced ones without a palette (some CalDAV) may drop or overwrite
@@ -794,48 +683,6 @@ object SettingsKeys {
val perCalendarAllDayReminderOverride: Pref<Map<Long, List<Int>>> =
reminderOverridePref("per_calendar_allday_reminder_override")
/**
* The "N days before at HH:MM" reminder added to new **timed** events
* (#459), or null for none — the factory default. Stored as `days@minute`.
*/
val defaultFixedTimeReminder: Pref<FixedTimeReminder?> =
nullableStringPref("default_fixed_time_reminder").map(FixedTimeReminder::parse) { it?.toToken() }
/**
* Per-calendar overrides of [defaultFixedTimeReminder]: present with a
* value replaces it, present as null turns it off, absent inherits.
* Serialised as `id=days@minute` or `id=none`, joined by `;`.
*/
val perCalendarFixedTimeReminderOverride: Pref<Map<Long, FixedTimeReminder?>> =
nullableStringPref("per_calendar_fixed_time_reminder").map(::parseFixedTimeOverrides) {
it.toStoredFixedTimeOverrides()
}
/** The daily summary notification (#459): opt-in, and which day it lists at what time. */
val dailyDigestEnabled: Pref<Boolean> = booleanPref("daily_digest_enabled", false)
val dailyDigestTimeMinutes: Pref<Int> =
intPref("daily_digest_time_minutes", DEFAULT_DIGEST_TIME_MINUTES).coerced {
it.coerceIn(0, SettingsPrefs.MINUTES_PER_DAY - 1)
}
val dailyDigestDay: Pref<DigestDay> = enumPref("daily_digest_day", DigestDay.Tomorrow)
/** Calendars left out of the daily summary; every other one is in. */
val digestExcludedCalendarIds: Pref<Set<Long>> = idSetPref("digest_excluded_calendar_ids")
/** When the summary's switch, time or day last changed; a moment before it is never owed. */
val dailyDigestArmedAt: Pref<Long> = longPref("daily_digest_armed_at", 0L)
/**
* Fixed-time reminders last saved by hand, and defaults since replaced,
* newest first — with the current defaults, the patterns a stored offset is
* recognised as. Capped, so the oldest falls out of recognition.
*/
val recentFixedTimeReminders: Pref<List<FixedTimeReminder>> =
nullableStringPref("recent_fixed_time_reminders").map(
{ stored -> stored?.split(LIST_SEP).orEmpty().mapNotNull(FixedTimeReminder::parse).distinct() },
{ reminders -> reminders.take(SettingsPrefs.MAX_RECENT_FIXED_TIME_REMINDERS).joinToString(LIST_SEP) { it.toToken() } },
)
/** Whether periodic automatic `.ics` export of local calendars is on (issue #8). */
val autoBackupEnabled: Pref<Boolean> = booleanPref("auto_backup_enabled", false)
@@ -1032,57 +879,6 @@ data class BackupStatus(
val consecutiveFailures: Int,
)
/** The daily summary's settings, read together. */
internal val Preferences.dailyDigest: DailyDigestSettings
get() = DailyDigestSettings(
enabled = this[SettingsKeys.dailyDigestEnabled],
timeMinutes = this[SettingsKeys.dailyDigestTimeMinutes],
day = this[SettingsKeys.dailyDigestDay],
excludedCalendarIds = this[SettingsKeys.digestExcludedCalendarIds],
armedAtMillis = this[SettingsKeys.dailyDigestArmedAt],
)
/** A calendar's choice for the fixed-time default, as a picker returns it. */
sealed interface FixedTimeOverride {
data object Inherit : FixedTimeOverride
data object Off : FixedTimeOverride
data class At(val reminder: FixedTimeReminder) : FixedTimeOverride
}
/** [calendarId]'s entry read as a [FixedTimeOverride]. */
fun Map<Long, FixedTimeReminder?>.fixedTimeOverrideFor(calendarId: Long): FixedTimeOverride = when {
!containsKey(calendarId) -> FixedTimeOverride.Inherit
else -> getValue(calendarId)?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off
}
/** The fixed-time reminder a new timed event on [calendarId] gets, or null for none. */
fun resolveDefaultFixedTimeReminder(
global: FixedTimeReminder?,
overrides: Map<Long, FixedTimeReminder?>,
calendarId: Long?,
): FixedTimeReminder? =
if (calendarId != null && overrides.containsKey(calendarId)) overrides[calendarId] else global
private fun parseFixedTimeOverrides(stored: String?): Map<Long, FixedTimeReminder?> =
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP)
if (parts.size != 2) return@mapNotNull null
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
val value = parts[1].trim()
if (value == NONE) return@mapNotNull calendarId to null
val reminder = FixedTimeReminder.parse(value) ?: return@mapNotNull null
calendarId to reminder
}.toMap()
private fun Map<Long, FixedTimeReminder?>.toStoredFixedTimeOverrides(): String? =
if (isEmpty()) {
null
} else {
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, reminder) ->
"$id$KEY_VALUE_SEP${reminder?.toToken() ?: NONE}"
}
}
/**
* The lead times to prefill on a new event: the matching per-calendar override
* if [calendarId] has one for this event kind, otherwise the global default for
@@ -1,157 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.MainActivity
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import kotlinx.coroutines.flow.first
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.toKotlinLocalDate
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts the daily summary (#459): one notification listing a day's events, on
* its own channel, replacing the one before it. Tapping opens that day.
*/
@Singleton
class DailyDigestNotifier @Inject constructor(
@ApplicationContext private val context: Context,
private val settingsPrefs: SettingsPrefs,
) {
/**
* Post the summary of [day] listing [listed], or withdraw the last one when
* there is nothing to list — yesterday's would otherwise stand in for it.
*/
suspend fun post(day: LocalDate, listed: List<ReminderEventInstance>) {
val manager = NotificationManagerCompat.from(context)
if (listed.isEmpty()) {
manager.cancel(TAG_DIGEST, NOTIFICATION_ID)
return
}
ensureChannel()
val zone = ZoneId.systemDefault()
val locale = Locale.getDefault()
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
val today = LocalDate.now(zone)
val firstDayOfWeek = DayOfWeek.of(settingsPrefs.weekStart.first().resolveFirstDay(locale).isoDayNumber)
// The date rides along: the summary stays up into the day it lists,
// when "Tomorrow" alone would point at the wrong one.
val date = DateTimeFormatter.ofPattern(
android.text.format.DateFormat.getBestDateTimePattern(locale, "EEEdMMM"),
locale,
).format(day)
val dayLabel = when (day) {
today -> context.getString(R.string.digest_day_relative, context.getString(R.string.reminder_day_today), date)
today.plusDays(1) ->
context.getString(R.string.digest_day_relative, context.getString(R.string.reminder_day_tomorrow), date)
else -> date
}
val allDay = context.getString(R.string.event_detail_all_day)
val lines = listed.map { entry ->
// Read against the listed day, so a timed event on it shows its bare
// range and one crossing midnight its dates.
val time = if (entry.isAllDay) allDay else reminderTimeText(
beginMillis = entry.beginMillis,
endMillis = entry.endMillis,
isAllDay = entry.isAllDay,
zone = zone,
locale = locale,
is24Hour = is24Hour,
today = day,
firstDayOfWeek = firstDayOfWeek,
todayLabel = allDay,
tomorrowLabel = context.getString(R.string.reminder_day_tomorrow),
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
context.getString(R.string.digest_line, time, entry.title.ifBlank { context.getString(R.string.event_untitled) })
}
val title = context.resources.getQuantityString(R.plurals.digest_title, listed.size, dayLabel, listed.size)
val style = NotificationCompat.InboxStyle().setBigContentTitle(title)
lines.take(MAX_LINES).forEach(style::addLine)
if (lines.size > MAX_LINES) {
val more = lines.size - MAX_LINES
style.setSummaryText(context.resources.getQuantityString(R.plurals.digest_more, more, more))
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(listed.joinToString(", ") { it.title.ifBlank { context.getString(R.string.event_untitled) } })
.setStyle(style)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setAutoCancel(true)
// Gone once its day is over; "Tomorrow" would read wrong by then anyway.
.setTimeoutAfter(
(day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - System.currentTimeMillis())
.coerceAtLeast(1L),
)
.setContentIntent(openDay(day))
.build()
try {
manager.notify(TAG_DIGEST, NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
Log.w(TAG, "Could not post the daily summary", e)
}
}
/** Whether the summary's own channel is switched off in the system settings. */
fun isChannelOff(): Boolean =
context.getSystemService(NotificationManager::class.java)
.getNotificationChannel(CHANNEL_ID)?.importance == NotificationManager.IMPORTANCE_NONE
/** Withdraw the summary showing, if any. */
fun cancel() {
NotificationManagerCompat.from(context).cancel(TAG_DIGEST, NOTIFICATION_ID)
}
/** The summary's channel id, created if missing — for the settings deep-link. */
fun channelId(): String {
ensureChannel()
return CHANNEL_ID
}
/** Creates the channel, or renames it after a language change; the user's settings on it stay. */
private fun ensureChannel() {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.digest_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply { description = context.getString(R.string.digest_channel_description) },
)
}
private fun openDay(day: LocalDate): PendingIntent = PendingIntent.getActivity(
context,
REQUEST_CODE,
MainActivity.openDayIntent(context, day.toKotlinLocalDate()),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
private companion object {
const val TAG = "DailyDigestNotifier"
const val CHANNEL_ID = "daily_digest"
const val TAG_DIGEST = "daily_digest"
const val NOTIFICATION_ID = 2
const val REQUEST_CODE = 0xD16E
const val MAX_LINES = 6
}
}
@@ -1,161 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
/** The single channel every reminder posted on before they moved per calendar (#145). */
const val LEGACY_REMINDER_CHANNEL_ID = "reminders"
private const val CHANNEL_PREFIX = "reminders.calendar."
private const val GROUP_PREFIX = "reminders.account."
private const val LOCAL_GROUP_ID = GROUP_PREFIX + "local"
/** The notification channel [calendarId]'s reminders post on. */
fun reminderChannelId(calendarId: Long): String = CHANNEL_PREFIX + calendarId
/** Whether [channelId] is one of the per-calendar reminder channels. */
fun isReminderChannelId(channelId: String): Boolean = channelId.startsWith(CHANNEL_PREFIX)
/** Whether [groupId] is one of the per-account reminder channel groups. */
fun isReminderGroupId(groupId: String): Boolean = groupId.startsWith(GROUP_PREFIX)
/**
* One account's channel group, labelled as the calendar manager labels it. The
* app's own device calendars share a single group ([isLocal]); [ambiguous]
* marks a [label] another group shows too, so it should name the source app.
*/
data class ReminderChannelGroupSpec(
val id: String,
val label: String,
val accountType: String,
val isLocal: Boolean,
val ambiguous: Boolean,
)
data class ReminderChannelSpec(
val id: String,
val calendarId: Long,
val name: String,
/** Null only for a channel created before its calendar could be looked up. */
val groupId: String?,
/**
* Whether a sync creates it up front. A calendar that syncs no events never
* fires, so its channel only appears once something posts on it.
*/
val upFront: Boolean = true,
)
/** What a channel already looks like in the system, as far as the plan compares it. */
data class ExistingReminderChannel(val id: String, val name: String, val description: String?, val groupId: String?)
data class ReminderChannelPlan(
val groups: List<ReminderChannelGroupSpec>,
val channels: List<ReminderChannelSpec>,
/**
* The [channels] a sync writes: missing up-front ones, and existing ones
* whose name or (localized) description changed, or that still lack a group.
*/
val channelsToWrite: List<ReminderChannelSpec>,
/** The [groups] holding a channel that exists or is about to be written. */
val groupsInUse: List<ReminderChannelGroupSpec>,
val staleChannelIds: List<String>,
val staleGroupIds: List<String>,
)
/**
* What the reminder channels should look like for [calendars]: one channel per
* calendar inside a group per account, which of them need writing, and the
* reminder channels and groups in [existing] / [existingGroupIds] no calendar
* claims any more. Ids outside the reminder namespace (the backup channel, the
* legacy channel) are never touched.
*/
fun planReminderChannels(
calendars: List<CalendarSource>,
existing: Collection<ExistingReminderChannel>,
existingGroupIds: Collection<String>,
unnamed: String,
description: String,
): ReminderChannelPlan {
val (local, synced) = calendars.partition { it.isLocal }
val groups = buildList {
if (local.isNotEmpty()) {
add(ReminderChannelGroupSpec(LOCAL_GROUP_ID, "", local.first().accountType, isLocal = true, ambiguous = false))
}
synced.groupByAccount().forEach { group ->
add(
ReminderChannelGroupSpec(
id = accountGroupId(group.key.type, group.key.name),
label = group.label,
accountType = group.accountType,
isLocal = false,
ambiguous = group.ambiguous,
),
)
}
}
val channels = calendars.map { calendar ->
ReminderChannelSpec(
id = reminderChannelId(calendar.id),
calendarId = calendar.id,
name = calendar.displayName.ifBlank { unnamed },
groupId = if (calendar.isLocal) LOCAL_GROUP_ID else accountGroupId(calendar.accountType, calendar.accountName),
upFront = calendar.isLocal || calendar.syncsEvents,
)
}
val existingById = existing.associateBy { it.id }
val wantedChannels = channels.mapTo(HashSet()) { it.id }
val wantedGroups = groups.mapTo(HashSet()) { it.id }
val toWrite = channels.filter { spec ->
val current = existingById[spec.id]
if (current == null) {
spec.upFront
} else {
current.name != spec.name || current.description != description ||
(current.groupId == null && spec.groupId != null)
}
}
val usedGroupIds = channels
.filter { it.id in existingById || it in toWrite }
.mapNotNullTo(HashSet()) { it.groupId }
return ReminderChannelPlan(
groups = groups,
channels = channels,
channelsToWrite = toWrite,
groupsInUse = groups.filter { it.id in usedGroupIds },
staleChannelIds = existingById.keys.filter { isReminderChannelId(it) && it !in wantedChannels },
staleGroupIds = existingGroupIds.filter { isReminderGroupId(it) && it !in wantedGroups },
)
}
private fun accountGroupId(type: String, name: String): String = "$GROUP_PREFIX$type/$name"
/** A channel as far as its blocked state goes: switched off itself, and the group it sits in. */
data class ReminderChannelState(val blocked: Boolean, val groupId: String?)
/** Reminder notifications the system blocks, while Calendula would post them. */
data class ReminderChannelStatus(
val appBlocked: Boolean = false,
val blockedCalendarIds: Set<Long> = emptySet(),
/** The blocked ones whose whole account group is off, which only the app's own screen can undo. */
val groupBlockedCalendarIds: Set<Long> = emptySet(),
)
/**
* What the system blocks of [calendarIds]' reminders: all of them while the
* app's notifications are off, otherwise those whose channel, or the group it
* sits in, is switched off. A calendar without a channel yet isn't blocked.
*/
fun reminderChannelStatus(
calendarIds: Collection<Long>,
appEnabled: Boolean,
channels: Map<String, ReminderChannelState>,
blockedGroupIds: Set<String>,
): ReminderChannelStatus {
val groupBlocked = calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.groupId in blockedGroupIds }
val blocked = if (!appEnabled) {
calendarIds.toSet()
} else {
calendarIds.filterTo(HashSet()) { channels[reminderChannelId(it)]?.blocked == true } + groupBlocked
}
return ReminderChannelStatus(appBlocked = !appEnabled, blockedCalendarIds = blocked, groupBlockedCalendarIds = groupBlocked)
}
@@ -1,208 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.ui.common.sourceAppLabel
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
/**
* The reminder notification channels: one per calendar, grouped by account
* (#145), created up front so each calendar can be muted or given its own sound
* in the system settings before it ever fires.
*
* A channel the user has touched keeps their settings; [sync] only creates the
* missing ones, renames changed ones, and deletes those whose calendar is gone.
*/
@Singleton
class ReminderChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val calendarDataSource: CalendarDataSource,
) {
private val manager: NotificationManager
get() = context.getSystemService(NotificationManager::class.java)
/** What the last [sync] saw, so an unchanged provider costs no channel writes. */
@Volatile
private var lastSync: Any? = null
/**
* Bring the channels in line with the provider's calendars. An empty
* snapshot changes nothing: that is a provider hiccup far more often than
* every calendar gone at once.
*/
@Synchronized
fun sync() {
val calendars = calendarDataSource.calendars()
if (calendars.isEmpty()) return
val manager = manager
val existing = manager.notificationChannels
val existingIds = existing.mapTo(HashSet()) { it.id }
if (syncKey(calendars, existingIds) == lastSync && LEGACY_REMINDER_CHANNEL_ID !in existingIds) return
val groups = manager.notificationChannelGroups
val plan = plan(calendars, existing, groups.map { it.id })
plan.staleChannelIds.forEach(manager::deleteNotificationChannel)
plan.staleGroupIds.forEach(manager::deleteNotificationChannelGroup)
val groupNames = groups.associate { it.id to it.name?.toString() }
write(
groups = plan.groupsInUse.filter { groupNames[it.id] != groupLabel(it) },
channels = plan.channelsToWrite,
existing = existing,
)
val ids = existingIds - plan.staleChannelIds.toSet() + plan.channelsToWrite.map { it.id }
lastSync = syncKey(calendars, if (retireLegacyChannel()) ids - LEGACY_REMINDER_CHANNEL_ID else ids)
}
private fun syncKey(calendars: List<CalendarSource>, channelIds: Set<String>): Any = listOf(
calendars.map { listOf(it.id, it.displayName, it.accountName, it.accountType, it.isLocal, it.syncsEvents) },
Locale.getDefault(),
channelIds,
)
/**
* The channel to post [calendarId]'s reminder on, created first if a sync
* has not got to it yet. Null when the calendar no longer exists. Should the
* calendar lookup fail or come back empty, the channel is still created,
* plainly named and ungrouped, for the next sync to name and file — a
* reminder is never lost to its channel.
*/
@Synchronized
fun channelFor(calendarId: Long): String? {
val id = reminderChannelId(calendarId)
val manager = manager
if (manager.getNotificationChannel(id) != null) return id
val existing = manager.notificationChannels
val calendars = try {
calendarDataSource.calendars()
} catch (e: Exception) {
Log.w(TAG, "Could not look up calendar $calendarId for its channel", e)
emptyList()
}
val fallback = ReminderChannelSpec(id, calendarId, context.getString(R.string.reminder_channel_name), null)
if (calendars.isEmpty()) {
write(emptyList(), listOf(fallback), existing)
return id
}
val plan = plan(calendars, existing, emptyList())
val spec = plan.channels.firstOrNull { it.calendarId == calendarId } ?: return null
try {
val knownGroups = manager.notificationChannelGroups.mapTo(HashSet()) { it.id }
write(
groups = plan.groups.filter { it.id == spec.groupId && it.id !in knownGroups },
channels = listOf(spec),
existing = existing,
)
} catch (e: RuntimeException) {
Log.w(TAG, "Could not file the channel of calendar $calendarId", e)
write(emptyList(), listOf(fallback), existing)
}
return id
}
/** What the system currently lets through for [calendarIds]' reminders (#145). */
fun status(calendarIds: Collection<Long>): ReminderChannelStatus {
val manager = manager
val appEnabled = manager.areNotificationsEnabled()
val channels = manager.notificationChannels.associate {
it.id to ReminderChannelState(it.importance == NotificationManager.IMPORTANCE_NONE, it.group)
}
val blockedGroups = manager.notificationChannelGroups.filter { it.isBlocked }.mapTo(HashSet()) { it.id }
return reminderChannelStatus(calendarIds, appEnabled, channels, blockedGroups)
}
private fun plan(
calendars: List<CalendarSource>,
existing: List<NotificationChannel>,
existingGroupIds: List<String>,
): ReminderChannelPlan = planReminderChannels(
calendars = calendars,
existing = existing.map { ExistingReminderChannel(it.id, it.name.toString(), it.description, it.group) },
existingGroupIds = existingGroupIds,
unnamed = context.getString(R.string.calendar_unnamed),
description = context.getString(R.string.reminder_channel_description),
)
private fun write(
groups: List<ReminderChannelGroupSpec>,
channels: List<ReminderChannelSpec>,
existing: List<NotificationChannel>,
) {
val manager = manager
if (groups.isNotEmpty()) {
manager.createNotificationChannelGroups(groups.map { NotificationChannelGroup(it.id, groupLabel(it)) })
}
if (channels.isEmpty()) return
val byId = existing.associateBy { it.id }
val description = context.getString(R.string.reminder_channel_description)
val template = byId[LEGACY_REMINDER_CHANNEL_ID]
// Importance only applies on creation; an existing channel keeps its own.
manager.createNotificationChannels(
channels.map { spec ->
val current = byId[spec.id]
NotificationChannel(
spec.id,
spec.name,
current?.importance ?: template?.importance ?: NotificationManager.IMPORTANCE_HIGH,
).apply {
this.description = description
spec.groupId?.let { group = it }
if (current == null && template != null) inheritFrom(template)
}
},
)
}
/**
* Carry the old single channel's sound, vibration, lights and badge over to
* a new per-calendar one (the system resets lock-screen visibility itself), so an upgrade keeps how reminders sounded.
*/
private fun NotificationChannel.inheritFrom(template: NotificationChannel) {
setSound(template.sound, template.audioAttributes)
// The pattern setter also flips vibration on or off, so it goes first.
vibrationPattern = template.vibrationPattern
enableVibration(template.shouldVibrate())
enableLights(template.shouldShowLights())
lightColor = template.lightColor
setShowBadge(template.canShowBadge())
// Only honoured with Do Not Disturb access, so usually left to the user.
setBypassDnd(template.canBypassDnd())
}
/**
* Delete the pre-#145 channel once the per-calendar ones carry its settings,
* but not while a reminder posted on it is still on screen — deleting the
* channel would take that notification with it. Returns whether it is gone.
*/
private fun retireLegacyChannel(): Boolean {
val manager = manager
if (manager.getNotificationChannel(LEGACY_REMINDER_CHANNEL_ID) == null) return true
val showing = runCatching {
manager.activeNotifications.any { it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
}.getOrElse { e ->
Log.w(TAG, "Could not read active notifications", e)
true
}
if (!showing) manager.deleteNotificationChannel(LEGACY_REMINDER_CHANNEL_ID)
return !showing
}
private fun groupLabel(group: ReminderChannelGroupSpec): String {
if (group.isLocal) return context.getString(R.string.calendars_local_header)
val name = group.label.ifBlank { context.getString(R.string.calendar_unnamed) }
if (!group.ambiguous) return name
return context.getString(R.string.calendars_account_from_source, name, sourceAppLabel(context, group.accountType))
}
private companion object {
const val TAG = "ReminderChannels"
}
}
@@ -1,6 +1,8 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
@@ -27,8 +29,8 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per occurrence on its calendar's channel
* ([ReminderChannels], #145). Tapping opens the event's detail screen.
* Posts one notification per occurrence on a dedicated channel. Tapping opens
* the event's detail screen.
*
* The tag is the [occurrenceKey], so each reminder for an occurrence replaces
* the one before it in place (#463). A different reminder alerts afresh; the
@@ -41,7 +43,6 @@ class ReminderNotifier @Inject constructor(
private val settingsPrefs: SettingsPrefs,
private val calendarPrefs: CalendarPrefs,
private val calendarDataSource: CalendarDataSource,
private val channels: ReminderChannels,
) {
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
@@ -52,28 +53,19 @@ class ReminderNotifier @Inject constructor(
}
/**
* The single choke point for "this calendar posts no reminders": switched
* off (also covering a snooze armed before the switch-off, and a read-only
* install whose switch lives in [CalendarPrefs]), or its notifications
* turned off in Calendula's own settings (#272).
* The single choke point for "this calendar is switched off", covering the
* two paths that reach [post] around the scan's own filter: a snooze armed
* before the switch-off, and a read-only install whose switch lives in
* [CalendarPrefs].
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in settingsPrefs.mutedReminderCalendarIds.first() ||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
calendarDataSource.isCalendarVisible(calendarId) == false
/** Post [alert], unless its calendar is silenced or gone. */
/** Post [alert], unless its calendar is switched off. */
suspend fun post(alert: ReminderAlert) {
if (isSilenced(alert.calendarId)) return
val channelId = try {
channels.channelFor(alert.calendarId) ?: run {
Log.i(TAG, "Dropped the reminder of event ${alert.eventId}: calendar ${alert.calendarId} is gone")
return
}
} catch (e: Exception) {
Log.w(TAG, "No channel for the reminder of event ${alert.eventId}", e)
return
}
ensureChannel()
val title = displayTitle(alert)
val is24Hour = settingsPrefs.timeFormat.first()
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
@@ -96,7 +88,7 @@ class ReminderNotifier @Inject constructor(
yesterdayLabel = context.getString(R.string.reminder_day_yesterday),
)
val text = listOfNotNull(time, alert.location).joinToString(" · ")
val notification = NotificationCompat.Builder(context, channelId)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(text)
@@ -161,7 +153,7 @@ class ReminderNotifier @Inject constructor(
private fun cancelLegacy(alert: ReminderAlert) {
val manager = NotificationManagerCompat.from(context)
manager.activeNotifications
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == LEGACY_REMINDER_CHANNEL_ID }
.filter { it.id == NOTIFICATION_ID && it.notification.channelId == CHANNEL_ID }
.filter { !it.notification.extras.containsKey(EXTRA_REMINDER_KEY) }
.filter { it.notification.`when` == alert.beginMillis }
.filter { it.notification.extras.getCharSequence(NotificationCompat.EXTRA_TITLE)?.toString() == displayTitle(alert) }
@@ -189,8 +181,21 @@ class ReminderNotifier @Inject constructor(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Channel creation is idempotent; re-running refreshes the localized name. */
private fun ensureChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
context.getString(R.string.reminder_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = context.getString(R.string.reminder_channel_description)
}
NotificationManagerCompat.from(context).createNotificationChannel(channel)
}
private companion object {
const val TAG = "ReminderNotifier"
const val CHANNEL_ID = "reminders"
// One id, distinct tags: the tag (occurrence) already keys the
// notification, so a fixed id keeps cancellation/replacement simple.
@@ -6,13 +6,9 @@ import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.withoutProviderDefaults
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.digestEntries
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.scheduleDigest
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
@@ -47,12 +43,9 @@ class ReminderScanner @Inject constructor(
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val digestNotifier: DailyDigestNotifier,
private val channels: ReminderChannels,
private val snoozes: ReminderSnoozeScheduler,
private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs,
private val calendarPrefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
) {
@@ -84,37 +77,18 @@ class ReminderScanner @Inject constructor(
val now = System.currentTimeMillis()
if (!hasReadCalendar()) return
val lastFired = reminderWatermark(state.lastScanMillis(), now)
val horizon = now + MAX_ALARM_INTERVAL_MILLIS
val digestSettings = settingsPrefs.dailyDigest.first()
val nextDigest = if (digestSettings.enabled) postDigest(digestSettings, lastFired, now) else null
val nextReminder = try {
if (settingsPrefs.remindersEnabled.first()) reminders(lastFired, now, horizon) else null
} catch (e: Exception) {
// The watermark stays, so the next pass owes these reminders again;
// keep that pass, and the summary's next moment, armed.
alarms.scheduleScan(minOf(nextDigest ?: horizon, now + RETRY_AFTER_FAILURE_MILLIS))
throw e
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving
// so switching them back on doesn't replay the backlog.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog — and while both are off, so switching one
// back on doesn't replay it.
state.setLastScanMillis(now)
val next = listOfNotNull(nextReminder, nextDigest).minOrNull()
if (next == null) alarms.cancelScan() else alarms.scheduleScan(minOf(next, horizon))
}
/** Post the event reminders that are due; returns the next wake-up they need. */
private suspend fun reminders(lastFired: Long, now: Long, horizon: Long): Long {
syncChannels()
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach into the past too: an all-day "at time of event" encodes to a
// negative offset, and a catch-up pass needs the occurrences it missed.
// Calendars muted in-app (#272) plan nothing, so they arm no wake-ups.
val muted = settingsPrefs.mutedReminderCalendarIds.first()
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
.filter { it.calendarId !in muted }
val planned = planReminders(
instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId })
@@ -125,9 +99,9 @@ class ReminderScanner @Inject constructor(
val schedule = scheduleReminders(
planned = planned,
lastFiredMillis = lastFired,
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
nowMillis = now,
horizonMillis = horizon,
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
)
if (notifier.canPost()) {
@@ -137,61 +111,10 @@ class ReminderScanner @Inject constructor(
notifier.post(alert)
}
}
return schedule.nextAlarmMillis
}
/**
* Post the daily summary if it is owed (#459); returns its next moment. It
* leaves out calendars excluded from it, not those muted for reminders —
* a calendar too noisy to ping per event can still belong in the overview.
*/
private suspend fun postDigest(settings: DailyDigestSettings, lastFired: Long, now: Long): Long {
val zone = ZoneId.systemDefault()
val listed = try {
state.lastDigestDay()
} catch (e: Exception) {
Log.w(TAG, "Could not read the last daily summary", e)
null
}
val schedule = scheduleDigest(
timeMinutes = settings.timeMinutes,
day = settings.day,
zone = zone,
// A moment that passed before the summary was last changed is not owed.
lastFiredMillis = maxOf(lastFired, settings.armedAtMillis),
nowMillis = now,
lastListedDay = listed,
)
val day = schedule.dueDay
// A blocked channel would swallow it; the day stays owed until allowed.
if (day != null && notifier.canPost() && !digestNotifier.isChannelOff()) {
// A failed summary must not cost the event reminders this pass owes.
try {
// A day either side: all-day occurrences sit at UTC midnight, which
// can fall outside the local day they stand for.
val from = day.minusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val to = day.plusDays(2).atStartOfDay(zone).toInstant().toEpochMilli()
val left = settings.excludedCalendarIds + calendarPrefs.pendingDisabledCalendarIds.first()
digestNotifier.post(day, digestEntries(source.occurrences(from, to), day, zone, left))
state.setLastDigestDay(day)
} catch (e: Exception) {
Log.w(TAG, "Daily summary failed", e)
}
}
return schedule.nextAlarmMillis
}
/**
* A provider change re-scans, so a calendar added, renamed or removed keeps
* its channel in step here. Skipped while reminders are off, when the
* channels have nothing to do. A failure never costs the reminders.
*/
private fun syncChannels() {
try {
channels.sync()
} catch (e: Exception) {
Log.w(TAG, "Reminder channel sync failed", e)
}
// Advance even when nothing could be posted, so muting notifications
// doesn't build a backlog.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = context.hasPermission(Manifest.permission.READ_CALENDAR)
@@ -235,8 +158,5 @@ class ReminderScanner @Inject constructor(
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
/** How soon a pass whose reminders failed is tried again. */
const val RETRY_AFTER_FAILURE_MILLIS = 15L * 60 * 1000
}
}
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.domain
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.reminders.splitTimedReminders
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
@@ -42,12 +40,6 @@ data class EventForm(
val description: String = "",
/** Reminder lead times in minutes before the start, deduplicated. */
val reminders: List<Int> = emptyList(),
/**
* "N days before at HH:MM" reminders of a timed event (#459), written as lead
* times measured from [start] at save time. Ignored while [isAllDay], so a
* round trip through all-day keeps them.
*/
val fixedReminders: List<FixedTimeReminder> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
/** Confirmed / Tentative / Cancelled (#388). */
@@ -294,7 +286,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
if (location.isNotBlank()) add(EventFormField.Location)
if (description.isNotBlank()) add(EventFormField.Description)
if (timezone != null) add(EventFormField.Timezone)
if (reminders.isNotEmpty() || (!isAllDay && fixedReminders.isNotEmpty())) add(EventFormField.Reminders)
if (reminders.isNotEmpty()) add(EventFormField.Reminders)
if (rrule != null) add(EventFormField.Recurrence)
if (availability != Availability.Busy) add(EventFormField.Availability)
if (status != EventStatus.Confirmed) add(EventFormField.Status)
@@ -332,37 +324,13 @@ fun EventForm.problems(deviceZone: TimeZone): Set<EventFormProblem> = buildSet {
* cleared so the create resolves to a writable calendar, which lets a read-only
* event be copied into an editable one. [instance]'s own times carry over.
*/
fun EventDetail.toDuplicateForm(
canModify: Boolean,
zone: TimeZone,
knownFixed: Set<FixedTimeReminder> = emptySet(),
): EventForm =
fun EventDetail.toDuplicateForm(canModify: Boolean, zone: TimeZone): EventForm =
toEditForm(
beginMillis = instance.start.toEpochMilliseconds(),
endMillis = instance.end.toEpochMilliseconds(),
zone = zone,
).withFixedTimeRemindersRead(knownFixed, zone).copy(
).copy(
rrule = null,
calendarId = instance.calendarId.takeIf { canModify },
status = status.forNewEvent(),
)
/**
* This form with its timed reminders read back into lead times and
* [EventForm.fixedReminders] ([splitTimedReminders]), the wall clock taken in
* [deviceZone]. All-day forms come back untouched.
*/
fun EventForm.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, deviceZone: TimeZone): EventForm {
if (isAllDay || reminders.isEmpty()) return this
val split = splitTimedReminders(
reminders, startInstant(resolvedZone(deviceZone)), deviceZone, known, wallClock = rrule != null,
)
return copy(reminders = split.leadMinutes, fixedReminders = (fixedReminders + split.fixed).distinct())
}
/** The offsets [EventForm.fixedReminders] store for this form's start; empty for an all-day form. */
fun EventForm.fixedReminderOffsets(deviceZone: TimeZone): List<Int> {
if (isAllDay || fixedReminders.isEmpty()) return emptyList()
val start = startInstant(resolvedZone(deviceZone))
return fixedReminders.map { it.offsetMinutes(start, deviceZone, wallClock = rrule != null) }
}
@@ -1,87 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
/** Which day the daily summary (#459) lists, counted from the day it arrives. */
enum class DigestDay(val daysAhead: Long) { Today(0), Tomorrow(1) }
/** 20:00, when the daily summary arrives until changed. */
const val DEFAULT_DIGEST_TIME_MINUTES = 1_200
/**
* The daily summary's settings. Calendars in [excludedCalendarIds] are left out
* of it; [armedAtMillis] is when its switch, time or day last changed.
*/
data class DailyDigestSettings(
val enabled: Boolean = false,
val timeMinutes: Int = DEFAULT_DIGEST_TIME_MINUTES,
val day: DigestDay = DigestDay.Tomorrow,
val excludedCalendarIds: Set<Long> = emptySet(),
val armedAtMillis: Long = 0L,
)
/**
* What one scan concluded for the summary: list [dueDay] now (null when nothing
* is owed), and wake up again at [nextAlarmMillis].
*/
data class DigestSchedule(
val dueDay: LocalDate?,
val nextAlarmMillis: Long,
)
/**
* When the summary is owed: a moment in `(lastFiredMillis, nowMillis]` is due.
* Only the latest such moment counts — a phone off for three evenings gets one
* summary, not three — and only while the day it lists has not passed and is
* not [lastListedDay]: each day is listed once.
*/
fun scheduleDigest(
timeMinutes: Int,
day: DigestDay,
zone: ZoneId,
lastFiredMillis: Long,
nowMillis: Long,
lastListedDay: LocalDate? = null,
): DigestSchedule {
val time = LocalTime.of(timeMinutes / 60, timeMinutes % 60)
val fireAt = { date: LocalDate -> date.atTime(time).atZone(zone).toInstant().toEpochMilli() }
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
val latestDate = if (fireAt(today) <= nowMillis) today else today.minusDays(1)
val listed = latestDate.plusDays(day.daysAhead)
val dueAt = fireAt(latestDate)
val dueDay = listed.takeIf { dueAt > lastFiredMillis && !it.isBefore(today) && it != lastListedDay }
return DigestSchedule(dueDay = dueDay, nextAlarmMillis = fireAt(latestDate.plusDays(1)))
}
/**
* The occurrences the summary for [day] lists: all-day ones covering it (their
* UTC dates), then timed ones overlapping it in [zone], each group in start
* order. Calendars in [excludedCalendarIds] are left out.
*/
fun digestEntries(
instances: List<ReminderEventInstance>,
day: LocalDate,
zone: ZoneId,
excludedCalendarIds: Set<Long>,
): List<ReminderEventInstance> {
val dayStart = day.atStartOfDay(zone).toInstant().toEpochMilli()
val dayEnd = day.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
val utcDay = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
return instances
.asSequence()
.filter { it.calendarId !in excludedCalendarIds }
.filter {
if (it.isAllDay) {
it.beginMillis <= utcDay && maxOf(it.endMillis, it.beginMillis + 1) > utcDay
} else {
it.beginMillis < dayEnd && maxOf(it.endMillis, it.beginMillis + 1) > dayStart
}
}
.distinctBy { occurrenceKey(it.eventId, it.beginMillis) }
.sortedWith(compareBy({ !it.isAllDay }, { it.beginMillis }, { it.title }))
.toList()
}
@@ -1,142 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* A timed event's reminder "[daysBefore] days before, at [minuteOfDay]" (#459).
*
* The provider only knows lead times, so it is stored as an ordinary `Reminders`
* row whose offset is the distance from that wall-clock moment to the start
* ([offsetMinutes]) — it fires on every device and in every app. The pattern is
* only known to Calendula while it can be read back ([classifyTimedReminder]); the
* editor then re-derives the offset whenever the start moves.
*/
data class FixedTimeReminder(val daysBefore: Int, val minuteOfDay: Int) {
init {
require(daysBefore in 1..MAX_DAYS_BEFORE) { "daysBefore out of range: $daysBefore" }
require(minuteOfDay in 0 until MINUTES_PER_DAY) { "minuteOfDay out of range: $minuteOfDay" }
}
/** The wall clock this reminder names for an event starting at [start], read in [zone]. */
private fun alarmWallClock(start: Instant, zone: TimeZone): LocalDateTime {
val day = start.toLocalDateTime(zone).date.minus(daysBefore, DateTimeUnit.DAY)
return LocalDateTime(day, LocalTime(minuteOfDay / 60, minuteOfDay % 60))
}
/**
* The provider offset to store for an event starting at [start]: the real
* minutes to that moment, or with [wallClock] the minutes on the clock face.
* A recurring series stores one offset for every occurrence, so it takes the
* wall-clock reading — otherwise an occurrence opened next to a DST change
* would bake that hour into all the others.
*/
fun offsetMinutes(start: Instant, zone: TimeZone, wallClock: Boolean = false): Int {
val alarm = alarmWallClock(start, zone)
return if (wallClock) {
(start.toLocalDateTime(zone).toInstant(TimeZone.UTC) - alarm.toInstant(TimeZone.UTC)).inWholeMinutes.toInt()
} else {
(start - alarm.toInstant(zone)).inWholeMinutes.toInt()
}
}
/** The stored form, `days@minuteOfDay`. */
fun toToken(): String = "$daysBefore$TOKEN_SEP$minuteOfDay"
companion object {
/** Two weeks, well inside the four weeks Google Calendar takes for a reminder. */
const val MAX_DAYS_BEFORE = 14
private const val TOKEN_SEP = "@"
/** [toToken] read back; null for anything malformed or out of range. */
fun parse(token: String?): FixedTimeReminder? {
val parts = token?.trim()?.split(TOKEN_SEP) ?: return null
if (parts.size != 2) return null
val days = parts[0].toIntOrNull() ?: return null
val minute = parts[1].toIntOrNull() ?: return null
if (days !in 1..MAX_DAYS_BEFORE || minute !in 0 until MINUTES_PER_DAY) return null
return FixedTimeReminder(days, minute)
}
}
}
/**
* The "N days before at HH:MM" a raw offset lands on for an event starting at
* [start], or null when it fires on the start's own day (or after it), or
* further back than [FixedTimeReminder.MAX_DAYS_BEFORE]. [wallClock] reads the
* offset as [FixedTimeReminder.offsetMinutes] wrote it.
*/
fun fixedTimeReminderAt(
offsetMinutes: Int,
start: Instant,
zone: TimeZone,
wallClock: Boolean = false,
): FixedTimeReminder? {
if (offsetMinutes <= 0) return null
val startLocal = start.toLocalDateTime(zone)
val alarm = if (wallClock) {
(startLocal.toInstant(TimeZone.UTC) - offsetMinutes.minutes).toLocalDateTime(TimeZone.UTC)
} else {
(start - offsetMinutes.minutes).toLocalDateTime(zone)
}
val startDate = startLocal.date
val days = startDate.toEpochDays() - alarm.date.toEpochDays()
if (days !in 1..FixedTimeReminder.MAX_DAYS_BEFORE) return null
return FixedTimeReminder(days.toInt(), alarm.hour * 60 + alarm.minute)
}
/** A timed event's reminders, read back: plain lead times and the fixed-time ones. */
data class TimedReminders(
val leadMinutes: List<Int>,
val fixed: List<FixedTimeReminder>,
)
/**
* The fixed-time reminder [offsetMinutes] stands for on an event starting at
* [start], or null when it reads as a plain lead time. The provider can't say
* which an offset was meant as, so only the patterns in [known] — the configured
* defaults, past and present, and the ones saved by hand on this device — are
* recognised: guessing from the offset's shape alone would turn ordinary leads
* ("12 hours before") into fixed-time ones. Where a lead and a known pattern
* coincide ("1 day before" on an event at the default's own time) the pattern
* wins. A wrong reading fires at the same moment; only a later change of the
* start's time of day then treats the reminder the other way.
*/
fun classifyTimedReminder(
offsetMinutes: Int,
start: Instant,
zone: TimeZone,
known: Set<FixedTimeReminder>,
wallClock: Boolean = false,
): FixedTimeReminder? = fixedTimeReminderAt(offsetMinutes, start, zone, wallClock)?.takeIf { it in known }
/** Sort a timed event's raw offsets into lead times and fixed-time reminders ([classifyTimedReminder]). */
fun splitTimedReminders(
offsets: List<Int>,
start: Instant,
zone: TimeZone,
known: Set<FixedTimeReminder>,
wallClock: Boolean = false,
): TimedReminders {
val leads = mutableListOf<Int>()
val fixed = mutableListOf<FixedTimeReminder>()
offsets.distinct().forEach { offset ->
val pattern = classifyTimedReminder(offset, start, zone, known, wallClock)
if (pattern != null) fixed += pattern else leads += offset
}
return TimedReminders(leads.sorted(), fixed.distinct().sortedWith(FIXED_ORDER))
}
/** Most days before first, then by time — the order they fire in. */
val FIXED_ORDER: Comparator<FixedTimeReminder> =
compareByDescending<FixedTimeReminder> { it.daysBefore }.thenBy { it.minuteOfDay }
private const val MINUTES_PER_DAY = 1_440
@@ -10,6 +10,7 @@ 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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
@@ -76,7 +77,9 @@ private val RESTORE_MIME_TYPES = arrayOf(
/**
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
* optional automatic backup, and the app's settings as JSON (#481). Shares
* [CalendarsViewModel] with the manager; the settings block works whatever
* state the calendar list is in.
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
* [onImport] hands a picked file to the app's normal .ics import flow.
@@ -87,6 +90,7 @@ fun BackupScreen(
onImport: (Uri) -> Unit,
visible: Boolean = true,
viewModel: CalendarsViewModel = hiltViewModel(),
settingsBackup: SettingsBackupViewModel = hiltViewModel(),
) {
val state by viewModel.backupState.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
@@ -132,20 +136,27 @@ fun BackupScreen(
}
}
val settings = rememberSettingsBackupActions(settingsBackup)
CollapsingScaffold(
title = stringResource(R.string.settings_section_backup),
onBack = onBack,
// Loading and failure fill the screen and centre themselves, which they
// can only do in an unscrolled column — the scrolling one measures them
// against an unbounded height and leaves them hanging under the header.
scrollable = state is BackupUiState.Ready,
snackbarHost = { SnackbarHost(snackbarHostState) },
predictiveBack = true,
visible = visible,
) {
// Loading and failure get a fixed-height slot in the scrolling column (it
// can't centre them in an unbounded height); the settings block below
// works in every state.
when (val s = state) {
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
BackupUiState.Loading -> {
StateSlot { BackupLoading() }
SettingsBackupSection(settings)
}
is BackupUiState.Failure -> {
StateSlot { CalendarFailure(reason = s.reason, onRetry = onBack) }
SettingsBackupSection(settings)
}
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
@@ -158,6 +169,7 @@ fun BackupScreen(
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
settings = settings,
)
}
}
@@ -173,6 +185,7 @@ fun BackupScreen(
)
}
}
SettingsBackupDialogs(settings, settingsBackup, snackbarHostState)
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
@@ -182,6 +195,14 @@ fun BackupScreen(
}
}
@Composable
private fun StateSlot(content: @Composable () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth().heightIn(min = STATE_MIN_HEIGHT),
contentAlignment = Alignment.Center,
) { content() }
}
/** The screen's loading state, centred in the scaffold's content column. */
@Composable
private fun BackupLoading() {
@@ -213,6 +234,7 @@ private fun BackupContent(
onExportAll: () -> Unit,
onExportPick: () -> Unit,
onEditInterval: () -> Unit,
settings: SettingsBackupActions,
) {
val manageCalendars = LocalManageCalendars.current
// Restore never depends on export eligibility, and never disappears: when no
@@ -233,6 +255,7 @@ private fun BackupContent(
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
SettingsBackupSection(settings)
return
}
@@ -277,6 +300,7 @@ private fun BackupContent(
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
}
SettingsBackupSection(settings)
}
/**
@@ -433,5 +457,6 @@ private fun BackupIntervalDialog(
)
}
private val STATE_MIN_HEIGHT = 360.dp
private const val MINUTES_PER_DAY = 1_440L
private const val MINUTES_PER_WEEK = 10_080L
@@ -0,0 +1,224 @@
package de.jeanlucmakiola.calendula.ui.calendars
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.backup.SettingsImportSummary
import de.jeanlucmakiola.calendula.data.backup.SkippedSetting
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.AppLanguage
import java.time.LocalDate
// What the import picker accepts: providers label .json inconsistently.
private val SETTINGS_MIME_TYPES = arrayOf("application/json", "text/plain", "application/octet-stream")
/** The Settings block's pickers and the import waiting for its confirmation. */
class SettingsBackupActions internal constructor(
val busy: Boolean,
internal val pendingImport: MutableState<String?>,
val export: () -> Unit,
val import: () -> Unit,
)
@Composable
fun rememberSettingsBackupActions(viewModel: SettingsBackupViewModel): SettingsBackupActions {
val busy by viewModel.busy.collectAsStateWithLifecycle()
val pendingImport = rememberSaveable { mutableStateOf<String?>(null) }
val createFile = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json"),
) { uri -> uri?.let(viewModel::export) }
val openFile = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri -> pendingImport.value = uri?.toString() }
return SettingsBackupActions(
busy = busy,
pendingImport = pendingImport,
export = { runCatching { createFile.launch("calendula-settings-${LocalDate.now()}.json") } },
import = { runCatching { openFile.launch(SETTINGS_MIME_TYPES) } },
)
}
/** Settings export and import (#481), below the events' backup. */
@Composable
fun SettingsBackupSection(actions: SettingsBackupActions) {
GroupedSectionHeader(stringResource(R.string.settings_backup_header))
GroupedHint(stringResource(R.string.settings_backup_hint), style = MaterialTheme.typography.bodySmall)
GroupedRow(
title = stringResource(R.string.settings_backup_export),
summary = stringResource(R.string.settings_backup_export_summary),
position = Position.Top,
leading = { LeadingAvatar(Icons.Default.FileDownload) },
dimmed = actions.busy,
onClick = { if (!actions.busy) actions.export() },
)
GroupedRow(
title = stringResource(R.string.settings_backup_import),
summary = stringResource(R.string.settings_backup_import_summary),
position = Position.Bottom,
leading = { LeadingAvatar(Icons.Default.FileUpload) },
dimmed = actions.busy,
onClick = { if (!actions.busy) actions.import() },
)
if (actions.busy) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = GroupedListInset, vertical = 16.dp),
)
}
}
/**
* The import's confirmation and summary dialogs, and the snackbars for
* everything else.
*/
@Composable
fun SettingsBackupDialogs(
actions: SettingsBackupActions,
viewModel: SettingsBackupViewModel,
snackbarHostState: SnackbarHostState,
) {
val result by viewModel.result.collectAsStateWithLifecycle()
val exported = stringResource(R.string.settings_backup_export_done)
val exportFailed = stringResource(R.string.settings_backup_export_failed)
val importFailed = BackupProblem.entries.associateWith { stringResource(it.messageRes()) }
LaunchedEffect(result) {
val message = when (val r = result) {
is SettingsBackupResult.Exported -> exported
is SettingsBackupResult.ExportFailed -> exportFailed
is SettingsBackupResult.ImportFailed -> importFailed.getValue(r.problem)
is SettingsBackupResult.Imported, null -> return@LaunchedEffect
}
snackbarHostState.showSnackbar(message)
viewModel.consumeResult()
}
(result as? SettingsBackupResult.Imported)?.let { imported ->
ImportSummaryDialog(imported.summary, onDismiss = viewModel::consumeResult)
}
actions.pendingImport.value?.let { uri ->
AlertDialog(
onDismissRequest = { actions.pendingImport.value = null },
title = { Text(stringResource(R.string.settings_backup_import_confirm_title)) },
text = { Text(stringResource(R.string.settings_backup_import_confirm_message)) },
confirmButton = {
TextButton(
onClick = {
actions.pendingImport.value = null
viewModel.import(uri.toUri())
},
) { Text(stringResource(R.string.settings_backup_import_confirm_action)) }
},
dismissButton = {
TextButton(onClick = { actions.pendingImport.value = null }) {
Text(stringResource(R.string.dialog_cancel))
}
},
)
}
}
@Composable
private fun ImportSummaryDialog(summary: SettingsImportSummary, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.settings_backup_imported_title)) },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Text(stringResource(R.string.settings_backup_imported_body))
if (summary.calendarsMatched > 0) {
Text(
pluralStringResource(
R.plurals.settings_backup_imported_calendars,
summary.calendarsMatched,
summary.calendarsMatched,
),
)
}
if (summary.skipped.isNotEmpty()) {
Text(
stringResource(R.string.settings_backup_imported_skipped),
style = MaterialTheme.typography.titleSmall,
)
summary.skipped.forEach { skipped ->
Text(
"• " + skipped.label(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_ok)) }
},
)
}
@Composable
private fun SkippedSetting.label(): String = when (this) {
is SkippedSetting.Calendar -> stringResource(
if (ambiguous) R.string.settings_backup_skipped_calendar_ambiguous else R.string.settings_backup_skipped_calendar,
displayName,
accountName,
)
SkippedSetting.CalendarsUnavailable -> stringResource(R.string.settings_backup_skipped_calendars_unavailable)
is SkippedSetting.CustomFont -> stringResource(
when (role) {
FontRole.BRAND -> R.string.settings_backup_skipped_font_headings
FontRole.PLAIN -> R.string.settings_backup_skipped_font_body
},
)
SkippedSetting.SpecialDates -> stringResource(R.string.settings_backup_skipped_special_dates)
SkippedSetting.AutoBackupFolder -> stringResource(R.string.settings_backup_skipped_auto_backup)
is SkippedSetting.Language -> stringResource(R.string.settings_backup_skipped_language, AppLanguage.displayName(tag))
}
private fun BackupProblem.messageRes(): Int = when (this) {
BackupProblem.NOT_A_BACKUP -> R.string.settings_backup_import_failed_not_backup
BackupProblem.NEWER_VERSION -> R.string.settings_backup_import_failed_newer
BackupProblem.INVALID_DATA -> R.string.settings_backup_import_failed_invalid
BackupProblem.TOO_LARGE -> R.string.settings_backup_import_failed_too_large
BackupProblem.UNREADABLE -> R.string.settings_backup_import_failed_unreadable
BackupProblem.NOT_APPLIED -> R.string.settings_backup_import_failed
}
@@ -0,0 +1,108 @@
package de.jeanlucmakiola.calendula.ui.calendars
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.SettingsBackupEngine
import de.jeanlucmakiola.calendula.data.backup.SettingsImportSummary
import de.jeanlucmakiola.floret.android.DocumentTooLargeException
import de.jeanlucmakiola.floret.android.readDocumentBytes
import de.jeanlucmakiola.floret.android.writeDocumentBytes
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
/**
* The outcome of a settings export or import, held until the screen has shown
* it. Plain classes, not data ones: two equal outcomes in a row must each count.
*/
sealed interface SettingsBackupResult {
class Exported : SettingsBackupResult
class ExportFailed : SettingsBackupResult
class Imported(val summary: SettingsImportSummary) : SettingsBackupResult
class ImportFailed(val problem: BackupProblem) : SettingsBackupResult
}
/**
* Settings export/import on the Backup & restore screen (#481). The file is a
* single SAF document the user picked; the engine knows nothing about it.
* The result is state rather than an event, so an import that changes the
* app language — and recreates the activity — still shows its summary.
*/
@HiltViewModel
class SettingsBackupViewModel @Inject constructor(
private val engine: SettingsBackupEngine,
@ApplicationContext private val context: Context,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
private val _busy = MutableStateFlow(false)
val busy: StateFlow<Boolean> = _busy.asStateFlow()
private val _result = MutableStateFlow<SettingsBackupResult?>(null)
val result: StateFlow<SettingsBackupResult?> = _result.asStateFlow()
fun export(uri: Uri) = launchWork {
try {
val text = engine.export()
withContext(io) { context.contentResolver.writeDocumentBytes(uri, text.toByteArray(Charsets.UTF_8)) }
SettingsBackupResult.Exported()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SettingsBackupResult.ExportFailed()
}
}
/** Call only once the user has confirmed replacing their settings. */
fun import(uri: Uri) = launchWork {
val text = try {
withContext(io) {
context.contentResolver.readDocumentBytes(uri, engine.maxImportBytes).toString(Charsets.UTF_8)
}
} catch (e: CancellationException) {
throw e
} catch (e: DocumentTooLargeException) {
return@launchWork SettingsBackupResult.ImportFailed(BackupProblem.TOO_LARGE)
} catch (e: Exception) {
return@launchWork SettingsBackupResult.ImportFailed(BackupProblem.UNREADABLE)
}
try {
when (val imported = engine.import(text)) {
is BackupResult.Success -> SettingsBackupResult.Imported(imported.value)
is BackupResult.Failure -> SettingsBackupResult.ImportFailed(imported.problem)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SettingsBackupResult.ImportFailed(BackupProblem.NOT_APPLIED)
}
}
fun consumeResult() {
_result.value = null
}
private fun launchWork(work: suspend () -> SettingsBackupResult) {
if (_busy.value) return
_busy.value = true
viewModelScope.launch {
try {
_result.value = work()
} finally {
_busy.value = false
}
}
}
}
@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
@@ -73,13 +72,11 @@ fun accountGroupTitle(group: CalendarAccountGroup): String {
@Composable
fun sourceAppName(accountType: String): String {
val context = LocalContext.current
return remember(accountType) { sourceAppLabel(context, accountType) }
}
/** [sourceAppName] outside composition, e.g. for notification channel groups. */
fun sourceAppLabel(context: Context, accountType: String): String {
val pm = context.packageManager
return sourceAppPackages(context, accountType).firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
return remember(accountType) {
val pm = context.packageManager
val packages = sourceAppPackages(context, accountType)
packages.firstNotNullOfOrNull { pkg ->
runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrNull()
} ?: accountType
}
}
@@ -15,7 +15,6 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.atOccurrence
import de.jeanlucmakiola.calendula.domain.toDuplicateForm
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
@@ -44,7 +43,6 @@ class EventActionsViewModel @Inject constructor(
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
/** Every calendar by id, for what a selected event's bar may offer. */
@@ -67,7 +65,7 @@ class EventActionsViewModel @Inject constructor(
suspend fun duplicateForm(event: EventInstance): EventForm? {
val detail = load(event) ?: return null
val canModify = calendars.value[event.calendarId]?.canModifyContents == true
return detail.toDuplicateForm(canModify, zoneSource.current(), settingsPrefs.knownFixedTimeReminders.first())
return detail.toDuplicateForm(canModify, zoneSource.current())
}
/** A staged `.ics` for [event], or null — reported — when it can't be built. */
@@ -1,216 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.components.CustomExpandRow
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import kotlinx.datetime.LocalTime
/** "1 day before at 20:00", in the app's 12/24-hour convention. */
@Composable
fun fixedTimeReminderLabel(reminder: FixedTimeReminder): String = stringResource(
R.string.reminder_fixed_time,
pluralStringResource(R.plurals.reminder_days, reminder.daysBefore, reminder.daysBefore),
formatMinuteOfDay(reminder.minuteOfDay, LocalUse24HourFormat.current, currentLocale()),
)
/**
* The "N days before at HH:MM" editor (#459): a day count and a time, read as
* one sentence. Seeded from [initial]; [onValueChange] follows every edit (null
* while the day count is out of range). With a [confirmLabel] it carries its own
* confirm, reporting through [onConfirm]; without one the host picker commits
* from its actions slot. [connected] gives it the shape that meets a row above it.
*/
@Composable
fun FixedTimeReminderEditor(
initial: FixedTimeReminder,
modifier: Modifier = Modifier,
confirmLabel: String? = null,
onConfirm: (FixedTimeReminder) -> Unit = {},
onValueChange: (FixedTimeReminder?) -> Unit = {},
connected: Boolean = true,
) {
var daysText by rememberSaveable { mutableStateOf(initial.daysBefore.toString()) }
var minuteOfDay by rememberSaveable { mutableStateOf(initial.minuteOfDay) }
var pickingTime by rememberSaveable { mutableStateOf(false) }
val days = daysText.toIntOrNull()?.takeIf { it in 1..FixedTimeReminder.MAX_DAYS_BEFORE }
val value = days?.let { FixedTimeReminder(it, minuteOfDay) }
LaunchedEffect(value) { onValueChange(value) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = if (connected) {
RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp)
} else {
RoundedCornerShape(22.dp)
},
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = daysText, onValueChange = { daysText = it }, placeholder = "1")
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.reminder_fixed_days_before_at),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(12.dp))
FilledTonalButton(onClick = { pickingTime = true }) {
Text(formatMinuteOfDay(minuteOfDay, LocalUse24HourFormat.current, currentLocale()))
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = value?.let { fixedTimeReminderLabel(it) }
?: stringResource(R.string.reminder_fixed_days_range, FixedTimeReminder.MAX_DAYS_BEFORE),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (confirmLabel != null) {
Spacer(Modifier.width(16.dp))
FilledTonalButton(
enabled = value != null,
onClick = { value?.let(onConfirm) },
) { Text(confirmLabel) }
}
}
}
}
if (pickingTime) {
TimePickerAlert(
initial = LocalTime(minuteOfDay / 60, minuteOfDay % 60),
onConfirm = {
minuteOfDay = it.hour * 60 + it.minute
pickingTime = false
},
onDismiss = { pickingTime = false },
)
}
}
/**
* Picker for a fixed-time default (#459), full-screen: off, or a reminder at a
* set time edited in place — and, on a per-calendar picker ([inheritedLabel]
* non-null), deferring to the global default, [inherited], which also seeds the
* editor. Applies live; the user leaves via back.
*/
@Composable
fun FixedTimeReminderPicker(
title: String,
selected: FixedTimeOverride,
onSelect: (FixedTimeOverride) -> Unit,
onDismiss: () -> Unit,
inheritedLabel: String? = null,
inherited: FixedTimeReminder? = null,
) {
var current by rememberSaveable(stateSaver = FixedTimeOverrideSaver) { mutableStateOf(selected) }
var editing by rememberSaveable { mutableStateOf(false) }
// Mirrors [selected] until the first pick, as ReminderDefaultPicker does.
var userEdited by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(selected) {
if (!userEdited) current = selected
}
fun apply(choice: FixedTimeOverride) {
userEdited = true
current = choice
onSelect(choice)
}
val chosen = (current as? FixedTimeOverride.At)?.reminder
FullScreenPicker(title = title, onDismiss = onDismiss) {
GroupedHint(text = stringResource(R.string.settings_fixed_reminder_hint))
Spacer(Modifier.height(8.dp))
val rowCount = if (inheritedLabel != null) 3 else 2
if (inheritedLabel != null) {
GroupedRow(
title = stringResource(R.string.reminder_use_default),
summary = inheritedLabel,
position = Position.Top,
checked = current is FixedTimeOverride.Inherit,
onClick = { apply(FixedTimeOverride.Inherit) },
)
}
GroupedRow(
title = stringResource(R.string.reminder_none),
position = if (rowCount == 3) Position.Middle else Position.Top,
checked = current is FixedTimeOverride.Off,
onClick = { apply(FixedTimeOverride.Off) },
)
CustomExpandRow(
title = stringResource(R.string.reminder_fixed_at_set_time),
summary = chosen?.let { fixedTimeReminderLabel(it) },
position = Position.Bottom,
expanded = editing,
onExpandedChange = { editing = it },
checked = chosen != null,
) {
FixedTimeReminderEditor(
initial = chosen ?: inherited ?: DEFAULT_FIXED_TIME_REMINDER,
confirmLabel = stringResource(R.string.reminder_custom_set),
onConfirm = {
apply(FixedTimeOverride.At(it))
editing = false
},
)
}
}
}
/** What the editor offers first: the evening before. */
val DEFAULT_FIXED_TIME_REMINDER = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
private val FixedTimeOverrideSaver = Saver<FixedTimeOverride, String>(
save = {
when (it) {
FixedTimeOverride.Inherit -> "inherit"
FixedTimeOverride.Off -> "off"
is FixedTimeOverride.At -> it.reminder.toToken()
}
},
restore = {
when (it) {
"inherit" -> FixedTimeOverride.Inherit
"off" -> FixedTimeOverride.Off
else -> FixedTimeReminder.parse(it)?.let(FixedTimeOverride::At) ?: FixedTimeOverride.Off
}
},
)
@@ -23,12 +23,9 @@ import de.jeanlucmakiola.calendula.domain.resolvedZone
import de.jeanlucmakiola.calendula.domain.shiftedByDays
import de.jeanlucmakiola.calendula.domain.shiftedTo
import de.jeanlucmakiola.calendula.domain.toEditForm
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -175,7 +172,6 @@ class RescheduleViewModel @Inject constructor(
private val repository: CalendarRepository,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
@@ -461,9 +457,7 @@ class RescheduleViewModel @Inject constructor(
dropped
}
// Read fixed-time reminders out, so the move keeps them on their wall clock (#459).
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
.withFixedTimeRemindersRead(settingsPrefs.knownFixedTimeReminders.first(), zone)
val shifted = when (val target = request.target) {
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
@@ -108,7 +108,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.calendula.ui.common.withRepeatedHourZone
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
@@ -533,8 +532,7 @@ private fun EventDetailContent(
.sortedBy { it.minutes }
.forEach { reminder ->
Text(
text = state.fixedReminders[reminder.minutes]?.let { fixedTimeReminderLabel(it) }
?: reminderLeadTimeLabel(reminder.minutes),
text = reminderLeadTimeLabel(reminder.minutes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 2.dp),
)
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.RichText
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
/**
* UI state for the event-detail screen (spec S4).
@@ -27,10 +26,6 @@ sealed interface EventDetailUiState {
val phoneLinks: EventPhoneLinks = EventPhoneLinks(),
/** The description as drawn — HTML rendered (#271); null when there is none. */
val description: RichText? = null,
/** Reminder offsets that read as "N days before at HH:MM" (#459), by raw minutes. */
val fixedReminders: Map<Int, FixedTimeReminder> = emptyMap(),
/** The fixed-time patterns the reminders were read against. */
val knownFixed: Set<FixedTimeReminder> = emptySet(),
) : EventDetailUiState
}
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui.detail
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.classifyTimedReminder
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -48,7 +46,6 @@ class EventDetailViewModel @Inject constructor(
private val icsExporter: IcsExporter,
@IoDispatcher private val io: CoroutineDispatcher,
private val zoneSource: ZoneSource,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val _target = MutableStateFlow<Target?>(null)
@@ -145,7 +142,7 @@ class EventDetailViewModel @Inject constructor(
*/
fun duplicateForm(): EventForm? {
val loaded = state.value as? EventDetailUiState.Success ?: return null
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current(), loaded.knownFixed)
return loaded.detail.toDuplicateForm(loaded.canModify, zoneSource.current())
}
private suspend fun loadDetail(target: Target): EventDetailUiState = try {
@@ -160,17 +157,6 @@ class EventDetailViewModel @Inject constructor(
}
val calendar = repository.calendars().first()
.firstOrNull { it.id == corrected.instance.calendarId }
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
val fixedReminders = if (corrected.instance.isAllDay) {
emptyMap()
} else {
val zone = zoneSource.current()
corrected.reminders.map { it.minutes }.distinct().mapNotNull { minutes ->
classifyTimedReminder(
minutes, corrected.instance.start, zone, knownFixed, wallClock = corrected.rrule != null,
)?.let { minutes to it }
}.toMap()
}
val description = corrected.description
?.let(::descriptionRichText)
?.takeIf { it.text.isNotBlank() }
@@ -187,8 +173,6 @@ class EventDetailViewModel @Inject constructor(
description = description?.text,
),
description = description,
fixedReminders = fixedReminders,
knownFixed = knownFixed,
)
} catch (e: CancellationException) {
throw e
@@ -125,10 +125,6 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventAttendee
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.common.DEFAULT_FIXED_TIME_REMINDER
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderEditor
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.domain.startInstant
import de.jeanlucmakiola.calendula.domain.endInstant
import de.jeanlucmakiola.calendula.domain.resolvedZone
@@ -157,7 +153,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
@@ -880,9 +875,7 @@ private fun EventEditContent(
Spacer(Modifier.height(gap))
RemindersSection(
reminders = form.reminders,
fixedReminders = if (form.isAllDay) emptyList() else form.fixedReminders,
onRemove = viewModel::removeReminder,
onRemoveFixed = viewModel::removeFixedReminder,
onAdd = { sheet = EditSheet.Reminder },
)
}
@@ -1291,25 +1284,9 @@ private fun LocationCard(
* connected corners, the last row being "Add reminder".
*/
@Composable
private fun RemindersSection(
reminders: List<Int>,
fixedReminders: List<FixedTimeReminder>,
onRemove: (Int) -> Unit,
onRemoveFixed: (FixedTimeReminder) -> Unit,
onAdd: () -> Unit,
) {
val rows = fixedReminders.size + reminders.size + 1
fixedReminders.forEachIndexed { index, reminder ->
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
title = fixedTimeReminderLabel(reminder),
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
onRemove = { onRemoveFixed(reminder) },
)
}
reminders.forEachIndexed { i, minutes ->
val index = fixedReminders.size + i
private fun RemindersSection(reminders: List<Int>, onRemove: (Int) -> Unit, onAdd: () -> Unit) {
val rows = reminders.size + 1
reminders.forEachIndexed { index, minutes ->
GroupedItemCard(
position = positionOf(index, rows),
icon = Icons.Default.Notifications,
@@ -1320,7 +1297,7 @@ private fun RemindersSection(
}
AddActionCard(
label = stringResource(R.string.event_edit_add_reminder),
position = positionOf(rows - 1, rows),
position = positionOf(reminders.size, rows),
onClick = onAdd,
)
}
@@ -1512,15 +1489,10 @@ private fun EditSheetHost(
}
EditSheet.Reminder -> ReminderPickerDialog(
alreadyChosen = form.reminders,
offerFixedTime = !form.isAllDay,
onSelect = { minutes ->
viewModel.addReminder(minutes)
onClose()
},
onSelectFixed = { reminder ->
viewModel.addFixedReminder(reminder)
onClose()
},
onDismiss = onClose,
)
EditSheet.Recurrence -> RecurrencePickerDialog(
@@ -1609,14 +1581,10 @@ private fun FieldPickerDialog(
@Composable
private fun ReminderPickerDialog(
alreadyChosen: List<Int>,
offerFixedTime: Boolean,
onSelect: (Int) -> Unit,
onSelectFixed: (FixedTimeReminder) -> Unit,
onDismiss: () -> Unit,
) {
var customMode by rememberSaveable { mutableStateOf(false) }
var fixedMode by rememberSaveable { mutableStateOf(false) }
var pendingFixed by remember { mutableStateOf<FixedTimeReminder?>(null) }
var amountText by rememberSaveable { mutableStateOf("") }
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
val customMinutes = amountText.toIntOrNull()
@@ -1627,31 +1595,18 @@ private fun ReminderPickerDialog(
title = stringResource(R.string.event_edit_add_reminder),
onDismiss = onDismiss,
actions = {
// The quick-pick list adds on tap; only the custom steps need Add.
// The quick-pick list adds on tap; only the custom step needs Add.
if (customMode) {
TextButton(
enabled = customMinutes != null && customMinutes !in alreadyChosen,
onClick = { customMinutes?.let(onSelect) },
) { Text(stringResource(R.string.event_edit_add)) }
} else if (fixedMode) {
TextButton(
enabled = pendingFixed != null,
onClick = { pendingFixed?.let(onSelectFixed) },
) { Text(stringResource(R.string.event_edit_add)) }
}
},
) {
if (fixedMode) {
GroupedHint(text = stringResource(R.string.event_edit_reminder_fixed_hint))
Spacer(Modifier.height(8.dp))
FixedTimeReminderEditor(
initial = DEFAULT_FIXED_TIME_REMINDER,
onValueChange = { pendingFixed = it },
connected = false,
)
} else if (!customMode) {
if (!customMode) {
val presets = REMINDER_PRESETS.filterNot { it in alreadyChosen }
val rowCount = presets.size + if (offerFixedTime) 2 else 1
val rowCount = presets.size + 1
presets.forEachIndexed { index, minutes ->
GroupedRow(
title = reminderLeadTimeLabel(minutes),
@@ -1659,14 +1614,6 @@ private fun ReminderPickerDialog(
onClick = { onSelect(minutes) },
)
}
if (offerFixedTime) {
GroupedRow(
title = stringResource(R.string.reminder_fixed_at_set_time),
summary = stringResource(R.string.event_edit_reminder_fixed_summary),
position = positionOf(presets.size, rowCount),
onClick = { fixedMode = true },
)
}
GroupedRow(
title = stringResource(R.string.event_edit_reminder_custom),
position = positionOf(rowCount - 1, rowCount),
@@ -13,12 +13,7 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.ZoneSource
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultFixedTimeReminder
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
import de.jeanlucmakiola.calendula.domain.reminders.FIXED_ORDER
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.fixedReminderOffsets
import de.jeanlucmakiola.calendula.domain.withFixedTimeRemindersRead
import de.jeanlucmakiola.calendula.domain.canSaveApartFromSeries
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
@@ -144,7 +139,6 @@ enum class ImportSource(internal val ownsDuration: Boolean) {
data class ImportReminderPrompt(
val currentReminderCount: Int,
val defaultReminders: List<Int>,
val defaultFixedReminders: List<FixedTimeReminder> = emptyList(),
)
/**
@@ -214,8 +208,6 @@ class EventEditViewModel @Inject constructor(
val zone: TimeZone,
/** The event as loaded, on the tapped occurrence's times — what Share exports. */
val detail: EventDetail,
/** The fixed-time patterns its reminders were read against, so a re-read splits alike. */
val knownFixed: Set<FixedTimeReminder>,
) {
val original: EventForm get() = snapshot.form
}
@@ -233,13 +225,7 @@ class EventEditViewModel @Inject constructor(
val allDay: List<Int>,
val timedOverrides: Map<Long, List<Int>>,
val allDayOverrides: Map<Long, List<Int>>,
val fixed: FixedTimeReminder?,
val fixedOverrides: Map<Long, FixedTimeReminder?>,
) {
/** The fixed-time reminders a new event on [calendarId] gets (#459); none on an all-day one. */
fun resolveFixedFor(calendarId: Long?, isAllDay: Boolean): List<FixedTimeReminder> =
if (isAllDay) emptyList() else listOfNotNull(resolveDefaultFixedTimeReminder(fixed, fixedOverrides, calendarId))
/** The default reminders for an event on [calendarId] of the given kind. */
fun resolveFor(calendarId: Long?, isAllDay: Boolean): List<Int> = resolveDefaultReminder(
timedGlobal = timed,
@@ -449,7 +435,7 @@ class EventEditViewModel @Inject constructor(
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
when (source) {
ImportSource.Insert, ImportSource.InsertOpenEnded ->
if (form.reminders.isNotEmpty() || form.fixedReminders.isNotEmpty()) _remindersTouched.value = true
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
else applyDefaultReminder()
ImportSource.File -> {
@@ -479,35 +465,27 @@ class EventEditViewModel @Inject constructor(
val form = _form.value ?: return@launch
if (_editTarget.value != null || _remindersTouched.value) return@launch
val reminders = defaults.resolveFor(targetId, form.isAllDay)
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
// The duration default resolves in parallel, so both must compose
// onto the current form, not a pre-suspend copy.
applyDefault { it.copy(reminders = reminders, fixedReminders = fixed) }
applyDefault { it.copy(reminders = reminders) }
// Surface the section so an auto-applied default is visible and
// removable, even when Reminders isn't a default-shown field.
if (reminders.isNotEmpty() || fixed.isNotEmpty()) {
if (reminders.isNotEmpty()) {
_revealed.value = _revealed.value + EventFormField.Reminders
}
}
}
/** Snapshot the settings-default reminder flows into one value. */
/** Snapshot the four settings-default reminder flows into one value. */
private suspend fun reminderDefaults(): ReminderDefaults = combine(
combine(
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
::Quadruple,
),
settingsPrefs.defaultFixedTimeReminder,
settingsPrefs.perCalendarFixedTimeReminderOverride,
) { (timed, allDay, timedOv, allDayOv), fixed, fixedOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv, fixed, fixedOv)
settingsPrefs.defaultReminderMinutes,
settingsPrefs.defaultAllDayReminderMinutes,
settingsPrefs.perCalendarReminderOverride,
settingsPrefs.perCalendarAllDayReminderOverride,
) { timed, allDay, timedOv, allDayOv ->
ReminderDefaults(timed, allDay, timedOv, allDayOv)
}.first()
private data class Quadruple<A, B, C, D>(val a: A, val b: B, val c: C, val d: D)
/**
* Stretch a new timed event to the configured default length (#54) — the
* resolved calendar's per-calendar duration, otherwise the global default —
@@ -553,19 +531,15 @@ class EventEditViewModel @Inject constructor(
val opened = session
viewModelScope.launch {
val targetId = resolvedCalendarId.first()
val defaults = reminderDefaults()
val default = defaults.resolveFor(targetId, form.isAllDay)
val fixed = defaults.resolveFixedFor(targetId, form.isAllDay)
val default = reminderDefaults().resolveFor(targetId, form.isAllDay)
// Bail if the form closed or became an edit while we resolved.
if (opened != session) return@launch
val current = _form.value ?: return@launch
if (_editTarget.value != null) return@launch
if (default.isEmpty() && fixed.isEmpty()) return@launch
if (default == current.reminders && fixed == current.fixedReminders) return@launch
if (default.isEmpty() || default == current.reminders) return@launch
_importReminderPrompt.value = ImportReminderPrompt(
currentReminderCount = current.reminders.size + current.fixedReminders.size,
currentReminderCount = current.reminders.size,
defaultReminders = default,
defaultFixedReminders = fixed,
)
}
}
@@ -575,7 +549,7 @@ class EventEditViewModel @Inject constructor(
val prompt = _importReminderPrompt.value ?: return
_importReminderPrompt.value = null
// Already frozen as touched by openImported; this just swaps the values.
edit { it.copy(reminders = prompt.defaultReminders, fixedReminders = prompt.defaultFixedReminders) }
edit { it.copy(reminders = prompt.defaultReminders) }
_revealed.value = _revealed.value + EventFormField.Reminders
}
@@ -605,15 +579,14 @@ class EventEditViewModel @Inject constructor(
if (opened == session) _loadFailed.value = true
return@launch
}
val knownFixed = settingsPrefs.knownFixedTimeReminders.first()
if (opened != session) return@launch
val zone = zoneSource.current()
val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone).withFixedTimeRemindersRead(knownFixed, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end), knownFixed)
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone, detail.atOccurrence(begin, end))
// Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields()
open(snapshot.form)
@@ -718,11 +691,7 @@ class EventEditViewModel @Inject constructor(
fun addReminder(minutes: Int) {
_remindersTouched.value = true
edit {
// A lead firing with a set-time reminder would be one row once saved.
if (minutes in it.fixedReminderOffsets(zoneSource.current())) it
else it.copy(reminders = (it.reminders + minutes).distinct().sorted())
}
edit { it.copy(reminders = (it.reminders + minutes).distinct().sorted()) }
}
fun removeReminder(minutes: Int) {
@@ -730,26 +699,6 @@ class EventEditViewModel @Inject constructor(
edit { it.copy(reminders = it.reminders - minutes) }
}
/**
* Add an "N days before at HH:MM" reminder (#459); timed events only. A lead
* time landing on the same moment is replaced by it — the two would be one
* row once saved.
*/
fun addFixedReminder(reminder: FixedTimeReminder) {
_remindersTouched.value = true
edit { form ->
if (form.isAllDay) return@edit form
val withFixed = form.copy(fixedReminders = (form.fixedReminders + reminder).distinct().sortedWith(FIXED_ORDER))
val offsets = withFixed.fixedReminderOffsets(zoneSource.current()).toSet()
withFixed.copy(reminders = form.reminders.filterNot { it in offsets })
}
}
fun removeFixedReminder(reminder: FixedTimeReminder) {
_remindersTouched.value = true
edit { it.copy(fixedReminders = it.fixedReminders - reminder) }
}
/**
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
* address just refreshes it (no duplicate row). A blank email is ignored —
@@ -866,7 +815,6 @@ class EventEditViewModel @Inject constructor(
val fresh = try {
repository.eventDetail(target.eventId)
.toEditSnapshot(target.beginMillis, target.endMillis, target.zone)
.withFixedTimeRemindersRead(target.knownFixed, target.zone)
} catch (e: CancellationException) {
throw e
} catch (e: NoSuchEventException) {
@@ -881,17 +829,6 @@ class EventEditViewModel @Inject constructor(
return@launch
}
}
// Remembered before the write, so the rows read back as these
// reminders from the first re-read on (#459).
if (!form.isAllDay && form.fixedReminders.isNotEmpty()) {
try {
settingsPrefs.rememberFixedTimeReminders(form.fixedReminders)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Could not remember the set-time reminders", e)
}
}
val result = try {
if (target == null) {
repository.createEvent(form)
@@ -1013,7 +950,3 @@ class EventEditViewModel @Inject constructor(
_form.value = _form.value?.let(block)
}
}
/** [EditSnapshot.form] with its timed reminders read back into lead times and fixed-time ones. */
private fun EditSnapshot.withFixedTimeRemindersRead(known: Set<FixedTimeReminder>, zone: TimeZone): EditSnapshot =
copy(form = form.withFixedTimeRemindersRead(known, zone))
@@ -9,28 +9,19 @@ import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -38,18 +29,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.data.prefs.fixedTimeOverrideFor
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.common.FixedTimeReminderPicker
import de.jeanlucmakiola.calendula.ui.common.fixedTimeReminderLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
@@ -58,13 +39,10 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
import de.jeanlucmakiola.calendula.ui.common.durationLabel
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.android.hasPermission
import de.jeanlucmakiola.floret.android.openNotificationSettings
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.ExpandIcon
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.GroupedSwitchRow
import de.jeanlucmakiola.floret.components.IconInfoCard
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.SelectedCheck
import de.jeanlucmakiola.floret.identity.collapseExit
@@ -72,7 +50,6 @@ import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.minutesOrEmpty
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import kotlinx.coroutines.launch
import kotlinx.datetime.LocalTime
/**
@@ -92,9 +69,8 @@ internal fun NotificationsScreen(
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* The pref is already on; a denial just leaves the OS gate shut. */ }
// Turning either kind of notification on asks for the permission if missing.
fun askingPermission(set: (Boolean) -> Unit): (Boolean) -> Unit = { enabled ->
set(enabled)
val toggleReminders: (Boolean) -> Unit = { enabled ->
viewModel.setRemindersEnabled(enabled)
val needsPermission = enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
!context.hasPermission(Manifest.permission.POST_NOTIFICATIONS)
@@ -102,31 +78,14 @@ internal fun NotificationsScreen(
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
val toggleReminders = askingPermission(viewModel::setRemindersEnabled)
val toggleDigest = askingPermission(viewModel::setDailyDigestEnabled)
var showDefaultReminder by remember { mutableStateOf(false) }
var showAllDayReminder by remember { mutableStateOf(false) }
var showAllDayReminderTime by remember { mutableStateOf(false) }
var showSnooze by remember { mutableStateOf(false) }
var showFixedDefault by remember { mutableStateOf(false) }
var fixedOverrideFor by remember { mutableStateOf<Long?>(null) }
var showDigestDay by remember { mutableStateOf(false) }
var showDigestTime by remember { mutableStateOf(false) }
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
var calendarSectionExpanded by remember { mutableStateOf(false) }
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
val scope = rememberCoroutineScope()
val channelStatus by viewModel.channelStatus.collectAsStateWithLifecycle()
val digestBlocked by viewModel.digestBlocked.collectAsStateWithLifecycle()
val calendarIds = state.reminderCalendars.map { it.id }
LifecycleResumeEffect(calendarIds) {
viewModel.refreshChannelStatus(calendarIds)
onPauseOrDispose { }
}
val openCalendarSound: (Long) -> Unit = { calendarId ->
scope.launch { context.openNotificationSettings(viewModel.soundSettingsChannel(calendarId)) }
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_notifications),
@@ -134,21 +93,6 @@ internal fun NotificationsScreen(
predictiveBack = true,
visible = visible,
) {
if (IS_SAMSUNG && !state.notificationCategoryHintDismissed) {
NotificationCategoryHint(
onOpen = {
viewModel.dismissNotificationCategoryHint()
scope.launch {
viewModel.syncReminderChannels()
// The summary's category too, so it is on the list to check.
if (state.dailyDigest.enabled) viewModel.digestChannel()
context.openNotificationSettings()
}
},
onDismiss = viewModel::dismissNotificationCategoryHint,
)
Spacer(Modifier.height(16.dp))
}
GroupedSwitchRow(
title = stringResource(R.string.settings_reminders),
checked = state.remindersEnabled,
@@ -156,34 +100,12 @@ internal fun NotificationsScreen(
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = if (channelStatus.appBlocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
stringResource(R.string.settings_notification_sound_hint)
},
position = Position.Middle,
trailing = { ExternalLinkIcon() },
onClick = {
scope.launch {
viewModel.syncReminderChannels()
context.openNotificationSettings()
}
},
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderChoiceLabel(state.defaultReminderMinutes),
position = Position.Middle,
onClick = { showDefaultReminder = true },
)
GroupedRow(
title = stringResource(R.string.settings_fixed_reminder),
summary = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
position = Position.Middle,
onClick = { showFixedDefault = true },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = reminderChoiceLabel(state.defaultAllDayReminderMinutes),
@@ -200,21 +122,6 @@ internal fun NotificationsScreen(
onClick = { showAllDayReminderTime = true },
)
Spacer(Modifier.height(24.dp))
DailyDigestRows(
digest = state.dailyDigest,
blocked = channelStatus.appBlocked || digestBlocked,
onEnable = toggleDigest,
onPickDay = { showDigestDay = true },
onPickTime = { showDigestTime = true },
onOpenSound = {
// The app's own page while the app is blocked: the channel's would be greyed out.
scope.launch {
context.openNotificationSettings(viewModel.digestChannel().takeUnless { channelStatus.appBlocked })
}
},
)
// Reliability is a soft, optional battery-optimisation exemption via
// a system-settings deep-link, shown as live status.
Spacer(Modifier.height(24.dp))
@@ -242,8 +149,10 @@ internal fun NotificationsScreen(
onClick = { showSnooze = true },
)
// Per-calendar notifications and reminder overrides (#145, #272).
if (state.reminderCalendars.isNotEmpty()) {
// Per-calendar overrides, folded behind one header. Each writable
// calendar may keep, drop or replace the global default, separately for
// timed and all-day events.
if (state.writableCalendars.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_calendar_reminders_title),
@@ -258,22 +167,30 @@ internal fun NotificationsScreen(
exit = collapseExit(),
) {
Column {
state.reminderCalendars.forEach { calendar ->
state.writableCalendars.forEach { calendar ->
Spacer(Modifier.height(16.dp))
// Special-dates calendars own their reminders in their
// own section — link there instead.
if (calendar.id in state.managedCalendarIds) {
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
return@forEach
}
val expanded = calendar.id in expandedCalendars
val notifying = calendar.id !in state.mutedReminderCalendarIds
val blocked = notifying && calendar.id in channelStatus.blockedCalendarIds
GroupedRow(
title = calendar.displayName.orUnnamed(),
summary = when {
// Still in the summary, so only its reminders are off.
!notifying && state.dailyDigest.enabled &&
calendar.id !in state.dailyDigest.excludedCalendarIds ->
stringResource(R.string.settings_calendar_reminders_off)
!notifying -> stringResource(R.string.settings_calendar_notifications_off)
blocked -> stringResource(R.string.settings_notifications_blocked)
else -> null
},
position = if (expanded) Position.Top else Position.Alone,
leading = { CalendarColorChip(calendar.color) },
trailing = { ExpandIcon(expanded) },
@@ -290,18 +207,22 @@ internal fun NotificationsScreen(
enter = expandEnter(),
exit = collapseExit(),
) {
CalendarNotificationRows(
calendar = calendar,
state = state,
notifying = notifying,
blocked = blocked,
onSetNotifying = { viewModel.setCalendarRemindersEnabled(calendar.id, it) },
onOpenSound = { openCalendarSound(calendar.id) },
onOpenSpecialDates = onOpenSpecialDates,
onEditOverride = { overrideDialog = it },
onEditFixedOverride = { fixedOverrideFor = calendar.id },
onSetInDigest = { viewModel.setCalendarInDailyDigest(calendar.id, it) },
)
Column {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
)
}
}
}
}
@@ -309,44 +230,6 @@ internal fun NotificationsScreen(
}
}
if (showFixedDefault) {
FixedTimeReminderPicker(
title = stringResource(R.string.settings_fixed_reminder),
selected = state.defaultFixedTimeReminder?.let { FixedTimeOverride.At(it) } ?: FixedTimeOverride.Off,
onSelect = { viewModel.setDefaultFixedTimeReminder((it as? FixedTimeOverride.At)?.reminder) },
onDismiss = { showFixedDefault = false },
)
}
fixedOverrideFor?.let { calendarId ->
FixedTimeReminderPicker(
title = stringResource(R.string.settings_fixed_reminder),
selected = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendarId),
onSelect = { viewModel.setCalendarFixedTimeReminderOverride(calendarId, it) },
onDismiss = { fixedOverrideFor = null },
inheritedLabel = fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
inherited = state.defaultFixedTimeReminder,
)
}
if (showDigestDay) {
OptionPicker(
title = stringResource(R.string.settings_digest_day),
options = DigestDay.entries,
selected = state.dailyDigest.day,
label = { stringResource(digestDayLabel(it)) },
onSelect = viewModel::setDailyDigestDay,
onDismiss = { showDigestDay = false },
)
}
if (showDigestTime) {
TimePickerAlert(
initial = LocalTime(state.dailyDigest.timeMinutes / 60, state.dailyDigest.timeMinutes % 60),
onConfirm = {
viewModel.setDailyDigestTimeMinutes(it.hour * 60 + it.minute)
showDigestTime = false
},
onDismiss = { showDigestTime = false },
)
}
if (showSnooze) {
DurationPicker(
title = stringResource(R.string.settings_snooze_duration),
@@ -426,199 +309,6 @@ internal fun NotificationsScreen(
}
}
/**
* One calendar's expanded rows: its notification switch and channel deep-link
* for every calendar, then the default-reminder overrides where it takes them —
* or, for a special-dates calendar, a link to the section that owns them.
*/
@Composable
private fun CalendarNotificationRows(
calendar: CalendarSource,
state: SettingsUiState,
notifying: Boolean,
blocked: Boolean,
onSetNotifying: (Boolean) -> Unit,
onOpenSound: () -> Unit,
onOpenSpecialDates: () -> Unit,
onEditOverride: (OverrideTarget) -> Unit,
onEditFixedOverride: () -> Unit,
onSetInDigest: (Boolean) -> Unit,
) {
val managed = calendar.id in state.managedCalendarIds
val takesOverrides = calendar.canModifyContents && !managed
val digestOn = state.dailyDigest.enabled
Column {
GroupedSwitchRow(
title = stringResource(R.string.settings_calendar_notifications),
checked = notifying,
onCheckedChange = onSetNotifying,
summary = stringResource(R.string.settings_calendar_notifications_hint),
position = Position.Middle,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = if (blocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
stringResource(R.string.settings_calendar_notification_sound_hint)
},
position = if (managed || takesOverrides || digestOn) Position.Middle else Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSound,
)
if (digestOn) {
GroupedSwitchRow(
title = stringResource(R.string.settings_calendar_in_digest),
checked = calendar.id !in state.dailyDigest.excludedCalendarIds,
onCheckedChange = onSetInDigest,
summary = stringResource(R.string.settings_calendar_in_digest_hint),
position = if (managed || takesOverrides) Position.Middle else Position.Bottom,
)
}
if (managed) {
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = stringResource(R.string.settings_calendar_reminders_managed_hint),
position = Position.Bottom,
trailing = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onOpenSpecialDates,
)
} else if (takesOverrides) {
val timed = state.perCalendarReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
position = Position.Middle,
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = false)) },
)
GroupedRow(
title = stringResource(R.string.settings_fixed_reminder),
summary = when (val fixed = state.perCalendarFixedTimeReminderOverride.fixedTimeOverrideFor(calendar.id)) {
FixedTimeOverride.Inherit -> stringResource(
R.string.settings_calendar_reminder_inherits,
fixedReminderChoiceLabel(state.defaultFixedTimeReminder),
)
FixedTimeOverride.Off -> stringResource(R.string.reminder_none)
is FixedTimeOverride.At -> fixedTimeReminderLabel(fixed.reminder)
},
position = Position.Middle,
onClick = onEditFixedOverride,
)
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
GroupedRow(
title = stringResource(R.string.settings_default_reminder_allday),
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
position = Position.Bottom,
onClick = { onEditOverride(OverrideTarget(calendar.id, isAllDay = true)) },
)
}
}
}
/**
* The daily summary (#459): its switch, and once on, which day it lists, when,
* and its channel's system settings.
*/
@Composable
private fun DailyDigestRows(
digest: DailyDigestSettings,
blocked: Boolean,
onEnable: (Boolean) -> Unit,
onPickDay: () -> Unit,
onPickTime: () -> Unit,
onOpenSound: () -> Unit,
) {
GroupedSwitchRow(
title = stringResource(R.string.settings_digest),
checked = digest.enabled,
onCheckedChange = onEnable,
summary = if (digest.enabled) {
stringResource(
R.string.settings_digest_summary,
stringResource(digestDayLabel(digest.day)),
settingsTimeOfDay(digest.timeMinutes),
)
} else {
stringResource(R.string.settings_digest_hint)
},
position = if (digest.enabled) Position.Top else Position.Alone,
)
AnimatedVisibility(visible = digest.enabled, enter = expandEnter(), exit = collapseExit()) {
Column {
GroupedRow(
title = stringResource(R.string.settings_digest_day),
summary = stringResource(digestDayLabel(digest.day)),
position = Position.Middle,
onClick = onPickDay,
)
GroupedRow(
title = stringResource(R.string.settings_digest_time),
summary = settingsTimeOfDay(digest.timeMinutes),
position = Position.Middle,
onClick = onPickTime,
)
GroupedRow(
title = stringResource(R.string.settings_notification_sound),
summary = if (blocked) {
stringResource(R.string.settings_notifications_blocked_hint)
} else {
stringResource(R.string.settings_digest_sound_hint)
},
position = Position.Bottom,
trailing = { ExternalLinkIcon() },
onClick = onOpenSound,
)
}
}
}
/** Samsung's One UI can start a new notification category switched off (#145). */
private val IS_SAMSUNG = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
/** The one-time Samsung note, with a way into the app's notification settings. */
@Composable
private fun NotificationCategoryHint(onOpen: () -> Unit, onDismiss: () -> Unit) {
IconInfoCard(
icon = Icons.Outlined.Info,
iconContentDescription = null,
iconAlignment = Alignment.Top,
) {
Text(
text = stringResource(R.string.settings_notification_category_hint),
style = MaterialTheme.typography.bodyMedium,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.reminder_action_dismiss)) }
TextButton(onClick = onOpen) { Text(stringResource(R.string.settings_notification_category_hint_open)) }
}
}
}
private fun digestDayLabel(day: DigestDay): Int = when (day) {
DigestDay.Today -> R.string.settings_digest_day_today
DigestDay.Tomorrow -> R.string.settings_digest_day_tomorrow
}
/** A fixed-time default's label, or "None". */
@Composable
private fun fixedReminderChoiceLabel(reminder: FixedTimeReminder?): String =
reminder?.let { fixedTimeReminderLabel(it) } ?: stringResource(R.string.reminder_none)
@Composable
private fun ExternalLinkIcon() {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
@@ -1,7 +1,5 @@
package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -98,20 +96,8 @@ data class SettingsUiState(
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
/** The "N days before at HH:MM" default for new timed events (#459); null = none. */
val defaultFixedTimeReminder: FixedTimeReminder? = null,
/** Per-calendar overrides of [defaultFixedTimeReminder]: absent inherits, null turns it off. */
val perCalendarFixedTimeReminderOverride: Map<Long, FixedTimeReminder?> = emptyMap(),
/** The daily summary notification (#459). */
val dailyDigest: DailyDigestSettings = DailyDigestSettings(),
/** Switched-on calendars you can edit: the per-calendar event-duration rows. */
/** Writable calendars, shown as per-calendar reminder-override rows. */
val writableCalendars: List<CalendarSource> = emptyList(),
/** Switched-on calendars, each with its own notification switch and channel (#145, #272). */
val reminderCalendars: List<CalendarSource> = emptyList(),
/** Calendars whose reminders Calendula doesn't post (#272). */
val mutedReminderCalendarIds: Set<Long> = emptySet(),
/** Whether the Samsung notification-category hint has been dismissed (#145); hidden until prefs load. */
val notificationCategoryHintDismissed: Boolean = true,
/**
* Whether the event-colour picker is offered on calendars that publish no
* colour palette (the colour may then not survive their next sync).
@@ -3,7 +3,6 @@ package de.jeanlucmakiola.calendula.ui.settings
import android.content.Context
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -19,15 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import de.jeanlucmakiola.floret.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.data.prefs.dailyDigest
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.reminders.DailyDigestNotifier
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -41,8 +33,6 @@ import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannelStatus
import de.jeanlucmakiola.calendula.data.reminders.ReminderChannels
import de.jeanlucmakiola.calendula.data.reminders.ReminderScanner
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
@@ -55,9 +45,7 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -74,15 +62,11 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: SettingsPrefs,
calendarPrefs: CalendarPrefs,
repository: CalendarRepository,
private val specialDatesEngine: SpecialDatesSyncEngine,
specialDatesSpec: SpecialDatesCalendarSpec,
private val launcherNameManager: LauncherNameManager,
private val reminderScanner: ReminderScanner,
private val reminderChannels: ReminderChannels,
private val digestNotifier: DailyDigestNotifier,
private val reminderState: ReminderStatePrefs,
@IoDispatcher private val io: CoroutineDispatcher,
@ApplicationContext private val appContext: Context,
) : ViewModel() {
@@ -90,16 +74,16 @@ class SettingsViewModel @Inject constructor(
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
/**
* Calendars that are switched on — the only ones that plan reminders at
* all (#75), so the only ones with notification settings.
* Writable calendars that are switched on — the only ones that take a
* per-calendar reminder override, since a switched-off one plans no
* reminders at all (#75).
*/
private val visibleCalendars: Flow<List<CalendarSource>> =
combine(repository.calendars(), calendarPrefs.pendingDisabledCalendarIds) { calendars, pending ->
calendars.filter { it.isVisibleInSystem && it.id !in pending }
}.catch { emit(emptyList()) }
private val writableCalendars: Flow<List<CalendarSource>> = repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents && it.isVisibleInSystem } }
.catch { emit(emptyList()) }
val state: StateFlow<SettingsUiState> =
combine(prefs.data, visibleCalendars) { stored, calendars -> stored.toUiState(calendars) }
combine(prefs.data, writableCalendars) { stored, calendars -> stored.toUiState(calendars) }
.stateWhileSubscribed(
scope = viewModelScope,
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
@@ -141,13 +125,7 @@ class SettingsViewModel @Inject constructor(
snoozeMinutes = this[SettingsKeys.snoozeMinutes],
perCalendarReminderOverride = this[SettingsKeys.perCalendarReminderOverride],
perCalendarAllDayReminderOverride = this[SettingsKeys.perCalendarAllDayReminderOverride],
defaultFixedTimeReminder = this[SettingsKeys.defaultFixedTimeReminder],
perCalendarFixedTimeReminderOverride = this[SettingsKeys.perCalendarFixedTimeReminderOverride],
dailyDigest = dailyDigest,
writableCalendars = calendars.filter { it.canModifyContents },
reminderCalendars = calendars,
mutedReminderCalendarIds = this[SettingsKeys.mutedReminderCalendarIds],
notificationCategoryHintDismissed = this[SettingsKeys.notificationCategoryHintDismissed],
writableCalendars = calendars,
allowColorOnUnsupportedCalendars = this[SettingsKeys.allowColorOnUnsupportedCalendars],
managedCalendarIds = specialDatesCalendars.values.toSet(),
)
@@ -372,73 +350,13 @@ class SettingsViewModel @Inject constructor(
* A scan follows the write: switching reminders off cancels the scan alarm,
* so switching them back on has to arm a new one (#75).
*/
fun setRemindersEnabled(enabled: Boolean) = saveAndScan { setRemindersEnabled(enabled) }
/**
* Re-plans, since a muted calendar armed no wake-up for its next reminder.
* Unmuting scans once more before the write, so the watermark starts at the
* unmute and nothing that came due while muted is replayed.
*/
fun setCalendarRemindersEnabled(calendarId: Long, enabled: Boolean) {
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch {
if (enabled) reminderScanner.scan()
prefs.setCalendarRemindersEnabled(calendarId, enabled)
prefs.setRemindersEnabled(enabled)
reminderScanner.scan()
}
}
private val _channelStatus = MutableStateFlow(ReminderChannelStatus())
/** What the system blocks of the reminders; re-read with [refreshChannelStatus] (#145). */
val channelStatus: StateFlow<ReminderChannelStatus> = _channelStatus.asStateFlow()
private var channelStatusJob: Job? = null
fun refreshChannelStatus(calendarIds: Collection<Long>) {
channelStatusJob?.cancel()
channelStatusJob = viewModelScope.launch {
onChannels { status(calendarIds) }?.let { _channelStatus.value = it }
_digestBlocked.value = withContext(io) { runCatching { digestNotifier.isChannelOff() }.getOrDefault(false) }
}
}
private val _digestBlocked = MutableStateFlow(false)
/**
* Whether the daily summary's own channel is switched off (#459); the app-wide
* block is [channelStatus]'s. Refreshed with [refreshChannelStatus].
*/
val digestBlocked: StateFlow<Boolean> = _digestBlocked.asStateFlow()
fun dismissNotificationCategoryHint() = save { dismissNotificationCategoryHint() }
/**
* The channel whose system screen to open for [calendarId] (#340), created
* if needed; null when only the app's own screen can help — the app or the
* calendar's account group is blocked there, or the channel couldn't be made.
*/
suspend fun soundSettingsChannel(calendarId: Long): String? = onChannels {
val channelId = channelFor(calendarId) ?: return@onChannels null
val status = status(listOf(calendarId))
channelId.takeUnless { status.appBlocked || calendarId in status.groupBlockedCalendarIds }
}
/** Create the per-calendar channels before showing the app's channel list, even with reminders off. */
suspend fun syncReminderChannels() {
onChannels { sync() }
}
private suspend fun <T> onChannels(block: ReminderChannels.() -> T): T? = withContext(io) {
try {
reminderChannels.block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("SettingsViewModel", "Reminder channel update failed", e)
null
}
}
fun setAutofocusEventTitle(enabled: Boolean) = save { setAutofocusEventTitle(enabled) }
fun setDefaultEventDuration(minutes: Int) = save { setDefaultEventDurationMinutes(minutes) }
@@ -451,7 +369,12 @@ class SettingsViewModel @Inject constructor(
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) = save { setDefaultAllDayReminderMinutes(minutes) }
/** The armed alarm was planned for the old hour, so re-plan against the new one. */
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) = saveAndScan { setAllDayReminderTimeMinutes(minutesOfDay) }
fun setAllDayReminderTimeMinutes(minutesOfDay: Int) {
viewModelScope.launch {
prefs.setAllDayReminderTimeMinutes(minutesOfDay)
reminderScanner.scan()
}
}
fun setSnoozeMinutes(minutes: Int) = save { setSnoozeMinutes(minutes) }
@@ -461,53 +384,6 @@ class SettingsViewModel @Inject constructor(
fun setAllowColorOnUnsupportedCalendars(enabled: Boolean) = save { setAllowColorOnUnsupportedCalendars(enabled) }
fun setDefaultFixedTimeReminder(reminder: FixedTimeReminder?) = save { setDefaultFixedTimeReminder(reminder) }
fun setCalendarFixedTimeReminderOverride(calendarId: Long, override: FixedTimeOverride) =
save { setCalendarFixedTimeReminderOverride(calendarId, override) }
/** Every summary change re-plans, so the next one is armed for its new moment (#459). */
fun setDailyDigestEnabled(enabled: Boolean) {
viewModelScope.launch {
prefs.setDailyDigestEnabled(enabled)
// Created now, so a phone that starts new categories switched off
// shows it blocked here rather than dropping the first summary.
if (enabled) {
digestChannel()
_digestBlocked.value = withContext(io) { runCatching { digestNotifier.isChannelOff() }.getOrDefault(false) }
}
// After the scan, so one already under way can't post past the cancel;
// a summary left showing would go stale with nothing to replace it.
reminderScanner.scan()
if (!enabled) digestNotifier.cancel()
}
}
fun setDailyDigestTimeMinutes(minutesOfDay: Int) = saveAndScan { setDailyDigestTimeMinutes(minutesOfDay) }
/** A new mode lists afresh: from "tomorrow" to "today", the day just listed comes again. */
fun setDailyDigestDay(day: DigestDay) {
viewModelScope.launch {
prefs.setDailyDigestDay(day)
reminderState.clearLastDigestDay()
reminderScanner.scan()
}
}
fun setCalendarInDailyDigest(calendarId: Long, included: Boolean) = save { setCalendarInDailyDigest(calendarId, included) }
/** The summary's channel, created if needed so its system settings can open. */
suspend fun digestChannel(): String? = withContext(io) {
runCatching { digestNotifier.channelId() }.getOrNull()
}
private fun saveAndScan(write: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch {
prefs.write()
reminderScanner.scan()
}
}
private fun save(write: suspend SettingsPrefs.() -> Unit) {
viewModelScope.launch { prefs.write() }
}
+36 -49
View File
@@ -731,56 +731,8 @@
<string name="reminder_custom_amount">Amount</string>
<string name="reminder_custom_set">Set</string>
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
<string name="settings_calendar_reminders_hint">Turn notifications off or pick a sound per calendar, and override the default reminder for calendars you can edit.</string>
<string name="settings_calendar_notifications">Notifications</string>
<string name="settings_calendar_notifications_hint">Notify at this calendar\'s reminder times</string>
<string name="settings_calendar_notifications_off">Notifications off</string>
<string name="settings_calendar_reminders_off">Reminders off</string>
<string name="settings_notification_sound">Notification sound</string>
<string name="settings_notification_sound_hint">Sound, vibration and pop-ups for each calendar, in system settings</string>
<string name="settings_notifications_blocked">Blocked in system settings</string>
<string name="settings_notifications_blocked_hint">Blocked in system settings — tap to allow</string>
<string name="settings_notification_category_hint">On Samsung phones, new notification categories can start switched off. If a calendar\'s reminders don\'t arrive, check that its category is on.</string>
<string name="settings_notification_category_hint_open">Open settings</string>
<string name="settings_calendar_notification_sound_hint">Sound, vibration and pop-ups for this calendar, in system settings</string>
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
<!-- Reminders at a set time (#459). %1$s is a lead in days ("1 day before"), %2$s a clock time. -->
<string name="reminder_fixed_time">%1$s at %2$s</string>
<string name="reminder_fixed_at_set_time">At a set time</string>
<!-- Read as one sentence with a number field before it and a time after it: "[2] days before, at [20:00]". The full wording shows below it. -->
<string name="reminder_fixed_days_before_at">days before, at</string>
<string name="reminder_fixed_days_range">Pick 1 to %1$d days</string>
<string name="settings_fixed_reminder">Reminder at a set time</string>
<string name="event_edit_reminder_fixed_summary">E.g. the evening before</string>
<string name="event_edit_reminder_fixed_hint">Fires at this time however early or late the event starts, and moves along when you change the event\'s time.</string>
<string name="settings_fixed_reminder_hint">Added to new timed events, e.g. the evening before. It follows the event when you move it to another time.</string>
<!-- Daily summary notification (#459) -->
<string name="settings_digest">Daily summary</string>
<string name="settings_digest_hint">One notification listing a day\'s events</string>
<!-- %1$s is settings_digest_day_today/tomorrow, %2$s a clock time. -->
<string name="settings_digest_summary">%1$s at %2$s</string>
<string name="settings_digest_day">Lists</string>
<string name="settings_digest_day_today">Today\'s events</string>
<string name="settings_digest_day_tomorrow">Tomorrow\'s events</string>
<string name="settings_digest_time">Time</string>
<string name="settings_digest_sound_hint">Sound and pop-up for the summary, in system settings</string>
<string name="settings_calendar_in_digest">In daily summary</string>
<string name="settings_calendar_in_digest_hint">List this calendar\'s events in the daily summary</string>
<string name="digest_channel_name">Daily summary</string>
<string name="digest_channel_description">One notification listing a day\'s events</string>
<!-- %1$s is "Today", "Tomorrow" or a date; %2$d the number of events. -->
<plurals name="digest_title">
<item quantity="one">%1$s: %2$d event</item>
<item quantity="other">%1$s: %2$d events</item>
</plurals>
<!-- %1$s is "Today" or "Tomorrow", %2$s a short date, e.g. "Fri, 14 Nov". -->
<string name="digest_day_relative">%1$s, %2$s</string>
<!-- One event in the summary: %1$s its time ("09:00 – 10:00" or "All day"), %2$s its title. -->
<string name="digest_line">%1$s · %2$s</string>
<plurals name="digest_more">
<item quantity="one">+%d more</item>
<item quantity="other">+%d more</item>
</plurals>
<string name="settings_reliable_delivery">Reliable delivery</string>
<string name="settings_reliable_delivery_hint">Android may delay reminders to save battery. Exempt Calendula so they arrive on time.</string>
<string name="settings_reliable_delivery_exempt">Exempt from battery optimisation — reminders arrive on time.</string>
@@ -958,6 +910,41 @@
<item>Weeks</item>
</string-array>
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
<string name="settings_backup_header">Settings</string>
<string name="settings_backup_hint">Save Calendula\'s settings to a file and restore them after reinstalling or on a new phone. Events aren\'t part of it.</string>
<string name="settings_backup_export">Export settings</string>
<string name="settings_backup_export_summary">Save every setting to a .json file</string>
<string name="settings_backup_import">Import settings</string>
<string name="settings_backup_import_summary">Replace the current settings with a saved file</string>
<string name="settings_backup_export_done">Settings exported.</string>
<string name="settings_backup_export_failed">Couldn\'t export the settings.</string>
<string name="settings_backup_import_confirm_title">Replace your settings?</string>
<string name="settings_backup_import_confirm_message">Every setting is replaced with the one in the file. Your events and calendars stay as they are, apart from contact special dates, which follow the imported setting.</string>
<string name="settings_backup_import_confirm_action">Replace</string>
<string name="settings_backup_import_failed_not_backup">That file isn\'t a Calendula settings backup.</string>
<string name="settings_backup_import_failed_newer">That backup is from a newer Calendula. Update the app to import it.</string>
<string name="settings_backup_import_failed_invalid">That settings backup is damaged and wasn\'t imported.</string>
<string name="settings_backup_import_failed_too_large">That file is too large to be a settings backup.</string>
<string name="settings_backup_import_failed_unreadable">Couldn\'t read that file.</string>
<string name="settings_backup_import_failed">Couldn\'t import the settings.</string>
<string name="settings_backup_imported_title">Settings imported</string>
<string name="settings_backup_imported_body">Your settings now match the ones in the file.</string>
<plurals name="settings_backup_imported_calendars">
<item quantity="one">Calendar settings were applied to %d calendar.</item>
<item quantity="other">Calendar settings were applied to %d calendars.</item>
</plurals>
<string name="settings_backup_imported_skipped">Not imported</string>
<!-- %1$s: calendar name, %2$s: its account -->
<string name="settings_backup_skipped_calendar">%1$s (%2$s): no matching calendar on this phone</string>
<!-- %1$s: calendar name, %2$s: its account -->
<string name="settings_backup_skipped_calendar_ambiguous">%1$s (%2$s): more than one calendar on this phone has that name</string>
<string name="settings_backup_skipped_calendars_unavailable">Calendar settings: Calendula can\'t read your calendars, so they were left as they are</string>
<string name="settings_backup_skipped_font_headings">Custom headings font: choose the font file again</string>
<string name="settings_backup_skipped_font_body">Custom body font: choose the font file again</string>
<string name="settings_backup_skipped_special_dates">Contact special dates: turn them on again to allow access to contacts</string>
<string name="settings_backup_skipped_auto_backup">Automatic backup: choose the backup folder again</string>
<!-- %1$s: the language's name -->
<string name="settings_backup_skipped_language">%1$s isn\'t available in this version</string>
<plurals name="calendars_backup_done">
<item quantity="one">Exported %d event.</item>
<item quantity="other">Exported %d events.</item>
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.calendula.data.backup
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.ReminderStateKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.floret.prefs.Pref
import org.junit.jupiter.api.Test
/**
* Every key of the settings store is either backed up or deliberately left
* out. A new key fails here until [SettingsBackupSchema] says which.
*/
class SettingsBackupSchemaTest {
private val schema = SettingsBackupSchema
private fun keysOf(holder: Any): List<String> = holder.javaClass.declaredFields
.filter { java.lang.reflect.Modifier.isStatic(it.modifiers) && it.name != "INSTANCE" }
.flatMap { field ->
field.isAccessible = true
when (val value = field.get(null)) {
is Pref<*> -> listOf(value.name)
is Map<*, *> -> value.values.filterIsInstance<Pref<*>>().map { it.name }
else -> emptyList()
}
}
@Test
fun `every stored key is classified`() {
val stored = (keysOf(SettingsKeys) + keysOf(CalendarPrefKeys) + keysOf(ReminderStateKeys)).toSet()
val classified = (schema.plain + schema.perCalendar + schema.excluded).map { it.name }
assertThat(stored).isNotEmpty()
assertThat(classified).containsAtLeastElementsIn(stored)
assertThat(stored).containsAtLeastElementsIn(classified)
}
@Test
fun `no key is classified twice`() {
val classified = (schema.plain + schema.perCalendar + schema.excluded).map { it.name }
assertThat(classified).containsNoDuplicates()
}
}
@@ -0,0 +1,408 @@
package de.jeanlucmakiola.calendula.data.backup
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.mutablePreferencesOf
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.appname.LauncherName
import de.jeanlucmakiola.calendula.data.calendar.ManagedCalendarRow
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefKeys
import de.jeanlucmakiola.calendula.data.prefs.SettingsKeys
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.floret.backup.BackupProblem
import de.jeanlucmakiola.floret.backup.BackupResult
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.get
import de.jeanlucmakiola.floret.prefs.set
import org.junit.jupiter.api.Test
class SettingsBackupTest {
private fun calendar(id: Long, name: String, account: String = "me@example.org", managed: Boolean = false) =
CalendarSource(
id = id,
displayName = name,
accountName = account,
accountType = "com.google",
color = 0,
isVisibleInSystem = true,
isManaged = managed,
)
private val work = calendar(1, "Work")
private val home = calendar(2, "Home")
private val birthdays = calendar(3, "Birthdays", account = "Calendula", managed = true)
private fun environment(
calendars: List<CalendarSource>? = listOf(work, home, birthdays),
managed: List<ManagedCalendarRow> = listOf(ManagedCalendarRow(3, SpecialDateType.Birthday)),
contacts: Boolean = true,
fonts: Set<FontRole> = emptySet(),
folder: Boolean = false,
languages: List<String> = listOf("de", "en-US", "fr"),
) = ImportEnvironment(calendars, managed, contacts, fonts, folder, languages)
private fun roundTrip(source: MutablePreferences, calendars: List<CalendarSource> = listOf(work, home, birthdays)): SettingsBackup {
val backup = buildSettingsBackup(
snapshot = source,
calendars = calendars,
managed = listOf(ManagedCalendarRow(3, SpecialDateType.Birthday)),
language = LanguageChoice("de"),
launcherName = LauncherName.CALENDAR,
)
val decoded = decodeSettingsBackup(encodeSettingsBackup(backup, 1_700_000_000_000, "2.25.0"))
return (decoded as BackupResult.Success).value
}
private fun import(backup: SettingsBackup, current: MutablePreferences, env: ImportEnvironment = environment()) =
planSettingsImport(backup, current, env).also { it.applyTo(current) }
@Test
fun `plain settings survive the round trip`() {
val source = mutablePreferencesOf()
source[SettingsKeys.themeMode] = ThemeMode.BLACK
source[SettingsKeys.multiDayCount] = 5
source[SettingsKeys.defaultView] = CalendarView.Month
source[SettingsKeys.defaultReminderMinutes] = listOf(10, 60)
val target = mutablePreferencesOf()
import(roundTrip(source), target)
assertThat(target[SettingsKeys.themeMode]).isEqualTo(ThemeMode.BLACK)
assertThat(target[SettingsKeys.multiDayCount]).isEqualTo(5)
assertThat(target[SettingsKeys.defaultView]).isEqualTo(CalendarView.Month)
assertThat(target[SettingsKeys.defaultReminderMinutes]).containsExactly(10, 60).inOrder()
}
@Test
fun `a setting the file leaves at its default resets here`() {
val target = mutablePreferencesOf()
target[SettingsKeys.showWeekNumbers] = true
import(roundTrip(mutablePreferencesOf()), target)
assertThat(target[SettingsKeys.showWeekNumbers]).isFalse()
}
@Test
fun `device state is neither exported nor touched`() {
val source = mutablePreferencesOf()
source[SettingsKeys.autoBackupFolderUri] = "content://tree/backup"
source[SettingsKeys.recentTimeZones] = listOf("Europe/Berlin")
val target = mutablePreferencesOf()
target[SettingsKeys.autoBackupLastRun] = 42L
val backup = roundTrip(source)
import(backup, target)
assertThat(backup.prefs.keys.map { it.name }).doesNotContain("auto_backup_folder_uri")
assertThat(target[SettingsKeys.autoBackupFolderUri]).isNull()
assertThat(target[SettingsKeys.autoBackupLastRun]).isEqualTo(42L)
}
@Test
fun `per-calendar settings follow the calendar to its new id`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
source[CalendarPrefKeys.lastUsedCalendarId] = 2L
source[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
source[SettingsKeys.perCalendarReminderOverride] = mapOf(2L to emptyList())
val moved = listOf(calendar(11, "Work"), calendar(12, "Home"))
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(calendars = moved, managed = emptyList()))
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).containsExactly(11L)
assertThat(target[CalendarPrefKeys.lastUsedCalendarId]).isEqualTo(12L)
assertThat(target[SettingsKeys.perCalendarEventDuration]).containsExactly(11L, 30)
assertThat(target[SettingsKeys.perCalendarReminderOverride]).containsExactly(12L, emptyList<Int>())
assertThat(plan.calendars).hasSize(2)
}
@Test
fun `a calendar missing here is reported, and its old id is not reused`() {
val source = mutablePreferencesOf()
source[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
val target = mutablePreferencesOf()
target[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 45)
val plan = import(roundTrip(source), target, environment(calendars = listOf(calendar(1, "Other"))))
assertThat(target[SettingsKeys.perCalendarEventDuration]).isEmpty()
assertThat(plan.skipped).containsExactly(SkippedSetting.Calendar("Work", "me@example.org"))
}
@Test
fun `two calendars with the same name and account match neither`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(calendars = listOf(calendar(5, "Work"), calendar(6, "Work"))))
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.skipped).containsExactly(SkippedSetting.Calendar("Work", "me@example.org", ambiguous = true))
}
@Test
fun `every per-calendar key travels with its calendar`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
source[CalendarPrefKeys.lastUsedCalendarId] = 1L
source[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
source[SettingsKeys.perCalendarReminderOverride] = mapOf(1L to listOf(5))
source[SettingsKeys.perCalendarAllDayReminderOverride] = mapOf(1L to listOf(0))
val target = mutablePreferencesOf()
import(roundTrip(source), target, environment(calendars = listOf(calendar(9, "Work")), managed = emptyList()))
// One assertion per key in the schema: a new per-calendar key fails here until it is carried.
SettingsBackupSchema.perCalendar.forEach { pref ->
assertThat(target.asMap().keys.map { it.name }).contains(pref.name)
}
assertThat(target[SettingsKeys.perCalendarAllDayReminderOverride]).containsExactly(9L, listOf(0))
}
@Test
fun `a mirror type the import switches off is left out`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
source[SettingsKeys.specialDateTypeEnabled.getValue(SpecialDateType.Birthday)] = false
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(3L)
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target)
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.pendingSpecialDates).isEmpty()
assertThat(plan.skipped).isEmpty()
}
@Test
fun `an out-of-range calendar length is clamped, not refused`() {
val text = """{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{},"calendars":[
{"account_name":"me@example.org","account_type":"com.google","display_name":"Work","event_duration_minutes":5000}]}"""
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
val target = mutablePreferencesOf()
import(backup, target)
assertThat(target[SettingsKeys.perCalendarEventDuration]).containsExactly(1L, 1440)
}
@Test
fun `unreadable calendars leave the per-calendar settings alone`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L)
val target = mutablePreferencesOf()
target[SettingsKeys.perCalendarEventDuration] = mapOf(4L to 45)
val plan = import(roundTrip(source), target, environment(calendars = null))
assertThat(target[SettingsKeys.perCalendarEventDuration]).containsExactly(4L, 45)
assertThat(plan.skipped).containsExactly(SkippedSetting.CalendarsUnavailable)
}
@Test
fun `mirror calendars are left out when the import turns the mirror off`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(3L)
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target)
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.pendingSpecialDates).isEmpty()
assertThat(plan.skipped).isEmpty()
}
@Test
fun `a stale id in the prefs is dropped on export`() {
val source = mutablePreferencesOf()
source[CalendarPrefKeys.hiddenCalendarIds] = setOf(1L, 99L)
assertThat(roundTrip(source).calendars.map { it.calendar.displayName }).containsExactly("Work")
}
@Test
fun `a mirror calendar is matched by its date type`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
source[SettingsKeys.perCalendarAllDayReminderOverride] = mapOf(3L to listOf(0, 10080))
val target = mutablePreferencesOf()
val renamed = listOf(calendar(7, "Geburtstage", account = "Calendula", managed = true))
import(roundTrip(source), target, environment(calendars = renamed, managed = listOf(ManagedCalendarRow(7, SpecialDateType.Birthday))))
assertThat(target[SettingsKeys.perCalendarAllDayReminderOverride]).containsExactly(7L, listOf(0, 10080))
}
@Test
fun `a mirror calendar that doesn't exist yet waits for the sync`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
source[SettingsKeys.perCalendarAllDayReminderOverride] = mapOf(3L to listOf(0))
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(managed = emptyList()))
plan.pendingSpecialDates.forEach { target.addCalendarSettings(8L, it) }
assertThat(plan.skipped).isEmpty()
assertThat(target[SettingsKeys.perCalendarAllDayReminderOverride]).containsExactly(8L, listOf(0))
}
@Test
fun `a custom font without its file keeps the current font`() {
val source = mutablePreferencesOf()
source[SettingsKeys.brandFont] = FONT_CUSTOM_TOKEN
source[SettingsKeys.plainFont] = FONT_CUSTOM_TOKEN
val target = mutablePreferencesOf()
target[SettingsKeys.brandFont] = "lora"
val plan = import(roundTrip(source), target, environment(fonts = setOf(FontRole.PLAIN)))
assertThat(target[SettingsKeys.brandFont]).isEqualTo("lora")
assertThat(target[SettingsKeys.plainFont]).isEqualTo(FONT_CUSTOM_TOKEN)
assertThat(plan.skipped).containsExactly(SkippedSetting.CustomFont(FontRole.BRAND))
}
@Test
fun `contact dates without the permission stay as they are`() {
val source = mutablePreferencesOf()
source[SettingsKeys.specialDatesEnabled] = true
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target, environment(contacts = false))
assertThat(target[SettingsKeys.specialDatesEnabled]).isFalse()
assertThat(plan.skipped).contains(SkippedSetting.SpecialDates)
}
@Test
fun `automatic backup without a folder here stays off, its interval comes along`() {
val source = mutablePreferencesOf()
source[SettingsKeys.autoBackupEnabled] = true
source[SettingsKeys.autoBackupIntervalMinutes] = 120L
val target = mutablePreferencesOf()
val plan = import(roundTrip(source), target)
assertThat(target[SettingsKeys.autoBackupEnabled]).isFalse()
assertThat(target[SettingsKeys.autoBackupIntervalMinutes]).isEqualTo(120L)
assertThat(plan.skipped).contains(SkippedSetting.AutoBackupFolder)
}
@Test
fun `automatic backup keeps going where a folder is already set`() {
val source = mutablePreferencesOf()
source[SettingsKeys.autoBackupEnabled] = true
val target = mutablePreferencesOf()
import(roundTrip(source), target, environment(folder = true))
assertThat(target[SettingsKeys.autoBackupEnabled]).isTrue()
}
@Test
fun `the language resolves against what this build ships`() {
val backup = roundTrip(mutablePreferencesOf())
assertThat(planSettingsImport(backup.copy(language = LanguageChoice("en-US")), mutablePreferencesOf(), environment()).language)
.isEqualTo(LanguageChoice("en-US"))
assertThat(planSettingsImport(backup.copy(language = LanguageChoice(null)), mutablePreferencesOf(), environment()).language)
.isEqualTo(LanguageChoice(null))
val missing = planSettingsImport(backup.copy(language = LanguageChoice("ja")), mutablePreferencesOf(), environment())
assertThat(missing.language).isNull()
assertThat(missing.skipped).containsExactly(SkippedSetting.Language("ja"))
}
@Test
fun `app-level choices travel along`() {
val backup = roundTrip(mutablePreferencesOf())
assertThat(backup.language).isEqualTo(LanguageChoice("de"))
assertThat(backup.launcherName).isEqualTo(LauncherName.CALENDAR)
}
@Test
fun `unknown fields from a later build are ignored`() {
val text = """
{"format":"calendula-settings","version":1,"exportedAt":1,"future":{"x":1},
"settings":{"multi_day_count":4,"some_new_key":true},
"calendars":[{"account_name":"a","account_type":"t","display_name":"d","colour":"red"}],
"app":{"launcher_name":"SOMETHING_NEW"}}
""".trimIndent()
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
assertThat(backup.prefs.values).containsExactly(4)
assertThat(backup.calendars).hasSize(1)
assertThat(backup.launcherName).isNull()
assertThat(backup.language).isNull()
}
@Test
fun `a malformed known field rejects the whole file`() {
listOf(
"""{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{"multi_day_count":"4"}}""",
"""{"format":"calendula-settings","version":1,"exportedAt":1,"calendars":[{"account_name":"a"}]}""",
"""{"format":"calendula-settings","version":1,"exportedAt":1,"calendars":[{"account_name":"a","account_type":"t","display_name":"d","reminders":["x"]}]}""",
).forEach { text ->
val result = decodeSettingsBackup(text) as BackupResult.Failure
assertThat(result.problem).isEqualTo(BackupProblem.INVALID_DATA)
}
}
@Test
fun `a file without its settings section is refused`() {
val result = decodeSettingsBackup("""{"format":"calendula-settings","version":1,"exportedAt":1,"calendars":[]}""")
assertThat((result as BackupResult.Failure).problem).isEqualTo(BackupProblem.INVALID_DATA)
}
@Test
fun `an import without per-calendar settings leaves no empty keys behind`() {
val target = mutablePreferencesOf()
target[SettingsKeys.perCalendarEventDuration] = mapOf(1L to 30)
import(roundTrip(mutablePreferencesOf()), target)
SettingsBackupSchema.perCalendar.forEach { pref ->
assertThat(target.asMap().keys.map { it.name }).doesNotContain(pref.name)
}
}
@Test
fun `two file entries naming the same calendar are both skipped`() {
val text = """{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{},"calendars":[
{"account_name":"me@example.org","account_type":"com.google","display_name":"Work","hidden":true},
{"account_name":"me@example.org","account_type":"com.google","display_name":"Work","event_duration_minutes":90}]}"""
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
val target = mutablePreferencesOf()
val plan = import(backup, target)
assertThat(target[CalendarPrefKeys.hiddenCalendarIds]).isEmpty()
assertThat(plan.skipped).containsExactly(SkippedSetting.Calendar("Work", "me@example.org", ambiguous = true))
}
@Test
fun `reminder lists are cleaned the way the setters store them`() {
val text = """{"format":"calendula-settings","version":1,"exportedAt":1,"settings":{},"calendars":[
{"account_name":"a","account_type":"t","display_name":"d","reminders":[60,-5,30,30]}]}"""
val backup = (decodeSettingsBackup(text) as BackupResult.Success).value
assertThat(backup.calendars.single().reminders).containsExactly(30, 60).inOrder()
}
@Test
fun `a newer schema is refused`() {
val result = decodeSettingsBackup("""{"format":"calendula-settings","version":2,"exportedAt":1}""")
assertThat((result as BackupResult.Failure).problem).isEqualTo(BackupProblem.NEWER_VERSION)
}
}
@@ -10,9 +10,6 @@ import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.reminders.DailyDigestSettings
import de.jeanlucmakiola.calendula.domain.reminders.DigestDay
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
@@ -604,19 +601,6 @@ class SettingsPrefsTest {
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
}
@Test
fun `calendar notifications default on and toggle per calendar`(
@TempDir tempDir: Path,
) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.mutedReminderCalendarIds.first()).isEmpty()
prefs.setCalendarRemindersEnabled(7L, false)
prefs.setCalendarRemindersEnabled(9L, false)
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(7L, 9L)
prefs.setCalendarRemindersEnabled(7L, true)
assertThat(prefs.mutedReminderCalendarIds.first()).containsExactly(9L)
}
@Test
fun `garbage per-calendar duration entries are dropped, the rest survive`(
@TempDir tempDir: Path,
@@ -721,62 +705,4 @@ class SettingsPrefsTest {
prefs.recordSpecialDatesRun(2000L, null)
assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null))
}
@Test
fun `fixed-time reminder defaults to off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
val evening = FixedTimeReminder(1, 1_200)
prefs.setDefaultFixedTimeReminder(evening)
assertThat(prefs.defaultFixedTimeReminder.first()).isEqualTo(evening)
prefs.setDefaultFixedTimeReminder(null)
assertThat(prefs.defaultFixedTimeReminder.first()).isNull()
}
@Test
fun `per-calendar fixed-time overrides keep inherit, off and a value apart`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
val morning = FixedTimeReminder(2, 480)
prefs.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200))
prefs.setCalendarFixedTimeReminderOverride(1L, FixedTimeOverride.Off)
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.At(morning))
val overrides = prefs.perCalendarFixedTimeReminderOverride.first()
assertThat(overrides.fixedTimeOverrideFor(1L)).isEqualTo(FixedTimeOverride.Off)
assertThat(overrides.fixedTimeOverrideFor(2L)).isEqualTo(FixedTimeOverride.At(morning))
assertThat(overrides.fixedTimeOverrideFor(3L)).isEqualTo(FixedTimeOverride.Inherit)
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 1L)).isNull()
assertThat(resolveDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200), overrides, 3L))
.isEqualTo(FixedTimeReminder(1, 1_200))
assertThat(prefs.knownFixedTimeReminders.first()).containsExactly(FixedTimeReminder(1, 1_200), morning)
val picked = FixedTimeReminder(3, 600)
prefs.rememberFixedTimeReminders(listOf(picked, morning))
assertThat(prefs.knownFixedTimeReminders.first()).contains(picked)
// A default already known takes no recents slot.
assertThat(prefs.knownFixedTimeReminders.first()).hasSize(3)
// A replaced default stays known.
prefs.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_140))
assertThat(prefs.knownFixedTimeReminders.first()).contains(FixedTimeReminder(1, 1_200))
prefs.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Inherit)
assertThat(prefs.perCalendarFixedTimeReminderOverride.first().containsKey(2L)).isFalse()
}
@Test
fun `daily summary is off by default and its settings round-trip`(@TempDir tempDir: Path) = runTest {
val prefs = testSettings(tempDir)
assertThat(prefs.dailyDigest.first()).isEqualTo(DailyDigestSettings())
prefs.setDailyDigestEnabled(true)
prefs.setDailyDigestTimeMinutes(420)
prefs.setDailyDigestDay(DigestDay.Today)
prefs.setCalendarInDailyDigest(5L, included = false)
val digest = prefs.dailyDigest.first()
assertThat(digest.copy(armedAtMillis = 0L)).isEqualTo(
DailyDigestSettings(enabled = true, timeMinutes = 420, day = DigestDay.Today, excludedCalendarIds = setOf(5L)),
)
assertThat(digest.armedAtMillis).isGreaterThan(0L)
prefs.setCalendarInDailyDigest(5L, included = true)
assertThat(prefs.dailyDigest.first().excludedCalendarIds).isEmpty()
}
}
@@ -1,151 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import org.junit.jupiter.api.Test
class ReminderChannelPlanTest {
private fun calendar(id: Long, account: String, type: String, local: Boolean = false) = CalendarSource(
id = id,
displayName = "Cal $id",
accountName = account,
accountType = type,
color = 0,
isVisibleInSystem = true,
isLocal = local,
)
@Test
fun `one channel per calendar, grouped by account`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "me@example.org", "com.google"),
calendar(2, "me@example.org", "com.google"),
calendar(3, "dav", "bitfire.at.davdroid"),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups).hasSize(2)
assertThat(plan.channels.map { it.id })
.containsExactly(reminderChannelId(1), reminderChannelId(2), reminderChannelId(3))
assertThat(plan.channels[0].groupId).isEqualTo(plan.channels[1].groupId)
assertThat(plan.channels[2].groupId).isNotEqualTo(plan.channels[0].groupId)
assertThat(plan.staleChannelIds).isEmpty()
}
@Test
fun `device calendars share one group`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "Calendula", "LOCAL", local = true),
calendar(2, "Other", "LOCAL", local = true),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups).hasSize(1)
assertThat(plan.groups.single().isLocal).isTrue()
}
@Test
fun `same account name under two types is ambiguous`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "me@example.org", "com.google"),
calendar(2, "me@example.org", "bitfire.at.davdroid"),
calendar(3, "work", "bitfire.at.davdroid"),
),
existing = emptyList(),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.groups.filter { it.ambiguous }.map { it.accountType })
.containsExactly("com.google", "bitfire.at.davdroid")
assertThat(plan.groups.single { it.label == "work" }.ambiguous).isFalse()
}
@Test
fun `stale reminder channels and groups go, foreign ones stay`() {
val current = calendar(1, "me@example.org", "com.google")
fun plan(calendars: List<CalendarSource>, existing: List<String> = emptyList(), groups: List<String> = emptyList()) =
planReminderChannels(calendars, existing.map { ExistingReminderChannel(it, "Cal ${it.last()}", DESC, null) }, groups, "Unnamed", DESC)
val keptGroup = plan(listOf(current)).groups.single().id
val goneGroup = plan(listOf(calendar(9, "old", "com.google"))).groups.single().id
val result = plan(
calendars = listOf(current),
existing = listOf(reminderChannelId(1), reminderChannelId(9), LEGACY_REMINDER_CHANNEL_ID, "backup"),
groups = listOf(keptGroup, goneGroup, "other"),
)
assertThat(result.staleChannelIds).containsExactly(reminderChannelId(9))
assertThat(result.staleGroupIds).containsExactly(goneGroup)
}
@Test
fun `only missing or renamed channels are written`() {
val plan = planReminderChannels(
calendars = listOf(
calendar(1, "a", "com.google"),
calendar(2, "a", "com.google"),
calendar(3, "a", "com.google").copy(displayName = ""),
calendar(4, "a", "com.google"),
),
existing = listOf(
ExistingReminderChannel(reminderChannelId(1), "Cal 1", DESC, "g"),
ExistingReminderChannel(reminderChannelId(4), "Cal 4", "Old description", "g"),
ExistingReminderChannel(reminderChannelId(2), "Old name", DESC, "g"),
),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(plan.channelsToWrite.map { it.calendarId }).containsExactly(2L, 3L, 4L)
assertThat(plan.channelsToWrite.single { it.calendarId == 3L }.name).isEqualTo("Unnamed")
}
@Test
fun `non-syncing calendars keep a channel but get none up front`() {
val quiet = calendar(2, "a", "com.google").copy(syncsEvents = false)
val fresh = planReminderChannels(listOf(quiet), emptyList(), emptyList(), "Unnamed", DESC)
assertThat(fresh.channelsToWrite).isEmpty()
assertThat(fresh.groupsInUse).isEmpty()
val posted = planReminderChannels(
calendars = listOf(quiet),
existing = listOf(ExistingReminderChannel(reminderChannelId(2), "Cal 2", DESC, null)),
existingGroupIds = emptyList(),
unnamed = "Unnamed",
description = DESC,
)
assertThat(posted.staleChannelIds).isEmpty()
assertThat(posted.channelsToWrite.map { it.calendarId }).containsExactly(2L)
assertThat(posted.groupsInUse).hasSize(1)
}
@Test
fun `blocked calendars follow app, channel and group`() {
val channels = mapOf(
reminderChannelId(1) to ReminderChannelState(blocked = true, groupId = "g1"),
reminderChannelId(2) to ReminderChannelState(blocked = false, groupId = "g2"),
reminderChannelId(3) to ReminderChannelState(blocked = false, groupId = "g1"),
)
val ids = listOf(1L, 2L, 3L, 4L)
val groupOff = reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = setOf("g2"))
assertThat(groupOff.blockedCalendarIds).containsExactly(1L, 2L)
assertThat(groupOff.groupBlockedCalendarIds).containsExactly(2L)
assertThat(reminderChannelStatus(ids, appEnabled = true, channels, blockedGroupIds = emptySet()).blockedCalendarIds)
.containsExactly(1L)
val appOff = reminderChannelStatus(ids, appEnabled = false, channels, blockedGroupIds = emptySet())
assertThat(appOff.appBlocked).isTrue()
assertThat(appOff.blockedCalendarIds).containsExactly(1L, 2L, 3L, 4L)
}
private companion object {
const val DESC = "Reminders"
}
}
@@ -1,47 +0,0 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
class FixedTimeReminderFormTest {
private val zone = TimeZone.of("Europe/Berlin")
private val evening = FixedTimeReminder(1, 20 * 60)
private fun form(start: String, reminders: List<Int> = emptyList(), allDay: Boolean = false) = EventForm(
calendarId = 1,
isAllDay = allDay,
start = FormTime(LocalDateTime.parse(start)),
end = FormTime(LocalDateTime.parse(start)),
reminders = reminders,
)
@Test
fun `a moved start re-derives the offset so the wall clock stays`() {
val loaded = form("2026-06-10T10:00", reminders = listOf(10, 840))
.withFixedTimeRemindersRead(known = setOf(evening), deviceZone = zone)
assertThat(loaded.reminders).containsExactly(10)
assertThat(loaded.fixedReminders).containsExactly(evening)
assertThat(loaded.fixedReminderOffsets(zone)).containsExactly(840)
val moved = loaded.copy(start = FormTime(LocalDateTime.parse("2026-06-10T15:30")))
assertThat(moved.fixedReminderOffsets(zone)).containsExactly(19 * 60 + 30)
}
@Test
fun `all-day forms keep their reminders and store no fixed ones`() {
val allDay = form("2026-06-10T00:00", reminders = listOf(840), allDay = true)
assertThat(allDay.withFixedTimeRemindersRead(setOf(evening), zone)).isEqualTo(allDay)
assertThat(allDay.copy(fixedReminders = listOf(evening)).fixedReminderOffsets(zone)).isEmpty()
}
@Test
fun `fixed reminders count as a populated reminders section on a timed event only`() {
val withFixed = form("2026-06-10T10:00").copy(fixedReminders = listOf(evening))
assertThat(withFixed.populatedFields()).contains(EventFormField.Reminders)
assertThat(withFixed.copy(isAllDay = true).populatedFields()).doesNotContain(EventFormField.Reminders)
}
}
@@ -1,89 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
class DailyDigestTest {
private val zone = ZoneId.of("Europe/Berlin")
private val minute = 60_000L
private val eight = 20 * 60
private fun millis(text: String): Long = LocalDateTime.parse(text).atZone(zone).toInstant().toEpochMilli()
@Test
fun `due once the moment passes the watermark`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T19:59"), millis("2026-06-10T20:00"))
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T20:00"))
}
@Test
fun `a second scan does not post again`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T20:00"), millis("2026-06-10T20:05"))
assertThat(schedule.dueDay).isNull()
}
@Test
fun `a day already listed is not listed again`() {
// Posted at 20:00, then the time moved to 21:00 the same evening.
val schedule = scheduleDigest(
21 * 60, DigestDay.Tomorrow, zone, millis("2026-06-10T20:30"), millis("2026-06-10T21:00"),
lastListedDay = LocalDate.of(2026, 6, 11),
)
assertThat(schedule.dueDay).isNull()
}
@Test
fun `before the moment it only arms the alarm`() {
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T08:00"), millis("2026-06-10T09:00"))
assertThat(schedule.dueDay).isNull()
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-10T20:00"))
}
@Test
fun `a late catch-up still lists a day that has not passed`() {
// Off overnight: last scan before 20:00, back on the next morning.
val schedule = scheduleDigest(eight, DigestDay.Tomorrow, zone, millis("2026-06-10T18:00"), millis("2026-06-11T07:00"))
assertThat(schedule.dueDay).isEqualTo(LocalDate.of(2026, 6, 11))
}
@Test
fun `a today summary missed until the next day is dropped`() {
val seven = 7 * 60
val schedule = scheduleDigest(seven, DigestDay.Today, zone, millis("2026-06-10T06:00"), millis("2026-06-11T06:30"))
assertThat(schedule.dueDay).isNull()
assertThat(schedule.nextAlarmMillis).isEqualTo(millis("2026-06-11T07:00"))
}
@Test
fun `entries list all-day first, keep the day's own, and skip excluded calendars`() {
val day = LocalDate.of(2026, 6, 11)
val utcMidnight = day.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val allDay = instance(1, utcMidnight, utcMidnight + 24 * 60 * minute, isAllDay = true)
val previousAllDay = instance(2, utcMidnight - 24 * 60 * minute, utcMidnight, isAllDay = true)
val morning = instance(3, millis("2026-06-11T09:00"), millis("2026-06-11T10:00"))
val early = instance(4, millis("2026-06-11T07:00"), millis("2026-06-11T08:00"))
val nextDay = instance(5, millis("2026-06-12T00:00"), millis("2026-06-12T01:00"))
val excluded = instance(6, millis("2026-06-11T12:00"), millis("2026-06-11T13:00"), calendarId = 9)
val entries = digestEntries(
listOf(morning, previousAllDay, nextDay, early, allDay, excluded),
day,
zone,
excludedCalendarIds = setOf(9),
)
assertThat(entries.map { it.eventId }).containsExactly(1L, 4L, 3L).inOrder()
}
private fun instance(
eventId: Long,
begin: Long,
end: Long,
isAllDay: Boolean = false,
calendarId: Long = 1,
) = ReminderEventInstance(eventId, calendarId, begin, end, "Event $eventId", null, isAllDay)
}
@@ -1,100 +0,0 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class FixedTimeReminderTest {
private val berlin = TimeZone.of("Europe/Berlin")
private val eveningBefore = FixedTimeReminder(daysBefore = 1, minuteOfDay = 20 * 60)
private fun at(text: String, zone: TimeZone = berlin): Instant = LocalDateTime.parse(text).toInstant(zone)
@Test
fun `offset is the distance from the named wall clock to the start`() {
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T10:00"), berlin)).isEqualTo(14 * 60)
assertThat(eveningBefore.offsetMinutes(at("2026-06-10T21:30"), berlin)).isEqualTo(25 * 60 + 30)
}
@Test
fun `offset spans a DST change in real minutes`() {
// Clocks go forward on 29 March 2026: 20:00 on the 28th to 10:00 on the 29th is 13 hours.
assertThat(eveningBefore.offsetMinutes(at("2026-03-29T10:00"), berlin)).isEqualTo(13 * 60)
}
@Test
fun `a series offset counts clock minutes, so a DST-side occurrence bakes in no hour`() {
// Opened on the Sunday clocks go back: the real span is 15 hours, the clock face says 14.
val start = at("2026-10-25T10:00")
assertThat(eveningBefore.offsetMinutes(start, berlin)).isEqualTo(15 * 60)
val offset = eveningBefore.offsetMinutes(start, berlin, wallClock = true)
assertThat(offset).isEqualTo(14 * 60)
// Read back the same way on any occurrence of the series.
assertThat(fixedTimeReminderAt(offset, at("2026-11-01T10:00"), berlin, wallClock = true)).isEqualTo(eveningBefore)
assertThat(fixedTimeReminderAt(offset, start, berlin, wallClock = true)).isEqualTo(eveningBefore)
}
@Test
fun `an offset reads back as the pattern it was written from`() {
val start = at("2026-03-29T10:00")
val offset = eveningBefore.offsetMinutes(start, berlin)
assertThat(fixedTimeReminderAt(offset, start, berlin)).isEqualTo(eveningBefore)
}
@Test
fun `a reminder on the start's own day or after it is no pattern`() {
val start = at("2026-06-10T10:00")
assertThat(fixedTimeReminderAt(60, start, berlin)).isNull()
assertThat(fixedTimeReminderAt(0, start, berlin)).isNull()
assertThat(fixedTimeReminderAt(-30, start, berlin)).isNull()
}
@Test
fun `only known patterns read as fixed-time`() {
val start = at("2026-06-10T10:00")
// 840 is "1 day before at 20:00" here, but also a plain "14 hours before".
val unknown = splitTimedReminders(listOf(10, 1_440, 840), start, berlin, known = emptySet())
assertThat(unknown.leadMinutes).containsExactly(10, 840, 1_440).inOrder()
assertThat(unknown.fixed).isEmpty()
val known = splitTimedReminders(listOf(10, 1_440, 840), start, berlin, known = setOf(eveningBefore))
assertThat(known.leadMinutes).containsExactly(10, 1_440).inOrder()
assertThat(known.fixed).containsExactly(eveningBefore)
}
@Test
fun `a whole-day offset landing on a known pattern is fixed-time`() {
// An event at 20:00 with "1 day before at 20:00" stores exactly a day.
val start = at("2026-06-10T20:00")
assertThat(splitTimedReminders(listOf(1_440), start, berlin, known = emptySet()).fixed).isEmpty()
val split = splitTimedReminders(listOf(1_440), start, berlin, known = setOf(eveningBefore))
assertThat(split.fixed).containsExactly(eveningBefore)
assertThat(split.leadMinutes).isEmpty()
}
@Test
fun `fixed reminders sort by when they fire`() {
val start = at("2026-06-10T10:00")
val twoDays = FixedTimeReminder(2, 9 * 60)
val split = splitTimedReminders(
listOf(eveningBefore.offsetMinutes(start, berlin), twoDays.offsetMinutes(start, berlin)),
start,
berlin,
known = setOf(eveningBefore, twoDays),
)
assertThat(split.fixed).containsExactly(twoDays, eveningBefore).inOrder()
}
@Test
fun `token round-trips and rejects garbage`() {
assertThat(FixedTimeReminder.parse(eveningBefore.toToken())).isEqualTo(eveningBefore)
assertThat(FixedTimeReminder.parse("0@600")).isNull()
assertThat(FixedTimeReminder.parse("1@1440")).isNull()
assertThat(FixedTimeReminder.parse("x")).isNull()
assertThat(FixedTimeReminder.parse(null)).isNull()
}
}
@@ -92,13 +92,12 @@ class RescheduleViewModelTest {
io: CoroutineDispatcher = dispatcher,
): RescheduleViewModel {
fake.occurrencesAt(present)
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
fake, tempDir, io,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = settings,
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
return RescheduleViewModel(repo, io, FakeZoneSource(), settings)
return RescheduleViewModel(repo, io, FakeZoneSource())
}
private fun slowViewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel =
@@ -51,14 +51,13 @@ class EventDetailViewModelTest {
)
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): EventDetailViewModel {
val settings = testSettings(tempDir, CoroutineScope(dispatcher))
val repo = testRepository(
fake, tempDir, dispatcher,
prefs = testPrefs(tempDir, CoroutineScope(dispatcher)),
settings = settings,
settings = testSettings(tempDir, CoroutineScope(dispatcher)),
)
// Only `shareUri()` touches the exporter, and nothing here shares.
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource(), settings)
return EventDetailViewModel(repo, IcsExporter(ContextWrapper(null)), dispatcher, FakeZoneSource())
}
private fun fakeSource(description: () -> String?) = FakeCalendarDataSource().apply {
@@ -9,11 +9,7 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.time.FakeZoneSource
import de.jeanlucmakiola.calendula.data.prefs.FixedTimeOverride
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.reminders.FixedTimeReminder
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
@@ -723,121 +719,4 @@ class EventEditViewModelTest {
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Idle)
job.cancel()
}
@Test
fun `a new timed event gets the fixed-time default, and a calendar can turn it off`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
val s = settings(tempDir)
val evening = FixedTimeReminder(1, 1_200)
s.setDefaultFixedTimeReminder(evening)
s.setCalendarFixedTimeReminderOverride(2L, FixedTimeOverride.Off)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 600)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders).containsExactly(evening)
vm.setCalendar(2L)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders).isEmpty()
job.cancel()
}
@Test
fun `an event's set-time reminder opens as one and saves without a conflict`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val zone = FakeZoneSource().current()
val start = Instant.fromEpochMilliseconds(beginMillis)
val evening = FixedTimeReminder(1, 1_200)
val offset = evening.offsetMinutes(start, zone)
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L))
eventDetailResult = {
detail(calendarId = 1L).copy(reminders = listOf(Reminder(offset, ReminderMethod.Alert)))
}
}
val s = settings(tempDir)
s.setDefaultFixedTimeReminder(evening)
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.fixedReminders).containsExactly(evening)
assertThat(form.reminders).isEmpty()
assertThat(vm.hasUnsavedChanges.value).isFalse()
vm.setTitle("Renamed")
vm.save()
advanceUntilIdle()
assertThat(vm.state.value?.saveState).isEqualTo(SaveUiState.Saved)
assertThat(fake.updatedEvents.single().third.fixedReminders).containsExactly(evening)
job.cancel()
}
@Test
fun `a round trip through all-day keeps set-time reminders`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setDefaultFixedTimeReminder(FixedTimeReminder(1, 1_200))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 600)
advanceUntilIdle()
vm.addFixedReminder(FixedTimeReminder(2, 480))
vm.setAllDay(true)
vm.setAllDay(false)
advanceUntilIdle()
assertThat(vm.state.value!!.form!!.fixedReminders)
.containsExactly(FixedTimeReminder(2, 480), FixedTimeReminder(1, 1_200))
job.cancel()
}
@Test
fun `a set-time reminder replaces a lead firing at the same moment`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setDefaultReminderMinutes(listOf(1_440, 10))
val vm = viewModel(tempDir, fake, s, zones = FakeZoneSource(berlin))
val job = activate(vm)
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 20 * 60)
advanceUntilIdle()
vm.addFixedReminder(FixedTimeReminder(1, 20 * 60))
val form = vm.state.value!!.form!!
assertThat(form.reminders).containsExactly(10)
assertThat(form.fixedReminders).containsExactly(FixedTimeReminder(1, 20 * 60))
job.cancel()
}
@Test
fun `a copy carrying only set-time reminders keeps them`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L)) }
val s = settings(tempDir)
s.setDefaultReminderMinutes(listOf(15))
val vm = viewModel(tempDir, fake, s)
val job = activate(vm)
val copy = importedForm(end = LocalDateTime(2030, 1, 15, 10, 0))
.copy(reminders = emptyList(), fixedReminders = listOf(FixedTimeReminder(1, 1_200)))
vm.openImported(copy, ImportSource.Insert)
advanceUntilIdle()
val form = vm.state.value!!.form!!
assertThat(form.fixedReminders).containsExactly(FixedTimeReminder(1, 1_200))
assertThat(form.reminders).isEmpty()
job.cancel()
}
}
+11 -65
View File
@@ -391,71 +391,6 @@ gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders.
**A channel per calendar (#145).** `ReminderChannels` keeps one notification
channel per calendar, grouped by account (one that syncs no events only gets
its channel once something posts on it), so a calendar can be
muted or given its own sound in the system settings before it ever fires. Every
scan with reminders on syncs them (skipped when nothing changed): missing
channels are created, renamed ones updated, and channels of deleted calendars
removed — but never on an empty snapshot, which is a provider hiccup more often
than every calendar gone. Switched-off calendars keep theirs, so toggling one
never cancels a showing reminder or loses its settings. `post` creates a missing
channel itself, so a reminder never waits on a sync. A new channel copies the sound, vibration and
importance of the pre-#145 single `reminders` channel, which is deleted once
nothing showing still sits on it. Settings → Notifications only deep-links into
the system's channel screens (#340); the app never sets a sound itself. Separate
from both, `SettingsPrefs.mutedReminderCalendarIds` is the in-app per-calendar
off switch (#272): the scan plans nothing for those calendars (so they arm no
wake-ups; toggling it re-scans), and `ReminderNotifier.post` checks it again
for snoozes. The settings screen re-reads `ReminderChannels.status` on every
resume and marks a calendar "Blocked in system settings" when the app, its
channel or its account group is switched off there.
**Reminders at a set time (#459).** "1 day before at 20:00" on a timed event
is an ordinary `Reminders` row whose offset is the distance from that wall-clock
moment (device zone) to the start, so it syncs and fires everywhere — the scan
plans it like any lead time. Only the editor knows it as a pattern
(`EventForm.fixedReminders`), and it re-derives the offset from the start it
saves with, so moving the event, in the form or by drag, keeps the wall clock.
Reading a row back is a guess, made in one place (`classifyTimedReminder`): an
offset is fixed-time only when it lands on a known pattern — a current or
replaced default, or one saved by hand on this device
(`recentFixedTimeReminders`, capped). Guessing from the offset's shape alone
would turn ordinary leads ("12 hours before") into fixed ones. Limits: where a
lead and a known pattern coincide ("1 day before" on an event at the default's
own time) the pattern wins, and a pattern that falls out of the recents reads as
a lead again — either way the alarm fires at the same moment until the start's
time of day changes. A recurring series stores one offset for all occurrences,
so it counts clock minutes rather than real ones (`wallClock`); the occurrence
whose lead spans a DST change then fires an hour off, as in every other app
reading that row. On another device or app the row is simply a lead time.
New timed events get the default (`SettingsKeys.defaultFixedTimeReminder`, with
a per-calendar inherit/off/value override) like the lead-time defaults.
**The daily summary (#459)** rides the same scan and watermark: its moment is
due when it falls in `(lastScan, now]` (`scheduleDigest`), only the latest one
counts after a long gap, and it is dropped once the day it lists has passed.
It is independent of the event-reminder switch, posts on its own channel
(`DailyDigestNotifier`), lists switched-on calendars minus its own exclusions,
and replaces the previous summary — or withdraws it when the day is empty.
Each day is listed once: the day is recorded as soon as it is posted
(`ReminderStatePrefs.lastDigestDay`), so neither a pass failing later nor a later
time picked the same evening lists it twice, and changing the switch, time or
day stamps `dailyDigestArmedAt`, so a moment that passed before the change is
not owed by it. A failing summary is logged and skipped; it never costs the
event reminders of the same pass, and a failing reminder pass still arms a retry
and the summary's next moment. Switching between "today's" and "tomorrow's"
events clears the day last listed, so the first summary of the new mode comes.
The notification names its date and times out when that day ends.
Its channel's blocked state is read separately (`DailyDigestNotifier.isChannelOff`,
with the app-wide block from `ReminderChannels.status`); while it is off the
scan neither posts nor records the day, so allowing it brings the summary back.
The channel is created when the summary is switched on, so a phone that starts
new categories off shows it blocked in settings before the first summary.
The per-calendar reminder mute does not apply: a calendar too noisy to ping per
event can still belong in the overview, and the summary has its own
per-calendar switch next to it.
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —
a latch that disables its own scheduling once a real broadcast arrives — cannot
@@ -557,6 +492,17 @@ one the calendar accepts. The single-event review form deliberately keeps no col
first question is which calendar to use, and answering it clears the colour
anyway.
## Settings backup
Backup & restore also exports the app's settings as versioned JSON (#481),
with the envelope and the generic preference section from floret-kit's
`core-backup`. Every stored key is either carried or deliberately left out
(`SettingsBackupSchema`, enforced by a test); per-calendar settings travel
under the calendar's account and name, since provider ids are per device. An
import replaces rather than merges, in one DataStore edit, then re-runs what
reads settings outside DataStore. Format and per-key decisions:
docs/SETTINGS_BACKUP.md.
## Testing
JUnit 5 + Truth + Turbine on the JVM. The seams that make it work:
+135
View File
@@ -0,0 +1,135 @@
# Settings backup format
Backup & restore → Settings exports Calendula's settings to a JSON file and
imports one back (#481). Events are not part of it; they have the `.ics`
backup. This file is the schema's source of truth. The envelope and the
`settings` section come from floret-kit's `core-backup`
(`BackupFormat`, `PrefsSection`); `data/backup/SettingsBackup*.kt` implements
the rest.
## Top level
```json
{
"format": "calendula-settings",
"version": 1,
"exportedAt": 1700000000000,
"appVersion": "2.25.0",
"settings": { … },
"calendars": [ … ],
"app": { … }
}
```
- `format` must be exactly `"calendula-settings"`, or the file is not a
settings backup.
- `version` is `1`. A higher version is refused as "from a newer Calendula";
a change that an older build could misread bumps it.
- `exportedAt` (epoch ms) and `appVersion` are informational.
- The file is limited to 8 MiB; a leading byte-order mark is ignored.
- The `settings` section is required: a file without it would reset every setting.
## Parsing rules
- **Unknown fields are ignored**, at any level, so a file from a later build
imports as far as this one understands it.
- **Known fields are strict.** A known key holding the wrong JSON type rejects
the whole file before anything is written.
- **Values are not range-checked.** They are stored as they are and go through
the same reads every setting already does (clamps, enum fallbacks), so an
out-of-range or unknown value degrades to the nearest valid one or the
default.
## Import semantics
An import **replaces** every setting the backup covers; it does not merge. A
backed-up setting the file doesn't carry is reset to its default (that is what
the exporting install had). Everything is written in one DataStore edit. After
that the import refreshes what reads settings outside DataStore: the
contact-dates mirror (turned on and synced, or off and torn down, as the
settings toggle does), the reminder scan, the automatic-backup schedule, the
launcher name and the app language. Theme and widgets follow the stored
values on their own. A summary lists what could not be imported.
## `settings`
The settings stored under their own on-disk key names, in their stored form,
e.g. `"theme_mode": "DARK"`, `"multi_day_count": 4`,
`"default_reminder_minutes": "10,60"`. Only keys that have been set are
written. `SettingsBackupSchema.plain` lists them: appearance, fonts,
views, the agenda screen, the event form, reminders, automatic backup
(switch and interval) and contact special dates (switch, types, title
templates, year).
Three are taken only where this device can honour them, otherwise the current
value stays and the summary says why:
- `brand_font` / `plain_font` set to `custom`: the font file is not in the
backup, so it is taken only if this install already has one for that role.
- `special_dates_enabled` on: needs the contacts permission here.
- `auto_backup_enabled` on: needs a backup folder chosen on this install.
## `calendars`
Per-calendar settings are stored by provider id, which means nothing on
another device. Each calendar with any of them is written with a portable
identity instead:
| Field | Type | Notes |
|---|---|---|
| `account_name` | String | Required. |
| `account_type` | String | Required. |
| `display_name` | String | Required. |
| `special_dates` | String? | `Birthday`/`Anniversary`/`Custom` for a contact-dates mirror calendar. |
| `hidden` | Boolean | Hidden in the drawer's filter. |
| `last_used` | Boolean | Preselected for new events. |
| `event_duration_minutes` | Int? | Per-calendar default length, clamped to 1–1440 like the setting. |
| `reminders` | Int[]? | Timed reminder override; `[]` is "no reminder". |
| `all_day_reminders` | Int[]? | All-day reminder override. |
On import an entry matches the one calendar here with the same account name,
account type and display name. No match, or more than one, skips it and the
summary names it, as does a calendar the file itself lists twice under one
identity. Reminder lists are stored the way the setters store them (no
negative values, each once, ascending). A mirror calendar matches by `special_dates` instead, since
its name is localised, and only when the imported settings keep the mirror on
and the type (otherwise the import tears that calendar down). When the mirror has not created it yet,
it is matched after the import's sync. Every mirror's all-day reminders are
applied through the mirror so its existing events follow; one the file doesn't
override inherits the default again. An id in the prefs whose calendar no
longer exists is left out of the export.
If this device's calendars can't be read, the per-calendar settings here are
left untouched and the summary says so; an export in that state fails rather
than writing a file without them.
## `app`
| Field | Type | Notes |
|---|---|---|
| `language` | String? | The app language tag; `null` follows the system. Absent: left as is. A language this build doesn't ship is skipped. |
| `launcher_name` | String? | `CALENDULA` or `CALENDAR`. Unknown: left as is. |
## Not exported
`SettingsBackupSchema.excluded`, each for a reason: this install's own state,
bookkeeping, or meaningless on another device.
- Font stamps (`*_font_stamp`): cache keys for the local font file.
- `agenda_widget_range`, `widget_size`: legacy defaults for unconfigured
widgets. Widget configuration is not part of the backup at all.
- `recent_time_zones`: usage history.
- Onboarding progress and `visibility_notice_pending`.
- `auto_backup_folder_uri`: a SAF grant does not survive a reinstall or
another device.
- Run status: `auto_backup_last_*`, `auto_backup_failures`,
`special_dates_last_run`, `special_dates_stalled_reason`,
`special_dates_last_foreground_sync`.
- `special_dates_calendar_*`: the mirror re-adopts its calendars by their
provider marker.
- `disabled_calendar_ids`: switch-offs not yet written to this device's
provider. Whether a calendar is switched on lives in the provider.
- `reminder_last_scan_millis`: the reminder delivery watermark.
`SettingsBackupSchemaTest` fails for any stored key that is in none of the
three lists, so a new setting has to be placed before it ships.