From 8b680388efa2c13e9faa744bb0d6f6f435a0ef43 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 2 Jun 2026 11:19:33 -0400 Subject: [PATCH] feat(notifications): rich contact-chat notifications with name resolution fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../flipcash/app/contacts/ContactResolver.kt | 22 +++ .../contacts/device/DeviceContactLookup.kt | 5 + .../internal/AndroidDeviceContactLookup.kt | 40 ++++++ .../app/contacts/inject/ContactModule.kt | 7 + .../app/contacts/ContactResolverTest.kt | 97 +++++++++++++ .../shared/notifications/build.gradle.kts | 2 + .../app/notifications/NotificationService.kt | 130 +++++++++++++----- .../src/main/res/values/colors.xml | 2 +- .../src/main/res/values/strings.xml | 3 + .../app/persistence/dao/ContactDao.kt | 3 + .../persistence/sources/ContactDataSource.kt | 3 + .../network/extensions/ProtobufToLocal.kt | 15 +- .../services/models/NotificationPayload.kt | 10 +- .../ui/components/chat/utils/LocalizedText.kt | 24 ---- 14 files changed, 297 insertions(+), 66 deletions(-) create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt create mode 100644 apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt new file mode 100644 index 000000000..0e6476b77 --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactResolver.kt @@ -0,0 +1,22 @@ +package com.flipcash.app.contacts + +import com.flipcash.app.contacts.device.DeviceContactLookup +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.app.phone.PhoneUtils +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ContactResolver @Inject constructor( + private val contactDataSource: ContactDataSource, + private val deviceContactLookup: DeviceContactLookup, + private val phoneUtils: PhoneUtils, +) { + suspend fun resolveName(e164: String, fallback: String = e164): String = + contactDataSource.getDisplayName(e164) + ?: deviceContactLookup.lookupDisplayName(e164) + ?: runCatching { phoneUtils.formatNumber(e164) }.getOrDefault(fallback) + + suspend fun resolvePhotoUri(e164: String): String? = + contactDataSource.getPhotoUri(e164) +} \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt new file mode 100644 index 000000000..4cec7e89a --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt @@ -0,0 +1,5 @@ +package com.flipcash.app.contacts.device + +interface DeviceContactLookup { + fun lookupDisplayName(e164: String): String? +} \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt new file mode 100644 index 000000000..14299c42b --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt @@ -0,0 +1,40 @@ +package com.flipcash.app.contacts.device.internal + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import com.flipcash.app.contacts.device.DeviceContactLookup +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class AndroidDeviceContactLookup @Inject constructor( + @param:ApplicationContext private val context: Context, +) : DeviceContactLookup { + + override fun lookupDisplayName(e164: String): String? { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED + ) return null + + val uri = ContactsContract.PhoneLookup.CONTENT_FILTER_URI + .buildUpon() + .appendPath(e164) + .build() + + return try { + context.contentResolver.query( + uri, + arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), + null, null, null + )?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotEmpty() } else null + } + } catch (e: Exception) { + null + } + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt index f8cd7f868..3411df77f 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt @@ -1,6 +1,8 @@ package com.flipcash.app.contacts.inject import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.contacts.device.DeviceContactLookup +import com.flipcash.app.contacts.device.internal.AndroidDeviceContactLookup import com.getcode.opencode.providers.SessionListener import dagger.Binds import dagger.Module @@ -17,4 +19,9 @@ abstract class ContactModule { abstract fun bindSessionListener( coordinator: ContactCoordinator ): SessionListener + + @Binds + internal abstract fun bindDeviceContactLookup( + impl: AndroidDeviceContactLookup + ): DeviceContactLookup } diff --git a/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt new file mode 100644 index 000000000..9cc553e9a --- /dev/null +++ b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/ContactResolverTest.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.contacts + +import com.flipcash.app.contacts.device.DeviceContactLookup +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.app.phone.PhoneUtils +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ContactResolverTest { + + private val e164 = "+15551234567" + + private fun resolver( + dbName: String? = null, + deviceName: String? = null, + formatted: String? = null, + formatThrows: Boolean = false, + photoUri: String? = null, + ): ContactResolver { + val dataSource = mockk { + coEvery { getDisplayName(e164) } returns dbName + coEvery { getPhotoUri(e164) } returns photoUri + } + val deviceLookup = mockk { + every { lookupDisplayName(e164) } returns deviceName + } + val phoneUtils = mockk { + if (formatThrows) { + every { formatNumber(any()) } throws RuntimeException("parse error") + } else { + every { formatNumber(any()) } returns (formatted ?: e164) + } + } + return ContactResolver(dataSource, deviceLookup, phoneUtils) + } + + // region resolveName + + @Test + fun `DB display name is returned when present`() = runTest { + val result = resolver(dbName = "Alice").resolveName(e164) + assertEquals("Alice", result) + } + + @Test + fun `device lookup is used when DB returns null`() = runTest { + val result = resolver(deviceName = "Bob Device").resolveName(e164) + assertEquals("Bob Device", result) + } + + @Test + fun `formatted number is used when DB and device both return null`() = runTest { + val result = resolver(formatted = "+1 (555) 123-4567").resolveName(e164) + assertEquals("+1 (555) 123-4567", result) + } + + @Test + fun `fallback is returned when all sources fail`() = runTest { + val result = resolver(formatThrows = true).resolveName(e164, fallback = "unknown") + assertEquals("unknown", result) + } + + @Test + fun `raw e164 is default fallback`() = runTest { + val result = resolver(formatThrows = true).resolveName(e164) + assertEquals(e164, result) + } + + @Test + fun `DB takes priority over device lookup`() = runTest { + val result = resolver(dbName = "Alice DB", deviceName = "Alice Device").resolveName(e164) + assertEquals("Alice DB", result) + } + + // endregion + + // region resolvePhotoUri + + @Test + fun `photo URI is returned from data source`() = runTest { + val result = resolver(photoUri = "content://photo/1").resolvePhotoUri(e164) + assertEquals("content://photo/1", result) + } + + @Test + fun `null photo URI when data source has none`() = runTest { + val result = resolver().resolvePhotoUri(e164) + assertNull(result) + } + + // endregion +} diff --git a/apps/flipcash/shared/notifications/build.gradle.kts b/apps/flipcash/shared/notifications/build.gradle.kts index 41948f5da..1d805dcf9 100644 --- a/apps/flipcash/shared/notifications/build.gradle.kts +++ b/apps/flipcash/shared/notifications/build.gradle.kts @@ -23,4 +23,6 @@ dependencies { testImplementation(kotlin("test")) testImplementation(libs.robolectric) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) } 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 1e23580b1..88f49e69e 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 @@ -5,16 +5,20 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Build +import android.graphics.Bitmap +import android.graphics.ImageDecoder import android.media.RingtoneManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri import com.flipcash.app.auth.AuthManager import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.contacts.ContactResolver import com.flipcash.app.core.util.Linkify -import com.flipcash.app.persistence.sources.ContactDataSource -import com.flipcash.app.phone.PhoneUtils import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.PushController import com.flipcash.services.models.NavigationTrigger @@ -42,6 +46,7 @@ class NotificationService : FirebaseMessagingService(), private const val KEY_TITLE = "push_notification_title" private const val KEY_BODY = "push_notification_body" private const val KEY_PAYLOAD = "flipcash_payload" + private const val CONVERSATION_ID_OFFSET = 0x10000000 } @Inject @@ -59,14 +64,11 @@ class NotificationService : FirebaseMessagingService(), @Inject lateinit var tokenCoordinator: TokenCoordinator - @Inject - lateinit var contactDataSource: ContactDataSource - @Inject lateinit var contactCoordinator: ContactCoordinator @Inject - lateinit var phoneUtils: PhoneUtils + lateinit var contactResolver: ContactResolver override fun onNewToken(token: String) { super.onNewToken(token) @@ -106,48 +108,57 @@ class NotificationService : FirebaseMessagingService(), .takeIf { it.isNotEmpty() } ?.let { NotificationPayload.fromEncoded(it) } - when { - payload?.navigation is NavigationTrigger.CurrencyInfo -> { - launch { tokenCoordinator.update() } - } - payload?.category == NotificationCategory.CONTACT_JOIN -> { - launch { contactCoordinator.sync() } - } + if (payload?.navigation is NavigationTrigger.CurrencyInfo) { + launch { tokenCoordinator.update() } } launch { - val resolvedTitle = applySubstitutions(title, payload?.titleSubstitutions.orEmpty()) - val resolvedBody = body?.let { applySubstitutions(it, payload?.bodySubstitutions.orEmpty()) } - postNotification(resolvedTitle, resolvedBody, payload) + try { + if (payload?.category == NotificationCategory.CONTACT_JOIN) { + launch { contactCoordinator.sync() } + } + val resolvedTitle = applySubstitutions(title, payload?.titleSubstitutions.orEmpty()) + val resolvedBody = body?.let { applySubstitutions(it, payload?.bodySubstitutions.orEmpty()) } + postNotification(resolvedTitle, resolvedBody, payload) + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Failed to post notification", error = e) + } } } - private fun postNotification(title: String, body: String?, payload: NotificationPayload?) { + private suspend fun postNotification(title: String, body: String?, payload: NotificationPayload?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + val category = payload?.category ?: NotificationCategory.DEFAULT NotificationChannels.ensureChannelGroups(this, notificationManager) val channel = NotificationChannels.channelFor(this, category) notificationManager.createNotificationChannel(channel) val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() } + val isContactChat = groupKey?.startsWith("+") == true - val notification = NotificationCompat.Builder(this, channel.id) + val builder = NotificationCompat.Builder(this, channel.id) .setPriority(NotificationCompat.PRIORITY_HIGH) .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) .setSmallIcon(R.drawable.flipcash_logo) .setColor(getColor(R.color.notification_color)) .setAutoCancel(true) - .setContentTitle(title) - .setContentText(body) .setContentIntent(buildContentIntent(payload?.navigation)) - .apply { if (groupKey != null) setGroup(groupKey) } - .build() + .apply { + if (groupKey != null) setGroup(groupKey) + } - if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED - ) return + val notificationId = if (isContactChat) { + applyContactChatStyle(builder, groupKey, body) + } else { + builder.setContentTitle(title).setContentText(body) + SecureRandom().nextInt(Int.MAX_VALUE) + } - val notificationId = SecureRandom().nextInt(Int.MAX_VALUE) - notificationManager.notify(notificationId, notification) + notificationManager.notify(notificationId, builder.build()) if (groupKey != null) { val summary = NotificationCompat.Builder(this, channel.id) @@ -155,17 +166,74 @@ class NotificationService : FirebaseMessagingService(), .setColor(getColor(R.color.notification_color)) .setGroup(groupKey) .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setAutoCancel(true) .build() notificationManager.notify(groupKey.hashCode(), summary) } } + private suspend fun applyContactChatStyle( + builder: NotificationCompat.Builder, + groupKey: String, + body: String?, + ): Int { + val contactPhoto = resolveContactPhoto(groupKey) + val senderName = contactResolver.resolveName(groupKey) + + val profile = userManager.profile + val selfPerson = Person.Builder() + .setName( + profile?.displayName?.takeIf { it.isNotEmpty() } + ?: profile?.verifiedPhoneNumber?.takeIf { it.isNotEmpty() } + ?: getString(R.string.notification_self_person_name) + ) + .setKey("self") + .build() + + val senderPerson = Person.Builder() + .setName(senderName) + .setKey(groupKey) + .apply { + if (contactPhoto != null) setIcon(IconCompat.createWithAdaptiveBitmap(contactPhoto)) + } + .build() + + val notificationId = groupKey.hashCode() xor CONVERSATION_ID_OFFSET + + val style = notificationManager.activeNotifications + .firstOrNull { it.id == notificationId } + ?.notification + ?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) } + ?: NotificationCompat.MessagingStyle(selfPerson) + + style.addMessage(body.orEmpty(), System.currentTimeMillis(), senderPerson) + + builder.setStyle(style) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + + return notificationId + } + + private suspend fun resolveContactPhoto(e164: String): Bitmap? { + val uriString = contactResolver.resolvePhotoUri(e164) ?: return null + return try { + val source = ImageDecoder.createSource(contentResolver, uriString.toUri()) + ImageDecoder.decodeBitmap(source) { decoder, _, _ -> + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Failed to decode contact photo: ${e.message}", type = TraceType.Log) + null + } + } + private suspend fun resolveSubstitution(substitution: Substitution): String { - val phoneNumber = substitution.phoneNumber ?: return substitution.fallback - val displayName = contactDataSource.getDisplayName(phoneNumber) - if (displayName != null) return displayName - return runCatching { phoneUtils.formatNumber(phoneNumber) }.getOrDefault(substitution.fallback) + return when (substitution) { + is Substitution.Phone -> { + contactResolver.resolveName(substitution.phoneNumber, substitution.fallback) + } + } } private suspend fun applySubstitutions(text: String, substitutions: List): String { diff --git a/apps/flipcash/shared/notifications/src/main/res/values/colors.xml b/apps/flipcash/shared/notifications/src/main/res/values/colors.xml index 928820685..882456493 100644 --- a/apps/flipcash/shared/notifications/src/main/res/values/colors.xml +++ b/apps/flipcash/shared/notifications/src/main/res/values/colors.xml @@ -1,4 +1,4 @@ - #FF001A0C + #FF19191A \ No newline at end of file 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 0ead05eaf..a431e9b47 100644 --- a/apps/flipcash/shared/notifications/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/notifications/src/main/res/values/strings.xml @@ -16,6 +16,9 @@ Incoming chat messages When someone you know joins Flipcash + + Me + Transactions Social diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt index 96e6bb604..031d68527 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt @@ -45,6 +45,9 @@ interface ContactDao { @Query("SELECT displayName FROM contact_mapping WHERE e164 = :e164 LIMIT 1") suspend fun getDisplayName(e164: String): String? + @Query("SELECT photoUri FROM contact_mapping WHERE e164 = :e164 LIMIT 1") + suspend fun getPhotoUri(e164: String): String? + @Query("DELETE FROM contact_mapping WHERE e164 IN (:e164s)") suspend fun deleteMappings(e164s: List) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt index e28be7b85..20466f4d6 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt @@ -90,5 +90,8 @@ class ContactDataSource @Inject constructor( suspend fun getDisplayName(e164: String): String? = db?.contactDao()?.getDisplayName(e164) + suspend fun getPhotoUri(e164: String): String? = + db?.contactDao()?.getPhotoUri(e164) + // endregion } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index a4d743545..eb66cffc5 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -46,8 +46,8 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { else -> NotificationCategory.DEFAULT } - val titleSubs = titleSubstitutionsList.map { it.asSubstitution() } - val bodySubs = bodySubstitutionsList.map { it.asSubstitution() } + val titleSubs = titleSubstitutionsList.mapNotNull { it.asSubstitution() } + val bodySubs = bodySubstitutionsList.mapNotNull { it.asSubstitution() } return NotificationPayload( navigation = navigationTrigger, @@ -58,12 +58,15 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { ) } -internal fun PushModels.Substitution.asSubstitution(): Substitution { - val phoneNumber = when (kindCase) { - PushModels.Substitution.KindCase.CONTACT -> contact.value +internal fun PushModels.Substitution.asSubstitution(): Substitution? { + return when (kindCase) { + PushModels.Substitution.KindCase.CONTACT -> { + val phoneNumber = contact.value + Substitution.Phone(fallback = fallback, phoneNumber = phoneNumber) + } + else -> null } - return Substitution(fallback = fallback, phoneNumber = phoneNumber) } internal fun Common.Signature.toSignature(): Signature { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt index 79b454d3f..e8f8e50fb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt @@ -14,10 +14,12 @@ enum class NotificationCategory { CONTACT_JOIN, } -data class Substitution( - val fallback: String, - val phoneNumber: String?, -) +sealed interface Substitution { + data class Phone( + val fallback: String, + val phoneNumber: String, + ): Substitution +} data class NotificationPayload( val navigation: NavigationTrigger?, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt index 78d06ed2a..9d25f6d7b 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/utils/LocalizedText.kt @@ -157,9 +157,6 @@ val MessageContent.localizedText: String is GenericAmount.Partial -> { FormatUtils.formatCurrency(kinAmount.fiat.amount, kinAmount.currencyCode) - .let { - "$it ${context.getString(R.string.core_ofKin)}" - } } } @@ -317,25 +314,4 @@ val Verb.localizedText: String } resId?.let { getString(it) } ?: this@localizedText.toString() - } - -val Title?.localized: String - @Composable get() = when (val t = this) { - is Title.Domain -> { - t.value.capitalize(Locale.getDefault()) - } - - is Title.Localized -> { - with(LocalContext.current) { - val resId = resources.getIdentifier( - t.value, - "string", - packageName - ).let { if (it == 0) null else it } - - resId?.let { getString(it) } ?: t.value - } - } - - else -> "" } \ No newline at end of file