P4 Stream N: FCM service + NotificationChannels matching iOS categories
FcmService + NotificationPayload + 4 NotificationChannels (task_reminder, task_overdue, residence_invite, subscription) parity with iOS NotificationCategories.swift. Deep-link routing from payload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,9 +74,11 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<!-- Firebase Cloud Messaging Service -->
|
<!-- Firebase Cloud Messaging Service (iOS-parity, P4 Stream N).
|
||||||
|
Routes incoming data-messages into iOS-equivalent channels
|
||||||
|
(task_reminder, task_overdue, residence_invite, subscription). -->
|
||||||
<service
|
<service
|
||||||
android:name=".MyFirebaseMessagingService"
|
android:name=".notifications.FcmService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.tt.honeyDue.notifications
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import com.tt.honeyDue.MainActivity
|
||||||
|
import com.tt.honeyDue.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New-generation FirebaseMessagingService built for iOS parity. Routes each
|
||||||
|
* incoming FCM data-message to the correct [NotificationChannels] channel,
|
||||||
|
* attaches a deep-link PendingIntent when present, and uses a hashed
|
||||||
|
* messageId as the notification id so duplicate redeliveries replace (not
|
||||||
|
* stack).
|
||||||
|
*
|
||||||
|
* This lives alongside the legacy `MyFirebaseMessagingService`. The manifest
|
||||||
|
* currently wires FCM to this service; the legacy class is retained until
|
||||||
|
* its call sites (widget + notification-action flows) are migrated.
|
||||||
|
*/
|
||||||
|
class FcmService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
super.onNewToken(token)
|
||||||
|
Log.d(TAG, "FCM token refreshed (len=${token.length})")
|
||||||
|
// TODO: wire token registration to APILayer.registerDevice.
|
||||||
|
// Registration is already performed by MyFirebaseMessagingService.onNewToken
|
||||||
|
// at composeApp/src/androidMain/kotlin/com/tt/honeyDue/MyFirebaseMessagingService.kt:20-65
|
||||||
|
// using NotificationApi().registerDevice(...). When that legacy service is
|
||||||
|
// removed, port the same call path here (auth-token guard + device-id +
|
||||||
|
// platform="android"). P4 Stream N scope is receive-side only.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
super.onMessageReceived(message)
|
||||||
|
|
||||||
|
val payload = NotificationPayload.parse(message.data)
|
||||||
|
if (payload == null) {
|
||||||
|
Log.w(TAG, "Dropping malformed FCM payload (keys=${message.data.keys})")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure channels exist — safe to call every time.
|
||||||
|
NotificationChannels.ensureChannels(this)
|
||||||
|
|
||||||
|
val channelId = NotificationChannels.channelIdForType(payload.type)
|
||||||
|
val notification = buildNotification(payload, channelId, message.messageId)
|
||||||
|
|
||||||
|
val notificationId = (message.messageId ?: payload.deepLink ?: payload.title)
|
||||||
|
.hashCode()
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(this).apply {
|
||||||
|
if (areNotificationsEnabled()) {
|
||||||
|
notify(notificationId, notification)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Notifications disabled — skipping notify()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(
|
||||||
|
payload: NotificationPayload,
|
||||||
|
channelId: String,
|
||||||
|
messageId: String?
|
||||||
|
): android.app.Notification {
|
||||||
|
val contentIntent = buildContentIntent(payload, messageId)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, channelId)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(payload.title.ifBlank { getString(R.string.app_name) })
|
||||||
|
.setContentText(payload.body)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(payload.body))
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(contentIntent)
|
||||||
|
.setPriority(priorityForChannel(channelId))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildContentIntent(
|
||||||
|
payload: NotificationPayload,
|
||||||
|
messageId: String?
|
||||||
|
): PendingIntent {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
// Deep-link → set data URI so the launcher activity can route from the
|
||||||
|
// existing intent-filter for the honeydue:// scheme.
|
||||||
|
payload.deepLink?.let { data = Uri.parse(it) }
|
||||||
|
payload.taskId?.let { putExtra(EXTRA_TASK_ID, it) }
|
||||||
|
payload.residenceId?.let { putExtra(EXTRA_RESIDENCE_ID, it) }
|
||||||
|
putExtra(EXTRA_TYPE, payload.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
val requestCode = (messageId ?: payload.deepLink ?: payload.title).hashCode()
|
||||||
|
return PendingIntent.getActivity(this, requestCode, intent, flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun priorityForChannel(channelId: String): Int = when (channelId) {
|
||||||
|
NotificationChannels.TASK_OVERDUE -> NotificationCompat.PRIORITY_HIGH
|
||||||
|
NotificationChannels.SUBSCRIPTION -> NotificationCompat.PRIORITY_LOW
|
||||||
|
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FcmService"
|
||||||
|
const val EXTRA_TASK_ID = "fcm_task_id"
|
||||||
|
const val EXTRA_RESIDENCE_ID = "fcm_residence_id"
|
||||||
|
const val EXTRA_TYPE = "fcm_type"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.tt.honeyDue.notifications
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android NotificationChannels that map to the iOS UNNotificationCategory
|
||||||
|
* identifiers defined in `iosApp/iosApp/PushNotifications/NotificationCategories.swift`.
|
||||||
|
*
|
||||||
|
* iOS uses categories + per-notification actions. Android uses channels for
|
||||||
|
* importance grouping. Channels here mirror the four high-level iOS tones:
|
||||||
|
*
|
||||||
|
* task_reminder — default importance (upcoming/due-soon reminders)
|
||||||
|
* task_overdue — high importance (user is late, needs attention)
|
||||||
|
* residence_invite — default importance (social-style invite, not urgent)
|
||||||
|
* subscription — low importance (billing/status changes, passive info)
|
||||||
|
*
|
||||||
|
* User-visible names and descriptions match the keys in
|
||||||
|
* `composeApp/src/commonMain/composeResources/values/strings.xml`
|
||||||
|
* (`notif_channel_*_name`, `notif_channel_*_description`).
|
||||||
|
*/
|
||||||
|
object NotificationChannels {
|
||||||
|
|
||||||
|
const val TASK_REMINDER: String = "task_reminder"
|
||||||
|
const val TASK_OVERDUE: String = "task_overdue"
|
||||||
|
const val RESIDENCE_INVITE: String = "residence_invite"
|
||||||
|
const val SUBSCRIPTION: String = "subscription"
|
||||||
|
|
||||||
|
// English fallback strings. These are duplicated in composeResources
|
||||||
|
// strings.xml under the matching notif_channel_* keys so localised builds
|
||||||
|
// can override them. Services without access to Compose resources fall
|
||||||
|
// back to these values.
|
||||||
|
private const val NAME_TASK_REMINDER = "Task Reminders"
|
||||||
|
private const val NAME_TASK_OVERDUE = "Overdue Tasks"
|
||||||
|
private const val NAME_RESIDENCE_INVITE = "Residence Invites"
|
||||||
|
private const val NAME_SUBSCRIPTION = "Subscription Updates"
|
||||||
|
|
||||||
|
private const val DESC_TASK_REMINDER = "Upcoming and due-soon task reminders"
|
||||||
|
private const val DESC_TASK_OVERDUE = "Alerts when a task is past its due date"
|
||||||
|
private const val DESC_RESIDENCE_INVITE = "Invitations to join a shared residence"
|
||||||
|
private const val DESC_SUBSCRIPTION = "Subscription status and billing updates"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create all four channels if they don't already exist. Safe to call
|
||||||
|
* repeatedly — `NotificationManagerCompat.createNotificationChannel` is
|
||||||
|
* a no-op when a channel with the same id already exists.
|
||||||
|
*/
|
||||||
|
fun ensureChannels(context: Context) {
|
||||||
|
// Channels only exist on O+; on older versions this is a no-op and the
|
||||||
|
// NotificationCompat layer ignores channel ids.
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
|
||||||
|
val compat = NotificationManagerCompat.from(context)
|
||||||
|
val channels = listOf(
|
||||||
|
NotificationChannel(
|
||||||
|
TASK_REMINDER,
|
||||||
|
NAME_TASK_REMINDER,
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply { description = DESC_TASK_REMINDER },
|
||||||
|
NotificationChannel(
|
||||||
|
TASK_OVERDUE,
|
||||||
|
NAME_TASK_OVERDUE,
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply { description = DESC_TASK_OVERDUE },
|
||||||
|
NotificationChannel(
|
||||||
|
RESIDENCE_INVITE,
|
||||||
|
NAME_RESIDENCE_INVITE,
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT
|
||||||
|
).apply { description = DESC_RESIDENCE_INVITE },
|
||||||
|
NotificationChannel(
|
||||||
|
SUBSCRIPTION,
|
||||||
|
NAME_SUBSCRIPTION,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply { description = DESC_SUBSCRIPTION }
|
||||||
|
)
|
||||||
|
channels.forEach { compat.createNotificationChannel(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a [NotificationPayload.type] string to a channel id. Unknown types
|
||||||
|
* fall back to [TASK_REMINDER] (default importance, safe default).
|
||||||
|
*/
|
||||||
|
fun channelIdForType(type: String): String = when (type) {
|
||||||
|
TASK_OVERDUE -> TASK_OVERDUE
|
||||||
|
RESIDENCE_INVITE -> RESIDENCE_INVITE
|
||||||
|
SUBSCRIPTION -> SUBSCRIPTION
|
||||||
|
TASK_REMINDER -> TASK_REMINDER
|
||||||
|
else -> TASK_REMINDER
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.tt.honeyDue.notifications
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structured representation of a Firebase Cloud Messaging data-payload for
|
||||||
|
* iOS-parity notification types (task_reminder, task_overdue, residence_invite,
|
||||||
|
* subscription).
|
||||||
|
*
|
||||||
|
* Mirrors the iOS `PushNotificationManager.swift` userInfo handling. The
|
||||||
|
* backend sends a `data` map only (no `notification` field) so we can always
|
||||||
|
* deliver actionable payloads regardless of app foreground state.
|
||||||
|
*/
|
||||||
|
data class NotificationPayload(
|
||||||
|
val type: String,
|
||||||
|
val taskId: Long?,
|
||||||
|
val residenceId: Long?,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val deepLink: String?
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// Keys used by the backend. Kept in a single place so they can be updated
|
||||||
|
// in lockstep with the Go API `internal/notification/` constants.
|
||||||
|
private const val KEY_TYPE = "type"
|
||||||
|
private const val KEY_TASK_ID = "task_id"
|
||||||
|
private const val KEY_RESIDENCE_ID = "residence_id"
|
||||||
|
private const val KEY_TITLE = "title"
|
||||||
|
private const val KEY_BODY = "body"
|
||||||
|
private const val KEY_DEEP_LINK = "deep_link"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a raw FCM data map into a [NotificationPayload], or null if the
|
||||||
|
* payload is missing the minimum required fields (type + at least one of
|
||||||
|
* title/body). Numeric id fields that fail to parse are treated as null
|
||||||
|
* (rather than failing the whole payload) so we still surface the text.
|
||||||
|
*/
|
||||||
|
fun parse(data: Map<String, String>): NotificationPayload? {
|
||||||
|
val type = data[KEY_TYPE]?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
val title = data[KEY_TITLE]?.takeIf { it.isNotBlank() }
|
||||||
|
val body = data[KEY_BODY]?.takeIf { it.isNotBlank() }
|
||||||
|
// Require at least one of title/body, otherwise there's nothing to show.
|
||||||
|
if (title == null && body == null) return null
|
||||||
|
|
||||||
|
return NotificationPayload(
|
||||||
|
type = type,
|
||||||
|
taskId = data[KEY_TASK_ID]?.toLongOrNull(),
|
||||||
|
residenceId = data[KEY_RESIDENCE_ID]?.toLongOrNull(),
|
||||||
|
title = title ?: "",
|
||||||
|
body = body ?: "",
|
||||||
|
deepLink = data[KEY_DEEP_LINK]?.takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.tt.honeyDue.notifications
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.Robolectric
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for [FcmService] — verify channel routing and deep-link handling
|
||||||
|
* when data messages arrive.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||||
|
class FcmServiceTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var manager: NotificationManager
|
||||||
|
private lateinit var service: FcmService
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
manager.notificationChannels.forEach { manager.deleteNotificationChannel(it.id) }
|
||||||
|
manager.cancelAll()
|
||||||
|
service = Robolectric.setupService(FcmService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun remoteMessage(id: String, data: Map<String, String>): RemoteMessage {
|
||||||
|
val b = RemoteMessage.Builder("test@fcm")
|
||||||
|
b.setMessageId(id)
|
||||||
|
data.forEach { (k, v) -> b.addData(k, v) }
|
||||||
|
return b.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onMessageReceived_routesTaskReminder_toCorrectChannel() {
|
||||||
|
val msg = remoteMessage(
|
||||||
|
id = "m-1",
|
||||||
|
data = mapOf(
|
||||||
|
"type" to "task_reminder",
|
||||||
|
"task_id" to "7",
|
||||||
|
"title" to "Reminder",
|
||||||
|
"body" to "Do it"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.onMessageReceived(msg)
|
||||||
|
|
||||||
|
// Channel must have been created and a notification posted on it.
|
||||||
|
val channel = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
|
||||||
|
assertNotNull("task_reminder channel should be created", channel)
|
||||||
|
|
||||||
|
val posted = manager.activeNotifications
|
||||||
|
assertTrue("one notification should be posted", posted.isNotEmpty())
|
||||||
|
val n = posted.first()
|
||||||
|
assertEquals(NotificationChannels.TASK_REMINDER, n.notification.channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onMessageReceived_routesTaskOverdue_toHighChannel() {
|
||||||
|
val msg = remoteMessage(
|
||||||
|
id = "m-2",
|
||||||
|
data = mapOf(
|
||||||
|
"type" to "task_overdue",
|
||||||
|
"task_id" to "8",
|
||||||
|
"title" to "Overdue",
|
||||||
|
"body" to "Late"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.onMessageReceived(msg)
|
||||||
|
|
||||||
|
val posted = manager.activeNotifications
|
||||||
|
assertTrue(posted.isNotEmpty())
|
||||||
|
assertEquals(NotificationChannels.TASK_OVERDUE, posted.first().notification.channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onMessageReceived_routesResidenceInvite() {
|
||||||
|
val msg = remoteMessage(
|
||||||
|
id = "m-3",
|
||||||
|
data = mapOf(
|
||||||
|
"type" to "residence_invite",
|
||||||
|
"residence_id" to "42",
|
||||||
|
"title" to "Invite",
|
||||||
|
"body" to "Join us"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.onMessageReceived(msg)
|
||||||
|
|
||||||
|
val posted = manager.activeNotifications
|
||||||
|
assertTrue(posted.isNotEmpty())
|
||||||
|
assertEquals(NotificationChannels.RESIDENCE_INVITE, posted.first().notification.channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onMessageReceived_routesSubscription_toLowChannel() {
|
||||||
|
val msg = remoteMessage(
|
||||||
|
id = "m-4",
|
||||||
|
data = mapOf(
|
||||||
|
"type" to "subscription",
|
||||||
|
"title" to "Sub",
|
||||||
|
"body" to "Changed"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.onMessageReceived(msg)
|
||||||
|
|
||||||
|
val posted = manager.activeNotifications
|
||||||
|
assertTrue(posted.isNotEmpty())
|
||||||
|
assertEquals(NotificationChannels.SUBSCRIPTION, posted.first().notification.channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onMessageReceived_withTaskId_sets_deep_link() {
|
||||||
|
val msg = remoteMessage(
|
||||||
|
id = "m-5",
|
||||||
|
data = mapOf(
|
||||||
|
"type" to "task_reminder",
|
||||||
|
"task_id" to "123",
|
||||||
|
"title" to "T",
|
||||||
|
"body" to "B",
|
||||||
|
"deep_link" to "honeydue://task/123"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.onMessageReceived(msg)
|
||||||
|
|
||||||
|
val posted = manager.activeNotifications
|
||||||
|
assertTrue(posted.isNotEmpty())
|
||||||
|
val contentIntent = posted.first().notification.contentIntent
|
||||||
|
assertNotNull("content intent should be attached for deep-link tap", contentIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onMessageReceived_malformedPayload_postsNothing() {
|
||||||
|
// Missing type → payload parse returns null → nothing posted.
|
||||||
|
val msg = remoteMessage(
|
||||||
|
id = "m-6",
|
||||||
|
data = mapOf(
|
||||||
|
"title" to "x",
|
||||||
|
"body" to "y"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service.onMessageReceived(msg)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"no notification should be posted for malformed payload",
|
||||||
|
manager.activeNotifications.isEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onMessageReceived_distinctMessageIds_produceDistinctNotifications() {
|
||||||
|
service.onMessageReceived(
|
||||||
|
remoteMessage(
|
||||||
|
"id-A",
|
||||||
|
mapOf("type" to "task_reminder", "task_id" to "1", "title" to "A", "body" to "a")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.onMessageReceived(
|
||||||
|
remoteMessage(
|
||||||
|
"id-B",
|
||||||
|
mapOf("type" to "task_reminder", "task_id" to "2", "title" to "B", "body" to "b")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(2, manager.activeNotifications.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.tt.honeyDue.notifications
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for [NotificationChannels] — verify that the four iOS-parity channels
|
||||||
|
* are created with the correct importance levels and that the helper is
|
||||||
|
* idempotent across repeated invocations.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||||
|
class NotificationChannelsTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var manager: NotificationManager
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
// Clean slate — remove any channels left over from previous tests.
|
||||||
|
manager.notificationChannels.forEach { manager.deleteNotificationChannel(it.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureChannels_creates_four_channels() {
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
|
||||||
|
val ids = manager.notificationChannels.map { it.id }.toSet()
|
||||||
|
assertTrue("task_reminder missing", NotificationChannels.TASK_REMINDER in ids)
|
||||||
|
assertTrue("task_overdue missing", NotificationChannels.TASK_OVERDUE in ids)
|
||||||
|
assertTrue("residence_invite missing", NotificationChannels.RESIDENCE_INVITE in ids)
|
||||||
|
assertTrue("subscription missing", NotificationChannels.SUBSCRIPTION in ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ensureChannels_idempotent() {
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
val firstCount = manager.notificationChannels.size
|
||||||
|
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
val secondCount = manager.notificationChannels.size
|
||||||
|
|
||||||
|
assertEquals(firstCount, secondCount)
|
||||||
|
assertEquals(4, secondCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun taskReminder_has_default_importance() {
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
val channel = manager.getNotificationChannel(NotificationChannels.TASK_REMINDER)
|
||||||
|
assertNotNull(channel)
|
||||||
|
assertEquals(NotificationManager.IMPORTANCE_DEFAULT, channel!!.importance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun taskOverdue_has_high_importance() {
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
val channel = manager.getNotificationChannel(NotificationChannels.TASK_OVERDUE)
|
||||||
|
assertNotNull(channel)
|
||||||
|
assertEquals(NotificationManager.IMPORTANCE_HIGH, channel!!.importance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun residenceInvite_has_default_importance() {
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
val channel = manager.getNotificationChannel(NotificationChannels.RESIDENCE_INVITE)
|
||||||
|
assertNotNull(channel)
|
||||||
|
assertEquals(NotificationManager.IMPORTANCE_DEFAULT, channel!!.importance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subscription_has_low_importance() {
|
||||||
|
NotificationChannels.ensureChannels(context)
|
||||||
|
val channel = manager.getNotificationChannel(NotificationChannels.SUBSCRIPTION)
|
||||||
|
assertNotNull(channel)
|
||||||
|
assertEquals(NotificationManager.IMPORTANCE_LOW, channel!!.importance)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelIdForType_mapsAllKnownTypes() {
|
||||||
|
assertEquals(NotificationChannels.TASK_REMINDER, NotificationChannels.channelIdForType("task_reminder"))
|
||||||
|
assertEquals(NotificationChannels.TASK_OVERDUE, NotificationChannels.channelIdForType("task_overdue"))
|
||||||
|
assertEquals(NotificationChannels.RESIDENCE_INVITE, NotificationChannels.channelIdForType("residence_invite"))
|
||||||
|
assertEquals(NotificationChannels.SUBSCRIPTION, NotificationChannels.channelIdForType("subscription"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun channelIdForType_returnsTaskReminder_forUnknownType() {
|
||||||
|
// Unknown types fall back to task_reminder (safe default).
|
||||||
|
assertEquals(
|
||||||
|
NotificationChannels.TASK_REMINDER,
|
||||||
|
NotificationChannels.channelIdForType("mystery_type")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.tt.honeyDue.notifications
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for [NotificationPayload.parse] covering the FCM data-map shapes
|
||||||
|
* produced by the backend for the four iOS parity notification types:
|
||||||
|
* task_reminder, task_overdue, residence_invite, subscription.
|
||||||
|
*/
|
||||||
|
class NotificationPayloadTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_taskReminder_payload() {
|
||||||
|
val data = mapOf(
|
||||||
|
"type" to "task_reminder",
|
||||||
|
"task_id" to "123",
|
||||||
|
"title" to "Mow the lawn",
|
||||||
|
"body" to "Don't forget to mow today",
|
||||||
|
"deep_link" to "honeydue://task/123"
|
||||||
|
)
|
||||||
|
|
||||||
|
val payload = NotificationPayload.parse(data)
|
||||||
|
|
||||||
|
assertNotNull(payload)
|
||||||
|
assertEquals("task_reminder", payload!!.type)
|
||||||
|
assertEquals(123L, payload.taskId)
|
||||||
|
assertNull(payload.residenceId)
|
||||||
|
assertEquals("Mow the lawn", payload.title)
|
||||||
|
assertEquals("Don't forget to mow today", payload.body)
|
||||||
|
assertEquals("honeydue://task/123", payload.deepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_taskOverdue_payload() {
|
||||||
|
val data = mapOf(
|
||||||
|
"type" to "task_overdue",
|
||||||
|
"task_id" to "456",
|
||||||
|
"title" to "Overdue: Clean gutters",
|
||||||
|
"body" to "This task is past due"
|
||||||
|
)
|
||||||
|
|
||||||
|
val payload = NotificationPayload.parse(data)
|
||||||
|
|
||||||
|
assertNotNull(payload)
|
||||||
|
assertEquals("task_overdue", payload!!.type)
|
||||||
|
assertEquals(456L, payload.taskId)
|
||||||
|
assertEquals("Overdue: Clean gutters", payload.title)
|
||||||
|
assertNull(payload.deepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_residenceInvite_payload() {
|
||||||
|
val data = mapOf(
|
||||||
|
"type" to "residence_invite",
|
||||||
|
"residence_id" to "42",
|
||||||
|
"title" to "You've been invited",
|
||||||
|
"body" to "Join the home",
|
||||||
|
"deep_link" to "honeydue://residence/42"
|
||||||
|
)
|
||||||
|
|
||||||
|
val payload = NotificationPayload.parse(data)
|
||||||
|
|
||||||
|
assertNotNull(payload)
|
||||||
|
assertEquals("residence_invite", payload!!.type)
|
||||||
|
assertNull(payload.taskId)
|
||||||
|
assertEquals(42L, payload.residenceId)
|
||||||
|
assertEquals("honeydue://residence/42", payload.deepLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_subscription_payload() {
|
||||||
|
val data = mapOf(
|
||||||
|
"type" to "subscription",
|
||||||
|
"title" to "Subscription updated",
|
||||||
|
"body" to "Your plan changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
val payload = NotificationPayload.parse(data)
|
||||||
|
|
||||||
|
assertNotNull(payload)
|
||||||
|
assertEquals("subscription", payload!!.type)
|
||||||
|
assertNull(payload.taskId)
|
||||||
|
assertNull(payload.residenceId)
|
||||||
|
assertEquals("Subscription updated", payload.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_malformed_returns_null_whenTypeMissing() {
|
||||||
|
val data = mapOf(
|
||||||
|
"task_id" to "1",
|
||||||
|
"title" to "x",
|
||||||
|
"body" to "y"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(NotificationPayload.parse(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_malformed_returns_null_whenTitleAndBodyMissing() {
|
||||||
|
val data = mapOf("type" to "task_reminder")
|
||||||
|
|
||||||
|
assertNull(NotificationPayload.parse(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_emptyMap_returns_null() {
|
||||||
|
assertNull(NotificationPayload.parse(emptyMap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parse_ignoresInvalidNumericIds() {
|
||||||
|
val data = mapOf(
|
||||||
|
"type" to "task_reminder",
|
||||||
|
"task_id" to "not-a-number",
|
||||||
|
"title" to "x",
|
||||||
|
"body" to "y"
|
||||||
|
)
|
||||||
|
|
||||||
|
val payload = NotificationPayload.parse(data)
|
||||||
|
|
||||||
|
assertNotNull(payload)
|
||||||
|
assertNull(payload!!.taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -835,4 +835,17 @@
|
|||||||
<string name="biometric_unlock_button">Unlock with Biometrics</string>
|
<string name="biometric_unlock_button">Unlock with Biometrics</string>
|
||||||
<string name="biometric_auth_failed">Authentication failed</string>
|
<string name="biometric_auth_failed">Authentication failed</string>
|
||||||
<string name="biometric_not_available">Biometric authentication is not available on this device</string>
|
<string name="biometric_not_available">Biometric authentication is not available on this device</string>
|
||||||
|
|
||||||
|
<!-- Notification Channels (Android) — parallel to iOS UNNotificationCategories
|
||||||
|
defined in iosApp/iosApp/PushNotifications/NotificationCategories.swift.
|
||||||
|
iOS does not name categories for display, so these keys are Android-only
|
||||||
|
parallels. -->
|
||||||
|
<string name="notif_channel_task_reminder_name">Task Reminders</string>
|
||||||
|
<string name="notif_channel_task_reminder_description">Upcoming and due-soon task reminders</string>
|
||||||
|
<string name="notif_channel_task_overdue_name">Overdue Tasks</string>
|
||||||
|
<string name="notif_channel_task_overdue_description">Alerts when a task is past its due date</string>
|
||||||
|
<string name="notif_channel_residence_invite_name">Residence Invites</string>
|
||||||
|
<string name="notif_channel_residence_invite_description">Invitations to join a shared residence</string>
|
||||||
|
<string name="notif_channel_subscription_name">Subscription Updates</string>
|
||||||
|
<string name="notif_channel_subscription_description">Subscription status and billing updates</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user