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