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 = 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) } }