From 09198c61d708dcbc95dd160b7fe24db5795a4de2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:11:48 -0400 Subject: [PATCH] fix(notifications): show amount and title in payment request pushes A push for a payment-request widget carries only the request id, so the notification kept the server's generic body, and in a private group a stand-in because the request text is sealed. Fetch the request (bounded to 5s like the avatar load), take the opened title and the viewer's share (or the whole split), and post "Requested $X for {title}". A failed or timed-out fetch, or a request from another chat, keeps today's body. --- .../app/notifications/NotificationService.kt | 44 ++++++++- .../PaymentRequestBodyPlanner.kt | 62 ++++++++++++ .../src/main/res/values/strings.xml | 5 + .../PaymentRequestBodyPlannerTest.kt | 99 +++++++++++++++++++ 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlanner.kt create mode 100644 apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlannerTest.kt 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 49f3a67e3c..480f64b423 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 @@ -31,6 +31,7 @@ import com.flipcash.app.contacts.ContactResolver import com.flipcash.app.core.util.Linkify import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.PaymentRequests import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.app.persistence.sources.UserProfileDataSource @@ -48,6 +49,9 @@ import com.flipcash.services.models.PushChatMetadata import com.flipcash.services.models.Substitution import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent +import com.flipcash.services.models.paymentrequest.PaymentRequest import com.flipcash.services.user.UserManager import com.flipcash.shared.notifications.R import com.getcode.utils.TraceType @@ -92,6 +96,7 @@ class NotificationService : FirebaseMessagingService(), // 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 + private const val REQUEST_FETCH_TIMEOUT_MS = 5_000L } @Inject @@ -121,6 +126,9 @@ class NotificationService : FirebaseMessagingService(), @Inject lateinit var chatCoordinator: ChatCoordinator + @Inject + lateinit var paymentRequests: PaymentRequests + @Inject lateinit var profileController: ProfileController @@ -481,7 +489,8 @@ class NotificationService : FirebaseMessagingService(), // The server can't read an end-to-end encrypted DM or private group, so its body is a // stand-in. The opened text is the message itself, with no sender prefix to strip; a group // line takes its sender from the Person. Unencrypted messages open to null and keep the body. - val opened = openPushedMessage(chatId, metadata) + // A payment request carries only its id, so its body comes from fetching the request. + val opened = openPushedMessage(chatId, metadata) ?: pushedRequestBody(chatId, metadata) style.addMessage(opened ?: planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) // After the extract above, not before: a re-post rebuilds the style from the notification @@ -512,6 +521,39 @@ class NotificationService : FirebaseMessagingService(), } } + /** + * What a payment-request push asks, from fetching the request, or `null` to keep the server's + * body: the message isn't a request, or the fetch failed or took longer than + * [REQUEST_FETCH_TIMEOUT_MS]. A sealed (private group) request's title is opened by the fetch. + */ + private suspend fun pushedRequestBody(chatId: ChatId, metadata: PushChatMetadata?): String? { + if (metadata == null || (metadata.message == null && metadata.messageId == null)) return null + return try { + val widget = (metadata.message?.content?.singleOrNull() as? MessageContent.Widget)?.widget + ?: (chatCoordinator.openPushedChatMessage(chatId, metadata.message, metadata.messageId) + ?.content?.singleOrNull() as? MessageContent.Widget)?.widget + val requestId = (widget as? WidgetContent.PaymentRequest)?.id ?: return null + val request: PaymentRequest? = withTimeoutOrNull(REQUEST_FETCH_TIMEOUT_MS.milliseconds) { + paymentRequests.fetch(requestId).getOrNull()?.paymentRequest + } + planPaymentRequestBody( + request = request, + chatId = chatId, + viewerId = userManager.accountId, + templates = PaymentRequestBodyTemplates( + amountAndTitle = getString(R.string.notification_request_amountAndTitle), + amountOnly = getString(R.string.notification_request_amountOnly), + titleOnly = getString(R.string.notification_request_titleOnly), + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Couldn't build a payment request body", error = e) + null + } + } + /** Who a chat push is attributed to: their id (for blob access) and their profile. */ private data class Sender(val userId: ID, val profile: UserProfile) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlanner.kt new file mode 100644 index 0000000000..c4c16fafea --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlanner.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.notifications + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat + +/** + * The wording of a payment-request push, each a format string taking the amount and/or title as + * `%1$s` / `%2$s`. Kept apart from [planPaymentRequestBody] so the rule needs no `Context`. + * + * The sender is not named: `MessagingStyle` draws it above the line in a group and in the title of + * a DM, as it does for [planMessageBody]. + */ +data class PaymentRequestBodyTemplates( + /** Takes the amount, then the title. */ + val amountAndTitle: String, + /** Takes the amount. */ + val amountOnly: String, + /** Takes the title. */ + val titleOnly: String, +) + +/** + * The body for a push whose message is a payment-request widget, from the request it points at. + * + * The server's body for a widget is generic, and in a private group a stand-in because the request's + * text is sealed. The fetched request carries the opened [PaymentRequestText.Plain] title and the + * amounts, so the notification can say what was asked. + * + * The amount is the viewer's own share when they owe one, since that is what the push asks of them, + * and otherwise the whole split. A blank or unopened title is left out. + * + * `null` when there is nothing worth saying (no request, one from another chat, or neither a title + * nor an amount), in which case the caller keeps the push's own body. + * + * @param request the fetched request, null when the fetch failed or timed out + * @param chatId the chat the push is for; a request made elsewhere is not shown here + * @param viewerId the signed-in user, to pick their share + */ +fun planPaymentRequestBody( + request: PaymentRequest?, + chatId: ChatId, + viewerId: ID?, + templates: PaymentRequestBodyTemplates, +): String? { + val split = request?.split ?: return null + if (request.chatId != chatId) return null + + val title = (split.text as? PaymentRequestText.Plain)?.title?.trim()?.takeIf { it.isNotEmpty() } + val amount = (viewerId?.let { split.shareOf(it)?.amount } ?: split.requested.takeIf { it.quarks > 0 }) + ?.takeIf { it.quarks > 0 } + ?.formatted(Fiat.FormattingRule.Truncated) + + return when { + amount != null && title != null -> templates.amountAndTitle.format(amount, title) + amount != null -> templates.amountOnly.format(amount) + title != null -> templates.titleOnly.format(title) + else -> null + } +} diff --git a/apps/flipcash/shared/notifications/src/main/res/values/strings.xml b/apps/flipcash/shared/notifications/src/main/res/values/strings.xml index ef0e23adc5..d52415f78a 100644 --- a/apps/flipcash/shared/notifications/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/notifications/src/main/res/values/strings.xml @@ -26,4 +26,9 @@ Transactions Social + + + Requested %1$s for %2$s + Requested %1$s + Sent request for %1$s diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlannerTest.kt new file mode 100644 index 0000000000..ee3e423cf5 --- /dev/null +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PaymentRequestBodyPlannerTest.kt @@ -0,0 +1,99 @@ +package com.flipcash.app.notifications + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.Share +import com.flipcash.services.models.paymentrequest.Split +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import org.junit.Before +import org.junit.Test +import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class PaymentRequestBodyPlannerTest { + + private val chat = ChatId(ByteArray(32) { 3 }) + private val viewer = List(16) { 5 } + private val other = List(16) { 6 } + private val templates = PaymentRequestBodyTemplates( + amountAndTitle = "Requested %1\$s for %2\$s", + amountOnly = "Requested %1\$s", + titleOnly = "Sent request for %1\$s", + ) + + @Before + fun locale() = Locale.setDefault(Locale.US) + + private fun share(userId: List, dollars: Double) = + Share(userId, Fiat(fiat = dollars, currencyCode = CurrencyCode.USD), paid = null) + + private fun request( + text: PaymentRequestText = PaymentRequestText.Plain("Dinner", ""), + shares: List = listOf(share(viewer, 12.5), share(other, 20.0)), + chatId: ChatId = chat, + ) = PaymentRequest( + id = List(16) { 9 }, + creator = List(16) { 1 }, + createdAt = Instant.fromEpochSeconds(0), + status = PaymentRequestStatus.Open, + closedAt = null, + version = 1, + kind = PaymentRequest.Kind.GroupSplit( + chatId = chatId, + split = Split(text, CurrencyCode.USD, Instant.fromEpochSeconds(1_000), shares), + ), + ) + + private fun plan(request: PaymentRequest?, viewerId: List? = viewer) = + planPaymentRequestBody(request, chat, viewerId, templates) + + @Test + fun `a fetched request names the viewer's share and its title`() { + assertEquals("Requested \$12.50 for Dinner", plan(request())) + } + + @Test + fun `a viewer with no share is told the whole split`() { + assertEquals("Requested \$32.50 for Dinner", plan(request(), viewerId = List(16) { 7 })) + } + + @Test + fun `a whole-dollar amount drops its cents`() { + assertEquals("Requested \$20 for Dinner", plan(request(shares = listOf(share(viewer, 20.0))))) + } + + @Test + fun `a request without a title still says the amount`() { + assertEquals("Requested \$12.50", plan(request(text = PaymentRequestText.Plain(" ", "")))) + } + + @Test + fun `a title that stayed sealed is left out`() { + assertEquals("Requested \$12.50", plan(request(text = PaymentRequestText.Undecryptable))) + } + + @Test + fun `a request with a title but no amount says the title`() { + assertEquals("Sent request for Dinner", plan(request(shares = emptyList()))) + } + + @Test + fun `a request with neither falls back`() { + assertNull(plan(request(text = PaymentRequestText.Undecryptable, shares = emptyList()))) + } + + @Test + fun `a failed or timed out fetch falls back`() { + assertNull(plan(null)) + } + + @Test + fun `a request made in another chat falls back`() { + assertNull(plan(request(chatId = ChatId(ByteArray(32) { 4 })))) + } +}