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 @@ -12,7 +12,11 @@ class ContactResolver @Inject constructor(
private val deviceContactLookup: DeviceContactLookup,
private val phoneUtils: PhoneUtils,
) {
suspend fun resolveName(e164: String, fallback: String = e164): String {
suspend fun resolveName(e164: String, fallback: String = e164): String =
resolveContactName(e164) ?: formatNumber(e164, fallback)

/** The address-book name for [e164], or null when no contact has that number. */
suspend fun resolveContactName(e164: String): String? {
val cached = contactDataSource.getDisplayName(e164)
val device = deviceContactLookup.lookupDisplayName(e164)

Expand All @@ -22,9 +26,11 @@ class ContactResolver @Inject constructor(
}

return cached
?: runCatching { phoneUtils.formatNumber(e164) }.getOrDefault(fallback)
}

fun formatNumber(e164: String, fallback: String = e164): String =
runCatching { phoneUtils.formatNumber(e164) }.getOrDefault(fallback)

suspend fun resolvePhotoUri(e164: String): String? {
val cached = contactDataSource.getPhotoUri(e164)
if (cached != null) return cached
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ class ContactResolverTest {
assertEquals("Alice Device", result)
}

@Test
fun `contact name is null without a contact rather than the formatted number`() = runTest {
val result = resolver(formatted = "+1 (555) 123-4567").resolveContactName(e164)
assertNull(result)
}

@Test
fun `contact name comes from the device`() = runTest {
val result = resolver(dbName = "Alice DB", deviceName = "Alice Device").resolveContactName(e164)
assertEquals("Alice Device", result)
}

// endregion

// region resolvePhotoUri
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,11 +345,12 @@ class NotificationService : FirebaseMessagingService(),
// 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 senderNames = senderNameCandidates(
contactName = e164?.let { contactResolver.resolveContactName(it) },
displayName = sender?.profile?.displayName,
handle = sender?.profile?.socialHandle(),
title = title,
number = e164?.let { contactResolver.formatNumber(it) },
)
val senderName = senderNames.firstOrNull { !it.isNullOrBlank() }.orEmpty()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,33 @@ fun planSenderLookup(
chatType == ChatType.GROUP -> SenderLookup.None
else -> SenderLookup.OtherMember
}

/**
* The name a chat notification shows for its sender, best first.
*
* Only an address-book name beats the sender's own profile, because it's the name the user chose
* for them. A bare phone number comes last: a profile carries one only in some chats, so ranking it
* above the display name would flip the same sender between their name and their number depending
* on which chat's member list was stored last.
*
* @param contactName the address-book name for the sender's number, null when there is no contact
* @param number the sender's number, formatted for display
*/
fun senderNameCandidates(
contactName: String?,
displayName: String?,
handle: String?,
title: String?,
number: String?,
): List<String?> = listOf(contactName, displayName, handle, title, number)

/** The first usable name from [senderNameCandidates], or empty when there is none. */
fun planSenderName(
contactName: String?,
displayName: String?,
handle: String?,
title: String?,
number: String?,
): String = senderNameCandidates(contactName, displayName, handle, title, number)
.firstOrNull { !it.isNullOrBlank() }
.orEmpty()
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,56 @@ class SenderAttributionPlannerTest {
}

// endregion

// region Sender name

@Test
fun `a device-contact name wins over the profile`() {
assertEquals(
"Mom",
planSenderName(contactName = "Mom", displayName = "Alice", handle = "alice", title = "Alice", number = "+1 555-000-0001"),
)
}

@Test
fun `a display name wins over a phone number`() {
assertEquals(
"Alice",
planSenderName(contactName = null, displayName = "Alice", handle = null, title = null, number = "+1 555-000-0001"),
)
}

@Test
fun `a handle is used when there is no display name`() {
assertEquals(
"alice",
planSenderName(contactName = null, displayName = " ", handle = "alice", title = "Server", number = "+1 555-000-0001"),
)
}

@Test
fun `the push title is used before a phone number`() {
assertEquals(
"Server",
planSenderName(contactName = null, displayName = null, handle = null, title = "Server", number = "+1 555-000-0001"),
)
}

@Test
fun `a phone number is the last resort`() {
assertEquals(
"+1 555-000-0001",
planSenderName(contactName = null, displayName = null, handle = null, title = null, number = "+1 555-000-0001"),
)
}

@Test
fun `no name at all is empty`() {
assertEquals(
"",
planSenderName(contactName = null, displayName = null, handle = null, title = null, number = null),
)
}

// endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import com.flipcash.app.persistence.entities.UserProfileEntity
import com.flipcash.services.models.chat.MediaItem
Expand All @@ -12,13 +13,32 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface UserProfileDao {

/**
* Authoritative full-profile write (chat member sync). The caller has the complete
* profile, so a whole-row replace is correct — and it clears any staged migration blob.
*/
/** Whole-row replace, which also clears any staged migration blob. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertFull(profiles: List<UserProfileEntity>)

/**
* Writes the profiles a chat's member list carries. A user's row is shared by every chat
* they are in, but the server sends their phone number and email only in some of them (a
* CONTACT_DM member carries the number, the same user as a TIP_DM member does not). So a
* member without a phone or email keeps the one already stored instead of erasing it; every
* other column is replaced, and the staged migration blob is cleared as [upsertFull] does.
*/
@Transaction
suspend fun upsertMembers(profiles: List<UserProfileEntity>) {
upsertFull(
profiles.map { incoming ->
val stored = getByUserId(incoming.userIdHex) ?: return@map incoming
incoming.copy(
phoneValue = incoming.phoneValue ?: stored.phoneValue,
phoneVerified = if (incoming.phoneValue != null) incoming.phoneVerified else stored.phoneVerified,
emailValue = incoming.emailValue ?: stored.emailValue,
emailVerified = if (incoming.emailValue != null) incoming.emailVerified else stored.emailVerified,
)
}
)
}

/**
* Partial write for callers that only know a user's public identity — name, avatar and handle
* (blocklist sync, a single `GetProfile`). Inserts a new row, or updates *only* those three
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,70 @@ class UserProfileDaoTest {
assertEquals("", dao.getByUserId("u1")?.displayName)
}

// A user's row is shared by every chat they're in, but the server sends their phone number
// only in some of them: a CONTACT_DM member carries it, the same user in a TIP_DM does not.
private fun memberWithoutContact(userId: String, displayName: String = "Alice") =
fullProfile(userId).copy(
displayName = displayName,
phoneValue = null, phoneVerified = null,
emailValue = null, emailVerified = null,
)

@Test
fun `member write without a phone or email keeps the stored ones`() = runBlocking {
val dao = db.userProfileDao()
dao.upsertMembers(listOf(fullProfile("u1")))

dao.upsertMembers(listOf(memberWithoutContact("u1")))

val stored = dao.getByUserId("u1")
assertEquals("+15551234567", stored?.phoneValue)
assertEquals(true, stored?.phoneVerified)
assertEquals("a@b.com", stored?.emailValue)
assertEquals(false, stored?.emailVerified)
}

@Test
fun `member write without a phone still updates the name`() = runBlocking {
val dao = db.userProfileDao()
dao.upsertMembers(listOf(fullProfile("u1")))

dao.upsertMembers(listOf(memberWithoutContact("u1", displayName = "Alice B")))

assertEquals("Alice B", dao.getByUserId("u1")?.displayName)
}

@Test
fun `member write with a new phone replaces the stored one`() = runBlocking {
val dao = db.userProfileDao()
dao.upsertMembers(listOf(fullProfile("u1")))

dao.upsertMembers(listOf(fullProfile("u1").copy(phoneValue = "+15550000002", phoneVerified = false)))

val stored = dao.getByUserId("u1")
assertEquals("+15550000002", stored?.phoneValue)
assertEquals(false, stored?.phoneVerified)
}

@Test
fun `member write inserts a new user as given`() = runBlocking {
val dao = db.userProfileDao()

dao.upsertMembers(listOf(memberWithoutContact("u1")))

assertNull(dao.getByUserId("u1")?.phoneValue)
}

@Test
fun `member write clears a staged migration blob`() = runBlocking {
val dao = db.userProfileDao()
dao.upsertFull(listOf(fullProfile("u1").copy(pendingMigrationJson = "{}")))

dao.upsertMembers(listOf(memberWithoutContact("u1")))

assertNull(dao.getByUserId("u1")?.pendingMigrationJson)
}

@Test
fun `chat member relation joins the shared normalized profile`() = runBlocking {
db.userProfileDao().upsertFull(listOf(fullProfile("u1")))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ class ChatMemberDataSource @Inject constructor(
val hex = mapper.chatIdHex(chatId)
// Member rows and their normalized profiles are written in one transaction so a
// member never observes a missing profile mid-write. Profiles go first (the
// @Relation reads them), and use the authoritative full-profile upsert.
// @Relation reads them).
database.withTransaction {
database.userProfileDao().upsertFull(profileRows(members))
database.userProfileDao().upsertMembers(profileRows(members))
database.chatMemberDao().upsert(members.map { mapper.toEntity(hex, it) })
}
}
Expand Down Expand Up @@ -105,7 +105,7 @@ class ChatMemberDataSource @Inject constructor(
val database = db ?: return
val hex = mapper.chatIdHex(chatId)
database.withTransaction {
database.userProfileDao().upsertFull(profileRows(members))
database.userProfileDao().upsertMembers(profileRows(members))
database.chatMemberDao().upsert(members.map { mapper.toEntity(hex, it) })
database.chatMemberDao().deleteMembersNotIn(
chatIdHex = hex,
Expand Down
Loading