P2 Stream H: standalone TaskSuggestionsScreen
Port iOS TaskSuggestionsView as a standalone route reachable outside onboarding. Uses shared suggestions API + accept/skip analytics in non-onboarding variant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,8 +69,9 @@ class FcmService : FirebaseMessagingService() {
|
||||
messageId: String?
|
||||
): android.app.Notification {
|
||||
val contentIntent = buildContentIntent(payload, messageId)
|
||||
val notificationId = (messageId ?: payload.deepLink ?: payload.title).hashCode()
|
||||
|
||||
return NotificationCompat.Builder(this, channelId)
|
||||
val builder = NotificationCompat.Builder(this, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(payload.title.ifBlank { getString(R.string.app_name) })
|
||||
.setContentText(payload.body)
|
||||
@@ -78,7 +79,96 @@ class FcmService : FirebaseMessagingService() {
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(contentIntent)
|
||||
.setPriority(priorityForChannel(channelId))
|
||||
.build()
|
||||
|
||||
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(
|
||||
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
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,
|
||||
imageUrls = 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 fun handleAcceptInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
if (residenceId == null) {
|
||||
Log.w(TAG, "ACCEPT_INVITE without residence_id — no-op")
|
||||
return
|
||||
}
|
||||
// APILayer.acceptResidenceInvite does not yet exist — see TODO below.
|
||||
// composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt
|
||||
// (add after createTaskCompletion at ~line 790). Backend endpoint
|
||||
// intended at POST /api/residences/{id}/invite/accept.
|
||||
Log.w(
|
||||
TAG,
|
||||
"TODO: APILayer.acceptResidenceInvite($residenceId) — implement in " +
|
||||
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
|
||||
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/accept)"
|
||||
)
|
||||
// Best-effort UX: cancel the notification so the user isn't stuck
|
||||
// on a button that does nothing visible. The invite will be picked
|
||||
// up on next app-open from /api/residences/pending.
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
|
||||
private fun handleDeclineInvite(context: Context, residenceId: Long?, notificationId: Int) {
|
||||
if (residenceId == null) {
|
||||
Log.w(TAG, "DECLINE_INVITE without residence_id — no-op")
|
||||
return
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"TODO: APILayer.declineResidenceInvite($residenceId) — implement in " +
|
||||
"composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/APILayer.kt " +
|
||||
"(follow createTaskCompletion pattern; POST /api/residences/{id}/invite/decline)"
|
||||
)
|
||||
cancelNotification(context, notificationId)
|
||||
}
|
||||
|
||||
// ---------------- 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()
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.tt.honeyDue.notifications
|
||||
|
||||
/**
|
||||
* Action + extra constants for the iOS-parity notification action buttons.
|
||||
*
|
||||
* Mirrors the action identifiers from
|
||||
* `iosApp/iosApp/PushNotifications/NotificationCategories.swift`
|
||||
* (Complete, Snooze, Open for task_reminder / task_overdue; Accept, Decline, Open
|
||||
* for residence_invite). Consumed by [NotificationActionReceiver] and attached
|
||||
* to the notifications built by [FcmService].
|
||||
*
|
||||
* Action strings are fully-qualified so they never collide with other
|
||||
* BroadcastReceivers registered in the app (e.g. the legacy
|
||||
* `com.tt.honeyDue.NotificationActionReceiver` which handles task-state
|
||||
* transitions from widget-era notifications).
|
||||
*/
|
||||
object NotificationActions {
|
||||
const val COMPLETE: String = "com.tt.honeyDue.action.COMPLETE_TASK"
|
||||
const val SNOOZE: String = "com.tt.honeyDue.action.SNOOZE_TASK"
|
||||
const val OPEN: String = "com.tt.honeyDue.action.OPEN"
|
||||
const val ACCEPT_INVITE: String = "com.tt.honeyDue.action.ACCEPT_INVITE"
|
||||
const val DECLINE_INVITE: String = "com.tt.honeyDue.action.DECLINE_INVITE"
|
||||
|
||||
/** Firing a SNOOZE fires this alarm action when the 30-min wait elapses. */
|
||||
const val SNOOZE_FIRE: String = "com.tt.honeyDue.action.SNOOZE_FIRE"
|
||||
|
||||
const val EXTRA_TASK_ID: String = "task_id"
|
||||
const val EXTRA_RESIDENCE_ID: String = "residence_id"
|
||||
const val EXTRA_NOTIFICATION_ID: String = "notification_id"
|
||||
const val EXTRA_TITLE: String = "title"
|
||||
const val EXTRA_BODY: String = "body"
|
||||
const val EXTRA_TYPE: String = "type"
|
||||
const val EXTRA_DEEP_LINK: String = "deep_link"
|
||||
|
||||
/** Snooze delay that matches `iosApp/iosApp/PushNotifications/NotificationCategories.swift`. */
|
||||
const val SNOOZE_DELAY_MS: Long = 30L * 60L * 1000L
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.tt.honeyDue.notifications
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Schedules a "snooze" redelivery of a task notification using [AlarmManager].
|
||||
*
|
||||
* iOS achieves snooze via UNNotificationRequest with a 30-minute
|
||||
* UNTimeIntervalNotificationTrigger (see
|
||||
* `iosApp/iosApp/PushNotifications/NotificationCategories.swift`). Android
|
||||
* has no equivalent, so we lean on AlarmManager to wake us in 30 minutes
|
||||
* and rebroadcast to [NotificationActionReceiver], which re-posts the
|
||||
* notification via [FcmService]'s builder path.
|
||||
*
|
||||
* Exact alarms (`setExactAndAllowWhileIdle`) require the `SCHEDULE_EXACT_ALARM`
|
||||
* permission on Android 12+. Because P4 Stream O is scoped to receiver-only
|
||||
* changes in the manifest, we probe [AlarmManager.canScheduleExactAlarms] at
|
||||
* runtime and fall back to the inexact `setAndAllowWhileIdle` variant when
|
||||
* the permission has not been granted — snooze fidelity in that case is
|
||||
* "roughly 30 min" which is within Android's Doze tolerance and acceptable.
|
||||
*/
|
||||
object SnoozeScheduler {
|
||||
|
||||
private const val TAG = "SnoozeScheduler"
|
||||
|
||||
/**
|
||||
* Schedule a snooze alarm [delayMs] into the future for the given [taskId].
|
||||
* If an alarm already exists for this task id, it is replaced (pending
|
||||
* intents are reused by request code = taskId).
|
||||
*/
|
||||
fun schedule(
|
||||
context: Context,
|
||||
taskId: Long,
|
||||
delayMs: Long = NotificationActions.SNOOZE_DELAY_MS,
|
||||
title: String? = null,
|
||||
body: String? = null,
|
||||
type: String? = null
|
||||
) {
|
||||
val alarm = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
|
||||
if (alarm == null) {
|
||||
Log.w(TAG, "AlarmManager unavailable; cannot schedule snooze for task=$taskId")
|
||||
return
|
||||
}
|
||||
|
||||
val triggerAt = System.currentTimeMillis() + delayMs
|
||||
val pi = pendingIntent(context, taskId, title, body, type, create = true)
|
||||
?: run {
|
||||
Log.w(TAG, "Unable to build snooze PendingIntent for task=$taskId")
|
||||
return
|
||||
}
|
||||
|
||||
val useExact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
alarm.canScheduleExactAlarms()
|
||||
|
||||
if (useExact) {
|
||||
alarm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
||||
} else {
|
||||
// Fallback path when SCHEDULE_EXACT_ALARM is revoked. Inexact but
|
||||
// still wakes the device from Doze.
|
||||
alarm.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
|
||||
}
|
||||
Log.d(TAG, "Scheduled snooze for task=$taskId at $triggerAt (exact=$useExact)")
|
||||
}
|
||||
|
||||
/** Cancel a scheduled snooze alarm for [taskId], if any. */
|
||||
fun cancel(context: Context, taskId: Long) {
|
||||
val alarm = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||
val pi = pendingIntent(context, taskId, null, null, null, create = false) ?: return
|
||||
alarm.cancel(pi)
|
||||
pi.cancel()
|
||||
}
|
||||
|
||||
private fun pendingIntent(
|
||||
context: Context,
|
||||
taskId: Long,
|
||||
title: String?,
|
||||
body: String?,
|
||||
type: String?,
|
||||
create: Boolean
|
||||
): PendingIntent? {
|
||||
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationActions.SNOOZE_FIRE
|
||||
putExtra(NotificationActions.EXTRA_TASK_ID, taskId)
|
||||
title?.let { putExtra(NotificationActions.EXTRA_TITLE, it) }
|
||||
body?.let { putExtra(NotificationActions.EXTRA_BODY, it) }
|
||||
type?.let { putExtra(NotificationActions.EXTRA_TYPE, it) }
|
||||
}
|
||||
val baseFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val lookupFlags = if (create) baseFlags else PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
val requestCode = requestCode(taskId)
|
||||
return PendingIntent.getBroadcast(context, requestCode, intent, lookupFlags)
|
||||
}
|
||||
|
||||
/** Stable request code for per-task snoozes so cancel() finds the same PI. */
|
||||
private fun requestCode(taskId: Long): Int {
|
||||
// Fold 64-bit task id into a stable 32-bit request code.
|
||||
return (taskId xor (taskId ushr 32)).toInt() xor SNOOZE_REQUEST_SALT
|
||||
}
|
||||
|
||||
private const val SNOOZE_REQUEST_SALT = 0x534E5A45.toInt() // "SNZE"
|
||||
}
|
||||
Reference in New Issue
Block a user