Files
honeyDueKMP/composeApp/src/androidMain/kotlin/com/tt/honeyDue/notifications/NotificationActionReceiver.kt
T
Trey t b2d03ef8b2
Android UI Tests / ui-tests (pull_request) Has been cancelled
refactor(uploads): drop legacy multipart helpers; route Android UI through presigned flow
The KMP shared layer's task-completion-with-images path now exclusively
uses the presigned-URL flow: each image is compressed, uploaded directly
to B2 via APILayer.uploadImage, and the resulting upload_ids are passed
to /api/task-completions/ as JSON. Bytes never traverse our API server.

Changes:
  - TaskCompletionViewModel.createTaskCompletionWithImages now does the
    presign→POST→collect-ids dance internally. The signature stays the
    same so the three Android UI call sites (TasksScreen, AllTasksScreen,
    ResidenceDetailScreen, CompleteTaskDialog, CompleteTaskScreen) need
    no changes.
  - APILayer.createTaskCompletionWithImages removed (dead).
  - TaskCompletionApi.createCompletionWithImages removed (the multipart
    HTTP helper that posted to the legacy POST /api/task-completions/
    multipart endpoint).
  - TaskCompletionCreateRequest.imageUrls field removed.
  - Three Swift call sites (CompleteTaskView, WidgetActionProcessor,
    PushNotificationManager) updated to drop the imageUrls argument.
  - Two Kotlin call sites (CompleteTaskDialog, CompleteTaskScreen) updated.

Image uploads now match WhatsApp/Slack-class architecture: client-side
compression + direct-to-storage upload + lightweight JSON entity create.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:48:11 -07:00

318 lines
12 KiB
Kotlin

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<String, Any?>
): 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()
}