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? {