From d4d68b26b8efbe3f84181a9af8f028797f863dd3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 4 Jun 2026 09:08:25 -0400 Subject: [PATCH] fix(notifications): direct photo stream fallback when PHOTO_URI unavailable PhoneLookup.PHOTO_URI can be null when the contacts provider hasn't finished indexing a newly added contact's photo. Fall back to openContactPhotoInputStream which reads the raw photo blob by contact ID and doesn't depend on PHOTO_URI being populated. Also iterate all PhoneLookup rows instead of only the first, since linked contacts can return multiple rows where only one has a photo. Signed-off-by: Brandon McAnsh --- .../flipcash/app/contacts/ContactResolver.kt | 3 +++ .../contacts/device/DeviceContactLookup.kt | 1 + .../internal/AndroidDeviceContactLookup.kt | 24 ++++++++++++++++++- .../app/contacts/ContactResolverTest.kt | 1 + .../app/notifications/NotificationService.kt | 22 ++++++++++------- 5 files changed, 42 insertions(+), 9 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 a7d039f2a..cb4c35fd6 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 @@ -33,4 +33,7 @@ class ContactResolver @Inject constructor( contactDataSource.updatePhotoUri(e164, device) return device } + + fun resolvePhotoBytes(e164: String): ByteArray? = + deviceContactLookup.lookupPhotoBytes(e164) } \ 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 0de68409a..346be554d 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 @@ -3,4 +3,5 @@ package com.flipcash.app.contacts.device interface DeviceContactLookup { fun lookupDisplayName(e164: String): String? fun lookupPhotoUri(e164: String): String? + fun lookupPhotoBytes(e164: String): ByteArray? } \ 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 ee0df5ff5..0e677fc3a 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 @@ -1,6 +1,7 @@ package com.flipcash.app.contacts.device.internal import android.Manifest +import android.content.ContentUris import android.content.Context import android.content.pm.PackageManager import android.provider.ContactsContract @@ -21,6 +22,23 @@ internal class AndroidDeviceContactLookup @Inject constructor( override fun lookupPhotoUri(e164: String): String? = lookupField(e164, ContactsContract.PhoneLookup.PHOTO_URI) + override fun lookupPhotoBytes(e164: String): ByteArray? { + val contactId = lookupContactId(e164) ?: return null + val contactUri = ContentUris.withAppendedId( + ContactsContract.Contacts.CONTENT_URI, contactId + ) + return try { + ContactsContract.Contacts.openContactPhotoInputStream( + context.contentResolver, contactUri, true + )?.use { it.readBytes() } + } catch (e: Exception) { + null + } + } + + private fun lookupContactId(e164: String): Long? = + lookupField(e164, ContactsContract.PhoneLookup._ID)?.toLongOrNull() + private fun lookupField(e164: String, column: String): String? { if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED @@ -37,7 +55,11 @@ internal class AndroidDeviceContactLookup @Inject constructor( arrayOf(column), null, null, null )?.use { cursor -> - if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotEmpty() } else null + while (cursor.moveToNext()) { + val value = cursor.getString(0)?.takeIf { it.isNotEmpty() } + if (value != null) return@use value + } + null } } catch (e: Exception) { 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 963adc23b..fac3d2435 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 @@ -32,6 +32,7 @@ class ContactResolverTest { val deviceLookup = mockk { every { lookupDisplayName(e164) } returns deviceName every { lookupPhotoUri(e164) } returns devicePhotoUri + every { lookupPhotoBytes(e164) } returns null } val phoneUtils = mockk { if (formatThrows) { diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 628941485..dc7d82689 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.ImageDecoder import android.graphics.Paint @@ -219,16 +220,21 @@ class NotificationService : FirebaseMessagingService(), } private suspend fun resolveContactPhoto(e164: String): Bitmap? { - val uriString = contactResolver.resolvePhotoUri(e164) ?: return null - return try { - val source = ImageDecoder.createSource(contentResolver, uriString.toUri()) - ImageDecoder.decodeBitmap(source) { decoder, _, _ -> - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + val uriString = contactResolver.resolvePhotoUri(e164) + if (uriString != null) { + try { + val source = ImageDecoder.createSource(contentResolver, uriString.toUri()) + return ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Failed to decode contact photo: ${e.message}", type = TraceType.Log) } - } catch (e: Exception) { - trace(tag = "NotificationService", message = "Failed to decode contact photo: ${e.message}", type = TraceType.Log) - null } + + // URI unavailable or decode failed — read directly from contacts provider + val bytes = contactResolver.resolvePhotoBytes(e164) ?: return null + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } private suspend fun resolveSubstitution(substitution: Substitution): String {