Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> {
if (cluster.value == null) return Result.failure(IllegalStateException("No active session"))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, DeviceContact>> {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
return Result.failure(SecurityException("READ_CONTACTS not granted"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, DeviceContact>> {
val raw = pickedContacts.value
if (raw.isEmpty()) return Result.success(emptyMap())
Expand Down
Loading