975f6fde73
Move the legacy MyFirebaseMessagingService.onNewToken() device-registration path onto the new iOS-parity FcmService. The legacy service is already unwired from the manifest MESSAGING_EVENT filter; this removes its last functional responsibility. Behaviour preserved: auth-token guard, DeviceRegistrationRequest with ANDROID_ID + Build.MODEL, log-only on error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
257 lines
10 KiB
Kotlin
257 lines
10 KiB
Kotlin
package com.tt.honeyDue.notifications
|
|
|
|
import android.app.PendingIntent
|
|
import android.content.Context
|
|
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
|
|
import com.tt.honeyDue.models.DeviceRegistrationRequest
|
|
import com.tt.honeyDue.network.ApiResult
|
|
import com.tt.honeyDue.network.NotificationApi
|
|
import com.tt.honeyDue.storage.TokenStorage
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
|
|
/**
|
|
* 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 is the sole MESSAGING_EVENT handler after the deferred-cleanup pass:
|
|
* the manifest no longer wires the legacy `MyFirebaseMessagingService`, and
|
|
* [onNewToken] now carries the token-registration path that used to live
|
|
* there (auth-token guard + device-id + platform="android").
|
|
*/
|
|
class FcmService : FirebaseMessagingService() {
|
|
|
|
override fun onNewToken(token: String) {
|
|
super.onNewToken(token)
|
|
Log.d(TAG, "FCM token refreshed (len=${token.length})")
|
|
|
|
// Store token locally so the rest of the app can find it on demand.
|
|
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
.edit()
|
|
.putString(KEY_FCM_TOKEN, token)
|
|
.apply()
|
|
|
|
// Register with backend only if the user is logged in. Log-only on
|
|
// failure — FCM will re-invoke onNewToken on next rotation.
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val authToken = TokenStorage.getToken() ?: return@launch
|
|
val deviceId = android.provider.Settings.Secure.getString(
|
|
applicationContext.contentResolver,
|
|
android.provider.Settings.Secure.ANDROID_ID
|
|
)
|
|
val request = DeviceRegistrationRequest(
|
|
deviceId = deviceId,
|
|
registrationId = token,
|
|
platform = "android",
|
|
name = android.os.Build.MODEL
|
|
)
|
|
when (val result = NotificationApi().registerDevice(authToken, request)) {
|
|
is ApiResult.Success ->
|
|
Log.d(TAG, "Device registered successfully with new token")
|
|
is ApiResult.Error ->
|
|
Log.e(TAG, "Failed to register device with new token: ${result.message}")
|
|
is ApiResult.Loading,
|
|
is ApiResult.Idle -> {
|
|
// These states shouldn't occur for direct API calls.
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Error registering device with new token", e)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
val notificationId = (messageId ?: payload.deepLink ?: payload.title).hashCode()
|
|
|
|
val builder = 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))
|
|
|
|
addActionButtons(builder, payload, notificationId, messageId)
|
|
|
|
return builder.build()
|
|
}
|
|
|
|
/**
|
|
* Attach iOS-parity action buttons (`NotificationCategories.swift`) to
|
|
* the [builder] based on [payload.type]:
|
|
*
|
|
* - task_reminder / task_overdue → Complete, Snooze, Open
|
|
* - residence_invite → Accept, Decline, Open
|
|
* - subscription → no actions (matches iOS TASK_COMPLETED)
|
|
*
|
|
* All actions fan out to [NotificationActionReceiver] under the
|
|
* `com.tt.honeyDue.notifications` package.
|
|
*/
|
|
private fun addActionButtons(
|
|
builder: NotificationCompat.Builder,
|
|
payload: NotificationPayload,
|
|
notificationId: Int,
|
|
messageId: String?
|
|
) {
|
|
val seed = (messageId ?: payload.deepLink ?: payload.title).hashCode()
|
|
val extras: Map<String, Any?> = mapOf(
|
|
NotificationActions.EXTRA_TASK_ID to payload.taskId,
|
|
NotificationActions.EXTRA_RESIDENCE_ID to payload.residenceId,
|
|
NotificationActions.EXTRA_NOTIFICATION_ID to notificationId,
|
|
NotificationActions.EXTRA_TITLE to payload.title,
|
|
NotificationActions.EXTRA_BODY to payload.body,
|
|
NotificationActions.EXTRA_TYPE to payload.type,
|
|
NotificationActions.EXTRA_DEEP_LINK to payload.deepLink
|
|
)
|
|
|
|
when (payload.type) {
|
|
NotificationChannels.TASK_REMINDER,
|
|
NotificationChannels.TASK_OVERDUE -> {
|
|
if (payload.taskId != null) {
|
|
builder.addAction(
|
|
0,
|
|
getString(R.string.notif_action_complete),
|
|
NotificationActionReceiver.actionPendingIntent(
|
|
this, NotificationActions.COMPLETE, seed, extras
|
|
)
|
|
)
|
|
builder.addAction(
|
|
0,
|
|
getString(R.string.notif_action_snooze),
|
|
NotificationActionReceiver.actionPendingIntent(
|
|
this, NotificationActions.SNOOZE, seed, extras
|
|
)
|
|
)
|
|
}
|
|
builder.addAction(
|
|
0,
|
|
getString(R.string.notif_action_open),
|
|
NotificationActionReceiver.actionPendingIntent(
|
|
this, NotificationActions.OPEN, seed, extras
|
|
)
|
|
)
|
|
}
|
|
NotificationChannels.RESIDENCE_INVITE -> {
|
|
if (payload.residenceId != null) {
|
|
builder.addAction(
|
|
0,
|
|
getString(R.string.notif_action_accept),
|
|
NotificationActionReceiver.actionPendingIntent(
|
|
this, NotificationActions.ACCEPT_INVITE, seed, extras
|
|
)
|
|
)
|
|
builder.addAction(
|
|
0,
|
|
getString(R.string.notif_action_decline),
|
|
NotificationActionReceiver.actionPendingIntent(
|
|
this, NotificationActions.DECLINE_INVITE, seed, extras
|
|
)
|
|
)
|
|
}
|
|
builder.addAction(
|
|
0,
|
|
getString(R.string.notif_action_open),
|
|
NotificationActionReceiver.actionPendingIntent(
|
|
this, NotificationActions.OPEN, seed, extras
|
|
)
|
|
)
|
|
}
|
|
else -> {
|
|
// subscription + unknown: tap-to-open only. iOS parity.
|
|
}
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
private const val PREFS_NAME = "honeydue_prefs"
|
|
private const val KEY_FCM_TOKEN = "fcm_token"
|
|
|
|
/** Compatibility helper — mirrors the old MyFirebaseMessagingService API. */
|
|
fun getStoredToken(context: Context): String? =
|
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
.getString(KEY_FCM_TOKEN, null)
|
|
}
|
|
}
|