From 6611e861a01f95e657f05616ea9b54f00f50273c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 4 Jun 2026 08:32:17 -0400 Subject: [PATCH] fix(notifications): fall back to live device lookup for contact name and photo When a cached contact photo URI or display name is missing from the DB, resolve directly from the device contacts provider via PhoneLookup and cache the result for subsequent lookups. - Add lookupPhotoUri to DeviceContactLookup interface and implementation - ContactResolver.resolvePhotoUri falls back to device on DB cache miss - ContactResolver.resolveName prefers fresh device name over stale cache - Only write back to DB when the value actually changed - Add DAO/DataSource methods for targeted name and photo URI updates Signed-off-by: Brandon McAnsh --- .../flipcash/app/contacts/ContactResolver.kt | 24 +++++++++--- .../contacts/device/DeviceContactLookup.kt | 1 + .../internal/AndroidDeviceContactLookup.kt | 10 ++++- .../app/contacts/ContactResolverTest.kt | 37 ++++++++++++++----- .../app/persistence/dao/ContactDao.kt | 6 +++ .../persistence/sources/ContactDataSource.kt | 8 ++++ 6 files changed, 69 insertions(+), 17 deletions(-) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt index 0e6476b77..a7d039f2a 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt @@ -12,11 +12,25 @@ class ContactResolver @Inject constructor( private val deviceContactLookup: DeviceContactLookup, private val phoneUtils: PhoneUtils, ) { - suspend fun resolveName(e164: String, fallback: String = e164): String = - contactDataSource.getDisplayName(e164) - ?: deviceContactLookup.lookupDisplayName(e164) + suspend fun resolveName(e164: String, fallback: String = e164): String { + val cached = contactDataSource.getDisplayName(e164) + val device = deviceContactLookup.lookupDisplayName(e164) + + if (device != null) { + if (device != cached) contactDataSource.updateDisplayName(e164, device) + return device + } + + return cached ?: runCatching { phoneUtils.formatNumber(e164) }.getOrDefault(fallback) + } + + suspend fun resolvePhotoUri(e164: String): String? { + val cached = contactDataSource.getPhotoUri(e164) + if (cached != null) return cached - suspend fun resolvePhotoUri(e164: String): String? = - contactDataSource.getPhotoUri(e164) + val device = deviceContactLookup.lookupPhotoUri(e164) ?: return null + contactDataSource.updatePhotoUri(e164, device) + return device + } } \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt index 4cec7e89a..0de68409a 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt @@ -2,4 +2,5 @@ package com.flipcash.app.contacts.device interface DeviceContactLookup { fun lookupDisplayName(e164: String): String? + fun lookupPhotoUri(e164: String): String? } \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt index 14299c42b..ee0df5ff5 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt @@ -15,7 +15,13 @@ internal class AndroidDeviceContactLookup @Inject constructor( @param:ApplicationContext private val context: Context, ) : DeviceContactLookup { - override fun lookupDisplayName(e164: String): String? { + override fun lookupDisplayName(e164: String): String? = + lookupField(e164, ContactsContract.PhoneLookup.DISPLAY_NAME) + + override fun lookupPhotoUri(e164: String): String? = + lookupField(e164, ContactsContract.PhoneLookup.PHOTO_URI) + + private fun lookupField(e164: String, column: String): String? { if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED ) return null @@ -28,7 +34,7 @@ internal class AndroidDeviceContactLookup @Inject constructor( return try { context.contentResolver.query( uri, - arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), + arrayOf(column), null, null, null )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotEmpty() } else null diff --git a/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt index 9cc553e9a..963adc23b 100644 --- a/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt +++ b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt @@ -21,13 +21,17 @@ class ContactResolverTest { formatted: String? = null, formatThrows: Boolean = false, photoUri: String? = null, + devicePhotoUri: String? = null, ): ContactResolver { val dataSource = mockk { coEvery { getDisplayName(e164) } returns dbName coEvery { getPhotoUri(e164) } returns photoUri + coEvery { updateDisplayName(any(), any()) } returns Unit + coEvery { updatePhotoUri(any(), any()) } returns Unit } val deviceLookup = mockk { every { lookupDisplayName(e164) } returns deviceName + every { lookupPhotoUri(e164) } returns devicePhotoUri } val phoneUtils = mockk { if (formatThrows) { @@ -42,19 +46,19 @@ class ContactResolverTest { // region resolveName @Test - fun `DB display name is returned when present`() = runTest { - val result = resolver(dbName = "Alice").resolveName(e164) - assertEquals("Alice", result) + fun `device name is returned when present`() = runTest { + val result = resolver(deviceName = "Alice Device").resolveName(e164) + assertEquals("Alice Device", result) } @Test - fun `device lookup is used when DB returns null`() = runTest { - val result = resolver(deviceName = "Bob Device").resolveName(e164) - assertEquals("Bob Device", result) + fun `DB name is used when device returns null`() = runTest { + val result = resolver(dbName = "Alice DB").resolveName(e164) + assertEquals("Alice DB", result) } @Test - fun `formatted number is used when DB and device both return null`() = runTest { + fun `formatted number is used when device and DB both return null`() = runTest { val result = resolver(formatted = "+1 (555) 123-4567").resolveName(e164) assertEquals("+1 (555) 123-4567", result) } @@ -72,9 +76,9 @@ class ContactResolverTest { } @Test - fun `DB takes priority over device lookup`() = runTest { + fun `device takes priority over DB name`() = runTest { val result = resolver(dbName = "Alice DB", deviceName = "Alice Device").resolveName(e164) - assertEquals("Alice DB", result) + assertEquals("Alice Device", result) } // endregion @@ -88,10 +92,23 @@ class ContactResolverTest { } @Test - fun `null photo URI when data source has none`() = runTest { + fun `device photo URI used when data source has none`() = runTest { + val result = resolver(devicePhotoUri = "content://photo/2").resolvePhotoUri(e164) + assertEquals("content://photo/2", result) + } + + @Test + fun `null photo URI when both sources have none`() = runTest { val result = resolver().resolvePhotoUri(e164) assertNull(result) } + @Test + fun `data source photo URI takes priority over device`() = runTest { + val result = resolver(photoUri = "content://photo/1", devicePhotoUri = "content://photo/2") + .resolvePhotoUri(e164) + assertEquals("content://photo/1", result) + } + // endregion } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt index 031d68527..d88c5f7b9 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt @@ -45,9 +45,15 @@ interface ContactDao { @Query("SELECT displayName FROM contact_mapping WHERE e164 = :e164 LIMIT 1") suspend fun getDisplayName(e164: String): String? + @Query("UPDATE contact_mapping SET displayName = :displayName WHERE e164 = :e164") + suspend fun updateDisplayName(e164: String, displayName: String) + @Query("SELECT photoUri FROM contact_mapping WHERE e164 = :e164 LIMIT 1") suspend fun getPhotoUri(e164: String): String? + @Query("UPDATE contact_mapping SET photoUri = :photoUri WHERE e164 = :e164") + suspend fun updatePhotoUri(e164: String, photoUri: String) + @Query("DELETE FROM contact_mapping WHERE e164 IN (:e164s)") suspend fun deleteMappings(e164s: List) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt index 20466f4d6..0cbc727ba 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt @@ -90,8 +90,16 @@ class ContactDataSource @Inject constructor( suspend fun getDisplayName(e164: String): String? = db?.contactDao()?.getDisplayName(e164) + suspend fun updateDisplayName(e164: String, displayName: String) { + db?.contactDao()?.updateDisplayName(e164, displayName) + } + suspend fun getPhotoUri(e164: String): String? = db?.contactDao()?.getPhotoUri(e164) + suspend fun updatePhotoUri(e164: String, photoUri: String) { + db?.contactDao()?.updatePhotoUri(e164, photoUri) + } + // endregion }