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>
107 lines
4.3 KiB
Kotlin
107 lines
4.3 KiB
Kotlin
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"
|
|
}
|