diff --git a/apps/flipcash/shared/notifications/build.gradle.kts b/apps/flipcash/shared/notifications/build.gradle.kts index 141e9abfa..6ef87aedb 100644 --- a/apps/flipcash/shared/notifications/build.gradle.kts +++ b/apps/flipcash/shared/notifications/build.gradle.kts @@ -9,6 +9,8 @@ android { dependencies { implementation(project(":apps:flipcash:shared:authentication")) + implementation(project(":apps:flipcash:shared:persistence:sources")) + implementation(project(":apps:flipcash:shared:phone")) implementation(project(":apps:flipcash:shared:push")) implementation(project(":apps:flipcash:shared:tokens")) implementation(project(":services:flipcash")) 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 88e92e9f7..185c828bb 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 @@ -12,11 +12,14 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri import com.flipcash.app.auth.AuthManager 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 import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload +import com.flipcash.services.models.Substitution import com.flipcash.services.user.UserManager import com.flipcash.shared.notifications.R import com.getcode.utils.TraceType @@ -34,6 +37,12 @@ import javax.inject.Inject class NotificationService : FirebaseMessagingService(), CoroutineScope by CoroutineScope(Dispatchers.IO) { + companion object { + private const val KEY_TITLE = "push_notification_title" + private const val KEY_BODY = "push_notification_body" + private const val KEY_PAYLOAD = "flipcash_payload" + } + @Inject lateinit var authManager: AuthManager @@ -49,6 +58,12 @@ class NotificationService : FirebaseMessagingService(), @Inject lateinit var tokenCoordinator: TokenCoordinator + @Inject + lateinit var contactDataSource: ContactDataSource + + @Inject + lateinit var phoneUtils: PhoneUtils + override fun onNewToken(token: String) { super.onNewToken(token) authenticateIfNeeded { @@ -69,8 +84,8 @@ class NotificationService : FirebaseMessagingService(), override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) - val title = message.data["push_notification_title"]?.ifEmpty { message.notification?.title } - val body = message.data["push_notification_body"]?.ifEmpty { message.notification?.body } + val title = message.data[KEY_TITLE]?.ifEmpty { message.notification?.title } + val body = message.data[KEY_BODY]?.ifEmpty { message.notification?.body } trace( message = "onMessageReceived", @@ -81,22 +96,24 @@ class NotificationService : FirebaseMessagingService(), } ) - if (title == null) { - return - } + if (title == null) return - val payload = message.data.getOrDefault("flipcash_payload", "") + val payload = message.data.getOrDefault(KEY_PAYLOAD, "") .takeIf { it.isNotEmpty() } - ?.let { protoString -> - NotificationPayload.fromEncoded(protoString) - } + ?.let { NotificationPayload.fromEncoded(it) } if (payload?.navigation is NavigationTrigger.CurrencyInfo) { - launch { - tokenCoordinator.update() - } + launch { tokenCoordinator.update() } } + launch { + val resolvedTitle = applySubstitutions(title, payload?.titleSubstitutions.orEmpty()) + val resolvedBody = body?.let { applySubstitutions(it, payload?.bodySubstitutions.orEmpty()) } + postNotification(resolvedTitle, resolvedBody, payload) + } + } + + private fun postNotification(title: String, body: String?, payload: NotificationPayload?) { val category = payload?.category ?: NotificationCategory.DEFAULT NotificationChannels.ensureChannelGroups(this, notificationManager) val channel = NotificationChannels.channelFor(this, category) @@ -104,38 +121,51 @@ class NotificationService : FirebaseMessagingService(), val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() } - val notificationBuilder: NotificationCompat.Builder = - NotificationCompat.Builder(this, channel.id) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + val notification = 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() + + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + + val notificationId = SecureRandom().nextInt(Int.MAX_VALUE) + notificationManager.notify(notificationId, notification) + + if (groupKey != null) { + val summary = NotificationCompat.Builder(this, channel.id) .setSmallIcon(R.drawable.flipcash_logo) .setColor(getColor(R.color.notification_color)) + .setGroup(groupKey) + .setGroupSummary(true) .setAutoCancel(true) - .setContentTitle(title) - .setContentText(body) - .setContentIntent(buildContentIntent(payload?.navigation)) - .apply { if (groupKey != null) setGroup(groupKey) } + .build() + notificationManager.notify(groupKey.hashCode(), summary) + } + } - val notificationId = SecureRandom().nextInt(Int.MAX_VALUE) + 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) + } - if (ActivityCompat.checkSelfPermission( - this, - Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED - ) { - notificationManager.notify(notificationId, notificationBuilder.build()) - - if (groupKey != null) { - val summary = NotificationCompat.Builder(this, channel.id) - .setSmallIcon(R.drawable.flipcash_logo) - .setColor(getColor(R.color.notification_color)) - .setGroup(groupKey) - .setGroupSummary(true) - .setAutoCancel(true) - .build() - notificationManager.notify(groupKey.hashCode(), summary) - } + private suspend fun applySubstitutions(text: String, substitutions: List): String { + var result = text + for ((index, substitution) in substitutions.withIndex()) { + val resolved = resolveSubstitution(substitution) + result = result.replace("{$index}", resolved) } + return result } private fun authenticateIfNeeded(block: () -> Unit) { 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 cc3252d04..a4d743545 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 @@ -12,6 +12,7 @@ import com.flipcash.services.internal.extensions.toPublicKey import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload +import com.flipcash.services.models.Substitution import com.getcode.opencode.model.core.ID import com.getcode.solana.keys.Checksum import com.getcode.solana.keys.Mint @@ -45,13 +46,26 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { else -> NotificationCategory.DEFAULT } + val titleSubs = titleSubstitutionsList.map { it.asSubstitution() } + val bodySubs = bodySubstitutionsList.map { it.asSubstitution() } + return NotificationPayload( navigation = navigationTrigger, category = notificationCategory, groupKey = groupKey, + titleSubstitutions = titleSubs, + bodySubstitutions = bodySubs, ) } +internal fun PushModels.Substitution.asSubstitution(): Substitution { + val phoneNumber = when (kindCase) { + PushModels.Substitution.KindCase.CONTACT -> contact.value + else -> null + } + return Substitution(fallback = fallback, phoneNumber = phoneNumber) +} + internal fun Common.Signature.toSignature(): Signature { return Signature(value.toByteArray().toList()) } \ No newline at end of file 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 5030d9b9d..79b454d3f 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,17 @@ enum class NotificationCategory { CONTACT_JOIN, } +data class Substitution( + val fallback: String, + val phoneNumber: String?, +) + data class NotificationPayload( val navigation: NavigationTrigger?, val category: NotificationCategory = NotificationCategory.DEFAULT, val groupKey: String = "", + val titleSubstitutions: List = emptyList(), + val bodySubstitutions: List = emptyList(), ) { companion object { fun fromEncoded(encoded: String): NotificationPayload? {