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
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ fun ContactAvatar(
modifier = Modifier.matchParentSize(),
model = request,
contentDescription = null,
contentScale = ContentScale.FillBounds,
onError = { isError = true },
)
}
Expand Down Expand Up @@ -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 },
)
Expand Down
4 changes: 4 additions & 0 deletions apps/flipcash/shared/notifications/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,22 @@ 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
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
Expand All @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand All @@ -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<SocialAccount.TwitterX>()
.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) {
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Loading