From 0d50726490aad64a6d13e03c98316167ef5e6ab8 Mon Sep 17 00:00:00 2001 From: Trey T Date: Sat, 18 Apr 2026 12:45:37 -0500 Subject: [PATCH] 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) --- .../src/androidMain/AndroidManifest.xml | 6 +- .../tt/honeyDue/notifications/FcmService.kt | 119 ++++++++++++ .../notifications/NotificationChannels.kt | 93 +++++++++ .../notifications/NotificationPayload.kt | 53 +++++ .../honeyDue/notifications/FcmServiceTest.kt | 183 ++++++++++++++++++ .../notifications/NotificationChannelsTest.kt | 107 ++++++++++ .../notifications/NotificationPayloadTest.kt | 127 ++++++++++++ .../composeResources/values/strings.xml | 13 ++ 8 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/FcmService.kt create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationChannels.kt create mode 100644 composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationPayload.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/FcmServiceTest.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationChannelsTest.kt create mode 100644 composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationPayloadTest.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index d128980..b93bd23 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -74,9 +74,11 @@ android:resource="@xml/file_paths" /> - + diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/FcmService.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/FcmService.kt new file mode 100644 index 0000000..54cbf8e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/FcmService.kt @@ -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" + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationChannels.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationChannels.kt new file mode 100644 index 0000000..f11731f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationChannels.kt @@ -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 + } +} diff --git a/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationPayload.kt b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationPayload.kt new file mode 100644 index 0000000..82033ed --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationPayload.kt @@ -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): 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() } + ) + } + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/FcmServiceTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/FcmServiceTest.kt new file mode 100644 index 0000000..70d8f3b --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/FcmServiceTest.kt @@ -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): 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) + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationChannelsTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationChannelsTest.kt new file mode 100644 index 0000000..2826fa4 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationChannelsTest.kt @@ -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") + ) + } +} diff --git a/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationPayloadTest.kt b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationPayloadTest.kt new file mode 100644 index 0000000..792d8f4 --- /dev/null +++ b/composeApp/src/androidUnitTest/kotlin/com/tt/honeyDue/notifications/NotificationPayloadTest.kt @@ -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) + } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ef6571c..53f2246 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -835,4 +835,17 @@ Unlock with Biometrics Authentication failed Biometric authentication is not available on this device + + + Task Reminders + Upcoming and due-soon task reminders + Overdue Tasks + Alerts when a task is past its due date + Residence Invites + Invitations to join a shared residence + Subscription Updates + Subscription status and billing updates