From 6b4075fca3f8f7cf428ce6516e6fad6b5dec3628 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 3 Jun 2026 16:25:17 -0400 Subject: [PATCH] docs(contacts): add KDoc documenting E.164 contact dedupe strategy Signed-off-by: Brandon McAnsh --- .../app/contacts/ContactCoordinator.kt | 11 ++++++++++ .../internal/FullAccessContactReader.kt | 20 +++++++++++++++++++ .../device/internal/PickerContactReader.kt | 5 +++++ 3 files changed, 36 insertions(+) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index 190b514d2..644c7da32 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -336,6 +336,17 @@ class ContactCoordinator @Inject constructor( trace(tag = TAG, message = "Hydrated ${mappings.size} contacts from persistence", type = TraceType.Process) } + /** + * End-to-end contact sync flow: + * + * 1. **Read** — device contacts via [ScopeAwareContactReader], already deduplicated + * by E.164 (see [FullAccessContactReader.readAll]). + * 2. **Diff** — compares device E.164s against persisted [ContactMappingEntity] rows. + * 3. **Persist** — upserts all device contacts into Room. The `e164` primary key on + * [ContactMappingEntity] provides the persistence-layer dedupe guarantee. + * 4. **Upload** — syncs the E.164 set with the server (delta or full upload). + * 5. **Discover** — fetches which contacts are also on Flipcash. + */ private suspend fun performSync(): Result { if (cluster.value == null) return Result.failure(IllegalStateException("No active session")) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt index 4dc6ac08f..e7c810eab 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt @@ -16,6 +16,26 @@ class FullAccessContactReader @Inject constructor( private val phoneUtils: PhoneUtils, ) : DeviceContactReader { + /** + * Reads all device contacts and deduplicates by E.164-normalized phone number. + * + * The returned map is keyed by E.164, so each normalized number appears exactly once. + * When multiple rows resolve to the same E.164 (e.g. "+1 555-1234" and "5551234"), + * the **first occurrence wins** for `displayName` and `androidContactId`, with one + * exception: if the existing entry has no photo and a later row does, the later row's + * full record replaces the earlier one (photo promotion). + * + * A single Android contact with multiple phone numbers produces **separate** map entries, + * one per distinct E.164. + * + * | Scenario | Result | + * |---------------------------------------|-------------------------------------------------| + * | First occurrence of an E.164 | Inserted as-is | + * | Duplicate E.164, existing has photo | Skipped (first-occurrence-wins) | + * | Duplicate E.164, existing lacks photo | Replaced (photo promotion) | + * | Raw number fails E.164 normalization | Skipped entirely | + * | Multi-number contact | Each valid E.164 becomes its own entry | + */ override suspend fun readAll(): Result> { if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { return Result.failure(SecurityException("READ_CONTACTS not granted")) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt index 1cc52f3aa..402be4a18 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt @@ -30,6 +30,11 @@ class PickerContactReader @Inject constructor( pickedContacts.value = emptyList() } + /** + * Reads picked contacts and deduplicates by E.164-normalized phone number, + * using the same first-occurrence-wins + photo-promotion strategy as + * [FullAccessContactReader.readAll]. + */ override suspend fun readAll(): Result> { val raw = pickedContacts.value if (raw.isEmpty()) return Result.success(emptyMap())