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 }))))
+ }
+}