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:
Trey T
2026-04-18 12:45:37 -05:00
parent 6d7b5ee990
commit 0d50726490
8 changed files with 699 additions and 2 deletions

View File

@@ -74,9 +74,11 @@
android:resource="@xml/file_paths" />
</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
android:name=".MyFirebaseMessagingService"
android:name=".notifications.FcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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() }
)
}
}
}