Skip to content

Commit ade143b

Browse files
authored
feat(notifications): rich contact-chat notifications with name resolution fallback (#825)
Upgrade notification service to support MessagingStyle for contact chat conversations with sender photos, grouped messaging, and proper person attribution. Refactor Substitution from a data class to a sealed interface to model substitution variants type-safely, and fix ProtobufToLocal to use mapNotNull so unknown substitution kinds are dropped instead of mapped to null fields. Add ContactResolver (DB → device PhoneLookup → formatted number → raw e164 fallback chain) and DeviceContactLookup interface in shared:contacts, replacing inline resolution in NotificationService. The PhoneLookup fallback eliminates the race where slow contact sync caused raw phone numbers in CONTACT_JOIN notifications. The sync call is now fire-and-forget so it no longer blocks notification posting. Also adds ContactDao.getPhotoUri/ContactDataSource.getPhotoUri for contact photo resolution, updates notification color, and removes unused Title.localized and ofKin suffix from LocalizedText. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent afeba5f commit ade143b

14 files changed

Lines changed: 297 additions & 66 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.flipcash.app.contacts
2+
3+
import com.flipcash.app.contacts.device.DeviceContactLookup
4+
import com.flipcash.app.persistence.sources.ContactDataSource
5+
import com.flipcash.app.phone.PhoneUtils
6+
import javax.inject.Inject
7+
import javax.inject.Singleton
8+
9+
@Singleton
10+
class ContactResolver @Inject constructor(
11+
private val contactDataSource: ContactDataSource,
12+
private val deviceContactLookup: DeviceContactLookup,
13+
private val phoneUtils: PhoneUtils,
14+
) {
15+
suspend fun resolveName(e164: String, fallback: String = e164): String =
16+
contactDataSource.getDisplayName(e164)
17+
?: deviceContactLookup.lookupDisplayName(e164)
18+
?: runCatching { phoneUtils.formatNumber(e164) }.getOrDefault(fallback)
19+
20+
suspend fun resolvePhotoUri(e164: String): String? =
21+
contactDataSource.getPhotoUri(e164)
22+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.flipcash.app.contacts.device
2+
3+
interface DeviceContactLookup {
4+
fun lookupDisplayName(e164: String): String?
5+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.flipcash.app.contacts.device.internal
2+
3+
import android.Manifest
4+
import android.content.Context
5+
import android.content.pm.PackageManager
6+
import android.provider.ContactsContract
7+
import androidx.core.content.ContextCompat
8+
import com.flipcash.app.contacts.device.DeviceContactLookup
9+
import dagger.hilt.android.qualifiers.ApplicationContext
10+
import javax.inject.Inject
11+
import javax.inject.Singleton
12+
13+
@Singleton
14+
internal class AndroidDeviceContactLookup @Inject constructor(
15+
@param:ApplicationContext private val context: Context,
16+
) : DeviceContactLookup {
17+
18+
override fun lookupDisplayName(e164: String): String? {
19+
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS)
20+
!= PackageManager.PERMISSION_GRANTED
21+
) return null
22+
23+
val uri = ContactsContract.PhoneLookup.CONTENT_FILTER_URI
24+
.buildUpon()
25+
.appendPath(e164)
26+
.build()
27+
28+
return try {
29+
context.contentResolver.query(
30+
uri,
31+
arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME),
32+
null, null, null
33+
)?.use { cursor ->
34+
if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotEmpty() } else null
35+
}
36+
} catch (e: Exception) {
37+
null
38+
}
39+
}
40+
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.flipcash.app.contacts.inject
22

33
import com.flipcash.app.contacts.ContactCoordinator
4+
import com.flipcash.app.contacts.device.DeviceContactLookup
5+
import com.flipcash.app.contacts.device.internal.AndroidDeviceContactLookup
46
import com.getcode.opencode.providers.SessionListener
57
import dagger.Binds
68
import dagger.Module
@@ -17,4 +19,9 @@ abstract class ContactModule {
1719
abstract fun bindSessionListener(
1820
coordinator: ContactCoordinator
1921
): SessionListener
22+
23+
@Binds
24+
internal abstract fun bindDeviceContactLookup(
25+
impl: AndroidDeviceContactLookup
26+
): DeviceContactLookup
2027
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.flipcash.app.contacts
2+
3+
import com.flipcash.app.contacts.device.DeviceContactLookup
4+
import com.flipcash.app.persistence.sources.ContactDataSource
5+
import com.flipcash.app.phone.PhoneUtils
6+
import io.mockk.coEvery
7+
import io.mockk.every
8+
import io.mockk.mockk
9+
import kotlinx.coroutines.test.runTest
10+
import org.junit.Test
11+
import kotlin.test.assertEquals
12+
import kotlin.test.assertNull
13+
14+
class ContactResolverTest {
15+
16+
private val e164 = "+15551234567"
17+
18+
private fun resolver(
19+
dbName: String? = null,
20+
deviceName: String? = null,
21+
formatted: String? = null,
22+
formatThrows: Boolean = false,
23+
photoUri: String? = null,
24+
): ContactResolver {
25+
val dataSource = mockk<ContactDataSource> {
26+
coEvery { getDisplayName(e164) } returns dbName
27+
coEvery { getPhotoUri(e164) } returns photoUri
28+
}
29+
val deviceLookup = mockk<DeviceContactLookup> {
30+
every { lookupDisplayName(e164) } returns deviceName
31+
}
32+
val phoneUtils = mockk<PhoneUtils> {
33+
if (formatThrows) {
34+
every { formatNumber(any<String>()) } throws RuntimeException("parse error")
35+
} else {
36+
every { formatNumber(any<String>()) } returns (formatted ?: e164)
37+
}
38+
}
39+
return ContactResolver(dataSource, deviceLookup, phoneUtils)
40+
}
41+
42+
// region resolveName
43+
44+
@Test
45+
fun `DB display name is returned when present`() = runTest {
46+
val result = resolver(dbName = "Alice").resolveName(e164)
47+
assertEquals("Alice", result)
48+
}
49+
50+
@Test
51+
fun `device lookup is used when DB returns null`() = runTest {
52+
val result = resolver(deviceName = "Bob Device").resolveName(e164)
53+
assertEquals("Bob Device", result)
54+
}
55+
56+
@Test
57+
fun `formatted number is used when DB and device both return null`() = runTest {
58+
val result = resolver(formatted = "+1 (555) 123-4567").resolveName(e164)
59+
assertEquals("+1 (555) 123-4567", result)
60+
}
61+
62+
@Test
63+
fun `fallback is returned when all sources fail`() = runTest {
64+
val result = resolver(formatThrows = true).resolveName(e164, fallback = "unknown")
65+
assertEquals("unknown", result)
66+
}
67+
68+
@Test
69+
fun `raw e164 is default fallback`() = runTest {
70+
val result = resolver(formatThrows = true).resolveName(e164)
71+
assertEquals(e164, result)
72+
}
73+
74+
@Test
75+
fun `DB takes priority over device lookup`() = runTest {
76+
val result = resolver(dbName = "Alice DB", deviceName = "Alice Device").resolveName(e164)
77+
assertEquals("Alice DB", result)
78+
}
79+
80+
// endregion
81+
82+
// region resolvePhotoUri
83+
84+
@Test
85+
fun `photo URI is returned from data source`() = runTest {
86+
val result = resolver(photoUri = "content://photo/1").resolvePhotoUri(e164)
87+
assertEquals("content://photo/1", result)
88+
}
89+
90+
@Test
91+
fun `null photo URI when data source has none`() = runTest {
92+
val result = resolver().resolvePhotoUri(e164)
93+
assertNull(result)
94+
}
95+
96+
// endregion
97+
}

apps/flipcash/shared/notifications/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,6 @@ dependencies {
2323

2424
testImplementation(kotlin("test"))
2525
testImplementation(libs.robolectric)
26+
testImplementation(libs.mockk)
27+
testImplementation(libs.kotlinx.coroutines.test)
2628
}

0 commit comments

Comments
 (0)