Compare commits

..
Author SHA1 Message Date
makiolaj 754745586d Run the Play job on trixie for fastlane's Ruby 3.2 requirement (#509) 2026-11-13 19:17:46 +01:00
Jean-Luc Makiola 1a1df07dc3 Release v2.24.2 (#511)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Renovate / renovate (push) Successful in 1m53s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 17m38s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 34s
- Queued drops: moves made while an earlier one is still saving are queued instead of dropped, with messages for stale and skipped moves (#494)
- Day view title names the month (#501)
- Dependency updates: kotlinx-datetime, AppCompat, fastlane, Renovate image (#507–#510)
- Version 2.24.2 (2240299), CHANGELOG, What's New in all store locales

#494 stays open until the reporter answers; the fix covers the ways a quick move could get lost, the original report isn't reproduced.

Refs #494
Closes #501
2026-11-13 18:56:38 +01:00
makiolaj 75315d92a7 Merge main into release/2.24.2
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-11-13 16:10:53 +01:00
95ad3a30cf Release v2.24.1 (#504)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Renovate / renovate (push) Successful in 1m20s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m27s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 1m58s
- Agenda widget: Cards / Classic style option, opacity slider for all Solid/Tinted widgets (#489)
- Month widget fits events into short week rows (#491)
- Week numbers follow the locale's rules and the row's first day (#493)
- Readable dimmed events in dark widgets, pinned Light/Dark theme matches system colours (#475)
- Dropped unused WorkManager permissions: network state, wake lock, foreground service (#448)
- Weblate translations (#434), incl. the Arabic string from after the 2.24.0 squash
- Version 2.24.1 (2240199), CHANGELOG, What's New in all store locales

#489 deviates from the issue: the classic look comes back as a style option instead of replacing the new design.

Closes #489
Closes #491
Closes #493
Closes #475
Closes #448

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Louis Leblanc <sandraev@posteo.dk>
Co-authored-by: Dmitri Gabinski <dkgabi@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/504
2026-11-13 11:17:48 +01:00
Jean-Luc Makiola 8ce4831efa Translations update from Weblate (#434)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 12s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Skipped
Renovate / renovate (push) Successful in 2m59s
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/434
2026-11-13 11:07:42 +01:00
17 changed files with 32 additions and 1784 deletions

No files matched your search

+4 -1
View File
@@ -318,13 +318,16 @@ jobs:
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above.
# the same way sshpass, jq and fdroidserver are in the job above, from trixie:
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
container:
image: node:22-trixie
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
-1
View File
@@ -177,7 +177,6 @@ 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")
@@ -1,198 +0,0 @@
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"
@@ -1,240 +0,0 @@
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"
}
}
@@ -1,106 +0,0 @@
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()
}
@@ -1,214 +0,0 @@
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
}
@@ -28,9 +28,9 @@ class CalendarPrefs @Inject constructor(
) {
private val prefs = PrefStore(store)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(CalendarPrefKeys.hiddenCalendarIds)
val hiddenCalendarIds: Flow<Set<Long>> = prefs.flow(HIDDEN_IDS)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(CalendarPrefKeys.hiddenCalendarIds, ids)
suspend fun setHiddenCalendarIds(ids: Set<Long>) = prefs.set(HIDDEN_IDS, 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(CalendarPrefKeys.pendingDisabledCalendarIds)
val pendingDisabledCalendarIds: Flow<Set<Long>> = prefs.flow(DISABLED_IDS)
suspend fun addPendingDisabledCalendarIds(ids: Collection<Long>) {
prefs.update(CalendarPrefKeys.pendingDisabledCalendarIds) { it + ids }
prefs.update(DISABLED_IDS) { 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(CalendarPrefKeys.pendingDisabledCalendarIds) { it - ids.toSet() }
prefs.update(DISABLED_IDS) { it - ids.toSet() }
}
/**
@@ -60,26 +60,24 @@ 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(CalendarPrefKeys.visibilityNoticePending)
val visibilityNoticePending: Flow<Boolean?> = prefs.flow(VISIBILITY_NOTICE)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(CalendarPrefKeys.visibilityNoticePending, pending)
suspend fun setVisibilityNoticePending(pending: Boolean) = prefs.set(VISIBILITY_NOTICE, 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(CalendarPrefKeys.lastUsedCalendarId)
val lastUsedCalendarId: Flow<Long?> = prefs.flow(LAST_USED_CALENDAR)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(CalendarPrefKeys.lastUsedCalendarId, id)
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(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")
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")
}
}
/** A comma-joined id set; non-numeric tokens are dropped, and an empty set removes the key. */
@@ -2,7 +2,6 @@ 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 javax.inject.Inject
@@ -24,13 +23,11 @@ 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(ReminderStateKeys.lastScanMillis)
suspend fun lastScanMillis(): Long? = prefs.get(LAST_SCAN)
suspend fun setLastScanMillis(millis: Long) = prefs.set(ReminderStateKeys.lastScanMillis, millis)
suspend fun setLastScanMillis(millis: Long) = prefs.set(LAST_SCAN, millis)
}
/** The key [ReminderStatePrefs] stores. */
internal object ReminderStateKeys {
val lastScanMillis: Pref<Long?> = nullableLongPref("reminder_last_scan_millis")
private companion object {
val LAST_SCAN = nullableLongPref("reminder_last_scan_millis")
}
}
@@ -10,7 +10,6 @@ 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
@@ -77,9 +76,7 @@ private val RESTORE_MIME_TYPES = arrayOf(
/**
* Backup & restore (#69): `.ics` export and restore for local calendars, with
* 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.
* optional automatic backup. Shares [CalendarsViewModel] with the manager.
*
* A full-screen destination hoisted in `CalendarHost`; [onBack] pops it,
* [onImport] hands a picked file to the app's normal .ics import flow.
@@ -90,7 +87,6 @@ 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()
@@ -136,27 +132,20 @@ 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 -> {
StateSlot { BackupLoading() }
SettingsBackupSection(settings)
}
is BackupUiState.Failure -> {
StateSlot { CalendarFailure(reason = s.reason, onRetry = onBack) }
SettingsBackupSection(settings)
}
BackupUiState.Loading -> BackupLoading()
is BackupUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onBack)
is BackupUiState.Ready -> BackupContent(
exportable = s.exportable,
canImport = s.canImport,
@@ -169,7 +158,6 @@ fun BackupScreen(
},
onExportPick = { showExportPicker = true },
onEditInterval = { showInterval = true },
settings = settings,
)
}
}
@@ -185,7 +173,6 @@ fun BackupScreen(
)
}
}
SettingsBackupDialogs(settings, settingsBackup, snackbarHostState)
if (showInterval) {
BackupIntervalDialog(
currentMinutes = autoBackup.intervalMinutes,
@@ -195,14 +182,6 @@ 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() {
@@ -234,7 +213,6 @@ 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
@@ -255,7 +233,6 @@ private fun BackupContent(
leading = { LeadingAvatar(Icons.Default.FileUpload) },
onClick = onRestoreClick,
)
SettingsBackupSection(settings)
return
}
@@ -300,7 +277,6 @@ private fun BackupContent(
)
GroupedHint(backupStatusText(autoBackup.status), style = MaterialTheme.typography.bodySmall)
}
SettingsBackupSection(settings)
}
/**
@@ -457,6 +433,5 @@ 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
@@ -1,224 +0,0 @@
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
}
@@ -1,108 +0,0 @@
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
}
}
}
}
-35
View File
@@ -910,41 +910,6 @@
<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>
@@ -1,45 +0,0 @@
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()
}
}
@@ -1,408 +0,0 @@
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)
}
}
-11
View File
@@ -492,17 +492,6 @@ 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
@@ -1,135 +0,0 @@
# 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.