package com.tt.honeyDue.notifications import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver 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.tt.honeyDue.MainActivity import com.tt.honeyDue.R import com.tt.honeyDue.models.TaskCompletionCreateRequest import com.tt.honeyDue.network.APILayer import com.tt.honeyDue.network.ApiResult import com.tt.honeyDue.widget.WidgetUpdateManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** * BroadcastReceiver for the iOS-parity notification action buttons introduced * in P4 Stream O. Handles Complete / Snooze / Open for task_reminder + * task_overdue categories, and Accept / Decline / Open for residence_invite. * * Counterpart: `iosApp/iosApp/PushNotifications/PushNotificationManager.swift` * (handleNotificationAction). Categories defined in * `iosApp/iosApp/PushNotifications/NotificationCategories.swift`. * * There is a pre-existing [com.tt.honeyDue.NotificationActionReceiver] under * the root package that handles widget-era task-state transitions (mark in * progress, cancel, etc.). That receiver is untouched by this stream; this * one lives under `com.tt.honeyDue.notifications` and serves only the push * action buttons attached by [FcmService]. */ class NotificationActionReceiver : BroadcastReceiver() { /** * Hook for tests. Overridden to intercept async work and force it onto a * synchronous dispatcher. Production stays on [Dispatchers.IO]. */ internal var coroutineScopeOverride: CoroutineScope? = null override fun onReceive(context: Context, intent: Intent) { val scope = coroutineScopeOverride if (scope != null) { // Test path: run synchronously on the provided scope so Robolectric // assertions can observe post-conditions without goAsync hangs. scope.launch { handleAction(context.applicationContext, intent) } return } val pending = goAsync() defaultScope.launch { try { handleAction(context.applicationContext, intent) } catch (t: Throwable) { Log.e(TAG, "Action handler crashed", t) } finally { pending.finish() } } } internal suspend fun handleAction(context: Context, intent: Intent) { val action = intent.action ?: run { Log.w(TAG, "onReceive with null action") return } val taskId = intent.longTaskId() val residenceId = intent.longResidenceId() val notificationId = intent.getIntExtra(NotificationActions.EXTRA_NOTIFICATION_ID, 0) val title = intent.getStringExtra(NotificationActions.EXTRA_TITLE) val body = intent.getStringExtra(NotificationActions.EXTRA_BODY) val type = intent.getStringExtra(NotificationActions.EXTRA_TYPE) val deepLink = intent.getStringExtra(NotificationActions.EXTRA_DEEP_LINK) Log.d(TAG, "action=$action taskId=$taskId residenceId=$residenceId") when (action) { NotificationActions.COMPLETE -> handleComplete(context, taskId, notificationId) NotificationActions.SNOOZE -> handleSnooze(context, taskId, title, body, type, notificationId) NotificationActions.OPEN -> handleOpen(context, taskId, residenceId, deepLink, notificationId) NotificationActions.ACCEPT_INVITE -> handleAcceptInvite(context, residenceId, notificationId) NotificationActions.DECLINE_INVITE -> handleDeclineInvite(context, residenceId, notificationId) NotificationActions.SNOOZE_FIRE -> handleSnoozeFire(context, taskId, title, body, type) else -> Log.w(TAG, "Unknown action: $action") } } // ---------------- Complete ---------------- private suspend fun handleComplete(context: Context, taskId: Long?, notificationId: Int) { if (taskId == null) { Log.w(TAG, "COMPLETE without task_id — no-op") return } val request = TaskCompletionCreateRequest( taskId = taskId.toInt(), completedAt = null, notes = "Completed from notification", actualCost = null, rating = null, ) when (val result = APILayer.createTaskCompletion(request)) { is ApiResult.Success -> { Log.d(TAG, "Task $taskId completed from notification") cancelNotification(context, notificationId) WidgetUpdateManager.forceRefresh(context) } is ApiResult.Error -> { // Leave the notification so the user can retry. Log.e(TAG, "Complete failed: ${result.message}") } else -> Log.w(TAG, "Unexpected ApiResult from createTaskCompletion") } } // ---------------- Snooze ---------------- private fun handleSnooze( context: Context, taskId: Long?, title: String?, body: String?, type: String?, notificationId: Int ) { if (taskId == null) { Log.w(TAG, "SNOOZE without task_id — no-op") return } SnoozeScheduler.schedule( context = context, taskId = taskId, delayMs = NotificationActions.SNOOZE_DELAY_MS, title = title, body = body, type = type ) cancelNotification(context, notificationId) } /** Fired by [AlarmManager] when a snooze elapses — rebuild + post the notification. */ private fun handleSnoozeFire( context: Context, taskId: Long?, title: String?, body: String?, type: String? ) { if (taskId == null) { Log.w(TAG, "SNOOZE_FIRE without task_id — no-op") return } NotificationChannels.ensureChannels(context) val channelId = NotificationChannels.channelIdForType(type ?: NotificationChannels.TASK_REMINDER) val contentIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP putExtra(FcmService.EXTRA_TASK_ID, taskId) putExtra(FcmService.EXTRA_TYPE, type) } val pi = PendingIntent.getActivity( context, taskId.toInt(), contentIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title ?: context.getString(R.string.app_name)) .setContentText(body ?: "") .setStyle(NotificationCompat.BigTextStyle().bigText(body ?: "")) .setAutoCancel(true) .setContentIntent(pi) .build() NotificationManagerCompat.from(context).apply { if (areNotificationsEnabled()) { notify(taskId.hashCode(), notification) } } } // ---------------- Open ---------------- private fun handleOpen( context: Context, taskId: Long?, residenceId: Long?, deepLink: String?, notificationId: Int ) { val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP deepLink?.takeIf { it.isNotBlank() }?.let { data = Uri.parse(it) } taskId?.let { putExtra(FcmService.EXTRA_TASK_ID, it) } residenceId?.let { putExtra(FcmService.EXTRA_RESIDENCE_ID, it) } } context.startActivity(intent) cancelNotification(context, notificationId) } // ---------------- Accept / Decline invite ---------------- private suspend fun handleAcceptInvite(context: Context, residenceId: Long?, notificationId: Int) { if (residenceId == null) { Log.w(TAG, "ACCEPT_INVITE without residence_id — no-op") return } when (val result = APILayer.acceptResidenceInvite(residenceId.toInt())) { is ApiResult.Success -> { Log.d(TAG, "Residence invite $residenceId accepted") cancelNotification(context, notificationId) } is ApiResult.Error -> { // Leave the notification so the user can retry from the app. Log.e(TAG, "Accept invite failed: ${result.message}") } else -> Log.w(TAG, "Unexpected ApiResult from acceptResidenceInvite") } } private suspend fun handleDeclineInvite(context: Context, residenceId: Long?, notificationId: Int) { if (residenceId == null) { Log.w(TAG, "DECLINE_INVITE without residence_id — no-op") return } when (val result = APILayer.declineResidenceInvite(residenceId.toInt())) { is ApiResult.Success -> { Log.d(TAG, "Residence invite $residenceId declined") cancelNotification(context, notificationId) } is ApiResult.Error -> { Log.e(TAG, "Decline invite failed: ${result.message}") } else -> Log.w(TAG, "Unexpected ApiResult from declineResidenceInvite") } } // ---------------- helpers ---------------- private fun cancelNotification(context: Context, notificationId: Int) { if (notificationId == 0) return val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager if (mgr != null) { mgr.cancel(notificationId) } else { NotificationManagerCompat.from(context).cancel(notificationId) } } companion object { private const val TAG = "NotificationAction" private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** * Build a PendingIntent pointing at this receiver for the given action. * Used by [FcmService] to attach action buttons. */ fun actionPendingIntent( context: Context, action: String, requestCodeSeed: Int, extras: Map ): PendingIntent { val intent = Intent(context, NotificationActionReceiver::class.java).apply { this.action = action extras.forEach { (k, v) -> when (v) { is Int -> putExtra(k, v) is Long -> putExtra(k, v) is String -> putExtra(k, v) null -> { /* skip */ } else -> putExtra(k, v.toString()) } } } val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE } else { PendingIntent.FLAG_UPDATE_CURRENT } return PendingIntent.getBroadcast( context, (action.hashCode() xor requestCodeSeed), intent, flags ) } } } private fun Intent.longTaskId(): Long? { if (!hasExtra(NotificationActions.EXTRA_TASK_ID)) return null val asLong = getLongExtra(NotificationActions.EXTRA_TASK_ID, Long.MIN_VALUE) if (asLong != Long.MIN_VALUE) return asLong val asInt = getIntExtra(NotificationActions.EXTRA_TASK_ID, Int.MIN_VALUE) return if (asInt == Int.MIN_VALUE) null else asInt.toLong() } private fun Intent.longResidenceId(): Long? { if (!hasExtra(NotificationActions.EXTRA_RESIDENCE_ID)) return null val asLong = getLongExtra(NotificationActions.EXTRA_RESIDENCE_ID, Long.MIN_VALUE) if (asLong != Long.MIN_VALUE) return asLong val asInt = getIntExtra(NotificationActions.EXTRA_RESIDENCE_ID, Int.MIN_VALUE) return if (asInt == Int.MIN_VALUE) null else asInt.toLong() }