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
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -121,6 +126,9 @@ class NotificationService : FirebaseMessagingService(),
@Inject
lateinit var chatCoordinator: ChatCoordinator

@Inject
lateinit var paymentRequests: PaymentRequests

@Inject
lateinit var profileController: ProfileController

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@
<!-- Channel group names -->
<string name="notification_group_transactions">Transactions</string>
<string name="notification_group_social">Social</string>

<!-- Payment-request push bodies; the sender is drawn by MessagingStyle, so it is not named here. -->
<string name="notification_request_amountAndTitle">Requested %1$s for %2$s</string>
<string name="notification_request_amountOnly">Requested %1$s</string>
<string name="notification_request_titleOnly">Sent request for %1$s</string>
</resources>
Original file line number Diff line number Diff line change
@@ -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<Byte>(16) { 5 }
private val other = List<Byte>(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<Byte>, dollars: Double) =
Share(userId, Fiat(fiat = dollars, currencyCode = CurrencyCode.USD), paid = null)

private fun request(
text: PaymentRequestText = PaymentRequestText.Plain("Dinner", ""),
shares: List<Share> = 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<Byte>? = 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 }))))
}
}
Loading