diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt index e1ee984d7..99660b8ac 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ConversationReference.kt @@ -1,10 +1,15 @@ package com.flipcash.shared.chat.ui import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MediaItem /** Presentation state derived from an existing DM with a contact. */ data class ConversationReference( val chatId: ChatId, + /** Counterparty display name — used when the row has no separate contact (e.g. tip DMs). */ + val displayName: String? = null, + /** Counterparty avatar media; resolve a URL via [MediaItem.url]. */ + val image: MediaItem? = null, val lastMessagePreview: String? = null, val unreadCount: Int = 0, val isTyping: Boolean = false, diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index 808d04b3b..f49c83c0b 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -86,6 +86,7 @@ fun ContactAvatar( modifier = Modifier.matchParentSize(), model = request, contentDescription = null, + contentScale = ContentScale.FillBounds, onError = { isError = true }, ) } @@ -123,6 +124,10 @@ fun ContactAvatar( AsyncImage( modifier = Modifier.matchParentSize(), model = request, + // Crop fills the avatar while preserving aspect ratio (Fit letterboxed and let + // the gradient show through; FillBounds fills but distorts the photo). + // AsyncImage infers the Coil request scale from this, so the request is fine. + contentScale = ContentScale.Crop, contentDescription = null, onError = { isError = true }, ) diff --git a/apps/flipcash/shared/notifications/build.gradle.kts b/apps/flipcash/shared/notifications/build.gradle.kts index c371850af..3488b3360 100644 --- a/apps/flipcash/shared/notifications/build.gradle.kts +++ b/apps/flipcash/shared/notifications/build.gradle.kts @@ -20,6 +20,10 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + // Coil core (no Compose) to load remote profile-picture avatars for chat + // notifications, reusing the app's SingletonImageLoader + disk cache. + implementation(libs.coil3.core) + implementation(libs.androidx.datastore) testImplementation(kotlin("test")) 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 7c50f922f..a4e2d42a9 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 @@ -17,6 +17,11 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri +import coil3.SingletonImageLoader +import coil3.request.ImageRequest +import coil3.request.SuccessResult +import coil3.request.allowHardware +import coil3.toBitmap import com.flipcash.app.auth.AuthManager import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.ContactResolver @@ -24,7 +29,10 @@ import com.flipcash.app.core.util.Linkify import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.PushController +import com.flipcash.services.models.SocialAccount +import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MediaItemRendition import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload @@ -39,8 +47,10 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.security.SecureRandom import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @AndroidEntryPoint class NotificationService : FirebaseMessagingService(), @@ -50,6 +60,12 @@ class NotificationService : FirebaseMessagingService(), private const val KEY_TITLE = "push_notification_title" private const val KEY_BODY = "push_notification_body" private const val KEY_PAYLOAD = "flipcash_payload" + + // Upper bound on how long we'll wait for a remote avatar before posting + // without one. A memory/disk cache hit returns well under this; the + // bound only caps the cold-cache network fetch so the notification isn't + // held back indefinitely. + private const val AVATAR_FETCH_TIMEOUT_MS = 5_000L } @Inject @@ -188,7 +204,7 @@ class NotificationService : FirebaseMessagingService(), } val notificationId = if (chatId != null) { - builder.applyContactChatStyle(chatId, groupKey, body) + builder.applyChatStyle(chatId, groupKey, title, body) } else { builder.setContentTitle(title).setContentText(body) SecureRandom().nextInt(Int.MAX_VALUE) @@ -209,32 +225,49 @@ class NotificationService : FirebaseMessagingService(), } } - private suspend fun NotificationCompat.Builder.applyContactChatStyle( + private suspend fun NotificationCompat.Builder.applyChatStyle( chatId: ChatId, groupKey: String?, + title: String?, body: String?, ): Int { val notificationId = chatId.hashCode() - val lookupContact = contactCoordinator.lookupContactByDmChatId(chatId.toString()) - val e164 = lookupContact?.e164 - ?: chatCoordinator.getOtherMemberE164(chatId) + + // Prefer the device-contact identity (CONTACT_DM, or a counterparty saved + // in the address book): the user's own name + photo for them. Only when + // that's absent do we fetch the chat member and fall back to their + // server-side profile — which is the only identity a TIP_DM has. + val contactE164 = contactCoordinator.lookupContactByDmChatId(chatId.toString())?.e164 + val member = if (contactE164 == null) chatCoordinator.getOtherMember(chatId) else null + val e164 = contactE164 ?: member?.userProfile?.verifiedPhoneNumber + + val senderName = e164?.let { contactResolver.resolveName(it) } + ?: member?.userProfile?.displayName?.takeIf { it.isNotBlank() } + ?: member?.userProfile?.socialHandle() + ?: title + ?: "" + + // Device-contact photo (local, synchronous) first; otherwise the profile + // picture URL loaded through the app's shared Coil loader (cache-first, + // network-bounded). Works for CONTACT_DM and TIP_DM alike. + val avatar = e164?.let { resolveContactPhoto(it) } + ?: member?.userProfile?.profilePicture + ?.url(MediaItemRendition.Role.THUMBNAIL) + ?.let { loadRemoteAvatar(it) } trace( tag = "NotificationService", - message = "applyContactChatStyle: chatId=$chatId, groupKey=$groupKey, lookupE164=${lookupContact?.e164}, e164=$e164, authenticated=${userManager.accountCluster != null}", + message = "applyChatStyle: chatId=$chatId, groupKey=$groupKey, e164=$e164, hasMember=${member != null}, hasAvatar=${avatar != null}, authenticated=${userManager.accountCluster != null}", type = TraceType.Log, ) - val contactPhoto = e164?.let { resolveContactPhoto(it) } - val senderName = e164?.let { contactResolver.resolveName(it) } ?: "" - val selfPerson = buildSelfPerson(this@NotificationService, userManager.profile, contactResolver) val senderPerson = Person.Builder() .setName(senderName) .setKey(groupKey ?: "unknown") .apply { - if (contactPhoto != null) setIcon(IconCompat.createWithBitmap(contactPhoto.toCircularBitmap())) + if (avatar != null) setIcon(IconCompat.createWithBitmap(avatar.toCircularBitmap())) } .build() @@ -254,6 +287,33 @@ class NotificationService : FirebaseMessagingService(), return notificationId } + /** First social handle (e.g. an X username) to render as a display name, if any. */ + private fun UserProfile.socialHandle(): String? = + socialAccounts.filterIsInstance() + .firstOrNull() + ?.username + ?.let { "@$it" } + + /** + * Loads a remote avatar [url] into a software [Bitmap] via the app's shared + * Coil [SingletonImageLoader]. Serves from memory/disk cache without a + * network trip when possible; the network case is bounded by + * [AVATAR_FETCH_TIMEOUT_MS] so a slow fetch never holds back the + * notification. Returns `null` on timeout or failure (the notification then + * posts with the name monogram). + */ + private suspend fun loadRemoteAvatar(url: String): Bitmap? = + withTimeoutOrNull(AVATAR_FETCH_TIMEOUT_MS.milliseconds) { + runCatching { + val request = ImageRequest.Builder(this@NotificationService) + .data(url) + .allowHardware(false) // notification icons require a software bitmap + .build() + val result = SingletonImageLoader.get(this@NotificationService).execute(request) + (result as? SuccessResult)?.image?.toBitmap() + }.getOrNull() + } + private suspend fun resolveContactPhoto(e164: String): Bitmap? { val uriString = contactResolver.resolvePhotoUri(e164) if (uriString != null) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df7253caa..7b5f15e7f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -150,6 +150,7 @@ ksp-symbol-processing = { module = "com.google.devtools.ksp:symbol-processing-ap # Coil coil3 = { module = "io.coil-kt.coil3:coil-compose", version.ref = "compose-coil" } +coil3-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "compose-coil" } coil3-network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "compose-coil" } # Hilt diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt index 8c4c0d1ef..af9a2dd49 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MediaItem.kt @@ -16,7 +16,7 @@ data class MediaItem( * This never upgrades above [preferred] — e.g. `rendition(Role.THUMBNAIL)` will not * return the ORIGINAL. */ - fun rendition(preferred: MediaItemRendition.Role): MediaItemRendition? { + fun rendition(preferred: MediaItemRendition.Role = MediaItemRendition.Role.ORIGINAL): MediaItemRendition? { val start = QUALITY_LADDER.indexOf(preferred) if (start < 0) return null // UNKNOWN / non-ladder roles have no fallback chain for (i in start until QUALITY_LADDER.size) { @@ -27,7 +27,7 @@ data class MediaItem( } /** Download URL of the rendition resolved for [preferred], or null if none is available. */ - fun url(preferred: MediaItemRendition.Role): String? = + fun url(preferred: MediaItemRendition.Role = MediaItemRendition.Role.ORIGINAL): String? = rendition(preferred)?.blob?.downloadUrl companion object {