fix(contacts): keep distinct custom dates instead of collapsing them

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 23:04:06 +02:00
parent 8881559e67
commit 7fe59b36c3
3 changed files with 74 additions and 11 deletions

View File

@@ -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<ContactSpecialDate> {
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<Pair<String, SpecialDateType>>()
// 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<String>()
val result = ArrayList<ContactSpecialDate>()
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) }

View File

@@ -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 `<uuid>@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