Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
145ef6c6bf | ||
|
|
6161efab40 | ||
|
|
34e8e8571c | ||
|
|
a026f24bed | ||
|
|
14a0725b21 | ||
|
|
e88ad7f533 | ||
|
|
67052352ac | ||
|
|
88de8f42b0 | ||
|
|
8c31e0f6ee | ||
|
|
1688b206a5 | ||
|
|
55934664e2 | ||
|
|
d1c601a6e2 | ||
|
|
1dcbffd6f2 | ||
|
|
fb5cb226ee |
No files matched your search
@@ -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
@@ -37,6 +37,7 @@ class CalendarRepositorySmokeTest {
|
||||
CalendarPrefs(store),
|
||||
SettingsPrefs(store),
|
||||
Dispatchers.IO,
|
||||
AndroidSyncRequester(context),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
<!-- Sync adapters to ask for a calendar sync (#240, #303). -->
|
||||
<intent>
|
||||
<action android:name="android.content.SyncAdapter" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -301,6 +301,12 @@ interface CalendarDataSource {
|
||||
*/
|
||||
fun deleteOccurrence(eventId: Long, beginMillis: Long)
|
||||
|
||||
/** The account [calendarId] belongs to; null when the row is gone or unreadable. */
|
||||
fun calendarAccount(calendarId: Long): SyncAccount?
|
||||
|
||||
/** The calendar event [eventId] lives in; null when the row is gone or unreadable. */
|
||||
fun eventCalendarId(eventId: Long): Long?
|
||||
|
||||
fun registerChangeListener(listener: () -> Unit)
|
||||
fun unregisterChangeListener(listener: () -> Unit)
|
||||
}
|
||||
@@ -881,8 +887,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return eventId
|
||||
}
|
||||
|
||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||
override fun calendarAccount(calendarId: Long): SyncAccount? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||
arrayOf(
|
||||
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||
@@ -891,13 +896,17 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
|
||||
SyncAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private data class CalendarAccount(val name: String, val type: String)
|
||||
override fun eventCalendarId(eventId: Long): Long? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
arrayOf(CalendarContract.Events.CALENDAR_ID),
|
||||
null, null, null,
|
||||
)?.use { c -> if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null }
|
||||
|
||||
/**
|
||||
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlin.time.Instant
|
||||
|
||||
interface CalendarRepository {
|
||||
@@ -134,6 +135,28 @@ interface CalendarRepository {
|
||||
|
||||
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
|
||||
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
|
||||
|
||||
/**
|
||||
* Ask every account behind a visible synced calendar to sync now (#240).
|
||||
* Only a request: the adapter may still run it late, or fail.
|
||||
*/
|
||||
suspend fun requestSync(): SyncRequestResult = SyncRequestResult.NothingToSync
|
||||
|
||||
/**
|
||||
* The accounts behind visible calendars with a sync adapter, split by
|
||||
* whether [requestSync] can reach them yet. Account access isn't observable:
|
||||
* it is re-read on each collection and on every [refreshSyncAccess].
|
||||
*/
|
||||
val syncTargets: Flow<SyncTargets> get() = flowOf(SyncTargets.None)
|
||||
|
||||
/** Re-read account access for every [syncTargets] collector; it can change outside the app. */
|
||||
fun refreshSyncAccess() {}
|
||||
|
||||
/** Stop or resume asking for [account]'s sync-request access. */
|
||||
suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) {}
|
||||
|
||||
/** Every provider notification and [refresh], including the app's own writes. */
|
||||
val providerChanges: Flow<Unit> get() = emptyFlow()
|
||||
}
|
||||
|
||||
class NoSuchEventException(eventId: Long) :
|
||||
|
||||
+165
-15
@@ -18,6 +18,8 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -47,6 +49,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
private val syncRequester: SyncRequester,
|
||||
) : CalendarRepository {
|
||||
|
||||
/** The configured wall-clock fire time for all-day reminders, read per write. */
|
||||
@@ -84,13 +87,159 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** Run a provider write on [io], then re-query whether or not it threw. */
|
||||
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
|
||||
override val providerChanges: Flow<Unit> get() = ticks
|
||||
|
||||
/**
|
||||
* Run a provider write on [io], then re-query whether or not it threw. The
|
||||
* accounts behind [syncCalendars] (read before the write, which may delete
|
||||
* the row) then get one sync nudge each (#303), also after a failure, which
|
||||
* may have landed part of a multi-row write. Once per write, so a multi-row
|
||||
* save or a whole import asks once per account.
|
||||
*/
|
||||
private suspend fun <T> write(
|
||||
syncCalendars: suspend () -> Collection<Long?>,
|
||||
block: suspend () -> T,
|
||||
): T = withContext(io) {
|
||||
val accounts = syncAccountsOf(syncCalendars)
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
refresh()
|
||||
writes.tryEmit(Unit)
|
||||
requestSyncs(accounts, userInitiated = false)
|
||||
}
|
||||
}
|
||||
|
||||
private val noSync: suspend () -> Collection<Long?> = { emptyList() }
|
||||
|
||||
private suspend fun syncAccountsOf(calendarIds: suspend () -> Collection<Long?>): Set<SyncAccount> =
|
||||
try {
|
||||
calendarIds().filterNotNull().distinct()
|
||||
.mapNotNull { accountOf(it) }
|
||||
.withAdapter()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "No sync nudge: the calendar account could not be read", e)
|
||||
emptySet()
|
||||
}
|
||||
|
||||
/** From the shared per-tick read; a calendar newer than it is read on its own. */
|
||||
private suspend fun accountOf(calendarId: Long): SyncAccount? =
|
||||
calendarsSnapshot().firstOrNull { it.id == calendarId }
|
||||
?.let { SyncAccount(it.accountName, it.accountType) }
|
||||
?: dataSource.calendarAccount(calendarId)
|
||||
|
||||
/** The syncable accounts among these whose type has a calendar sync adapter. */
|
||||
private fun Collection<SyncAccount>.withAdapter(): Set<SyncAccount> {
|
||||
val candidates = filter { it.isSyncable }
|
||||
if (candidates.isEmpty()) return emptySet()
|
||||
val adapterTypes = try {
|
||||
syncRequester.adapterAccountTypes()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sync adapters unreadable", e)
|
||||
return emptySet()
|
||||
}
|
||||
return candidates.filterTo(linkedSetOf()) { it.type in adapterTypes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits [accounts] by whether a request may name them (see
|
||||
* [SyncRequester.addressableAccounts]). The rest wait for the user's grant:
|
||||
* an account-less request would reach them, but also every other calendar
|
||||
* account on the device, so none is sent.
|
||||
*/
|
||||
private fun reach(accounts: Set<SyncAccount>): SyncTargets {
|
||||
if (accounts.isEmpty()) return SyncTargets.None
|
||||
// Unreadable access counts as reachable: a grant can't fix a failing read.
|
||||
val addressable = try {
|
||||
syncRequester.addressableAccounts(accounts.mapTo(mutableSetOf()) { it.type })
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Account visibility unreadable", e)
|
||||
null
|
||||
}
|
||||
if (addressable == null) return SyncTargets(reachable = accounts, needsAccess = emptySet())
|
||||
val (reachable, hidden) = accounts.partition { it in addressable }
|
||||
return SyncTargets(reachable.toSet(), hidden.toSet())
|
||||
}
|
||||
|
||||
/** Ask each reachable one of [accounts] once. */
|
||||
private fun requestSyncs(accounts: Set<SyncAccount>, userInitiated: Boolean) {
|
||||
reach(accounts).reachable.forEach { requestSync(it, userInitiated) }
|
||||
}
|
||||
|
||||
private fun requestSync(account: SyncAccount, userInitiated: Boolean): Boolean = try {
|
||||
syncRequester.requestSync(account, userInitiated)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sync request refused", e)
|
||||
false
|
||||
}
|
||||
|
||||
/** The event's own calendar (from [original] when given, else read), plus [others]. */
|
||||
private fun eventCalendar(
|
||||
eventId: Long,
|
||||
original: EventForm? = null,
|
||||
vararg others: Long?,
|
||||
): suspend () -> Collection<Long?> =
|
||||
{ listOf(original?.calendarId ?: dataSource.eventCalendarId(eventId), *others) }
|
||||
|
||||
private fun targetsOf(calendars: List<CalendarSource>, dismissed: Set<SyncAccount>): SyncTargets =
|
||||
reach(
|
||||
calendars.syncAccounts().withAdapter()
|
||||
.sortedWith(compareBy({ it.name.lowercase() }, { it.type }))
|
||||
.toSet(),
|
||||
).let {
|
||||
if (dismissed.isEmpty()) {
|
||||
it
|
||||
} else {
|
||||
it.copy(needsAccess = it.needsAccess - dismissed, dismissed = it.needsAccess intersect dismissed)
|
||||
}
|
||||
}
|
||||
|
||||
private val accessChecks = MutableStateFlow(0)
|
||||
|
||||
override val syncTargets: Flow<SyncTargets>
|
||||
get() = combine(calendars(), prefs.syncAccessDismissed, accessChecks) { calendars, dismissed, _ ->
|
||||
targetsOf(calendars, dismissed)
|
||||
}.flowOn(io)
|
||||
|
||||
override fun refreshSyncAccess() {
|
||||
accessChecks.update { it + 1 }
|
||||
}
|
||||
|
||||
override suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) {
|
||||
try {
|
||||
prefs.setSyncAccessDismissed(account, dismissed)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sync access dismissal not saved", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestSync(): SyncRequestResult = withContext(io) {
|
||||
val targets = try {
|
||||
targetsOf(calendars().first(), prefs.syncAccessDismissed.first())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Sync request skipped: calendars unreadable", e)
|
||||
return@withContext SyncRequestResult.Failed
|
||||
}
|
||||
if (targets.reachable.isEmpty()) {
|
||||
return@withContext if (targets.needsAccess.isEmpty()) {
|
||||
SyncRequestResult.NothingToSync
|
||||
} else {
|
||||
SyncRequestResult.NeedsAccess
|
||||
}
|
||||
}
|
||||
val sent = targets.reachable.count { requestSync(it, userInitiated = true) }
|
||||
when {
|
||||
sent == 0 -> SyncRequestResult.Failed
|
||||
sent < targets.reachable.size -> SyncRequestResult.PartlyFailed
|
||||
targets.needsAccess.isNotEmpty() -> SyncRequestResult.SomeNeedAccess
|
||||
else -> SyncRequestResult.Requested
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +367,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
): Long = write {
|
||||
): Long = write(noSync) {
|
||||
dataSource.createLocalCalendar(displayName, color, description)
|
||||
}
|
||||
|
||||
@@ -227,13 +376,14 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
) = write { dataSource.updateCalendar(id, displayName, color, description) }
|
||||
) = write(noSync) { dataSource.updateCalendar(id, displayName, color, description) }
|
||||
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
write { dataSource.deleteCalendar(id) }
|
||||
write(noSync) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||
write {
|
||||
// VISIBLE is device-local: nothing for an adapter to upload.
|
||||
write(noSync) {
|
||||
val canWrite = dataSource.canWriteCalendars()
|
||||
if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||
// Nothing of ours is left waiting for the provider once the write
|
||||
@@ -253,7 +403,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
): IcsImportSummary = write {
|
||||
): IcsImportSummary = write({ listOf(targetCalendarId) }) {
|
||||
val existing = dataSource.existingUids(targetCalendarId)
|
||||
// Both are per-calendar, not per-event: looking them up once keeps a
|
||||
// thousand-event restore to two extra queries. The palette is the
|
||||
@@ -304,7 +454,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = write {
|
||||
override suspend fun createEvent(form: EventForm): Long = write({ listOf(form.calendarId) }) {
|
||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
@@ -312,11 +462,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) = write {
|
||||
) = write(eventCalendar(eventId, original, updated.calendarId)) {
|
||||
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
override suspend fun deleteEvent(eventId: Long) = write {
|
||||
override suspend fun deleteEvent(eventId: Long) = write(eventCalendar(eventId)) {
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
@@ -325,7 +475,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = write {
|
||||
): Long = write(eventCalendar(eventId, original, targetCalendarId)) {
|
||||
dataSource.moveEvent(
|
||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
@@ -336,7 +486,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
): Long = write {
|
||||
): Long = write(eventCalendar(eventId, original)) {
|
||||
dataSource.updateOccurrence(
|
||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||
)
|
||||
@@ -347,7 +497,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = write {
|
||||
): Long = write(eventCalendar(eventId, original)) {
|
||||
dataSource.updateEventFromOccurrence(
|
||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
@@ -357,14 +507,14 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
|
||||
override val seriesSplits: Flow<SeriesSplit> = splits
|
||||
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write(eventCalendar(eventId)) {
|
||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
override suspend fun deleteEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
) = write {
|
||||
) = write(eventCalendar(eventId)) {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The account behind a calendar row (`ACCOUNT_NAME` / `ACCOUNT_TYPE`). */
|
||||
data class SyncAccount(val name: String, val type: String) {
|
||||
/** Whether a sync adapter can stand behind it: device-only calendars have none. */
|
||||
val isSyncable: Boolean
|
||||
get() = name.isNotBlank() && type.isNotBlank() && type != CalendarContract.ACCOUNT_TYPE_LOCAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the system to run a calendar sync adapter sooner (#303, #240). The sync
|
||||
* itself stays the adapter's job; this only schedules it.
|
||||
*/
|
||||
interface SyncRequester {
|
||||
/** Account types with a calendar sync adapter installed; for any other a request does nothing. */
|
||||
fun adapterAccountTypes(): Set<String>
|
||||
|
||||
/**
|
||||
* The accounts of [types] a request may name, or null when any may. From
|
||||
* Android 13 a request for an account the app can't see is dropped without a word.
|
||||
*/
|
||||
fun addressableAccounts(types: Set<String>): Set<SyncAccount>?
|
||||
|
||||
/**
|
||||
* Schedule a sync of [account]; may throw if the system refuses. [userInitiated]
|
||||
* marks it manual, which also overrides master-sync-off — only for a user's tap.
|
||||
*/
|
||||
fun requestSync(account: SyncAccount, userInitiated: Boolean)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class AndroidSyncRequester @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : SyncRequester {
|
||||
// Lists only adapters in packages visible to the app — hence the SyncAdapter <queries> entry.
|
||||
override fun adapterAccountTypes(): Set<String> =
|
||||
ContentResolver.getSyncAdapterTypes()
|
||||
.filter { it.authority == CalendarContract.AUTHORITY }
|
||||
.mapTo(mutableSetOf()) { it.accountType }
|
||||
|
||||
override fun addressableAccounts(types: Set<String>): Set<SyncAccount>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
|
||||
val manager = AccountManager.get(context)
|
||||
return types.flatMapTo(mutableSetOf()) { type ->
|
||||
manager.getAccountsByType(type).map { SyncAccount(it.name, it.type) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun requestSync(account: SyncAccount, userInitiated: Boolean) {
|
||||
val extras = Bundle().apply {
|
||||
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
|
||||
if (userInitiated) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
|
||||
}
|
||||
ContentResolver.requestSync(
|
||||
Account(account.name, account.type),
|
||||
CalendarContract.AUTHORITY,
|
||||
extras,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The distinct non-local accounts behind the visible calendars — what a manual refresh asks. */
|
||||
fun List<CalendarSource>.syncAccounts(): Set<SyncAccount> =
|
||||
filter { it.isVisibleInSystem }
|
||||
.map { SyncAccount(it.accountName, it.accountType) }
|
||||
.filterTo(linkedSetOf()) { it.isSyncable }
|
||||
|
||||
/**
|
||||
* Who a sync request can go to: [reachable] by name, [needsAccess] only once the
|
||||
* user lets the app see the account (Android 13+, see [accountAccessIntent]).
|
||||
* [dismissed] are hidden accounts the user said not to ask about.
|
||||
*/
|
||||
data class SyncTargets(
|
||||
val reachable: Set<SyncAccount>,
|
||||
val needsAccess: Set<SyncAccount>,
|
||||
val dismissed: Set<SyncAccount> = emptySet(),
|
||||
) {
|
||||
companion object {
|
||||
val None = SyncTargets(emptySet(), emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The system account picker, limited to [account]: once picked it becomes
|
||||
* visible to the app, so sync requests can name it.
|
||||
*/
|
||||
fun accountAccessIntent(account: SyncAccount, description: String): Intent =
|
||||
AccountManager.newChooseAccountIntent(
|
||||
null,
|
||||
listOf(Account(account.name, account.type)),
|
||||
arrayOf(account.type),
|
||||
description,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
|
||||
/** Outcome of a user-triggered sync request (#240). */
|
||||
enum class SyncRequestResult { Requested, SomeNeedAccess, PartlyFailed, NeedsAccess, NothingToSync, Failed }
|
||||
@@ -11,9 +11,11 @@ import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.AndroidSyncRequester
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncRequester
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
@@ -49,6 +51,12 @@ abstract class DataBindModule {
|
||||
impl: CalendarRepositoryImpl,
|
||||
): CalendarRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSyncRequester(
|
||||
impl: AndroidSyncRequester,
|
||||
): SyncRequester
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderInstanceSource(
|
||||
|
||||
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||
import de.jeanlucmakiola.floret.prefs.stringSetPref
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -28,9 +30,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 +43,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 +54,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 +62,45 @@ 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)
|
||||
|
||||
}
|
||||
/**
|
||||
* Synced accounts the user chose never to be asked to grant sync-request
|
||||
* access for (#240): no grant row, no "other accounts" receipt.
|
||||
*/
|
||||
val syncAccessDismissed: Flow<Set<SyncAccount>> = prefs.flow(SYNC_ACCESS_DISMISSED)
|
||||
|
||||
/** 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")
|
||||
suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) {
|
||||
prefs.update(SYNC_ACCESS_DISMISSED) { if (dismissed) it + account else it - account }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SYNC_ACCESS_DISMISSED = stringSetPref("sync_access_dismissed").map<Set<SyncAccount>>(
|
||||
{ stored -> stored.mapNotNullTo(mutableSetOf()) { it.toSyncAccount() } },
|
||||
{ accounts -> accounts.mapTo(mutableSetOf()) { "${it.type}$FIELD_SEPARATOR${it.name}" } },
|
||||
)
|
||||
const val FIELD_SEPARATOR = '\u001F'
|
||||
|
||||
fun String.toSyncAccount(): SyncAccount? {
|
||||
val split = indexOf(FIELD_SEPARATOR)
|
||||
return if (split <= 0) null else SyncAccount(name = substring(split + 1), type = substring(0, split))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
|
||||
import de.jeanlucmakiola.calendula.ui.common.screen
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
|
||||
@@ -52,6 +53,13 @@ import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
|
||||
import de.jeanlucmakiola.calendula.ui.common.StayingPut
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.DrawerSync
|
||||
import de.jeanlucmakiola.calendula.ui.common.SyncViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.RefreshSyncAccessOnOpen
|
||||
import de.jeanlucmakiola.calendula.ui.common.SyncAccessSheet
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncTargets
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberSyncAccessPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.messageRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
|
||||
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
|
||||
@@ -441,6 +449,16 @@ fun CalendarHost(
|
||||
val eventActions: EventActionsViewModel = hiltViewModel()
|
||||
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
|
||||
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
|
||||
// "Sync now" in the drawer (#240); its receipt shares the move chip.
|
||||
val sync: SyncViewModel = hiltViewModel()
|
||||
val loadedSyncTargets by sync.targets.collectAsStateWithLifecycle()
|
||||
val syncTargets = loadedSyncTargets ?: SyncTargets.None
|
||||
val syncInFlight by sync.inFlight.collectAsStateWithLifecycle()
|
||||
val syncResult by sync.result.collectAsStateWithLifecycle()
|
||||
val selectionFailureMessage = eventActionFailureMessage(selectionFailure)
|
||||
val syncMessage = syncResult?.let { stringResource(it.messageRes()) }
|
||||
val launchAccessPicker = rememberSyncAccessPicker(sync)
|
||||
var showSyncAccess by rememberSaveable { mutableStateOf(false) }
|
||||
val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
|
||||
// A tap opens the edit form instead of the detail screen when the user chose
|
||||
// so (#178) — only for events whose calendar takes writes. Both are read at
|
||||
@@ -538,6 +556,7 @@ fun CalendarHost(
|
||||
val stayingPut = remember { StayingPut() }
|
||||
val chromeHost = remember { ChromeHost() }
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
RefreshSyncAccessOnOpen(drawerState, sync)
|
||||
val density = LocalDensity.current
|
||||
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
|
||||
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
|
||||
@@ -588,6 +607,18 @@ fun CalendarHost(
|
||||
onOpenSettings()
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
sync = remember(syncTargets, syncInFlight) {
|
||||
DrawerSync(
|
||||
canSync = syncTargets.reachable.isNotEmpty(),
|
||||
inFlight = syncInFlight,
|
||||
needsAccess = syncTargets.needsAccess.isNotEmpty(),
|
||||
onSync = {
|
||||
sync.requestSync()
|
||||
switchScope.launch { drawerState.close() }
|
||||
},
|
||||
onGrantAccess = { showSyncAccess = true },
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -613,10 +644,12 @@ fun CalendarHost(
|
||||
// A fresh event, like a duplicate from the detail screen (#52).
|
||||
openImportForm(form, ImportSource.Insert)
|
||||
},
|
||||
chipShown = moveOutcome != null || selectionFailure != null,
|
||||
chipShown = moveOutcome != null || selectionFailure != null || syncResult != null,
|
||||
onDismissChip = {
|
||||
reschedule.consumeOutcome()
|
||||
eventActions.consumeFailure()
|
||||
// Only the chip on show: a receipt queued behind it still gets its turn.
|
||||
if (moveOutcome == null && selectionFailure == null) sync.consumeResult()
|
||||
},
|
||||
)
|
||||
CalendarFab(chromeHost, todayInToolbar, fabSelection)
|
||||
@@ -679,10 +712,21 @@ fun CalendarHost(
|
||||
EventMoveHost(
|
||||
reschedule,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
actionFailure = eventActionFailureMessage(selectionFailure),
|
||||
onActionFailureShown = eventActions::consumeFailure,
|
||||
notice = selectionFailureMessage ?: syncMessage,
|
||||
onNoticeShown = {
|
||||
if (selectionFailureMessage != null) eventActions.consumeFailure() else sync.consumeResult()
|
||||
},
|
||||
)
|
||||
|
||||
if (showSyncAccess) {
|
||||
SyncAccessSheet(
|
||||
accounts = loadedSyncTargets?.needsAccess?.toList(),
|
||||
onGrant = launchAccessPicker,
|
||||
onDismissAccount = sync::dismissAccess,
|
||||
onDismiss = { showSyncAccess = false },
|
||||
)
|
||||
}
|
||||
|
||||
// The delete prompt and failure chip of a selected event's actions (#141).
|
||||
EventSelectionHost(host = chromeHost, viewModel = eventActions)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -85,6 +85,11 @@ import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedHint
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.SyncAccountRow
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
|
||||
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
@@ -130,6 +135,8 @@ fun CalendarsScreen(
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val syncAccessDismissed by viewModel.syncAccessDismissed.collectAsStateWithLifecycle()
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshAccess() }
|
||||
|
||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||
// [editorSession] bumps on every open so the editor's field state resets for
|
||||
@@ -174,6 +181,8 @@ fun CalendarsScreen(
|
||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||
onSetVisible = viewModel::setCalendarVisible,
|
||||
onSetAccountVisible = viewModel::setAccountVisible,
|
||||
syncAccessDismissed = syncAccessDismissed,
|
||||
onAskSyncAccessAgain = viewModel::askSyncAccessAgain,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -191,6 +200,8 @@ private fun CalendarsList(
|
||||
onEdit: (CalendarSource) -> Unit,
|
||||
onSetVisible: (Long, Boolean) -> Unit,
|
||||
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
|
||||
syncAccessDismissed: List<SyncAccount>,
|
||||
onAskSyncAccessAgain: (SyncAccount) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -321,6 +332,20 @@ private fun CalendarsList(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (syncAccessDismissed.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedSectionHeader(stringResource(R.string.calendars_sync_access_header))
|
||||
GroupedHint(stringResource(R.string.calendars_sync_access_hint), style = MaterialTheme.typography.bodySmall)
|
||||
syncAccessDismissed.forEachIndexed { index, account ->
|
||||
SyncAccountRow(
|
||||
account = account,
|
||||
position = positionOf(index, syncAccessDismissed.size),
|
||||
actionLabel = stringResource(R.string.calendars_sync_access_ask_again),
|
||||
onAction = { onAskSyncAccessAgain(account) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -56,6 +57,23 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Accounts the drawer was told not to ask sync-request access for (#240),
|
||||
* limited to those still behind a visible calendar and still unreachable.
|
||||
*/
|
||||
val syncAccessDismissed: StateFlow<List<SyncAccount>> =
|
||||
repository.syncTargets
|
||||
.map { it.dismissed.toList() }
|
||||
.catch { emit(emptyList()) }
|
||||
.stateWhileSubscribed(scope = viewModelScope, initialValue = emptyList())
|
||||
|
||||
/** Re-read account access, which can change outside the app; called on resume. */
|
||||
fun refreshAccess() = repository.refreshSyncAccess()
|
||||
|
||||
fun askSyncAccessAgain(account: SyncAccount) {
|
||||
viewModelScope.launch { repository.setSyncAccessDismissed(account, dismissed = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Backup & restore screen's own view of that list, with the loading and
|
||||
* failure states [calendars] flattens away — it starts empty and catches to
|
||||
|
||||
@@ -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
|
||||
}
|
||||
-108
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,12 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.ManageAccounts
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,6 +42,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
@@ -61,6 +67,8 @@ import kotlinx.datetime.LocalDate
|
||||
*
|
||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||
* [sync] adds "Sync now" beside it while there is a synced calendar (#240),
|
||||
* and a row to grant account access while some account can't be reached yet.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarDrawer(
|
||||
@@ -71,6 +79,7 @@ fun CalendarDrawer(
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
sync: DrawerSync? = null,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -103,11 +112,12 @@ fun CalendarDrawer(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.drawer_jump_to_date),
|
||||
position = Position.Alone,
|
||||
position = if (sync?.hasRows == true) Position.Top else Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||
onClick = { showDatePicker = true },
|
||||
)
|
||||
if (sync != null) SyncRows(sync)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -138,6 +148,61 @@ fun CalendarDrawer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer's sync rows: "Sync now" when [canSync] ([inFlight] shows progress
|
||||
* and ignores taps), and a grant row while some account [needsAccess].
|
||||
*/
|
||||
data class DrawerSync(
|
||||
val canSync: Boolean,
|
||||
val inFlight: Boolean,
|
||||
val needsAccess: Boolean,
|
||||
val onSync: () -> Unit,
|
||||
val onGrantAccess: () -> Unit,
|
||||
) {
|
||||
val hasRows: Boolean get() = canSync || needsAccess
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncRows(sync: DrawerSync) {
|
||||
if (sync.canSync) {
|
||||
SyncNowRow(sync, if (sync.needsAccess) Position.Middle else Position.Bottom)
|
||||
}
|
||||
if (sync.needsAccess) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.drawer_sync_access),
|
||||
summary = stringResource(R.string.drawer_sync_access_summary),
|
||||
position = Position.Bottom,
|
||||
minHeight = 56.dp,
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.ManageAccounts) },
|
||||
onClick = sync.onGrantAccess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SyncNowRow(sync: DrawerSync, position: Position) {
|
||||
val inFlightText = stringResource(R.string.sync_requested)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.drawer_sync_now),
|
||||
position = position,
|
||||
modifier = if (sync.inFlight) {
|
||||
Modifier.semantics { stateDescription = inFlightText }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
dimmed = sync.inFlight,
|
||||
minHeight = 56.dp,
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.Sync) },
|
||||
trailing = if (sync.inFlight) {
|
||||
{ LoadingIndicator(Modifier.size(32.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = sync.onSync.takeIf { !sync.inFlight },
|
||||
)
|
||||
}
|
||||
|
||||
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
||||
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
||||
@Composable
|
||||
|
||||
@@ -51,8 +51,8 @@ private val FAB_BAND = 88.dp
|
||||
fun EventMoveHost(
|
||||
viewModel: RescheduleViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
actionFailure: String? = null,
|
||||
onActionFailureShown: () -> Unit = {},
|
||||
notice: String? = null,
|
||||
onNoticeShown: () -> Unit = {},
|
||||
) {
|
||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
@@ -81,7 +81,7 @@ fun EventMoveHost(
|
||||
}
|
||||
// A failed selection action (#141) shares the chip rather than stacking on it.
|
||||
val message = when (outcome) {
|
||||
null -> actionFailure
|
||||
null -> notice
|
||||
is MoveOutcome.Moved -> if (resizedLabel != null) {
|
||||
stringResource(R.string.event_resize_done, resizedLabel)
|
||||
} else {
|
||||
@@ -116,10 +116,10 @@ fun EventMoveHost(
|
||||
viewModel.consumeOutcome()
|
||||
}
|
||||
// A failure waits for the chip to be free, then has its own turn.
|
||||
LaunchedEffect(actionFailure, outcome == null) {
|
||||
if (actionFailure == null || outcome != null) return@LaunchedEffect
|
||||
LaunchedEffect(notice, outcome == null) {
|
||||
if (notice == null || outcome != null) return@LaunchedEffect
|
||||
delay(CHIP_MILLIS)
|
||||
onActionFailureShown()
|
||||
onNoticeShown()
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
@@ -138,7 +138,7 @@ fun EventMoveHost(
|
||||
// write anyway, so offering it would be a button that does nothing.
|
||||
val undo = content.undo?.takeIf { !writeInFlight }
|
||||
SnackChip(
|
||||
visible = outcome != null || actionFailure != null,
|
||||
visible = outcome != null || notice != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.data.calendar.accountAccessIntent
|
||||
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.components.positionOf
|
||||
|
||||
/**
|
||||
* Launches the system account picker for an account sync requests can't reach
|
||||
* yet (#240), one launch at a time. Access is re-read on every resume, which covers
|
||||
* the picker's return and grants or revokes made in system settings.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSyncAccessPicker(sync: SyncViewModel): (SyncAccount) -> Unit {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
open = false
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { sync.refreshAccess() }
|
||||
val description = stringResource(R.string.sync_access_picker_description)
|
||||
return remember(launcher, description) {
|
||||
{ account ->
|
||||
if (!open) {
|
||||
try {
|
||||
launcher.launch(accountAccessIntent(account, description))
|
||||
open = true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.w("SyncAccessPicker", "No account picker", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-reads account access whenever [drawerState] opens; the app may stay resumed in multi-window. */
|
||||
@Composable
|
||||
fun RefreshSyncAccessOnOpen(drawerState: DrawerState, sync: SyncViewModel) {
|
||||
LaunchedEffect(drawerState.targetValue) {
|
||||
if (drawerState.targetValue == DrawerValue.Open) sync.refreshAccess()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts sync requests can't reach yet (#240), one row each: tapping one
|
||||
* opens the system picker for just that account, "Don't ask" stops offering it.
|
||||
* Closes itself once no account is left.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun SyncAccessSheet(
|
||||
accounts: List<SyncAccount>?,
|
||||
onGrant: (SyncAccount) -> Unit,
|
||||
onDismissAccount: (SyncAccount) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
LaunchedEffect(accounts?.isEmpty()) { if (accounts?.isEmpty() == true) onDismiss() }
|
||||
FullScreenPicker(title = stringResource(R.string.drawer_sync_access), onDismiss = onDismiss, predictiveBack = true) {
|
||||
GroupedHint(stringResource(R.string.sync_access_hint), verticalPadding = 8.dp)
|
||||
if (accounts == null) {
|
||||
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { LoadingIndicator() }
|
||||
}
|
||||
accounts.orEmpty().forEachIndexed { index, account ->
|
||||
SyncAccountRow(
|
||||
account = account,
|
||||
position = positionOf(index, accounts.orEmpty().size),
|
||||
actionLabel = stringResource(R.string.sync_access_dont_ask),
|
||||
onAction = { onDismissAccount(account) },
|
||||
onClick = { onGrant(account) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One synced account with a text action, as the access sheet and the calendar manager list them. */
|
||||
@Composable
|
||||
fun SyncAccountRow(
|
||||
account: SyncAccount,
|
||||
position: Position,
|
||||
actionLabel: String,
|
||||
onAction: () -> Unit,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = account.name,
|
||||
summary = sourceAppName(account.type),
|
||||
position = position,
|
||||
minHeight = 64.dp,
|
||||
leading = { SourceLogo(account.type) },
|
||||
trailing = { TextButton(onClick = onAction) { Text(actionLabel) } },
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncRequestResult
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncTargets
|
||||
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The drawer's "Sync now" (#240): asks the sync adapters of every visible synced
|
||||
* calendar to run now. [inFlight] holds until the provider next reports a change
|
||||
* or [SETTLE_MILLIS] pass — the request itself says nothing about when, or
|
||||
* whether, the sync completes.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SyncViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** Who "Sync now" can reach, and who first needs the user's grant; null until first read. */
|
||||
val targets: StateFlow<SyncTargets?> = repository.syncTargets
|
||||
.catch { emit(SyncTargets.None) }
|
||||
.stateWhileSubscribed(scope = viewModelScope, initialValue = null)
|
||||
|
||||
/** Re-read account access: on resume (which includes the picker returning) and drawer open. */
|
||||
fun refreshAccess() = repository.refreshSyncAccess()
|
||||
|
||||
private val _inFlight = MutableStateFlow(false)
|
||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||
|
||||
private val _result = MutableStateFlow<SyncRequestResult?>(null)
|
||||
|
||||
/** The last request's outcome, until [consumeResult]. */
|
||||
val result: StateFlow<SyncRequestResult?> = _result.asStateFlow()
|
||||
|
||||
fun requestSync() {
|
||||
if (_inFlight.value) return
|
||||
_inFlight.value = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Listening before asking, so a sync that lands at once still counts.
|
||||
val settled = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
withTimeoutOrNull(SETTLE_MILLIS) { repository.providerChanges.firstOrNull() }
|
||||
}
|
||||
val outcome = repository.requestSync()
|
||||
_result.value = outcome
|
||||
val sent = outcome == SyncRequestResult.Requested ||
|
||||
outcome == SyncRequestResult.SomeNeedAccess ||
|
||||
outcome == SyncRequestResult.PartlyFailed
|
||||
if (sent) settled.await() else settled.cancel()
|
||||
} finally {
|
||||
_inFlight.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop asking for [account]'s access (#240); undone from the calendar manager. */
|
||||
fun dismissAccess(account: SyncAccount) {
|
||||
viewModelScope.launch { repository.setSyncAccessDismissed(account, dismissed = true) }
|
||||
}
|
||||
|
||||
fun consumeResult() {
|
||||
_result.value = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SETTLE_MILLIS = 15_000L
|
||||
}
|
||||
}
|
||||
|
||||
/** The chip text for a sync request's outcome. */
|
||||
@StringRes
|
||||
fun SyncRequestResult.messageRes(): Int = when (this) {
|
||||
SyncRequestResult.Requested -> R.string.sync_requested
|
||||
SyncRequestResult.PartlyFailed -> R.string.sync_request_partly_failed
|
||||
SyncRequestResult.NeedsAccess -> R.string.sync_request_needs_access
|
||||
SyncRequestResult.SomeNeedAccess -> R.string.sync_request_some_need_access
|
||||
SyncRequestResult.NothingToSync -> R.string.sync_nothing_to_sync
|
||||
SyncRequestResult.Failed -> R.string.sync_request_failed
|
||||
}
|
||||
@@ -395,6 +395,21 @@
|
||||
|
||||
<!-- Jump to date (drawer) -->
|
||||
<string name="drawer_jump_to_date">Jump to date</string>
|
||||
<string name="drawer_sync_now">Sync now</string>
|
||||
<string name="drawer_sync_access">Allow sync requests</string>
|
||||
<string name="drawer_sync_access_summary">Pick each account Calendula may ask to sync</string>
|
||||
<string name="sync_access_hint">Android only lets Calendula ask an account to sync once you pick it here. Tap an account to allow it, or choose Don\'t ask to stop offering it.</string>
|
||||
<string name="sync_access_dont_ask">Don\'t ask</string>
|
||||
<string name="calendars_sync_access_header">Not asked for sync access</string>
|
||||
<string name="calendars_sync_access_hint">Calendula won\'t offer to request syncs for these accounts. Ask again to bring them back to Allow sync requests in the menu.</string>
|
||||
<string name="calendars_sync_access_ask_again">Ask again</string>
|
||||
<string name="sync_access_picker_description">Choose an account Calendula may ask to sync your calendars</string>
|
||||
<string name="sync_requested">Sync requested</string>
|
||||
<string name="sync_request_failed">Couldn\'t request a sync</string>
|
||||
<string name="sync_request_some_need_access">Sync requested. Allow sync requests in the menu for the other accounts</string>
|
||||
<string name="sync_request_needs_access">Allow sync requests in the menu first</string>
|
||||
<string name="sync_request_partly_failed">Sync requested, but not for every account</string>
|
||||
<string name="sync_nothing_to_sync">No synced calendars to sync</string>
|
||||
|
||||
<!-- Agenda view -->
|
||||
<string name="agenda_today_action">Today</string>
|
||||
@@ -910,41 +925,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>
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncRequester
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -43,7 +45,48 @@ fun testRepository(
|
||||
io: CoroutineDispatcher,
|
||||
prefs: CalendarPrefs = testPrefs(dir),
|
||||
settings: SettingsPrefs = testSettings(dir),
|
||||
): CalendarRepositoryImpl = CalendarRepositoryImpl(source, prefs, settings, io)
|
||||
syncRequester: SyncRequester = RecordingSyncRequester(),
|
||||
): CalendarRepositoryImpl = CalendarRepositoryImpl(source, prefs, settings, io, syncRequester)
|
||||
|
||||
/**
|
||||
* Records every sync request; [accepts] false models the system refusing all,
|
||||
* [refusedTypes] refusing those account types, [adapterTypes] the installed
|
||||
* calendar sync adapters (null: a fixed set covering the test accounts),
|
||||
* [hiddenTypes] account types the app can't address by name.
|
||||
*/
|
||||
class RecordingSyncRequester(
|
||||
var accepts: Boolean = true,
|
||||
var adapterTypes: Set<String>? = null,
|
||||
var refusedTypes: Set<String> = emptySet(),
|
||||
var hiddenTypes: Set<String> = emptySet(),
|
||||
) : SyncRequester {
|
||||
data class Request(val account: SyncAccount, val userInitiated: Boolean)
|
||||
|
||||
val requests = mutableListOf<Request>()
|
||||
|
||||
override fun adapterAccountTypes(): Set<String> =
|
||||
adapterTypes ?: setOf("bitfire.at.davdroid", "com.google", "LOCAL")
|
||||
|
||||
override fun addressableAccounts(types: Set<String>): Set<SyncAccount>? {
|
||||
visibilityError?.let { throw it }
|
||||
return if (hiddenTypes.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
knownAccounts.filterTo(mutableSetOf()) { it.type in types && it.type !in hiddenTypes }
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by [addressableAccounts] when set. */
|
||||
var visibilityError: Exception? = null
|
||||
|
||||
/** The accounts on the "device", for [addressableAccounts]. */
|
||||
var knownAccounts: Set<SyncAccount> = emptySet()
|
||||
|
||||
override fun requestSync(account: SyncAccount, userInitiated: Boolean) {
|
||||
if (!accepts || account.type in refusedTypes) throw SecurityException("refused")
|
||||
requests += Request(account, userInitiated)
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs [dispatcher] as `Dispatchers.Main` around each test. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,15 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
val updatedCalendars = mutableListOf<UpdatedCalendar>()
|
||||
val deletedCalendarIds = mutableListOf<Long>()
|
||||
|
||||
/** eventId → calendarId, for [eventCalendarId]. */
|
||||
val eventCalendars = mutableMapOf<Long, Long>()
|
||||
|
||||
override fun calendarAccount(calendarId: Long): SyncAccount? =
|
||||
calendarsResult.firstOrNull { it.id == calendarId }
|
||||
?.let { SyncAccount(it.accountName, it.accountType) }
|
||||
|
||||
override fun eventCalendarId(eventId: Long): Long? = eventCalendars[eventId]
|
||||
|
||||
private val listeners = mutableListOf<() -> Unit>()
|
||||
|
||||
/** How often [calendars] was queried — the repository shares one read per tick. */
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.RecordingSyncRequester
|
||||
import de.jeanlucmakiola.calendula.RecordingSyncRequester.Request
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.FormTime
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.testRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Instant
|
||||
|
||||
class SyncNudgeTest {
|
||||
|
||||
private val dav = SyncAccount("me@dav", "bitfire.at.davdroid")
|
||||
private val google = SyncAccount("me@gmail.com", "com.google")
|
||||
|
||||
private fun cal(id: Long, account: SyncAccount, visible: Boolean = true) = CalendarSource(
|
||||
id = id,
|
||||
displayName = "Cal $id",
|
||||
accountName = account.name,
|
||||
accountType = account.type,
|
||||
color = 0,
|
||||
isVisibleInSystem = visible,
|
||||
isLocal = account.type == "LOCAL",
|
||||
)
|
||||
|
||||
private val local = SyncAccount("Calendula", "LOCAL")
|
||||
|
||||
private fun fake() = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L, dav), cal(2L, dav), cal(3L, google), cal(4L, local))
|
||||
eventCalendars += mapOf(10L to 1L, 30L to 3L, 40L to 4L)
|
||||
}
|
||||
|
||||
private fun form(calendarId: Long) = EventForm(
|
||||
calendarId = calendarId,
|
||||
title = "Stand-up",
|
||||
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
|
||||
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
|
||||
)
|
||||
|
||||
private fun auto(account: SyncAccount) = Request(account, userInitiated = false)
|
||||
|
||||
@Test
|
||||
fun `a write to a synced calendar nudges its account once, not manually`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.createEvent(form(1L))
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(dav))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a write to a local calendar asks for no sync`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.createEvent(form(4L))
|
||||
repo.deleteEvent(40L)
|
||||
|
||||
assertThat(sync.requests).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event writes resolve the account from the event's calendar`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.updateEventFromOccurrence(10L, 0L, form(1L), form(1L))
|
||||
repo.deleteOccurrence(30L, 0L)
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(dav), auto(google)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a move between accounts nudges both, each once`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.moveEvent(10L, 3L, form(1L), form(3L))
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(dav), auto(google))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a move between calendars of one account nudges it once`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.moveEvent(10L, 2L, form(1L), form(2L))
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(dav))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an import nudges once at the end, however many events`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
val events = (1..5).map {
|
||||
ParsedIcsEvent(
|
||||
uid = "u$it",
|
||||
summary = "E$it",
|
||||
start = Instant.fromEpochMilliseconds(1_000_000_000L),
|
||||
end = Instant.fromEpochMilliseconds(1_000_003_600L),
|
||||
isAllDay = false,
|
||||
zoneId = "UTC",
|
||||
)
|
||||
}
|
||||
|
||||
repo.importEvents(3L, events)
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(google))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed write still nudges, since part of it may have landed`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val source = fake().apply { writeError = IllegalStateException("boom") }
|
||||
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
assertThrows<IllegalStateException> { repo.deleteEvent(10L) }
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(dav))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a throwing requester never fails the save`(@TempDir dir: Path) = runTest {
|
||||
val throwing = RecordingSyncRequester(accepts = false)
|
||||
val source = fake().apply { nextInsertId = 77L }
|
||||
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = throwing)
|
||||
|
||||
assertThat(repo.createEvent(form(1L))).isEqualTo(77L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual sync asks each visible synced account once, as manual`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val source = fake().apply {
|
||||
calendarsResult = calendarsResult + cal(5L, SyncAccount("hidden@dav", "bitfire.at.davdroid"), visible = false)
|
||||
}
|
||||
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
|
||||
assertThat(sync.requests).containsExactly(Request(dav, true), Request(google, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual sync with only local calendars has nothing to sync`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val source = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(4L, local)) }
|
||||
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.NothingToSync)
|
||||
assertThat(sync.requests).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual sync the system refuses reports a failure`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(accepts = false)
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Failed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `syncAccounts skips local, invisible and blank accounts`() {
|
||||
val calendars = listOf(
|
||||
cal(1L, dav),
|
||||
cal(2L, dav),
|
||||
cal(3L, google, visible = false),
|
||||
cal(4L, local),
|
||||
cal(5L, SyncAccount("", "com.google")),
|
||||
)
|
||||
|
||||
assertThat(calendars.syncAccounts()).containsExactly(dav)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accounts without a calendar sync adapter are never asked`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(adapterTypes = setOf(dav.type))
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.createEvent(form(3L))
|
||||
assertThat(sync.requests).isEmpty()
|
||||
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
|
||||
assertThat(sync.requests).containsExactly(Request(dav, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `syncTargets needs a visible calendar with an adapter`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(adapterTypes = setOf(google.type))
|
||||
val source = fake().apply {
|
||||
calendarsResult = listOf(cal(1L, dav), cal(3L, google, visible = false), cal(4L, local))
|
||||
}
|
||||
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets.None)
|
||||
|
||||
sync.adapterTypes = setOf(dav.type)
|
||||
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual sync refused for some accounts says so`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(refusedTypes = setOf(google.type))
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.PartlyFailed)
|
||||
assertThat(sync.requests).containsExactly(Request(dav, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accounts the app can't see wait for access instead of being asked`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf(google.type))
|
||||
sync.knownAccounts = setOf(dav, google)
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.moveEvent(10L, 3L, form(1L), form(3L))
|
||||
assertThat(sync.requests).containsExactly(auto(dav))
|
||||
|
||||
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), setOf(google)))
|
||||
|
||||
sync.requests.clear()
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.SomeNeedAccess)
|
||||
assertThat(sync.requests).containsExactly(Request(dav, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `once access is granted the account is asked by name`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf(google.type))
|
||||
sync.knownAccounts = setOf(dav, google)
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
sync.hiddenTypes = setOf("other")
|
||||
|
||||
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav, google), emptySet()))
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
|
||||
assertThat(sync.requests).containsExactly(Request(dav, true), Request(google, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only hidden accounts make manual sync ask for access, asking no one`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf(dav.type, google.type))
|
||||
sync.knownAccounts = setOf(dav, google)
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.NeedsAccess)
|
||||
repo.createEvent(form(1L))
|
||||
assertThat(sync.requests).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unreadable account visibility counts as reachable`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf("other"))
|
||||
sync.visibilityError = SecurityException("no")
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.createEvent(form(1L))
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(dav))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dismissed hidden account no longer needs access`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf(google.type))
|
||||
sync.knownAccounts = setOf(dav, google)
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.setSyncAccessDismissed(google, dismissed = true)
|
||||
|
||||
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), emptySet(), dismissed = setOf(google)))
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
|
||||
|
||||
repo.setSyncAccessDismissed(google, dismissed = false)
|
||||
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), setOf(google)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with every hidden account dismissed there is nothing to sync or grant`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf(dav.type, google.type))
|
||||
sync.knownAccounts = setOf(dav, google)
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.setSyncAccessDismissed(dav, dismissed = true)
|
||||
repo.setSyncAccessDismissed(google, dismissed = true)
|
||||
|
||||
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(emptySet(), emptySet(), dismissed = setOf(dav, google)))
|
||||
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.NothingToSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a dismissed account that becomes visible is still asked`(@TempDir dir: Path) = runTest {
|
||||
val sync = RecordingSyncRequester()
|
||||
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
|
||||
|
||||
repo.setSyncAccessDismissed(dav, dismissed = true)
|
||||
repo.createEvent(form(1L))
|
||||
|
||||
assertThat(sync.requests).containsExactly(auto(dav))
|
||||
assertThat(repo.syncTargets.first().dismissed).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.testDataStore
|
||||
import de.jeanlucmakiola.calendula.testPrefs
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -108,4 +109,18 @@ class CalendarPrefsTest {
|
||||
prefs.setVisibilityNoticePending(false)
|
||||
assertThat(prefs.visibilityNoticePending.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync access dismissals round-trip per account, names with any characters`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = testPrefs(tempDir)
|
||||
val dav = SyncAccount("me@dav.example/cal, 1", "bitfire.at.davdroid")
|
||||
val google = SyncAccount("me@dav.example/cal, 1", "com.google")
|
||||
|
||||
prefs.setSyncAccessDismissed(dav, dismissed = true)
|
||||
prefs.setSyncAccessDismissed(google, dismissed = true)
|
||||
assertThat(prefs.syncAccessDismissed.first()).containsExactly(dav, google)
|
||||
|
||||
prefs.setSyncAccessDismissed(dav, dismissed = false)
|
||||
assertThat(prefs.syncAccessDismissed.first()).containsExactly(google)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.calendula.RecordingSyncRequester
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncRequestResult
|
||||
import de.jeanlucmakiola.calendula.data.calendar.SyncTargets
|
||||
import kotlinx.coroutines.launch
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.testPrefs
|
||||
import de.jeanlucmakiola.calendula.testRepository
|
||||
import de.jeanlucmakiola.calendula.testSettings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SyncViewModelTest {
|
||||
|
||||
@JvmField @RegisterExtension
|
||||
val mainDispatcher = MainDispatcherExtension(StandardTestDispatcher())
|
||||
private val dispatcher = mainDispatcher.dispatcher
|
||||
|
||||
private val synced = CalendarSource(1L, "Work", "me@dav", "bitfire.at.davdroid", 0, true)
|
||||
private val davAccount = SyncAccount("me@dav", "bitfire.at.davdroid")
|
||||
|
||||
private fun viewModel(dir: Path, sync: RecordingSyncRequester = RecordingSyncRequester()):
|
||||
Pair<SyncViewModel, FakeCalendarDataSource> {
|
||||
val source = FakeCalendarDataSource().apply { calendarsResult = listOf(synced) }
|
||||
val repo = testRepository(
|
||||
source, dir, dispatcher,
|
||||
prefs = testPrefs(dir, CoroutineScope(dispatcher)),
|
||||
settings = testSettings(dir, CoroutineScope(dispatcher)),
|
||||
syncRequester = sync,
|
||||
)
|
||||
return SyncViewModel(repo) to source
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in flight until the provider next reports a change`(@TempDir dir: Path) = runTest(dispatcher) {
|
||||
val (vm, source) = viewModel(dir)
|
||||
|
||||
vm.requestSync()
|
||||
runCurrent()
|
||||
|
||||
assertThat(vm.inFlight.value).isTrue()
|
||||
assertThat(vm.result.value).isEqualTo(SyncRequestResult.Requested)
|
||||
|
||||
source.tick()
|
||||
runCurrent()
|
||||
|
||||
assertThat(vm.inFlight.value).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `in flight ends on its own when nothing changes`(@TempDir dir: Path) = runTest(dispatcher) {
|
||||
val (vm, _) = viewModel(dir)
|
||||
|
||||
vm.requestSync()
|
||||
advanceTimeBy(15_001L)
|
||||
|
||||
assertThat(vm.inFlight.value).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tap while in flight asks only once`(@TempDir dir: Path) = runTest(dispatcher) {
|
||||
val sync = RecordingSyncRequester()
|
||||
val (vm, _) = viewModel(dir, sync)
|
||||
|
||||
vm.requestSync()
|
||||
runCurrent()
|
||||
vm.requestSync()
|
||||
runCurrent()
|
||||
|
||||
assertThat(sync.requests).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused request stops at once and says so`(@TempDir dir: Path) = runTest(dispatcher) {
|
||||
val (vm, _) = viewModel(dir, RecordingSyncRequester(accepts = false))
|
||||
|
||||
vm.requestSync()
|
||||
runCurrent()
|
||||
|
||||
assertThat(vm.inFlight.value).isFalse()
|
||||
assertThat(vm.result.value).isEqualTo(SyncRequestResult.Failed)
|
||||
|
||||
vm.consumeResult()
|
||||
assertThat(vm.result.value).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshAccess picks up a grant`(@TempDir dir: Path) = runTest(dispatcher) {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf(davAccount.type))
|
||||
sync.knownAccounts = setOf(davAccount)
|
||||
val (vm, _) = viewModel(dir, sync)
|
||||
backgroundScope.launch { vm.targets.collect {} }
|
||||
runCurrent()
|
||||
|
||||
assertThat(vm.targets.value).isEqualTo(SyncTargets(emptySet(), setOf(davAccount)))
|
||||
|
||||
sync.hiddenTypes = setOf("other")
|
||||
vm.refreshAccess()
|
||||
runCurrent()
|
||||
|
||||
assertThat(vm.targets.value).isEqualTo(SyncTargets(setOf(davAccount), emptySet()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismissing a hidden account takes it out of the grant list`(@TempDir dir: Path) = runTest(dispatcher) {
|
||||
val sync = RecordingSyncRequester(hiddenTypes = setOf(davAccount.type))
|
||||
sync.knownAccounts = setOf(davAccount)
|
||||
val (vm, _) = viewModel(dir, sync)
|
||||
backgroundScope.launch { vm.targets.collect {} }
|
||||
runCurrent()
|
||||
assertThat(vm.targets.value?.needsAccess).containsExactly(davAccount)
|
||||
|
||||
vm.dismissAccess(davAccount)
|
||||
runCurrent()
|
||||
|
||||
assertThat(vm.targets.value).isEqualTo(SyncTargets(emptySet(), emptySet(), dismissed = setOf(davAccount)))
|
||||
}
|
||||
}
|
||||
+18
-11
@@ -10,6 +10,24 @@ the package list (recurring writes, save conflicts, reminder delivery).
|
||||
1. **`CalendarContract` is the single source of truth.** No app database,
|
||||
no caching layer, no sync code. Reads query the provider; writes go
|
||||
straight back to it. Sync is DAVx5's / Google's / the system's job.
|
||||
The app only *asks* for one through `ContentResolver.requestSync`: an
|
||||
expedited nudge for the account after each of its own writes to a synced
|
||||
calendar (#303, once per write, so a multi-row save or an import asks once
|
||||
per account, and never `MANUAL`, which would override master-sync-off), and
|
||||
a manual request from the drawer's "Sync now" (#240), which like the
|
||||
system's own "Sync now" also runs for accounts whose auto-sync is off. Both
|
||||
go through `SyncRequester` and only ask account types with a calendar sync
|
||||
adapter (visible to the app via a `<queries>` entry). From Android 13 a
|
||||
request naming an account the app can't see is silently dropped, and
|
||||
without `GET_ACCOUNTS` that is most of them. Such accounts are not asked at
|
||||
all (an account-less request would sync every account on the device); the
|
||||
drawer offers "Allow sync requests" instead, which opens the system account
|
||||
picker (`AccountManager.newChooseAccountIntent`) — the picked account
|
||||
becomes visible to the app. Access is re-read on every request and on
|
||||
every resume and drawer open, so a revoke simply puts the account back behind the
|
||||
grant row. Per account the user can choose "Don't ask" instead
|
||||
(`CalendarPrefs.syncAccessDismissed`); such accounts drop out of the grant
|
||||
row and the receipts, and the calendar manager lists them to ask again. Whether and when the adapter syncs stays its call.
|
||||
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
|
||||
re-queries; every screen recomposes from fresh provider state. After a
|
||||
write, nothing is patched by hand — the views refresh from the provider.
|
||||
@@ -492,17 +510,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:
|
||||
|
||||
@@ -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.
|
||||
+1
-1
Submodule floret-kit updated: da7dbc3e04...a039ab5bd4.
Reference in new issue
Block a user