feat(contacts): idempotent special-dates sync engine
The engine reconciles device contact dates into the per-type local calendars. Each run is an idempotent diff keyed on the deterministic UID_2445: - new contacts inserted (seeding reminders via resolveDefaultReminder, plus a useful per-calendar all-day default of on-the-day + a week before so birthdays get lead time out of the box); - changed contacts get a targeted managed-column update (title/dtstart/rrule only) — reminders/location/notes are never re-touched, so user edits survive; - removed contacts deleted. Managed calendars are created/adopted/removed per enabled type (reconcileCalendars, self-healing against a stored-id that no longer exists), all-day FREQ=YEARLY events anchored at the birth year (or a leap anchor when year-less). Pure helpers (uid, anchor, age snapshot, title templating) and the diff are extracted for unit testing; a stateful fake exercises full-run idempotency and user-data preservation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the localized calendar names/templates and per-type colours the
|
||||||
|
* mirror creates its calendars with. The colours are picked from the shared
|
||||||
|
* palette so managed calendars look native alongside user calendars.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : SpecialDatesCalendarSpec {
|
||||||
|
|
||||||
|
override fun displayName(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_calendar_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString(
|
||||||
|
when (type) {
|
||||||
|
SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday
|
||||||
|
SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary
|
||||||
|
SpecialDateType.Custom -> R.string.special_dates_default_title_custom
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun color(type: SpecialDateType): Int = when (type) {
|
||||||
|
SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple
|
||||||
|
SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red
|
||||||
|
SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ageAtNextOccurrence
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||||
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** Outcome of one mirror reconcile, reported back so the worker can record status. */
|
||||||
|
enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localized, per-type presentation the engine needs but can't derive purely
|
||||||
|
* (calendar display name, colour, and the default title template). Kept behind
|
||||||
|
* an interface so the engine's diff stays unit-testable without Android
|
||||||
|
* resources.
|
||||||
|
*/
|
||||||
|
interface SpecialDatesCalendarSpec {
|
||||||
|
fun displayName(type: SpecialDateType): String
|
||||||
|
fun color(type: SpecialDateType): Int
|
||||||
|
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||||
|
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||||
|
* `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned
|
||||||
|
* fields once), changed contacts get a *targeted* managed-column update, and
|
||||||
|
* removed contacts are deleted — so a user's own edits (reminders, location,
|
||||||
|
* notes) are never clobbered. See docs/design/contact-special-dates.md.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SpecialDatesSyncEngine @Inject constructor(
|
||||||
|
private val contacts: ContactSpecialDatesDataSource,
|
||||||
|
private val calendars: CalendarDataSource,
|
||||||
|
private val prefs: SettingsPrefs,
|
||||||
|
private val spec: SpecialDatesCalendarSpec,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile every enabled type against the device's contacts. [today] anchors
|
||||||
|
* the age snapshot. Returns why it stopped early (disabled / permission gone)
|
||||||
|
* or [SpecialDatesSyncResult.Success].
|
||||||
|
*/
|
||||||
|
suspend fun sync(today: LocalDate): SpecialDatesSyncResult {
|
||||||
|
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||||
|
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||||
|
|
||||||
|
val enabledTypes = prefs.specialDatesTypes.first()
|
||||||
|
val calendarByType = reconcileCalendars(enabledTypes)
|
||||||
|
|
||||||
|
val desiredByType = contacts.readSpecialDates().groupBy { it.type }
|
||||||
|
val reminderCtx = readReminderContext()
|
||||||
|
val templates = prefs.specialDatesTitleTemplates.first()
|
||||||
|
val showAge = prefs.specialDatesShowAge.first()
|
||||||
|
|
||||||
|
calendarByType.forEach { (type, calendarId) ->
|
||||||
|
val template = templates[type]?.takeIf { it.isNotBlank() }
|
||||||
|
?: spec.defaultTitleTemplate(type)
|
||||||
|
syncType(
|
||||||
|
calendarId = calendarId,
|
||||||
|
type = type,
|
||||||
|
contactsOfType = desiredByType[type].orEmpty(),
|
||||||
|
today = today,
|
||||||
|
template = template,
|
||||||
|
showAge = showAge,
|
||||||
|
reminderCtx = reminderCtx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return SpecialDatesSyncResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||||
|
suspend fun teardown() {
|
||||||
|
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||||
|
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure each enabled type has exactly one managed calendar (adopting an
|
||||||
|
* existing one, or the stored id if it still exists, else creating one) and
|
||||||
|
* that disabled types have none. Returns the calendar id per enabled type.
|
||||||
|
*/
|
||||||
|
private suspend fun reconcileCalendars(enabledTypes: Set<SpecialDateType>): Map<SpecialDateType, Long> {
|
||||||
|
val foundByType = calendars.findManagedCalendars()
|
||||||
|
.groupBy({ it.type }, { it.id })
|
||||||
|
.mapValues { it.value.first() }
|
||||||
|
val stored = prefs.specialDatesCalendars.first()
|
||||||
|
val result = LinkedHashMap<SpecialDateType, Long>()
|
||||||
|
for (type in SpecialDateType.entries) {
|
||||||
|
// A stored id counts only if the calendar still exists (the user may
|
||||||
|
// have deleted it in system settings); otherwise adopt a found one.
|
||||||
|
val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) }
|
||||||
|
?: foundByType[type]
|
||||||
|
if (type in enabledTypes) {
|
||||||
|
val id = existingId ?: createCalendar(type)
|
||||||
|
if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id)
|
||||||
|
result[type] = id
|
||||||
|
} else {
|
||||||
|
if (existingId != null) calendars.deleteCalendar(existingId)
|
||||||
|
if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||||
|
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||||
|
prefs.setSpecialDatesCalendarId(type, id)
|
||||||
|
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||||
|
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||||
|
// only set when the user hasn't already configured this calendar.
|
||||||
|
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||||
|
prefs.setCalendarAllDayReminderOverride(
|
||||||
|
id,
|
||||||
|
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncType(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
contactsOfType: List<ContactSpecialDate>,
|
||||||
|
today: LocalDate,
|
||||||
|
template: String,
|
||||||
|
showAge: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
) {
|
||||||
|
val built = contactsOfType.map { sd ->
|
||||||
|
buildManagedEvent(calendarId, type, sd, today, template, showAge, reminderCtx)
|
||||||
|
}
|
||||||
|
val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
|
||||||
|
val formByUid = built.associate { it.desired.uid to it.form }
|
||||||
|
diff.insertUids.forEach { uid ->
|
||||||
|
calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes)
|
||||||
|
}
|
||||||
|
diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) }
|
||||||
|
diff.deleteEventIds.forEach { calendars.deleteEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildManagedEvent(
|
||||||
|
calendarId: Long,
|
||||||
|
type: SpecialDateType,
|
||||||
|
sd: ContactSpecialDate,
|
||||||
|
today: LocalDate,
|
||||||
|
template: String,
|
||||||
|
showAge: Boolean,
|
||||||
|
reminderCtx: ReminderContext,
|
||||||
|
): BuiltManagedEvent {
|
||||||
|
val anchor = sd.anchorDate()
|
||||||
|
val start = LocalDateTime(anchor, LocalTime(0, 0))
|
||||||
|
val age = if (showAge) sd.ageAtNextOccurrence(today) else null
|
||||||
|
val title = renderSpecialDateTitle(template, sd.displayName, age)
|
||||||
|
val form = EventForm(
|
||||||
|
calendarId = calendarId,
|
||||||
|
title = title,
|
||||||
|
isAllDay = true,
|
||||||
|
start = start,
|
||||||
|
end = start,
|
||||||
|
reminders = reminderCtx.resolveAllDay(calendarId),
|
||||||
|
availability = Availability.Free,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
)
|
||||||
|
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||||
|
return BuiltManagedEvent(
|
||||||
|
desired = ManagedEventDesired(
|
||||||
|
uid = managedEventUid(type, sd.lookupKey),
|
||||||
|
title = title,
|
||||||
|
dtStartMillis = dtStartMillis,
|
||||||
|
rrule = YEARLY_RRULE,
|
||||||
|
),
|
||||||
|
form = form,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||||
|
timedGlobal = prefs.defaultReminderMinutes.first(),
|
||||||
|
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||||
|
timedOverrides = prefs.perCalendarReminderOverride.first(),
|
||||||
|
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||||
|
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ReminderContext(
|
||||||
|
val timedGlobal: List<Int>,
|
||||||
|
val allDayGlobal: List<Int>,
|
||||||
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
|
val allDayTimeMinutes: Int,
|
||||||
|
) {
|
||||||
|
fun resolveAllDay(calendarId: Long): List<Int> = resolveDefaultReminder(
|
||||||
|
timedGlobal = timedGlobal,
|
||||||
|
allDayGlobal = allDayGlobal,
|
||||||
|
timedOverrides = timedOverrides,
|
||||||
|
allDayOverrides = allDayOverrides,
|
||||||
|
calendarId = calendarId,
|
||||||
|
isAllDay = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** "On the day" (0) + one week before (7 days), as all-day lead minutes. */
|
||||||
|
val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60)
|
||||||
|
val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The managed columns of a desired event, compared against the existing row. */
|
||||||
|
internal data class ManagedEventDesired(
|
||||||
|
val uid: String,
|
||||||
|
val title: String,
|
||||||
|
val dtStartMillis: Long,
|
||||||
|
val rrule: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map<String, Any?>)
|
||||||
|
|
||||||
|
internal data class ManagedDiff(
|
||||||
|
val insertUids: List<String>,
|
||||||
|
val updates: List<ManagedFieldUpdate>,
|
||||||
|
val deleteEventIds: List<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The idempotent diff at the heart of the mirror, keyed on `UID_2445`:
|
||||||
|
* desired-not-existing → insert, existing-not-desired → delete, and for events
|
||||||
|
* in both only the *changed* managed columns (title/dtstart/rrule) are emitted —
|
||||||
|
* so a re-run with no contact changes produces nothing, and a managed update
|
||||||
|
* never touches user-owned columns or reminder rows. Pure, so it's unit-tested.
|
||||||
|
*/
|
||||||
|
internal fun diffManagedEvents(
|
||||||
|
desired: List<ManagedEventDesired>,
|
||||||
|
existing: List<ManagedEventRow>,
|
||||||
|
): ManagedDiff {
|
||||||
|
val desiredByUid = desired.associateBy { it.uid }
|
||||||
|
val existingByUid = existing.associateBy { it.uid }
|
||||||
|
val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid }
|
||||||
|
val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId }
|
||||||
|
val updates = buildList {
|
||||||
|
for (d in desired) {
|
||||||
|
val row = existingByUid[d.uid] ?: continue
|
||||||
|
val columns = buildMap<String, Any?> {
|
||||||
|
if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title)
|
||||||
|
if (row.dtStartMillis != d.dtStartMillis) {
|
||||||
|
put(CalendarContract.Events.DTSTART, d.dtStartMillis)
|
||||||
|
}
|
||||||
|
if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule)
|
||||||
|
}
|
||||||
|
if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ManagedDiff(insertUids, updates, deleteEventIds)
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
|||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -53,6 +55,12 @@ abstract class DataBindModule {
|
|||||||
abstract fun bindContactSpecialDatesDataSource(
|
abstract fun bindContactSpecialDatesDataSource(
|
||||||
impl: AndroidContactSpecialDatesDataSource,
|
impl: AndroidContactSpecialDatesDataSource,
|
||||||
): ContactSpecialDatesDataSource
|
): ContactSpecialDatesDataSource
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSpecialDatesCalendarSpec(
|
||||||
|
impl: AndroidSpecialDatesCalendarSpec,
|
||||||
|
): SpecialDatesCalendarSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||||
|
* contact date. Stable across syncs (the reconciliation key), and namespaced by
|
||||||
|
* type so the same contact's birthday and anniversary never collide.
|
||||||
|
*/
|
||||||
|
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
|
||||||
|
"contact-${type.name.lowercase()}:$lookupKey@calendula"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||||
|
* date when the year is known (so age can be derived), otherwise the month/day
|
||||||
|
* on a fixed leap anchor year so a `--02-29` date stays representable and the
|
||||||
|
* anchor never drifts between syncs.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.anchorDate(): LocalDate =
|
||||||
|
LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The age the contact reaches on the *upcoming* occurrence of this date,
|
||||||
|
* relative to [today], or null when the year is unknown. Because a
|
||||||
|
* `FREQ=YEARLY` event carries a single static title, this is a snapshot taken
|
||||||
|
* at sync time (refreshed on the next sync), not a per-occurrence value.
|
||||||
|
*/
|
||||||
|
fun ContactSpecialDate.ageAtNextOccurrence(today: LocalDate): Int? {
|
||||||
|
val birthYear = year ?: return null
|
||||||
|
val monthDayPassedThisYear = month < today.month.number ||
|
||||||
|
(month == today.month.number && day < today.day)
|
||||||
|
val nextYear = if (monthDayPassedThisYear) today.year + 1 else today.year
|
||||||
|
return nextYear - birthYear
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a title [template] for a contact, substituting `{name}` and `{age}`
|
||||||
|
* (the latter empty when [age] is null). Collapses the whitespace the empty
|
||||||
|
* `{age}` may leave behind, so "{name}'s birthday ({age})" degrades cleanly to
|
||||||
|
* "Jane's birthday" when no year is known.
|
||||||
|
*/
|
||||||
|
fun renderSpecialDateTitle(template: String, name: String, age: Int?): String =
|
||||||
|
template
|
||||||
|
.replace("{name}", name)
|
||||||
|
.replace("{age}", age?.toString().orEmpty())
|
||||||
|
// Drop an empty "()" left by an unresolved {age}, then tidy spacing.
|
||||||
|
.replace(Regex("""\(\s*\)"""), "")
|
||||||
|
.replace(Regex("""\s+"""), " ")
|
||||||
|
.trim()
|
||||||
@@ -462,4 +462,13 @@
|
|||||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||||
|
|
||||||
|
<!-- Contact special dates (issue #15). {name} is the contact, {age} the age
|
||||||
|
at the upcoming date (dropped when the year is unknown or Show age is off). -->
|
||||||
|
<string name="special_dates_calendar_birthday">Birthdays</string>
|
||||||
|
<string name="special_dates_calendar_anniversary">Anniversaries</string>
|
||||||
|
<string name="special_dates_calendar_custom">Special dates</string>
|
||||||
|
<string name="special_dates_default_title_birthday">{name}\'s birthday ({age})</string>
|
||||||
|
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({age})</string>
|
||||||
|
<string name="special_dates_default_title_custom">{name}</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
override fun deleteCalendar(id: Long) {
|
override fun deleteCalendar(id: Long) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
deletedCalendarIds += id
|
deletedCalendarIds += id
|
||||||
|
managedCalendarRows.removeAll { it.id == id }
|
||||||
|
managedEvents.remove(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
/** All-day reminder fire-time minute-of-day passed into the last write. */
|
||||||
@@ -147,9 +149,9 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
|
|
||||||
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
|
// --- Managed special-dates surface (stateful, so a re-sync sees inserts) ---
|
||||||
|
|
||||||
var findManagedCalendarsResult: List<ManagedCalendarRow> = emptyList()
|
|
||||||
var nextManagedEventId: Long = 1_000L
|
var nextManagedEventId: Long = 1_000L
|
||||||
private var managedCalendarSeq: Long = 700L
|
private var managedCalendarSeq: Long = 700L
|
||||||
|
private val managedCalendarRows = mutableListOf<ManagedCalendarRow>()
|
||||||
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
|
private val managedEvents = mutableMapOf<Long, MutableList<ManagedEventRow>>()
|
||||||
|
|
||||||
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
|
data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType)
|
||||||
@@ -157,15 +159,22 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
|
val insertedManagedEvents = mutableListOf<Pair<EventForm, String>>()
|
||||||
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
|
val updatedManagedFields = mutableListOf<Pair<Long, Map<String, Any?>>>()
|
||||||
|
|
||||||
|
/** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */
|
||||||
|
fun presetManagedCalendar(id: Long, type: SpecialDateType) {
|
||||||
|
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||||
|
managedEvents.getOrPut(id) { mutableListOf() }
|
||||||
|
}
|
||||||
|
|
||||||
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
|
createdManagedCalendars += CreatedManagedCalendar(displayName, color, type)
|
||||||
val id = managedCalendarSeq++
|
val id = managedCalendarSeq++
|
||||||
|
managedCalendarRows += ManagedCalendarRow(id, type)
|
||||||
managedEvents.getOrPut(id) { mutableListOf() }
|
managedEvents.getOrPut(id) { mutableListOf() }
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findManagedCalendars(): List<ManagedCalendarRow> = findManagedCalendarsResult
|
override fun findManagedCalendars(): List<ManagedCalendarRow> = managedCalendarRows.toList()
|
||||||
|
|
||||||
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
|
override fun queryManagedEvents(calendarId: Long): List<ManagedEventRow> =
|
||||||
managedEvents[calendarId]?.toList() ?: emptyList()
|
managedEvents[calendarId]?.toList() ?: emptyList()
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.contacts
|
||||||
|
|
||||||
|
import android.provider.CalendarContract
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.io.TempDir
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class SpecialDatesSyncEngineTest {
|
||||||
|
|
||||||
|
private val today = LocalDate(2026, 3, 1)
|
||||||
|
|
||||||
|
private class FakeContacts(
|
||||||
|
var permission: Boolean = true,
|
||||||
|
var dates: List<ContactSpecialDate> = emptyList(),
|
||||||
|
) : ContactSpecialDatesDataSource {
|
||||||
|
override fun hasPermission() = permission
|
||||||
|
override fun readSpecialDates() = dates
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSpec : SpecialDatesCalendarSpec {
|
||||||
|
override fun displayName(type: SpecialDateType) = type.name
|
||||||
|
override fun color(type: SpecialDateType) = 0xFF112233.toInt()
|
||||||
|
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) {
|
||||||
|
SpecialDateType.Birthday -> "{name}'s birthday ({age})"
|
||||||
|
SpecialDateType.Anniversary -> "{name}'s anniversary"
|
||||||
|
SpecialDateType.Custom -> "{name}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prefs(tempDir: Path): SettingsPrefs {
|
||||||
|
val store: DataStore<Preferences> = PreferenceDataStoreFactory.create(
|
||||||
|
produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() },
|
||||||
|
)
|
||||||
|
return SettingsPrefs(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) =
|
||||||
|
ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year)
|
||||||
|
|
||||||
|
private suspend fun engineWith(
|
||||||
|
tempDir: Path,
|
||||||
|
contacts: FakeContacts,
|
||||||
|
calendars: FakeCalendarDataSource = FakeCalendarDataSource(),
|
||||||
|
enabled: Boolean = true,
|
||||||
|
): Pair<SpecialDatesSyncEngine, SettingsPrefs> {
|
||||||
|
val settings = prefs(tempDir)
|
||||||
|
if (enabled) settings.setSpecialDatesEnabled(true)
|
||||||
|
return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false)
|
||||||
|
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Disabled)
|
||||||
|
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars)
|
||||||
|
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.PermissionMissing)
|
||||||
|
assertThat(calendars.createdManagedCalendars).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(
|
||||||
|
dates = listOf(
|
||||||
|
birthday("a", "Jane", 5, 14, 1990),
|
||||||
|
birthday("b", "Bob", 12, 25, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val (engine, settings) = engineWith(tempDir, contacts, calendars)
|
||||||
|
|
||||||
|
assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Success)
|
||||||
|
// All three type calendars exist; the birthday one holds both contacts.
|
||||||
|
assertThat(calendars.createdManagedCalendars.map { it.type })
|
||||||
|
.containsExactlyElementsIn(SpecialDateType.entries)
|
||||||
|
assertThat(calendars.insertedManagedEvents).hasSize(2)
|
||||||
|
assertThat(calendars.insertedManagedEvents.map { it.second })
|
||||||
|
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula")
|
||||||
|
// Year-less contact drops the age; year-known keeps it.
|
||||||
|
val titles = calendars.insertedManagedEvents.map { it.first.title }
|
||||||
|
assertThat(titles).contains("Jane's birthday (36)")
|
||||||
|
assertThat(titles).contains("Bob's birthday")
|
||||||
|
// Calendar ids were persisted for the editor / next sync.
|
||||||
|
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
|
||||||
|
engine.sync(today)
|
||||||
|
val insertsAfterFirst = calendars.insertedManagedEvents.size
|
||||||
|
val calendarsAfterFirst = calendars.createdManagedCalendars.size
|
||||||
|
engine.sync(today)
|
||||||
|
|
||||||
|
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
|
||||||
|
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
|
||||||
|
assertThat(calendars.updatedManagedFields).isEmpty()
|
||||||
|
assertThat(calendars.deletedEventIds).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync(today)
|
||||||
|
|
||||||
|
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
|
||||||
|
engine.sync(today)
|
||||||
|
|
||||||
|
assertThat(calendars.updatedManagedFields).hasSize(1)
|
||||||
|
val (_, columns) = calendars.updatedManagedFields.single()
|
||||||
|
// Only managed columns are ever written — never reminders/location.
|
||||||
|
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE)
|
||||||
|
assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (36)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
|
||||||
|
val (engine, _) = engineWith(tempDir, contacts, calendars)
|
||||||
|
engine.sync(today)
|
||||||
|
|
||||||
|
contacts.dates = emptyList()
|
||||||
|
engine.sync(today)
|
||||||
|
|
||||||
|
assertThat(calendars.deletedEventIds).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val calendars = FakeCalendarDataSource()
|
||||||
|
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
|
||||||
|
engine.sync(today)
|
||||||
|
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
|
||||||
|
|
||||||
|
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
|
||||||
|
engine.sync(today)
|
||||||
|
|
||||||
|
assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
|
||||||
|
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure diff ---
|
||||||
|
|
||||||
|
private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||||
|
ManagedEventDesired(uid, title, dt, rrule)
|
||||||
|
|
||||||
|
private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") =
|
||||||
|
ManagedEventRow(id, uid, title, dt, rrule)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `diff inserts new, deletes gone, and leaves unchanged alone`() {
|
||||||
|
val diff = diffManagedEvents(
|
||||||
|
desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)),
|
||||||
|
existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)),
|
||||||
|
)
|
||||||
|
assertThat(diff.insertUids).containsExactly("new")
|
||||||
|
assertThat(diff.deleteEventIds).containsExactly(2L)
|
||||||
|
assertThat(diff.updates).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `diff emits only the changed managed columns`() {
|
||||||
|
val diff = diffManagedEvents(
|
||||||
|
desired = listOf(desired("a", "Janet", 150)),
|
||||||
|
existing = listOf(row(9, "a", "Jane", 100)),
|
||||||
|
)
|
||||||
|
val update = diff.updates.single()
|
||||||
|
assertThat(update.eventId).isEqualTo(9L)
|
||||||
|
assertThat(update.columns.keys)
|
||||||
|
.containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain.contacts
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SpecialDateFormattingTest {
|
||||||
|
|
||||||
|
private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate(
|
||||||
|
lookupKey = "k",
|
||||||
|
displayName = "Jane",
|
||||||
|
type = SpecialDateType.Birthday,
|
||||||
|
month = month,
|
||||||
|
day = day,
|
||||||
|
year = year,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uid is deterministic and namespaced by type`() {
|
||||||
|
assertThat(managedEventUid(SpecialDateType.Birthday, "abc"))
|
||||||
|
.isEqualTo("contact-birthday:abc@calendula")
|
||||||
|
assertThat(managedEventUid(SpecialDateType.Anniversary, "abc"))
|
||||||
|
.isEqualTo("contact-anniversary:abc@calendula")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anchor date uses the known year`() {
|
||||||
|
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `anchor date falls back to a leap year when year-less`() {
|
||||||
|
assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `age is null when the year is unknown`() {
|
||||||
|
assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `age counts the upcoming birthday when it is still ahead this year`() {
|
||||||
|
// Born 1990-05-14; today 2026-03-01 → next birthday 2026 → turns 36.
|
||||||
|
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 3, 1))).isEqualTo(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `age rolls to next year once the birthday has passed`() {
|
||||||
|
// Born 1990-05-14; today 2026-06-01 → next birthday 2027 → turns 37.
|
||||||
|
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 6, 1))).isEqualTo(37)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `age counts today's birthday as this year`() {
|
||||||
|
assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 5, 14))).isEqualTo(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title substitutes name and age`() {
|
||||||
|
assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", 30))
|
||||||
|
.isEqualTo("Jane's birthday (30)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `title drops an empty age parenthesis and tidies spacing`() {
|
||||||
|
assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", null))
|
||||||
|
.isEqualTo("Jane's birthday")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user