diff --git a/apps/flipcash/shared/notifications/build.gradle.kts b/apps/flipcash/shared/notifications/build.gradle.kts
index 455b24689..141e9abfa 100644
--- a/apps/flipcash/shared/notifications/build.gradle.kts
+++ b/apps/flipcash/shared/notifications/build.gradle.kts
@@ -4,6 +4,7 @@ plugins {
android {
namespace = "${Gradle.flipcashNamespace}.shared.notifications"
+ testOptions.unitTests.isIncludeAndroidResources = true
}
dependencies {
@@ -16,4 +17,7 @@ dependencies {
implementation(libs.firebase.messaging)
implementation(libs.androidx.datastore)
+
+ testImplementation(kotlin("test"))
+ testImplementation(libs.robolectric)
}
diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationChannels.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationChannels.kt
new file mode 100644
index 000000000..13ddfd26b
--- /dev/null
+++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationChannels.kt
@@ -0,0 +1,97 @@
+package com.flipcash.app.notifications
+
+import android.content.Context
+import androidx.annotation.StringRes
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationChannelGroupCompat
+import androidx.core.app.NotificationManagerCompat
+import com.flipcash.services.models.NotificationCategory
+import com.flipcash.shared.notifications.R
+
+object NotificationChannels {
+ const val CHANNEL_DEFAULT = "fcm_fallback_notification_channel"
+ const val CHANNEL_DEPOSIT_WITHDRAWAL = "channel_deposit_withdrawal"
+ const val CHANNEL_BUY_SELL = "channel_buy_sell"
+ const val CHANNEL_GAIN = "channel_gain"
+ const val CHANNEL_CHAT = "channel_chat"
+ const val CHANNEL_CONTACT_JOIN = "channel_contact_join"
+
+ private const val GROUP_TRANSACTIONS = "group_transactions"
+ private const val GROUP_SOCIAL = "group_social"
+
+ private data class ChannelDef(
+ val id: String,
+ @StringRes val nameRes: Int,
+ @StringRes val descriptionRes: Int,
+ val importance: Int,
+ val groupId: String? = null,
+ val showBadge: Boolean = true,
+ )
+
+ fun ensureChannelGroups(context: Context, manager: NotificationManagerCompat) {
+ val groups = listOf(
+ NotificationChannelGroupCompat.Builder(GROUP_TRANSACTIONS)
+ .setName(context.getString(R.string.notification_group_transactions))
+ .build(),
+ NotificationChannelGroupCompat.Builder(GROUP_SOCIAL)
+ .setName(context.getString(R.string.notification_group_social))
+ .build(),
+ )
+ groups.forEach { manager.createNotificationChannelGroup(it) }
+ }
+
+ fun channelFor(context: Context, category: NotificationCategory): NotificationChannelCompat {
+ val def = when (category) {
+ NotificationCategory.DEFAULT -> ChannelDef(
+ id = CHANNEL_DEFAULT,
+ nameRes = R.string.notification_channel_default,
+ descriptionRes = R.string.notification_channel_default_description,
+ importance = NotificationManagerCompat.IMPORTANCE_DEFAULT,
+ showBadge = false,
+ )
+ NotificationCategory.DEPOSIT_WITHDRAWAL -> ChannelDef(
+ id = CHANNEL_DEPOSIT_WITHDRAWAL,
+ nameRes = R.string.notification_channel_deposit_withdrawal,
+ descriptionRes = R.string.notification_channel_deposit_withdrawal_description,
+ importance = NotificationManagerCompat.IMPORTANCE_HIGH,
+ groupId = GROUP_TRANSACTIONS,
+ )
+ NotificationCategory.BUY_SELL -> ChannelDef(
+ id = CHANNEL_BUY_SELL,
+ nameRes = R.string.notification_channel_buy_sell,
+ descriptionRes = R.string.notification_channel_buy_sell_description,
+ importance = NotificationManagerCompat.IMPORTANCE_HIGH,
+ groupId = GROUP_TRANSACTIONS,
+ )
+ NotificationCategory.GAIN -> ChannelDef(
+ id = CHANNEL_GAIN,
+ nameRes = R.string.notification_channel_gain,
+ descriptionRes = R.string.notification_channel_gain_description,
+ importance = NotificationManagerCompat.IMPORTANCE_HIGH,
+ groupId = GROUP_TRANSACTIONS,
+ )
+ NotificationCategory.CHAT -> ChannelDef(
+ id = CHANNEL_CHAT,
+ nameRes = R.string.notification_channel_chat,
+ descriptionRes = R.string.notification_channel_chat_description,
+ importance = NotificationManagerCompat.IMPORTANCE_HIGH,
+ groupId = GROUP_SOCIAL,
+ )
+ NotificationCategory.CONTACT_JOIN -> ChannelDef(
+ id = CHANNEL_CONTACT_JOIN,
+ nameRes = R.string.notification_channel_contact_join,
+ descriptionRes = R.string.notification_channel_contact_join_description,
+ importance = NotificationManagerCompat.IMPORTANCE_HIGH,
+ groupId = GROUP_SOCIAL,
+ showBadge = false,
+ )
+ }
+
+ return NotificationChannelCompat.Builder(def.id, def.importance)
+ .setName(context.getString(def.nameRes))
+ .setDescription(context.getString(def.descriptionRes))
+ .setShowBadge(def.showBadge)
+ .apply { def.groupId?.let { setGroup(it) } }
+ .build()
+ }
+}
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 8fdd8b658..88e92e9f7 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
@@ -1,17 +1,12 @@
package com.flipcash.app.notifications
import android.Manifest
-import android.app.NotificationChannel
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.RingtoneManager
-import android.net.Uri
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
import androidx.core.app.ActivityCompat
-import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
@@ -20,10 +15,10 @@ import com.flipcash.app.core.util.Linkify
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.user.UserManager
import com.flipcash.shared.notifications.R
-import com.getcode.opencode.controllers.TokenController
import com.getcode.utils.TraceType
import com.getcode.utils.trace
import com.google.firebase.messaging.FirebaseMessagingService
@@ -74,12 +69,6 @@ class NotificationService : FirebaseMessagingService(),
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
- // dump everything into FCM fallback channel for now
- val channel = NotificationChannelCompat.Builder(
- "fcm_fallback_notification_channel",
- NotificationManagerCompat.IMPORTANCE_DEFAULT
- ).setName("Misc.").build()
-
val title = message.data["push_notification_title"]?.ifEmpty { message.notification?.title }
val body = message.data["push_notification_body"]?.ifEmpty { message.notification?.body }
@@ -108,8 +97,13 @@ class NotificationService : FirebaseMessagingService(),
}
}
+ 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 notificationBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(this, channel.id)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -120,9 +114,9 @@ class NotificationService : FirebaseMessagingService(),
.setContentTitle(title)
.setContentText(body)
.setContentIntent(buildContentIntent(payload?.navigation))
+ .apply { if (groupKey != null) setGroup(groupKey) }
- val random = SecureRandom()
- val notificationId = random.nextInt(256)
+ val notificationId = SecureRandom().nextInt(Int.MAX_VALUE)
if (ActivityCompat.checkSelfPermission(
this,
@@ -130,6 +124,17 @@ class NotificationService : FirebaseMessagingService(),
) == 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)
+ }
}
}
diff --git a/apps/flipcash/shared/notifications/src/main/res/values/strings.xml b/apps/flipcash/shared/notifications/src/main/res/values/strings.xml
new file mode 100644
index 000000000..0ead05eaf
--- /dev/null
+++ b/apps/flipcash/shared/notifications/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+
+ Misc.
+ Deposits & Withdrawals
+ Buys & Sells
+ Price Alerts
+ Chat Messages
+ New Contacts
+
+
+ General notifications
+ Deposit and withdrawal confirmations
+ Token purchase and sale confirmations
+ Token price movement alerts
+ Incoming chat messages
+ When someone you know joins Flipcash
+
+
+ Transactions
+ Social
+
diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationChannelsTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationChannelsTest.kt
new file mode 100644
index 000000000..993eb18b2
--- /dev/null
+++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationChannelsTest.kt
@@ -0,0 +1,362 @@
+package com.flipcash.app.notifications
+
+import android.app.NotificationManager
+import android.content.Context
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.flipcash.services.models.NotificationCategory
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [36])
+class NotificationChannelsTest {
+
+ private lateinit var context: Context
+ private lateinit var notificationManager: NotificationManagerCompat
+
+ @Before
+ fun setUp() {
+ context = RuntimeEnvironment.getApplication()
+ notificationManager = NotificationManagerCompat.from(context)
+ }
+
+ // region Channel ID mapping
+
+ @Test
+ fun `DEFAULT category produces the fallback channel ID`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.DEFAULT)
+ assertEquals(NotificationChannels.CHANNEL_DEFAULT, channel.id)
+ }
+
+ @Test
+ fun `DEPOSIT_WITHDRAWAL category produces expected channel ID`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.DEPOSIT_WITHDRAWAL)
+ assertEquals(NotificationChannels.CHANNEL_DEPOSIT_WITHDRAWAL, channel.id)
+ }
+
+ @Test
+ fun `BUY_SELL category produces expected channel ID`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.BUY_SELL)
+ assertEquals(NotificationChannels.CHANNEL_BUY_SELL, channel.id)
+ }
+
+ @Test
+ fun `GAIN category produces expected channel ID`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.GAIN)
+ assertEquals(NotificationChannels.CHANNEL_GAIN, channel.id)
+ }
+
+ @Test
+ fun `CHAT category produces expected channel ID`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.CHAT)
+ assertEquals(NotificationChannels.CHANNEL_CHAT, channel.id)
+ }
+
+ @Test
+ fun `CONTACT_JOIN category produces expected channel ID`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.CONTACT_JOIN)
+ assertEquals(NotificationChannels.CHANNEL_CONTACT_JOIN, channel.id)
+ }
+
+ // endregion
+
+ // region Channel names
+
+ @Test
+ fun `DEFAULT channel has name Misc`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.DEFAULT)
+ assertEquals("Misc.", channel.name.toString())
+ }
+
+ @Test
+ fun `DEPOSIT_WITHDRAWAL channel has correct name`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.DEPOSIT_WITHDRAWAL)
+ assertEquals("Deposits & Withdrawals", channel.name.toString())
+ }
+
+ @Test
+ fun `BUY_SELL channel has correct name`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.BUY_SELL)
+ assertEquals("Buys & Sells", channel.name.toString())
+ }
+
+ @Test
+ fun `GAIN channel has correct name`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.GAIN)
+ assertEquals("Price Alerts", channel.name.toString())
+ }
+
+ @Test
+ fun `CHAT channel has correct name`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.CHAT)
+ assertEquals("Chat Messages", channel.name.toString())
+ }
+
+ @Test
+ fun `CONTACT_JOIN channel has correct name`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.CONTACT_JOIN)
+ assertEquals("New Contacts", channel.name.toString())
+ }
+
+ // endregion
+
+ // region Importance levels
+
+ @Test
+ fun `DEFAULT category has IMPORTANCE_DEFAULT`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.DEFAULT)
+ assertEquals(NotificationManagerCompat.IMPORTANCE_DEFAULT, channel.importance)
+ }
+
+ @Test
+ fun `all non-DEFAULT categories have IMPORTANCE_HIGH`() {
+ val highCategories = NotificationCategory.entries.filter { it != NotificationCategory.DEFAULT }
+ for (category in highCategories) {
+ val channel = NotificationChannels.channelFor(context,category)
+ assertEquals(
+ NotificationManagerCompat.IMPORTANCE_HIGH,
+ channel.importance,
+ "Expected IMPORTANCE_HIGH for $category"
+ )
+ }
+ }
+
+ // endregion
+
+ // region All enum values covered
+
+ @Test
+ fun `all NotificationCategory entries produce a non-null channel`() {
+ for (category in NotificationCategory.entries) {
+ val channel = NotificationChannels.channelFor(context,category)
+ assertTrue(channel.id.isNotEmpty(), "Channel ID should not be empty for $category")
+ }
+ }
+
+ // endregion
+
+ // region Channel descriptions
+
+ @Test
+ fun `all channels have a non-empty description`() {
+ for (category in NotificationCategory.entries) {
+ val channel = NotificationChannels.channelFor(context, category)
+ assertNotNull(channel.description, "Description should not be null for $category")
+ assertTrue(channel.description!!.isNotEmpty(), "Description should not be empty for $category")
+ }
+ }
+
+ // endregion
+
+ // region Show badge
+
+ @Test
+ fun `DEFAULT channel does not show badge`() {
+ val channel = NotificationChannels.channelFor(context, NotificationCategory.DEFAULT)
+ assertFalse(channel.canShowBadge(), "DEFAULT should not show badge")
+ }
+
+ @Test
+ fun `CONTACT_JOIN channel does not show badge`() {
+ val channel = NotificationChannels.channelFor(context, NotificationCategory.CONTACT_JOIN)
+ assertFalse(channel.canShowBadge(), "CONTACT_JOIN should not show badge")
+ }
+
+ @Test
+ fun `transaction and chat channels show badge`() {
+ val badgeCategories = listOf(
+ NotificationCategory.DEPOSIT_WITHDRAWAL,
+ NotificationCategory.BUY_SELL,
+ NotificationCategory.GAIN,
+ NotificationCategory.CHAT,
+ )
+ for (category in badgeCategories) {
+ val channel = NotificationChannels.channelFor(context, category)
+ assertTrue(channel.canShowBadge(), "Expected badge for $category")
+ }
+ }
+
+ // endregion
+
+ // region Channel groups
+
+ @Test
+ fun `transaction channels belong to the transactions group`() {
+ val transactionCategories = listOf(
+ NotificationCategory.DEPOSIT_WITHDRAWAL,
+ NotificationCategory.BUY_SELL,
+ NotificationCategory.GAIN,
+ )
+ for (category in transactionCategories) {
+ val channel = NotificationChannels.channelFor(context, category)
+ assertEquals("group_transactions", channel.group, "Expected transactions group for $category")
+ }
+ }
+
+ @Test
+ fun `social channels belong to the social group`() {
+ val socialCategories = listOf(
+ NotificationCategory.CHAT,
+ NotificationCategory.CONTACT_JOIN,
+ )
+ for (category in socialCategories) {
+ val channel = NotificationChannels.channelFor(context, category)
+ assertEquals("group_social", channel.group, "Expected social group for $category")
+ }
+ }
+
+ @Test
+ fun `DEFAULT channel has no group`() {
+ val channel = NotificationChannels.channelFor(context, NotificationCategory.DEFAULT)
+ assertNull(channel.group)
+ }
+
+ // endregion
+
+ // region Notification grouping
+
+ @Test
+ fun `two notifications with same groupKey both remain active`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.GAIN)
+ notificationManager.createNotificationChannel(channel)
+
+ val groupKey = "price_alerts_group"
+
+ val n1 = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("Alert 1")
+ .setGroup(groupKey)
+ .build()
+
+ val n2 = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("Alert 2")
+ .setGroup(groupKey)
+ .build()
+
+ notificationManager.notify(1, n1)
+ notificationManager.notify(2, n2)
+
+ val systemManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val active = systemManager.activeNotifications
+ assertEquals(2, active.size)
+ }
+
+ @Test
+ fun `grouped notifications have matching group key`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.CHAT)
+ notificationManager.createNotificationChannel(channel)
+
+ val groupKey = "chat_group"
+
+ val n1 = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("Message 1")
+ .setGroup(groupKey)
+ .build()
+
+ val n2 = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("Message 2")
+ .setGroup(groupKey)
+ .build()
+
+ notificationManager.notify(10, n1)
+ notificationManager.notify(11, n2)
+
+ val systemManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val active = systemManager.activeNotifications
+ for (sbn in active) {
+ assertEquals(groupKey, sbn.notification.group)
+ }
+ }
+
+ @Test
+ fun `summary notification exists with isGroupSummary true`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.GAIN)
+ notificationManager.createNotificationChannel(channel)
+
+ val groupKey = "gain_group"
+
+ val individual = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("Gain")
+ .setGroup(groupKey)
+ .build()
+
+ val summary = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setGroup(groupKey)
+ .setGroupSummary(true)
+ .build()
+
+ notificationManager.notify(20, individual)
+ notificationManager.notify(groupKey.hashCode(), summary)
+
+ val systemManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val active = systemManager.activeNotifications
+ val summaryNotification = active.find { it.id == groupKey.hashCode() }
+ assertTrue(
+ summaryNotification != null &&
+ (summaryNotification.notification.flags and NotificationCompat.FLAG_GROUP_SUMMARY) != 0
+ )
+ }
+
+ @Test
+ fun `notification without groupKey has no group set`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.DEFAULT)
+ notificationManager.createNotificationChannel(channel)
+
+ val notification = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("Standalone")
+ .build()
+
+ notificationManager.notify(30, notification)
+
+ val systemManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val active = systemManager.activeNotifications
+ val posted = active.find { it.id == 30 }
+ assertNull(posted?.notification?.group)
+ }
+
+ @Test
+ fun `notifications without a group each have unique IDs`() {
+ val channel = NotificationChannels.channelFor(context,NotificationCategory.DEFAULT)
+ notificationManager.createNotificationChannel(channel)
+
+ val n1 = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("A")
+ .build()
+
+ val n2 = NotificationCompat.Builder(context, channel.id)
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
+ .setContentTitle("B")
+ .build()
+
+ val id1 = 40
+ val id2 = 41
+ notificationManager.notify(id1, n1)
+ notificationManager.notify(id2, n2)
+
+ assertNotEquals(id1, id2)
+
+ val systemManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val active = systemManager.activeNotifications
+ assertEquals(2, active.size)
+ }
+
+ // endregion
+}
diff --git a/definitions/flipcash/protos/src/main/proto/common/v1/common.proto b/definitions/flipcash/protos/src/main/proto/common/v1/common.proto
index 752669f6b..d43c840dd 100644
--- a/definitions/flipcash/protos/src/main/proto/common/v1/common.proto
+++ b/definitions/flipcash/protos/src/main/proto/common/v1/common.proto
@@ -22,6 +22,13 @@ message Signature {
}];
}
+message Hash {
+ bytes value = 1 [(validate.rules).bytes = {
+ min_len: 32
+ max_len: 32
+ }];
+}
+
// Auth provides an authentication information for RPCs/messages.
//
// Currently, only a single form is supported, but it may be useful in
diff --git a/definitions/flipcash/protos/src/main/proto/push/v1/model.proto b/definitions/flipcash/protos/src/main/proto/push/v1/model.proto
index ae040fc32..41a8d35c5 100644
--- a/definitions/flipcash/protos/src/main/proto/push/v1/model.proto
+++ b/definitions/flipcash/protos/src/main/proto/push/v1/model.proto
@@ -7,6 +7,7 @@ option java_package = "com.codeinc.flipcash.gen.push.v1";
option objc_class_prefix = "FPBPushV1";
import "common/v1/common.proto";
+import "phone/v1/model.proto";
import "validate/validate.proto";
enum TokenType {
@@ -21,6 +22,29 @@ enum TokenType {
message Payload {
// If present, where the app should navigate to after clicking the push
Navigation navigation = 1;
+
+ // Ordered substitutions to apply to push title
+ repeated Substitution title_substitutions = 2;
+
+ // Ordered substitutions to apply to push body
+ repeated Substitution body_substitutions = 3;
+
+ // Push notification category
+ Category category = 4;
+ enum Category {
+ DEFAULT = 0;
+ DEPOSIT_WITHDRAWAL = 1;
+ BUY_SELL = 2;
+ GAIN = 3;
+ CHAT = 4;
+ CONTACT_JOIN = 5;
+ }
+
+ // Push notification key for grouping pushes. If not set, then no grouping
+ // is applied.
+ string group_key = 5 [(validate.rules).string = {
+ max_len: 4096 // Arbitrary
+ }];
}
// Navigation within the app upon clicking the push
@@ -32,3 +56,18 @@ message Navigation {
common.v1.PublicKey currency_info = 1;
}
}
+
+message Substitution {
+ // Fallback string for forwards compatibility
+ string fallback = 1 [(validate.rules).string = {
+ min_len: 1
+ max_len: 4096 // Arbitrary
+ }];
+
+ oneof kind {
+ option (validate.required) = true;
+
+ // Phone number -> contact name or formatted phone number
+ phone.v1.PhoneNumber contact = 2;
+ }
+}
\ No newline at end of file
diff --git a/scripts/fcm-buy-token-group-test.sh b/scripts/fcm-buy-token-group-test.sh
new file mode 100755
index 000000000..85fcf806f
--- /dev/null
+++ b/scripts/fcm-buy-token-group-test.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+# Sends multiple buy-token notifications to verify on-device grouping.
+# Usage: ./scripts/fcm-buy-token-group-test.sh [mint]
+#
+# Default mint is 54ggcQ23uen5b9QXMAns99MQNTKn7iyzq4wvCW6e8r25 (Jeffy).
+# Sends 3 notifications in quick succession — check the notification shade
+# to confirm they appear bundled under a single group.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+DEVICE_TOKEN=$1
+MINT=${2:-"54ggcQ23uen5b9QXMAns99MQNTKn7iyzq4wvCW6e8r25"}
+
+if [ -z "$DEVICE_TOKEN" ]; then
+ echo "Usage: $0 [mint]"
+ exit 1
+fi
+
+encode_payload() {
+ local mint="$1"
+ local group_key="$2"
+ python3 -c "
+import base64, base58
+def varint(v):
+ r = b''
+ while v > 0x7F:
+ r += bytes([(v & 0x7F) | 0x80]); v >>= 7
+ return r + bytes([v & 0x7F])
+def field_bytes(num, data):
+ return varint((num << 3) | 2) + varint(len(data)) + data
+def field_varint(num, val):
+ return varint((num << 3) | 0) + varint(val)
+mint = base58.b58decode('$mint')
+assert len(mint) == 32, f'Expected 32-byte key, got {len(mint)}'
+# field 1: navigation (Navigation message with currency_info)
+nav = field_bytes(1, field_bytes(1, field_bytes(1, mint)))
+# field 4: category = BUY_SELL (enum value 2)
+cat = field_varint(4, 2)
+# field 5: group_key
+gk = field_bytes(5, b'$group_key')
+print(base64.b64encode(nav + cat + gk).decode())
+"
+}
+
+GROUP_KEY="buy_sell_${MINT}"
+PAYLOAD=$(encode_payload "$MINT" "$GROUP_KEY")
+
+notifications=(
+ "Purchase Successful|You received 100.0 JEFFY"
+ "Purchase Successful|You received 250.0 JEFFY"
+ "Purchase Successful|You received 50.0 JEFFY"
+)
+
+echo "Sending ${#notifications[@]} buy-token notifications to test grouping..."
+echo ""
+
+for entry in "${notifications[@]}"; do
+ IFS='|' read -r title body <<< "$entry"
+
+ JSON_PAYLOAD=$(jq -n \
+ --arg payload "$PAYLOAD" \
+ --arg title "$title" \
+ --arg body "$body" \
+ '{
+ flipcash_payload: $payload,
+ push_notification_title: $title,
+ push_notification_body: $body
+ }')
+
+ echo "→ Sending: $title — $body"
+ "$SCRIPT_DIR/fcm.sh" "$DEVICE_TOKEN" "$JSON_PAYLOAD"
+ echo ""
+ sleep 1
+done
+
+echo "Done. Check the notification shade — all 3 should be grouped together."
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 eb70f8158..f32e7054f 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
@@ -9,6 +9,7 @@ import com.codeinc.flipcash.gen.push.v1.Model as PushModels
import com.flipcash.services.internal.extensions.toMint
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.getcode.opencode.model.core.ID
import com.getcode.solana.keys.Mint
@@ -32,8 +33,19 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload {
}
}
+ val notificationCategory = when (category) {
+ PushModels.Payload.Category.DEPOSIT_WITHDRAWAL -> NotificationCategory.DEPOSIT_WITHDRAWAL
+ PushModels.Payload.Category.BUY_SELL -> NotificationCategory.BUY_SELL
+ PushModels.Payload.Category.GAIN -> NotificationCategory.GAIN
+ PushModels.Payload.Category.CHAT -> NotificationCategory.CHAT
+ PushModels.Payload.Category.CONTACT_JOIN -> NotificationCategory.CONTACT_JOIN
+ else -> NotificationCategory.DEFAULT
+ }
+
return NotificationPayload(
- navigation = navigationTrigger
+ navigation = navigationTrigger,
+ category = notificationCategory,
+ groupKey = groupKey,
)
}
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 54126ef25..5030d9b9d 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
@@ -5,8 +5,19 @@ import com.flipcash.services.internal.network.extensions.asPayload
import com.getcode.solana.keys.Mint
import com.getcode.utils.decodeBase64
+enum class NotificationCategory {
+ DEFAULT,
+ DEPOSIT_WITHDRAWAL,
+ BUY_SELL,
+ GAIN,
+ CHAT,
+ CONTACT_JOIN,
+}
+
data class NotificationPayload(
- val navigation: NavigationTrigger?
+ val navigation: NavigationTrigger?,
+ val category: NotificationCategory = NotificationCategory.DEFAULT,
+ val groupKey: String = "",
) {
companion object {
fun fromEncoded(encoded: String): NotificationPayload? {