Compare commits

..
Author SHA1 Message Date
makiolaj 145ef6c6bf refactor(#240): one access refresh in the repository, loading state in the access sheet 2026-11-14 01:09:00 +01:00
makiolaj 6161efab40 fix(#240): access sheet waits for the first read, manager re-reads access, shared account row 2026-11-14 01:02:58 +01:00
makiolaj 34e8e8571c fix(#240): list only dismissals that still apply, keep the access sheet across recreation 2026-11-14 00:56:47 +01:00
makiolaj a026f24bed feat(#240): per-account Don't ask for sync access, undone from the calendar manager 2026-11-14 00:51:00 +01:00
makiolaj 14a0725b21 refactor(#240): grant-flow state in its own helper, clearer receipt when accounts need access 2026-11-13 22:18:09 +01:00
makiolaj e88ad7f533 fix(#240): guard double picker launch, flag skipped accounts in the receipt, translatable grant summary 2026-11-13 22:10:04 +01:00
makiolaj 67052352ac fix(#240): access re-read on resume, needs-access receipt, drawer stays open for the next grant 2026-11-13 22:04:44 +01:00
makiolaj 88de8f42b0 feat(#240, #303): grant account access for sync requests instead of an account-less fallback 2026-11-13 21:57:55 +01:00
makiolaj 8c31e0f6ee fix(#303): nudge after failed writes again, per-type account visibility 2026-11-13 19:04:07 +01:00
makiolaj 1688b206a5 fix(#303, #240): one account-less request when any account is hidden, nudge only after a successful write 2026-11-13 18:58:19 +01:00
makiolaj 55934664e2 fix(#303, #240): fall back to an account-less request for accounts the app can't see 2026-11-13 18:52:42 +01:00
makiolaj d1c601a6e2 fix(#303, #240): see sync adapters behind package visibility, review fixes 2026-11-13 18:44:49 +01:00
makiolaj 1dcbffd6f2 fix(#303, #240): only ask accounts with a calendar sync adapter, review fixes 2026-11-13 18:35:03 +01:00
makiolaj fb5cb226ee feat(#303, #240): nudge sync adapters after writes, add Sync now to the drawer 2026-11-13 18:27:51 +01:00
35 changed files with 1302 additions and 1813 deletions

No files matched your search

-1
View File
@@ -177,7 +177,6 @@ dependencies {
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:core-di")
implementation("de.jeanlucmakiola.floret:core-prefs")
implementation("de.jeanlucmakiola.floret:core-backup")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-android")
implementation("de.jeanlucmakiola.floret:identity")
@@ -37,6 +37,7 @@ class CalendarRepositorySmokeTest {
CalendarPrefs(store),
SettingsPrefs(store),
Dispatchers.IO,
AndroidSyncRequester(context),
)
}
+4
View File
@@ -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) :
@@ -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
}
@@ -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
}
+15 -35
View File
@@ -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
View File
@@ -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:
-135
View File
@@ -1,135 +0,0 @@
# Settings backup format
Backup & restore → Settings exports Calendula's settings to a JSON file and
imports one back (#481). Events are not part of it; they have the `.ics`
backup. This file is the schema's source of truth. The envelope and the
`settings` section come from floret-kit's `core-backup`
(`BackupFormat`, `PrefsSection`); `data/backup/SettingsBackup*.kt` implements
the rest.
## Top level
```json
{
"format": "calendula-settings",
"version": 1,
"exportedAt": 1700000000000,
"appVersion": "2.25.0",
"settings": { … },
"calendars": [ … ],
"app": { … }
}
```
- `format` must be exactly `"calendula-settings"`, or the file is not a
settings backup.
- `version` is `1`. A higher version is refused as "from a newer Calendula";
a change that an older build could misread bumps it.
- `exportedAt` (epoch ms) and `appVersion` are informational.
- The file is limited to 8 MiB; a leading byte-order mark is ignored.
- The `settings` section is required: a file without it would reset every setting.
## Parsing rules
- **Unknown fields are ignored**, at any level, so a file from a later build
imports as far as this one understands it.
- **Known fields are strict.** A known key holding the wrong JSON type rejects
the whole file before anything is written.
- **Values are not range-checked.** They are stored as they are and go through
the same reads every setting already does (clamps, enum fallbacks), so an
out-of-range or unknown value degrades to the nearest valid one or the
default.
## Import semantics
An import **replaces** every setting the backup covers; it does not merge. A
backed-up setting the file doesn't carry is reset to its default (that is what
the exporting install had). Everything is written in one DataStore edit. After
that the import refreshes what reads settings outside DataStore: the
contact-dates mirror (turned on and synced, or off and torn down, as the
settings toggle does), the reminder scan, the automatic-backup schedule, the
launcher name and the app language. Theme and widgets follow the stored
values on their own. A summary lists what could not be imported.
## `settings`
The settings stored under their own on-disk key names, in their stored form,
e.g. `"theme_mode": "DARK"`, `"multi_day_count": 4`,
`"default_reminder_minutes": "10,60"`. Only keys that have been set are
written. `SettingsBackupSchema.plain` lists them: appearance, fonts,
views, the agenda screen, the event form, reminders, automatic backup
(switch and interval) and contact special dates (switch, types, title
templates, year).
Three are taken only where this device can honour them, otherwise the current
value stays and the summary says why:
- `brand_font` / `plain_font` set to `custom`: the font file is not in the
backup, so it is taken only if this install already has one for that role.
- `special_dates_enabled` on: needs the contacts permission here.
- `auto_backup_enabled` on: needs a backup folder chosen on this install.
## `calendars`
Per-calendar settings are stored by provider id, which means nothing on
another device. Each calendar with any of them is written with a portable
identity instead:
| Field | Type | Notes |
|---|---|---|
| `account_name` | String | Required. |
| `account_type` | String | Required. |
| `display_name` | String | Required. |
| `special_dates` | String? | `Birthday`/`Anniversary`/`Custom` for a contact-dates mirror calendar. |
| `hidden` | Boolean | Hidden in the drawer's filter. |
| `last_used` | Boolean | Preselected for new events. |
| `event_duration_minutes` | Int? | Per-calendar default length, clamped to 1–1440 like the setting. |
| `reminders` | Int[]? | Timed reminder override; `[]` is "no reminder". |
| `all_day_reminders` | Int[]? | All-day reminder override. |
On import an entry matches the one calendar here with the same account name,
account type and display name. No match, or more than one, skips it and the
summary names it, as does a calendar the file itself lists twice under one
identity. Reminder lists are stored the way the setters store them (no
negative values, each once, ascending). A mirror calendar matches by `special_dates` instead, since
its name is localised, and only when the imported settings keep the mirror on
and the type (otherwise the import tears that calendar down). When the mirror has not created it yet,
it is matched after the import's sync. Every mirror's all-day reminders are
applied through the mirror so its existing events follow; one the file doesn't
override inherits the default again. An id in the prefs whose calendar no
longer exists is left out of the export.
If this device's calendars can't be read, the per-calendar settings here are
left untouched and the summary says so; an export in that state fails rather
than writing a file without them.
## `app`
| Field | Type | Notes |
|---|---|---|
| `language` | String? | The app language tag; `null` follows the system. Absent: left as is. A language this build doesn't ship is skipped. |
| `launcher_name` | String? | `CALENDULA` or `CALENDAR`. Unknown: left as is. |
## Not exported
`SettingsBackupSchema.excluded`, each for a reason: this install's own state,
bookkeeping, or meaningless on another device.
- Font stamps (`*_font_stamp`): cache keys for the local font file.
- `agenda_widget_range`, `widget_size`: legacy defaults for unconfigured
widgets. Widget configuration is not part of the backup at all.
- `recent_time_zones`: usage history.
- Onboarding progress and `visibility_notice_pending`.
- `auto_backup_folder_uri`: a SAF grant does not survive a reinstall or
another device.
- Run status: `auto_backup_last_*`, `auto_backup_failures`,
`special_dates_last_run`, `special_dates_stalled_reason`,
`special_dates_last_foreground_sync`.
- `special_dates_calendar_*`: the mirror re-adopts its calendars by their
provider marker.
- `disabled_calendar_ids`: switch-offs not yet written to this device's
provider. Whether a calendar is switched on lives in the provider.
- `reminder_last_scan_millis`: the reminder delivery watermark.
`SettingsBackupSchemaTest` fails for any stored key that is in none of the
three lists, so a new setting has to be placed before it ships.