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
@@ -0,0 +1,37 @@
package com.flipcash.app.notifications

/**
* The text a chat push should show once `MessagingStyle` is drawing the sender itself.
*
* Pure for the same reason [planSenderLookup] is: the rule is what's worth testing, and stating it
* needs neither a database nor a live service.
*
* The server composes a body that names its sender — "Kevin Ricoy: whoa" — because a plain
* notification has nowhere else to put it. Under `MessagingStyle` there is: the platform renders
* the `Person` above the line in a group, and the notification's own title carries it in a DM. The
* name then appears twice.
*
* [senderNames] is every name this sender is known by rather than just the one being displayed,
* because they can disagree — the line is rendered with the address-book name when there is one,
* while the server composed the body from the profile's display name. Only an exact match is
* stripped, so a message that happens to start "Neil: " stays intact unless Neil sent it.
*
* @param body the resolved push body, null when the push carries none
* @param senderNames the sender's known names, in any order; blanks are ignored
*/
fun planMessageBody(body: String?, senderNames: List<String?>): String {
val text = body.orEmpty()
val prefix = senderNames
.asSequence()
.filterNot { it.isNullOrBlank() }
.map { "$it:" }
// Longest first, so a sender known by both a name and a fuller version of it loses the
// whole thing rather than leaving the remainder behind.
.sortedByDescending { it.length }
.firstOrNull { text.startsWith(it, ignoreCase = true) }
?: return text

// A body that is only the prefix isn't an attribution to strip — dropping it would post an
// empty message.
return text.substring(prefix.length).trimStart().ifBlank { text }
}
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,16 @@ class NotificationService : FirebaseMessagingService(),
}
val e164 = contactE164 ?: sender?.profile?.verifiedPhoneNumber

val senderName = e164?.let { contactResolver.resolveName(it) }
?: sender?.profile?.displayName?.takeIf { it.isNotBlank() }
?: sender?.profile?.socialHandle()
?: title
?: ""
// Every name this sender is known by, not just the one rendered: the server composed the
// body from one of them and the line is drawn with another, so stripping the sender's name
// out of their own message needs all of them. See [planMessageBody].
val senderNames = listOf(
e164?.let { contactResolver.resolveName(it) },
sender?.profile?.displayName,
sender?.profile?.socialHandle(),
title,
)
val senderName = senderNames.firstOrNull { !it.isNullOrBlank() }.orEmpty()

// Device-contact photo (local, synchronous) first; otherwise the profile
// picture URL loaded through the app's shared Coil loader (cache-first,
Expand Down Expand Up @@ -397,7 +402,7 @@ class NotificationService : FirebaseMessagingService(),
?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) }
?: NotificationCompat.MessagingStyle(selfPerson)

style.addMessage(body.orEmpty(), System.currentTimeMillis(), senderPerson)
style.addMessage(planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson)

// After the extract above, not before: a re-post rebuilds the style from the notification
// already on screen, which carries the old flag and title back with it.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.flipcash.app.notifications

import kotlin.test.Test
import kotlin.test.assertEquals

/**
* `MessagingStyle` renders the sender itself, so a body that names its own sender shows the name
* twice — which is what the server sends, since a plain notification has nowhere else to put it.
*/
class MessageBodyPlannerTest {

@Test
fun `the sender's own name is dropped from the front of their message`() {
assertEquals("whoa", planMessageBody("Kevin Ricoy: whoa", listOf("Kevin Ricoy")))
}

@Test
fun `a name the sender is known by elsewhere is dropped too`() {
// Rendered under the address-book name; composed by the server from the profile's.
assertEquals(
"Howdy",
planMessageBody("neilbryan: Howdy", listOf("Neil", "neilbryan")),
)
}

@Test
fun `the longest matching name is what gets dropped`() {
assertEquals("hi", planMessageBody("Ted Livingston: hi", listOf("Ted", "Ted Livingston")))
}

@Test
fun `someone else's name is left alone`() {
assertEquals("Neil: Howdy", planMessageBody("Neil: Howdy", listOf("Kevin Ricoy")))
}

@Test
fun `a message that is only the sender's name survives`() {
assertEquals("Kevin Ricoy:", planMessageBody("Kevin Ricoy:", listOf("Kevin Ricoy")))
}

@Test
fun `a body with no sender to match is unchanged`() {
assertEquals("whoa", planMessageBody("whoa", listOf(null, "", " ")))
}

@Test
fun `a push with no body plans an empty message`() {
assertEquals("", planMessageBody(null, listOf("Kevin Ricoy")))
}
}
Loading