b2d03ef8b2
Android UI Tests / ui-tests (pull_request) Has been cancelled
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>
318 lines
12 KiB
Kotlin
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()
|
|
}
|