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:
@@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
|
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -50,9 +51,12 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
|||||||
override fun readSpecialDates(): List<ContactSpecialDate> {
|
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||||
if (!hasPermission()) return emptyList()
|
if (!hasPermission()) return emptyList()
|
||||||
val resolver = context.contentResolver
|
val resolver = context.contentResolver
|
||||||
// A contact can carry the same date more than once (multiple raw
|
// A contact can carry the same date more than once (multiple raw contacts
|
||||||
// contacts under one aggregate); keep the first per (contact, type).
|
// under one aggregate); dedup on the mirror's reconciliation key so exact
|
||||||
val seen = HashSet<Pair<String, SpecialDateType>>()
|
// 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>()
|
val result = ArrayList<ContactSpecialDate>()
|
||||||
runCatching {
|
runCatching {
|
||||||
resolver.query(
|
resolver.query(
|
||||||
@@ -60,7 +64,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
|||||||
PROJECTION,
|
PROJECTION,
|
||||||
"${ContactsContract.Data.MIMETYPE} = ?",
|
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||||
arrayOf(Event.CONTENT_ITEM_TYPE),
|
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||||
null,
|
"${ContactsContract.Data._ID} ASC",
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||||
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||||
@@ -71,8 +75,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
|||||||
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||||
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||||
if (!seen.add(lookup to type)) continue
|
val date = ContactSpecialDate(
|
||||||
result += ContactSpecialDate(
|
|
||||||
lookupKey = lookup,
|
lookupKey = lookup,
|
||||||
displayName = c.getString(idxName)?.trim().orEmpty(),
|
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||||
type = type,
|
type = type,
|
||||||
@@ -81,6 +84,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
|||||||
year = parts.year,
|
year = parts.year,
|
||||||
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
if (seen.add(date.managedUid())) result += date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||||
|
|||||||
@@ -3,12 +3,40 @@ package de.jeanlucmakiola.calendula.domain.contacts
|
|||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
|
||||||
* contact date. Stable across syncs (the reconciliation key), and namespaced by
|
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
|
||||||
* type so the same contact's birthday and anniversary never collide.
|
* only ever reconciles — and never deletes — events it actually owns.
|
||||||
*/
|
*/
|
||||||
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
|
const val MANAGED_UID_PREFIX = "contact-"
|
||||||
"contact-${type.name.lowercase()}:$lookupKey@calendula"
|
|
||||||
|
/**
|
||||||
|
* 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
|
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||||
|
|||||||
@@ -23,6 +23,37 @@ class SpecialDateFormattingTest {
|
|||||||
.isEqualTo("contact-anniversary:abc@calendula")
|
.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
|
@Test
|
||||||
fun `anchor date uses the known year`() {
|
fun `anchor date uses the known year`() {
|
||||||
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
|
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
|
||||||
|
|||||||
Reference in New Issue
Block a user