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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.tt.honeyDue.ui.haptics
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.View
|
||||
|
||||
/**
|
||||
* Android backend using [HapticFeedbackConstants] when a host [View] is available,
|
||||
* with graceful [Vibrator] fallback for older APIs or headless contexts.
|
||||
*
|
||||
* API-30+ (Android 11+) gets the richer CONFIRM / REJECT / GESTURE_END constants.
|
||||
* Pre-30 falls back to predefined [VibrationEffect]s (EFFECT_TICK, EFFECT_CLICK,
|
||||
* EFFECT_HEAVY_CLICK) on API 29+, or one-shot vibrations on API 26–28,
|
||||
* or legacy Vibrator.vibrate(duration) on pre-26.
|
||||
*
|
||||
* Call [HapticsInit.install] from your Application / MainActivity so the app
|
||||
* context is available for vibrator resolution. Without it, the backend is
|
||||
* silently a no-op (never crashes).
|
||||
*/
|
||||
class AndroidDefaultHapticBackend(
|
||||
private val viewProvider: () -> View? = { null },
|
||||
private val vibratorProvider: () -> Vibrator? = { null }
|
||||
) : HapticBackend {
|
||||
|
||||
override fun perform(event: HapticEvent) {
|
||||
val view = viewProvider()
|
||||
if (view != null && performViaView(view, event)) return
|
||||
performViaVibrator(event)
|
||||
}
|
||||
|
||||
private fun performViaView(view: View, event: HapticEvent): Boolean {
|
||||
val constant = when (event) {
|
||||
HapticEvent.LIGHT -> HapticFeedbackConstants.CONTEXT_CLICK
|
||||
HapticEvent.MEDIUM -> HapticFeedbackConstants.KEYBOARD_TAP
|
||||
HapticEvent.HEAVY -> HapticFeedbackConstants.LONG_PRESS
|
||||
HapticEvent.SUCCESS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.CONFIRM
|
||||
} else {
|
||||
HapticFeedbackConstants.CONTEXT_CLICK
|
||||
}
|
||||
HapticEvent.WARNING -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.GESTURE_END
|
||||
} else {
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
}
|
||||
HapticEvent.ERROR -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
HapticFeedbackConstants.REJECT
|
||||
} else {
|
||||
HapticFeedbackConstants.LONG_PRESS
|
||||
}
|
||||
}
|
||||
return view.performHapticFeedback(constant)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun performViaVibrator(event: HapticEvent) {
|
||||
val vibrator = vibratorProvider() ?: return
|
||||
if (!vibrator.hasVibrator()) return
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val predefined = when (event) {
|
||||
HapticEvent.LIGHT, HapticEvent.SUCCESS -> VibrationEffect.EFFECT_TICK
|
||||
HapticEvent.MEDIUM, HapticEvent.WARNING -> VibrationEffect.EFFECT_CLICK
|
||||
HapticEvent.HEAVY, HapticEvent.ERROR -> VibrationEffect.EFFECT_HEAVY_CLICK
|
||||
}
|
||||
vibrator.vibrate(VibrationEffect.createPredefined(predefined))
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val (duration, amplitude) = when (event) {
|
||||
HapticEvent.LIGHT -> 10L to VibrationEffect.DEFAULT_AMPLITUDE
|
||||
HapticEvent.MEDIUM -> 20L to VibrationEffect.DEFAULT_AMPLITUDE
|
||||
HapticEvent.HEAVY -> 50L to VibrationEffect.DEFAULT_AMPLITUDE
|
||||
HapticEvent.SUCCESS -> 30L to VibrationEffect.DEFAULT_AMPLITUDE
|
||||
HapticEvent.WARNING -> 40L to VibrationEffect.DEFAULT_AMPLITUDE
|
||||
HapticEvent.ERROR -> 60L to VibrationEffect.DEFAULT_AMPLITUDE
|
||||
}
|
||||
vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
|
||||
return
|
||||
}
|
||||
|
||||
val duration = when (event) {
|
||||
HapticEvent.LIGHT -> 10L
|
||||
HapticEvent.MEDIUM -> 20L
|
||||
HapticEvent.HEAVY -> 50L
|
||||
HapticEvent.SUCCESS -> 30L
|
||||
HapticEvent.WARNING -> 40L
|
||||
HapticEvent.ERROR -> 60L
|
||||
}
|
||||
vibrator.vibrate(duration)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Android app-wide registry that plumbs an Application Context to the default
|
||||
* backend. Call [HapticsInit.install] from the Application or Activity init so
|
||||
* that call-sites in shared code can invoke [Haptics.light] etc. without any
|
||||
* Compose / View plumbing.
|
||||
*/
|
||||
object HapticsInit {
|
||||
@Volatile private var appContext: Context? = null
|
||||
@Volatile private var hostView: View? = null
|
||||
|
||||
fun install(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
fun attachView(view: View?) {
|
||||
hostView = view
|
||||
}
|
||||
|
||||
internal fun defaultBackend(): HapticBackend = AndroidDefaultHapticBackend(
|
||||
viewProvider = { hostView },
|
||||
vibratorProvider = { resolveVibrator() }
|
||||
)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun resolveVibrator(): Vibrator? {
|
||||
val ctx = appContext ?: return null
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
(ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||
} else {
|
||||
ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actual object Haptics {
|
||||
@Volatile private var backend: HapticBackend = HapticsInit.defaultBackend()
|
||||
|
||||
actual fun light() = backend.perform(HapticEvent.LIGHT)
|
||||
actual fun medium() = backend.perform(HapticEvent.MEDIUM)
|
||||
actual fun heavy() = backend.perform(HapticEvent.HEAVY)
|
||||
actual fun success() = backend.perform(HapticEvent.SUCCESS)
|
||||
actual fun warning() = backend.perform(HapticEvent.WARNING)
|
||||
actual fun error() = backend.perform(HapticEvent.ERROR)
|
||||
|
||||
actual fun setBackend(backend: HapticBackend) {
|
||||
this.backend = backend
|
||||
}
|
||||
|
||||
actual fun resetBackend() {
|
||||
this.backend = HapticsInit.defaultBackend()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user