Skip to content

Commit 2693c48

Browse files
committed
feat(notifications): add channel routing, grouping, and tests
Route push notifications to dedicated channels based on payload category (Buys & Sells, Deposits & Withdrawals, Price Alerts, Chat, New Contacts). Fix notification grouping by using unique IDs per notification with a summary notification for the group, instead of reusing groupKey.hashCode() which caused same-group notifications to replace each other. Add Robolectric tests for channel mapping and grouping behavior, and a test script for on-device verification. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent e53d2cd commit 2693c48

8 files changed

Lines changed: 605 additions & 16 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ plugins {
44

55
android {
66
namespace = "${Gradle.flipcashNamespace}.shared.notifications"
7+
testOptions.unitTests.isIncludeAndroidResources = true
78
}
89

910
dependencies {
@@ -16,4 +17,7 @@ dependencies {
1617
implementation(libs.firebase.messaging)
1718

1819
implementation(libs.androidx.datastore)
20+
21+
testImplementation(kotlin("test"))
22+
testImplementation(libs.robolectric)
1923
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package com.flipcash.app.notifications
2+
3+
import android.content.Context
4+
import androidx.annotation.StringRes
5+
import androidx.core.app.NotificationChannelCompat
6+
import androidx.core.app.NotificationChannelGroupCompat
7+
import androidx.core.app.NotificationManagerCompat
8+
import com.flipcash.services.models.NotificationCategory
9+
import com.flipcash.shared.notifications.R
10+
11+
object NotificationChannels {
12+
const val CHANNEL_DEFAULT = "fcm_fallback_notification_channel"
13+
const val CHANNEL_DEPOSIT_WITHDRAWAL = "channel_deposit_withdrawal"
14+
const val CHANNEL_BUY_SELL = "channel_buy_sell"
15+
const val CHANNEL_GAIN = "channel_gain"
16+
const val CHANNEL_CHAT = "channel_chat"
17+
const val CHANNEL_CONTACT_JOIN = "channel_contact_join"
18+
19+
private const val GROUP_TRANSACTIONS = "group_transactions"
20+
private const val GROUP_SOCIAL = "group_social"
21+
22+
private data class ChannelDef(
23+
val id: String,
24+
@StringRes val nameRes: Int,
25+
@StringRes val descriptionRes: Int,
26+
val importance: Int,
27+
val groupId: String? = null,
28+
val showBadge: Boolean = true,
29+
)
30+
31+
fun ensureChannelGroups(context: Context, manager: NotificationManagerCompat) {
32+
val groups = listOf(
33+
NotificationChannelGroupCompat.Builder(GROUP_TRANSACTIONS)
34+
.setName(context.getString(R.string.notification_group_transactions))
35+
.build(),
36+
NotificationChannelGroupCompat.Builder(GROUP_SOCIAL)
37+
.setName(context.getString(R.string.notification_group_social))
38+
.build(),
39+
)
40+
groups.forEach { manager.createNotificationChannelGroup(it) }
41+
}
42+
43+
fun channelFor(context: Context, category: NotificationCategory): NotificationChannelCompat {
44+
val def = when (category) {
45+
NotificationCategory.DEFAULT -> ChannelDef(
46+
id = CHANNEL_DEFAULT,
47+
nameRes = R.string.notification_channel_default,
48+
descriptionRes = R.string.notification_channel_default_description,
49+
importance = NotificationManagerCompat.IMPORTANCE_DEFAULT,
50+
showBadge = false,
51+
)
52+
NotificationCategory.DEPOSIT_WITHDRAWAL -> ChannelDef(
53+
id = CHANNEL_DEPOSIT_WITHDRAWAL,
54+
nameRes = R.string.notification_channel_deposit_withdrawal,
55+
descriptionRes = R.string.notification_channel_deposit_withdrawal_description,
56+
importance = NotificationManagerCompat.IMPORTANCE_HIGH,
57+
groupId = GROUP_TRANSACTIONS,
58+
)
59+
NotificationCategory.BUY_SELL -> ChannelDef(
60+
id = CHANNEL_BUY_SELL,
61+
nameRes = R.string.notification_channel_buy_sell,
62+
descriptionRes = R.string.notification_channel_buy_sell_description,
63+
importance = NotificationManagerCompat.IMPORTANCE_HIGH,
64+
groupId = GROUP_TRANSACTIONS,
65+
)
66+
NotificationCategory.GAIN -> ChannelDef(
67+
id = CHANNEL_GAIN,
68+
nameRes = R.string.notification_channel_gain,
69+
descriptionRes = R.string.notification_channel_gain_description,
70+
importance = NotificationManagerCompat.IMPORTANCE_HIGH,
71+
groupId = GROUP_TRANSACTIONS,
72+
)
73+
NotificationCategory.CHAT -> ChannelDef(
74+
id = CHANNEL_CHAT,
75+
nameRes = R.string.notification_channel_chat,
76+
descriptionRes = R.string.notification_channel_chat_description,
77+
importance = NotificationManagerCompat.IMPORTANCE_HIGH,
78+
groupId = GROUP_SOCIAL,
79+
)
80+
NotificationCategory.CONTACT_JOIN -> ChannelDef(
81+
id = CHANNEL_CONTACT_JOIN,
82+
nameRes = R.string.notification_channel_contact_join,
83+
descriptionRes = R.string.notification_channel_contact_join_description,
84+
importance = NotificationManagerCompat.IMPORTANCE_HIGH,
85+
groupId = GROUP_SOCIAL,
86+
showBadge = false,
87+
)
88+
}
89+
90+
return NotificationChannelCompat.Builder(def.id, def.importance)
91+
.setName(context.getString(def.nameRes))
92+
.setDescription(context.getString(def.descriptionRes))
93+
.setShowBadge(def.showBadge)
94+
.apply { def.groupId?.let { setGroup(it) } }
95+
.build()
96+
}
97+
}

apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
package com.flipcash.app.notifications
22

33
import android.Manifest
4-
import android.app.NotificationChannel
54
import android.app.PendingIntent
65
import android.content.Context
76
import android.content.Intent
87
import android.content.pm.PackageManager
98
import android.media.RingtoneManager
10-
import android.net.Uri
11-
import androidx.compose.ui.graphics.Color
12-
import androidx.compose.ui.graphics.toArgb
139
import androidx.core.app.ActivityCompat
14-
import androidx.core.app.NotificationChannelCompat
1510
import androidx.core.app.NotificationCompat
1611
import androidx.core.app.NotificationManagerCompat
1712
import androidx.core.net.toUri
@@ -20,10 +15,10 @@ import com.flipcash.app.core.util.Linkify
2015
import com.flipcash.app.tokens.TokenCoordinator
2116
import com.flipcash.services.controllers.PushController
2217
import com.flipcash.services.models.NavigationTrigger
18+
import com.flipcash.services.models.NotificationCategory
2319
import com.flipcash.services.models.NotificationPayload
2420
import com.flipcash.services.user.UserManager
2521
import com.flipcash.shared.notifications.R
26-
import com.getcode.opencode.controllers.TokenController
2722
import com.getcode.utils.TraceType
2823
import com.getcode.utils.trace
2924
import com.google.firebase.messaging.FirebaseMessagingService
@@ -74,12 +69,6 @@ class NotificationService : FirebaseMessagingService(),
7469
override fun onMessageReceived(message: RemoteMessage) {
7570
super.onMessageReceived(message)
7671

77-
// dump everything into FCM fallback channel for now
78-
val channel = NotificationChannelCompat.Builder(
79-
"fcm_fallback_notification_channel",
80-
NotificationManagerCompat.IMPORTANCE_DEFAULT
81-
).setName("Misc.").build()
82-
8372
val title = message.data["push_notification_title"]?.ifEmpty { message.notification?.title }
8473
val body = message.data["push_notification_body"]?.ifEmpty { message.notification?.body }
8574

@@ -108,8 +97,13 @@ class NotificationService : FirebaseMessagingService(),
10897
}
10998
}
11099

100+
val category = payload?.category ?: NotificationCategory.DEFAULT
101+
NotificationChannels.ensureChannelGroups(this, notificationManager)
102+
val channel = NotificationChannels.channelFor(this, category)
111103
notificationManager.createNotificationChannel(channel)
112104

105+
val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() }
106+
113107
val notificationBuilder: NotificationCompat.Builder =
114108
NotificationCompat.Builder(this, channel.id)
115109
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -120,16 +114,27 @@ class NotificationService : FirebaseMessagingService(),
120114
.setContentTitle(title)
121115
.setContentText(body)
122116
.setContentIntent(buildContentIntent(payload?.navigation))
117+
.apply { if (groupKey != null) setGroup(groupKey) }
123118

124-
val random = SecureRandom()
125-
val notificationId = random.nextInt(256)
119+
val notificationId = SecureRandom().nextInt(Int.MAX_VALUE)
126120

127121
if (ActivityCompat.checkSelfPermission(
128122
this,
129123
Manifest.permission.POST_NOTIFICATIONS
130124
) == PackageManager.PERMISSION_GRANTED
131125
) {
132126
notificationManager.notify(notificationId, notificationBuilder.build())
127+
128+
if (groupKey != null) {
129+
val summary = NotificationCompat.Builder(this, channel.id)
130+
.setSmallIcon(R.drawable.flipcash_logo)
131+
.setColor(getColor(R.color.notification_color))
132+
.setGroup(groupKey)
133+
.setGroupSummary(true)
134+
.setAutoCancel(true)
135+
.build()
136+
notificationManager.notify(groupKey.hashCode(), summary)
137+
}
133138
}
134139
}
135140

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<resources>
3+
<!-- Channel names -->
4+
<string name="notification_channel_default">Misc.</string>
5+
<string name="notification_channel_deposit_withdrawal">Deposits &amp; Withdrawals</string>
6+
<string name="notification_channel_buy_sell">Buys &amp; Sells</string>
7+
<string name="notification_channel_gain">Price Alerts</string>
8+
<string name="notification_channel_chat">Chat Messages</string>
9+
<string name="notification_channel_contact_join">New Contacts</string>
10+
11+
<!-- Channel descriptions -->
12+
<string name="notification_channel_default_description">General notifications</string>
13+
<string name="notification_channel_deposit_withdrawal_description">Deposit and withdrawal confirmations</string>
14+
<string name="notification_channel_buy_sell_description">Token purchase and sale confirmations</string>
15+
<string name="notification_channel_gain_description">Token price movement alerts</string>
16+
<string name="notification_channel_chat_description">Incoming chat messages</string>
17+
<string name="notification_channel_contact_join_description">When someone you know joins Flipcash</string>
18+
19+
<!-- Channel group names -->
20+
<string name="notification_group_transactions">Transactions</string>
21+
<string name="notification_group_social">Social</string>
22+
</resources>

0 commit comments

Comments
 (0)