diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6a6ce..daa0184 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- See your contacts' birthdays and anniversaries in your calendar. A new, + **optional** feature (Settings → Contact special dates) mirrors your contacts' + birthdays, anniversaries and other dates into local "Birthdays", + "Anniversaries" and "Other dates" calendars that stay in sync as your contacts + change. Each is a normal local calendar, so you set its colour, visibility and + reminders the usual way — new birthdays even start with a reminder a week before + *and* on the day. The title format is yours to customise (`{name}`, `{year}`). + This is the first feature to use the contacts permission: it is requested only + when you turn the feature on, everything stays on your device (Calendula has no + internet access), and your contacts are only ever read, never changed. Thanks + to @moonj for the suggestion ([#15]). - Set more than one default reminder per calendar. A calendar's default reminders — and the global defaults under Settings → Notifications — can now hold several lead times instead of just one, so new events can start with, say, @@ -764,6 +775,7 @@ automatically, with zero telemetry and no internet permission. [#12]: https://codeberg.org/jlmakiola/calendula/issues/12 [#13]: https://codeberg.org/jlmakiola/calendula/issues/13 [#14]: https://codeberg.org/jlmakiola/calendula/issues/14 +[#15]: https://codeberg.org/jlmakiola/calendula/issues/15 [#16]: https://codeberg.org/jlmakiola/calendula/issues/16 [#17]: https://codeberg.org/jlmakiola/calendula/issues/17 [#18]: https://codeberg.org/jlmakiola/calendula/issues/18 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29f9c5e..8ad7750 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,15 @@ + + Alle Termine, schön im Blick - Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie. + Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App zu Beginn nicht — und nichts verlässt je dein Gerät. Kalender-Zugriff erlauben Kalender-Zugriff abgelehnt Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben. @@ -387,4 +387,38 @@ Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage. Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n _(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_ + + + Geburtstage + Jahrestage + Besondere Tage + {name}s Geburtstag ({year}) + {name}s Jahrestag ({year}) + {name} + Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten. + Geburtstage & Jahrestage aus Kontakten + Besondere Kontakttage + Kontakttage anzeigen + Spiegelt Geburtstage und weitere Daten deiner Kontakte in lokale Kalender. Liest Kontakte nur auf diesem Gerät — nichts wird hochgeladen, und deine Kontakte werden nie verändert. + Geburtstage + Jahrestage + Weitere Tage + Titelformat + Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt). + Erinnerungen + Jahr anzeigen + {year} in Titeln anzeigen, wenn es bekannt ist + Jetzt synchronisieren + Noch nicht synchronisiert + Zuletzt synchronisiert %1$s + Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest. + In Besondere Kontakttage festgelegt + Pausiert + Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert. + Zugriff erlauben + Kontakttage deaktivieren? + Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren. + Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren. + Deaktivieren + Speichern diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2995a32..9c9e8df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ See all your events, beautifully - Calendula needs access to your calendar to show and manage your events. That\'s the only thing it ever asks for. + Calendula needs access to your calendar to show and manage your events. That\'s all it asks for up front — and nothing ever leaves your device. Grant calendar access Calendar access denied Calendula cannot show events without calendar access. You can grant it again in the system settings. @@ -68,6 +68,8 @@ Close Save Add title + + Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit. Starts Ends Ends before it starts @@ -360,6 +362,35 @@ Quick-switch button and menu order Default fields for new events Event reminders + Contact birthdays & anniversaries + + + Contact special dates + Show contact dates + Mirror your contacts\' birthdays and other dates into local calendars. Reads contacts on this device only — nothing is uploaded, and your contacts are never changed. + Birthdays + Anniversaries + Other dates + Title format + Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown). + Reminders + Show year + Include {year} in titles when it is known + Sync now + Not synced yet + + Last synced %1$s + Set each calendar\'s colour and visibility in Calendars settings. + Set in Contact special dates + Paused + Calendula can no longer read your contacts, so these calendars aren\'t updating. + Grant access + Turn off contact dates? + This deletes the contact-date calendars and their events. Any reminders or notes you added to them will be lost. + + This deletes the “%1$s” calendar and its events. Any reminders or notes you added to them will be lost. + Turn off + Save About License MIT @@ -478,4 +509,13 @@ _(The report was too long for this link — paste it from your clipboard here.)_ https://codeberg.org/jlmakiola/calendula/issues/new https://codeberg.org/jlmakiola/calendula/issues/new/choose + + + Birthdays + Anniversaries + Special dates + {name}\'s birthday ({year}) + {name}\'s anniversary ({year}) + {name} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt index 51083a5..7ebb69b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/AllDayReminderEncodingTest.kt @@ -83,6 +83,43 @@ class AllDayReminderEncodingTest { assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1) } + @Test + fun `next yearly occurrence wraps to next year once this year's date has passed`() { + val today = LocalDate.of(2026, 8, 1) + assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14)) + assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25)) + } + + @Test + fun `next yearly occurrence returns today when the date is today`() { + val today = LocalDate.of(2026, 3, 10) + assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today) + } + + @Test + fun `next yearly occurrence skips non-leap years for a Feb-29 date`() { + assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1))) + .isEqualTo(LocalDate.of(2028, 2, 29)) + } + + @Test + fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() { + // A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then, + // UTC+1). Sampling the offset there skews a modern CEST occurrence by an + // hour; sampling at the upcoming occurrence fires at the intended time. + val anchor1972 = LocalDate.of(1972, 7, 15) + val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST + val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm) + val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm) + + val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence) + .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime() + val fireFromNext = actualFire(rawFromNext, nextOccurrence) + .let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime() + assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h + assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct + } + @Test fun `a winter-anchored offset drifts one hour on a summer occurrence`() { // Known limitation: one fixed MINUTES per series can't track DST. An diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index 900c9bc..f5a023a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -1,12 +1,15 @@ package de.jeanlucmakiola.calendula.data.calendar +import android.provider.CalendarContract import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventColorOption import de.jeanlucmakiola.calendula.domain.EventDetail import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.ics.IcsEvent import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent +import java.time.ZoneId /** * Test-only fake. Tunable via the three `var` properties; `tick()` simulates @@ -83,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun deleteCalendar(id: Long) { writeError?.let { throw it } deletedCalendarIds += id + managedCalendarRows.removeAll { it.id == id } + managedEvents.remove(id) } /** All-day reminder fire-time minute-of-day passed into the last write. */ @@ -139,6 +144,80 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun deleteEvent(eventId: Long) { writeError?.let { throw it } deletedEventIds += eventId + managedEvents.values.forEach { rows -> rows.removeAll { it.eventId == eventId } } + } + + // --- Managed special-dates surface (stateful, so a re-sync sees inserts) --- + + var nextManagedEventId: Long = 1_000L + private var managedCalendarSeq: Long = 700L + private val managedCalendarRows = mutableListOf() + private val managedEvents = mutableMapOf>() + + data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType) + val createdManagedCalendars = mutableListOf() + val insertedManagedEvents = mutableListOf>() + val updatedManagedFields = mutableListOf>>() + + /** 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 { + writeError?.let { throw it } + createdManagedCalendars += CreatedManagedCalendar(displayName, color, type) + val id = managedCalendarSeq++ + managedCalendarRows += ManagedCalendarRow(id, type) + managedEvents.getOrPut(id) { mutableListOf() } + return id + } + + override fun findManagedCalendars(): List = managedCalendarRows.toList() + + override fun queryManagedEvents(calendarId: Long): List = + managedEvents[calendarId]?.toList() ?: emptyList() + + override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long { + writeError?.let { throw it } + insertedManagedEvents += form to uid + allDayReminderTimes += allDayReminderTimeMinutes + val id = nextManagedEventId++ + val times = form.toWriteTimes(ZoneId.systemDefault()) + managedEvents.getOrPut(requireNotNull(form.calendarId)) { mutableListOf() } += + ManagedEventRow(id, uid, form.title.trim(), times.dtStartMillis, form.rrule) + return id + } + + /** (calendarId, allDayTime, minutes) recorded from applyManagedCalendarReminders. */ + val appliedManagedReminders = mutableListOf>>() + + override fun applyManagedCalendarReminders( + calendarId: Long, + allDayReminderTimeMinutes: Int, + minutes: List, + ) { + writeError?.let { throw it } + appliedManagedReminders += Triple(calendarId, allDayReminderTimeMinutes, minutes) + } + + override fun updateManagedFields(eventId: Long, values: Map) { + writeError?.let { throw it } + if (values.isEmpty()) return + updatedManagedFields += eventId to values + managedEvents.values.forEach { rows -> + val idx = rows.indexOfFirst { it.eventId == eventId } + if (idx >= 0) { + var row = rows[idx] + (values[CalendarContract.Events.TITLE] as? String)?.let { row = row.copy(title = it) } + (values[CalendarContract.Events.DTSTART] as? Long)?.let { row = row.copy(dtStartMillis = it) } + if (values.containsKey(CalendarContract.Events.RRULE)) { + row = row.copy(rrule = values[CalendarContract.Events.RRULE] as? String) + } + rows[idx] = row + } + } } override fun deleteOccurrence(eventId: Long, beginMillis: Long) { diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt new file mode 100644 index 0000000..dbfcff8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -0,0 +1,226 @@ +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.CalendarReminderOverride +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 org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class SpecialDatesSyncEngineTest { + + private class FakeContacts( + var permission: Boolean = true, + var dates: List = 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 ({year})" + SpecialDateType.Anniversary -> "{name}'s anniversary" + SpecialDateType.Custom -> "{name}" + } + } + + private fun prefs(tempDir: Path): SettingsPrefs { + val store: DataStore = 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 { + 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()).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()).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()).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 year; year-known keeps it. + val titles = calendars.insertedManagedEvents.map { it.first.title } + assertThat(titles).contains("Jane's birthday (1990)") + 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() + val insertsAfterFirst = calendars.insertedManagedEvents.size + val calendarsAfterFirst = calendars.createdManagedCalendars.size + engine.sync() + + 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() + + contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990)) + engine.sync() + + 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 (1990)") + } + + @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() + + contacts.dates = emptyList() + engine.sync() + + 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() + val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary) + + settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) + engine.sync() + + assertThat(calendars.deletedCalendarIds).contains(anniversaryId) + assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) + } + + @Test + fun `applyReminders persists the override and applies it to all events`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) + val (engine, settings) = engineWith(tempDir, contacts, calendars) + engine.sync() + val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) + + engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440))) + + // Persisted as the per-calendar all-day override (so new events match)... + assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]) + .isEqualTo(listOf(0, 1440)) + // ...and applied to the existing events in that calendar. + assertThat(calendars.appliedManagedReminders).contains( + Triple(birthdayCal, settings.allDayReminderTimeMinutes.first(), listOf(0, 1440)), + ) + } + + @Test + fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars) + engine.sync() + val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) + + engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None) + + assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty() + assertThat(calendars.appliedManagedReminders.last().third).isEmpty() + } + + // --- 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index a8cd3fe..8fb656c 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.FontRole +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -465,4 +466,59 @@ class SettingsPrefsTest { assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY) assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY) } + + @Test + fun `special dates feature is off with all types on by default`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.specialDatesEnabled.first()).isFalse() + assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet()) + assertThat(prefs.specialDatesCalendars.first()).isEmpty() + assertThat(prefs.managedCalendarIds.first()).isEmpty() + assertThat(prefs.specialDatesShowYear.first()).isTrue() + } + + @Test + fun `special date type toggles round-trip`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) + assertThat(prefs.specialDatesTypes.first()) + .isEqualTo(setOf(SpecialDateType.Birthday, SpecialDateType.Custom)) + } + + @Test + fun `managed calendar ids round-trip and drive managedCalendarIds`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, 7L) + prefs.setSpecialDatesCalendarId(SpecialDateType.Anniversary, 9L) + assertThat(prefs.specialDatesCalendars.first()) + .isEqualTo(mapOf(SpecialDateType.Birthday to 7L, SpecialDateType.Anniversary to 9L)) + assertThat(prefs.managedCalendarIds.first()).isEqualTo(setOf(7L, 9L)) + + prefs.setSpecialDatesCalendarId(SpecialDateType.Birthday, null) + assertThat(prefs.specialDatesCalendars.first()) + .isEqualTo(mapOf(SpecialDateType.Anniversary to 9L)) + } + + @Test + fun `title template round-trips per type`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]).isEmpty() + prefs.setSpecialDatesTitleTemplate(SpecialDateType.Birthday, "{name}'s birthday") + assertThat(prefs.specialDatesTitleTemplates.first()[SpecialDateType.Birthday]) + .isEqualTo("{name}'s birthday") + } + + @Test + fun `sync status records run and stalled reason`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.specialDatesStatus.first()) + .isEqualTo(SpecialDatesStatus(lastRun = 0L, stalled = null)) + + prefs.recordSpecialDatesRun(1000L, SpecialDatesStalledReason.PermissionRevoked) + assertThat(prefs.specialDatesStatus.first()) + .isEqualTo(SpecialDatesStatus(1000L, SpecialDatesStalledReason.PermissionRevoked)) + + prefs.recordSpecialDatesRun(2000L, null) + assertThat(prefs.specialDatesStatus.first()).isEqualTo(SpecialDatesStatus(2000L, null)) + } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt new file mode 100644 index 0000000..1914e0b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/ContactSpecialDateTest.kt @@ -0,0 +1,64 @@ +package de.jeanlucmakiola.calendula.domain.contacts + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class ContactSpecialDateTest { + + @Test + fun `parses a full yyyy-MM-dd date with year`() { + val parts = parseContactEventDate("1990-05-14") + assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14)) + } + + @Test + fun `parses a year-less --MM-dd date`() { + val parts = parseContactEventDate("--05-14") + assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 5, day = 14)) + } + + @Test + fun `parses a compact yyyyMMdd date`() { + val parts = parseContactEventDate("19900514") + assertThat(parts).isEqualTo(ContactDateParts(year = 1990, month = 5, day = 14)) + } + + @Test + fun `parses a year-less MM-dd date without the double-dash prefix`() { + val parts = parseContactEventDate("12-25") + assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 12, day = 25)) + } + + @Test + fun `keeps a year-less Feb 29 by validating against a leap anchor`() { + val parts = parseContactEventDate("--02-29") + assertThat(parts).isEqualTo(ContactDateParts(year = null, month = 2, day = 29)) + } + + @Test + fun `keeps Feb 29 in a real leap year`() { + assertThat(parseContactEventDate("2000-02-29")) + .isEqualTo(ContactDateParts(year = 2000, month = 2, day = 29)) + } + + @Test + fun `rejects Feb 29 in a non-leap year`() { + assertThat(parseContactEventDate("1999-02-29")).isNull() + } + + @Test + fun `rejects an impossible month or day`() { + assertThat(parseContactEventDate("2020-13-01")).isNull() + assertThat(parseContactEventDate("2020-06-31")).isNull() + assertThat(parseContactEventDate("--00-10")).isNull() + } + + @Test + fun `rejects blank, null and garbage input`() { + assertThat(parseContactEventDate(null)).isNull() + assertThat(parseContactEventDate("")).isNull() + assertThat(parseContactEventDate(" ")).isNull() + assertThat(parseContactEventDate("not-a-date")).isNull() + assertThat(parseContactEventDate("2020")).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt new file mode 100644 index 0000000..47bcb83 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -0,0 +1,78 @@ +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") + } + + private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate( + lookupKey = "k", + displayName = "Jane", + type = SpecialDateType.Custom, + month = month, + day = day, + year = null, + label = label, + ) + + @Test + fun `birthday and anniversary uids ignore the discriminator`() { + assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula") + } + + @Test + fun `two custom dates on one contact get distinct uids by label`() { + assertThat(custom("Wedding", 6, 10).managedUid()) + .isEqualTo("contact-custom:k:wedding@calendula") + assertThat(custom("Graduation", 9, 1).managedUid()) + .isNotEqualTo(custom("Wedding", 6, 10).managedUid()) + } + + @Test + fun `label-less custom dates fall back to month-day so distinct dates survive`() { + assertThat(custom(null, 6, 10).managedUid()) + .isEqualTo("contact-custom:k:06-10@calendula") + assertThat(custom(null, 9, 1).managedUid()) + .isNotEqualTo(custom(null, 6, 10).managedUid()) + } + + @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 `title substitutes name and year`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990)) + .isEqualTo("Jane's birthday (1990)") + } + + @Test + fun `title drops an empty year parenthesis and tidies spacing`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null)) + .isEqualTo("Jane's birthday") + } +} diff --git a/docs/design/contact-special-dates.md b/docs/design/contact-special-dates.md new file mode 100644 index 0000000..e92965d --- /dev/null +++ b/docs/design/contact-special-dates.md @@ -0,0 +1,248 @@ +# Design: Contact special-dates calendars (+ per-calendar multiple reminders) + +Status: **implemented** on `feat/contact-special-dates` (#14 prerequisite merged in) +Date: 2026-06-30 (implemented 2026-07-01) +Tracking: Codeberg #15 (feature), Codeberg #14 (prerequisite) + +Implementation notes / deviations from this design: + +- **Event identity key is a deterministic `Events.UID_2445`** + (`contact-:@calendula`), not `SYNC_DATA1`. On a LOCAL calendar + the provider drops `_sync` columns unless written through a sync-adapter URI; + `UID_2445` is written/read through the normal event URIs the app already uses + (and doubles as a stable iCal UID). The managed **calendar** still carries a + `CAL_SYNC2` marker for re-adoption (calendar-row writes already use the + sync-adapter URI). +- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence + age is impossible without per-year exception rows (rejected as too heavy). The + template instead offers `{year}` — the birth year (or an anniversary's start + year) — which is static and correct on every occurrence. A "Show year" toggle + gates it; it's in the default templates ("{name}'s birthday ({year})"). +- New managed calendars seed a per-calendar all-day reminder default of + on-the-day + one week before, so birthdays get lead time out of the box. +- Title template is user-editable per type from day one (`{name}`, `{year}`). + +This document captures the full design for surfacing contact birthdays — +and, by extension, anniversaries and custom dates — as auto-updating local +calendars in Calendula. It also specifies #14 (per-calendar *multiple* +default reminders), which is a hard prerequisite and ships first. + +The two pieces are deliberately split across **two branches / two +shipments**: + +- `feat/per-calendar-multi-reminders` — #14, ships first. +- `feat/contact-special-dates` — #15, builds on #14. + +They *may* release together depending on timing, but #14 is independently +useful and is the foundation the special-dates calendars lean on. + +--- + +## Why this is worth doing carefully + +Calendula's identity is privacy: **no INTERNET permission**, and historically +**no `READ_CONTACTS`** — guest-picking uses a one-shot `ACTION_PICK` so the app +never holds the contacts permission (`EventEditScreen.kt:446-460`). + +This feature **declares `READ_CONTACTS`** for the first time. That is a +deliberate, owner-approved shift, mitigated by: + +- The permission is **opt-in and feature-gated** — requested only when the user + enables the feature, never at startup. +- Everything stays **offline**. Contacts are read locally and mirrored into a + local calendar; nothing leaves the device. +- The mirror is **one-way** (contacts → calendar). Calendula never writes to + Contacts. + +Store listing / About copy will need to explain the optional permission. + +--- + +## Prerequisite — #14: per-calendar *multiple* default reminders + +### Today + +- Events already support **multiple reminders** — `EventForm.reminders: + List` (`EventForm.kt:24`), each written as a separate + `CalendarContract.Reminders` row (`CalendarDataSource.kt:620-630`), each + firing independently. The runtime/notification path is done. +- The **defaults** layer is single-valued, though: + - Global: `defaultReminderMinutes` / `defaultAllDayReminderMinutes` — single + `Int` (`SettingsPrefs.kt:287,302`). + - Per-calendar override: `perCalendarReminderOverride: Map` and + the all-day variant (`SettingsPrefs.kt:350-381`), resolved by + `resolveDefaultReminder()` (`SettingsPrefs.kt:509-524`). + - UI: expandable per-calendar override section + (`SettingsScreen.kt:879-953`); `ReminderDefaultPicker` (`:956+`). + +### The change + +Widen the **defaults**, not the runtime, from one reminder to a list: + +1. Resolution model `Int` → `List` in global defaults + per-calendar + override + `resolveDefaultReminder()`. The DataStore string format already + parses `id=value;id=value` (`SettingsPrefs.kt:576-577`); `value` becomes a + comma-separated list. Keep the `Inherit / None / Minutes` override states + (`CalendarReminderOverride`, `SettingsPrefs.kt:493-500`) — `Minutes` carries + a list. +2. `ReminderDefaultPicker` → multi-select. Mirror the event-form multi-reminder + UI rather than inventing new interaction. +3. New events seed `EventForm.reminders` from the **resolved list** instead of + a single value. + +No new permissions, no provider changes, no scheduling changes. Type widening +plus one picker upgrade. + +### Why it gates #15 + +Each special-dates calendar is a normal local calendar, so it inherits the +**per-calendar reminder default** for free. A birthdays calendar is useless +without lead time (the reporter literally asked for "a week before *and* on the +day") — that requires *multiple* defaults, i.e. #14. + +--- + +## #15: Contact special-dates calendars + +### Core model: one-way mirror, one local calendar per type + +Contacts → **separate local calendars per type**: + +- "Birthdays" +- "Anniversaries" +- "Custom dates" (everything else / `TYPE_OTHER` / `TYPE_CUSTOM`) + +Separate-per-type is the key architectural decision: each is a normal local +calendar (`createLocalCalendar()`, `CalendarDataSource.kt:238-260`), so the +**existing per-calendar infra applies for free**: + +- **Color** → existing per-calendar color. +- **Show/hide** → existing calendar visibility. +- **Default reminders** → per-calendar override from #14. + +This shrinks the new surface dramatically: anniversaries and custom dates are +near copy-paste of birthdays (same engine, different `ContactsContract` data +kind, one extra local calendar each), and the settings sub-page does **not** +need to reinvent color/visibility/reminders. + +### Events: field-level managed, not read-only + +Calendula manages a few fields; the user owns the rest. + +- **Managed** (overwritten on every sync, disabled in the editor for these + events): title, start date, recurrence (`FREQ=YEARLY`), existence. +- **User-owned** (seeded once on insert, never touched again by sync): + location, reminders, notes, busy/free. + +The rule that makes this safe: sync performs a **targeted column update** +(managed columns only), **never delete-and-reinsert**. Reminders live in +separate rows, so once seeded they are simply never re-touched. A user can move +a birthday reminder to "2 weeks before" or add a location and neither is +clobbered. + +In the editor, when an event belongs to a managed calendar: disable the +title/date/recurrence fields; allow the rest. + +### Reconciliation identity + +Stamp each event with the contact's `LOOKUP_KEY` in `Events.SYNC_DATA1` (ours, +since it's a local calendar). Sync becomes an idempotent diff keyed on it: + +- contact added → insert +- birthday/date changed → targeted update of managed columns +- contact or date removed → delete the event + +Getting this wrong = duplicate birthdays on every sync, so the identity key is +non-negotiable. + +### Data source + +`ContactsContract.Data` rows of `Event.CONTENT_ITEM_TYPE`, split by +`Event.TYPE`: + +- `TYPE_BIRTHDAY` → Birthdays calendar +- `TYPE_ANNIVERSARY` → Anniversaries calendar +- `TYPE_OTHER` / `TYPE_CUSTOM` (+ label) → Custom dates calendar + +### Dates: year-less + age + +Many contacts store `--MM-DD` (no year). Handle both: + +- **Year present:** anchor `DTSTART` at that year; `FREQ=YEARLY`. We can show + age ("Jane turns 30") when the show-age option is on. +- **Year absent:** `FREQ=YEARLY` from an arbitrary anchor year; no age shown. + +Recurrence support is already there: `FREQ=YEARLY` + `UNTIL`/`COUNT`/`INTERVAL` +(`Recurrence.kt:32-174`). + +### Auto-update cadence + +Birthdays change rarely, so nothing live/expensive: + +- Sync **on enable**, **on app foreground**, and a **daily WorkManager** job + (catches new/edited/deleted contacts even if the app isn't opened). +- **No `ContentObserver`** — needs the process alive and buys almost nothing + for once-a-year events. + +### Permission + lifecycle + +- **Enable:** request `READ_CONTACTS` (feature-gated, contextual — mirror the + contextual `WRITE_CALENDAR` pattern at `EventEditScreen.kt:216-231`). On + grant, create the enabled calendars and run the first sync. +- **Disable:** delete the managed calendars + their events (with a confirm). +- **Permission revoked in system settings:** detect on next sync; surface that + the feature is stalled rather than failing silently. + +### Configurable settings sub-page + +Enabling the feature reveals a **dedicated sub-page** (not inline rows). Because +color/visibility/reminders already live in normal calendar settings, the +sub-page only carries the genuinely new knobs: + +- Master enable toggle. +- Per-type toggles: Birthdays / Anniversaries / Custom dates. +- Title/display template, e.g. `"{name}'s birthday"` (new translatable string). +- Show-age toggle (only meaningful when the year is known). +- (Possibly) sync-cadence / "sync now". +- Pointer to the normal calendar settings for color, visibility, reminders. + +The owner wants this broadly configurable — keep the sub-page the home for +display/behaviour options, and lean on existing per-calendar settings for the +rest. + +--- + +## Open questions / deferred + +- Title template: free-form vs a small set of presets. Start with one good + localized default; revisit configurability. +- Whether "Custom dates" is one bucket or split further by label. Start: one + bucket. +- Contact scope: all contacts vs per-account/group filtering. Start: all; + filtering is a later refinement. + +## Task outline + +### #14 — per-calendar multiple default reminders (`feat/per-calendar-multi-reminders`) +- [x] Widen global + per-calendar reminder defaults `Int` → `List` + (`SettingsPrefs.kt`), keep `Inherit/None/Minutes(list)`. +- [x] `resolveDefaultReminder()` returns a list. +- [x] `ReminderDefaultPicker` → multi-select (mirror event-form UI). +- [x] Seed new events' `EventForm.reminders` from the resolved list. +- [x] Migration / parse compatibility for existing single-value stored prefs. +- [x] Tests: resolution, parse round-trip, all-day variant. + +### #15 — contact special-dates (`feat/contact-special-dates`) +- [x] Declare `READ_CONTACTS`; feature-gated contextual request on enable. +- [x] Per-type local calendars (create on enable, delete on disable+confirm). +- [x] Contacts reader: `Event` rows by `TYPE`, year-less handling. +- [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445` + (see notes; not `SYNC_DATA1`); targeted managed-column updates; seed + user-owned fields on insert only. +- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct). +- [x] Auto-update: on enable, on foreground, daily WorkManager. +- [x] Editor: disable managed fields for managed-calendar events. +- [x] Revoked-permission detection + stalled-state surface. +- [x] Settings sub-page: enable, per-type toggles, title template, show-age. +- [x] Translatable strings (titles, sub-page). +- [x] About / store copy for the optional `READ_CONTACTS` permission. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 5055210..96ec0e3 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -10,6 +10,12 @@ Serie) und einem einfachen Wiederholungs-Picker. Erinnerungen stellt Calendula selbst als Benachrichtigung zu — ein Tipp darauf öffnet den Termin. +Optional lassen sich Geburtstage, Jahrestage und weitere besondere Tage +deiner Kontakte im Kalender anzeigen. Du schaltest das selbst ein; Kontakte +werden nur auf deinem Gerät gelesen, nie hochgeladen und nie verändert — +Calendula spiegelt die Daten lediglich in lokale Kalender, die du voll +kontrollierst. + Der Unterschied liegt im Design: echtes Material 3 Expressive durchgehend, mit Dynamic Color, expressiven Animationen und neuen Shape-Sprachen. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index f59f242..2ebf536 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -8,6 +8,11 @@ changes (only this event / this and all following / the whole series) and a simple repeat picker. Calendula also delivers your event reminders as notifications — tap one and you're on the event. +Optionally, show your contacts' birthdays, anniversaries and other special +dates in your calendar. You turn this on yourself; contacts are read only on +your device, are never uploaded, and are never changed — Calendula only +mirrors the dates into local calendars you fully control. + The differentiator is the design: real Material 3 Expressive throughout, with dynamic color, expressive motion, and expressive shapes.