From 7fe59b36c310290f039a5e34aa0f40b65270b2cf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 2 Jul 2026 23:04:06 +0200 Subject: [PATCH] fix(contacts): keep distinct custom dates instead of collapsing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror keyed each event on (contact, type) only, and the read deduped on that same pair from an unordered query. So a contact with two custom dates ('Wedding', 'Graduation') mirrored just one — and, the winner being order-dependent, the single event could ping-pong between the two dates across syncs. A Custom date's UID now carries a discriminator (its label, else its month-day), the read dedups on the full UID, and the query is ordered by Data._ID so a genuine duplicate resolves the same way every time. Co-Authored-By: Claude Fable 5 --- .../contacts/ContactSpecialDatesDataSource.kt | 16 +++++--- .../domain/contacts/SpecialDateFormatting.kt | 38 ++++++++++++++++--- .../contacts/SpecialDateFormattingTest.kt | 31 +++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt index 13449ed..f4edca0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/ContactSpecialDatesDataSource.kt @@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import de.jeanlucmakiola.calendula.domain.contacts.managedUid import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate import javax.inject.Inject import javax.inject.Singleton @@ -50,9 +51,12 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( override fun readSpecialDates(): List { if (!hasPermission()) return emptyList() val resolver = context.contentResolver - // A contact can carry the same date more than once (multiple raw - // contacts under one aggregate); keep the first per (contact, type). - val seen = HashSet>() + // A contact can carry the same date more than once (multiple raw contacts + // under one aggregate); dedup on the mirror's reconciliation key so exact + // duplicates collapse while genuinely distinct dates (two custom events on + // one contact) are all kept. Ordered by Data._ID so which of two truly + // conflicting rows wins is stable across syncs (no event ping-pong). + val seen = HashSet() val result = ArrayList() runCatching { resolver.query( @@ -60,7 +64,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( PROJECTION, "${ContactsContract.Data.MIMETYPE} = ?", arrayOf(Event.CONTENT_ITEM_TYPE), - null, + "${ContactsContract.Data._ID} ASC", )?.use { c -> val idxDate = c.getColumnIndexOrThrow(Event.START_DATE) val idxType = c.getColumnIndexOrThrow(Event.TYPE) @@ -71,8 +75,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue val parts = parseContactEventDate(c.getString(idxDate)) ?: continue val type = specialDateTypeForRawEventType(c.getInt(idxType)) - if (!seen.add(lookup to type)) continue - result += ContactSpecialDate( + val date = ContactSpecialDate( lookupKey = lookup, displayName = c.getString(idxName)?.trim().orEmpty(), type = type, @@ -81,6 +84,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor( year = parts.year, label = c.getString(idxLabel)?.takeIf { it.isNotBlank() }, ) + if (seen.add(date.managedUid())) result += date } } }.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt index f356abb..393ccf8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt @@ -3,12 +3,40 @@ package de.jeanlucmakiola.calendula.domain.contacts import kotlinx.datetime.LocalDate /** - * 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. + * Prefix of every managed-event `UID_2445`. Distinguishes mirror events from + * user-created ones (which carry a random `@calendula` UID), so the sync + * only ever reconciles — and never deletes — events it actually owns. */ -fun managedEventUid(type: SpecialDateType, lookupKey: String): String = - "contact-${type.name.lowercase()}:$lookupKey@calendula" +const val MANAGED_UID_PREFIX = "contact-" + +/** + * The deterministic `Events.UID_2445` that ties a mirrored event to its source + * contact date. Stable across syncs (the reconciliation key), namespaced by + * type so the same contact's birthday and anniversary never collide, and — when + * a [discriminator] is given — by it too, so two Custom dates on one contact + * (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering + * each other. + */ +fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String { + val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty() + return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula" +} + +/** + * The reconciliation key for this date's mirrored event. Birthdays/anniversaries + * are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom] + * date adds a discriminator (its label, else its month-day) so distinct custom + * dates on one contact don't collapse into a single event. + */ +fun ContactSpecialDate.managedUid(): String = + managedEventUid(type, lookupKey, customDiscriminator()) + +private fun ContactSpecialDate.customDiscriminator(): String? = + if (type == SpecialDateType.Custom) { + label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day) + } else { + null + } /** * The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real 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 index 9961f7a..47bcb83 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -23,6 +23,37 @@ class SpecialDateFormattingTest { .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))