Add comprehensive exact alarm permission handling for Android 12+ (API 31+) and fix critical bugs preventing notifications from triggering. Features: - Add checkExactAlarmPermission() and requestExactAlarmPermission() plugin methods - Add canScheduleExactAlarms() and canRequestExactAlarmPermission() helper methods - Update all scheduling methods to check/request permission before scheduling - Use reflection for canRequestScheduleExactAlarms() to avoid compilation issues Bug Fixes: - Fix receiver mismatch: change alarm intents from NotifyReceiver to DailyNotificationReceiver - Fix coroutine compilation error: wrap getLatest() suspend call in runBlocking - Store notification content in database before scheduling alarms - Update intent action to match manifest registration The permission request flow opens Settings intent when SCHEDULE_EXACT_ALARM permission is not granted, providing clear user guidance. All scheduling methods now check permission status and request it if needed before proceeding. Version bumped to 1.0.8
641 lines
29 KiB
Kotlin
641 lines
29 KiB
Kotlin
package com.timesafari.dailynotification
|
|
|
|
import android.app.AlarmManager
|
|
import android.app.AlarmManager.AlarmClockInfo
|
|
import android.app.NotificationChannel
|
|
import android.app.NotificationManager
|
|
import android.app.PendingIntent
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.os.Build
|
|
import android.util.Log
|
|
import androidx.core.app.NotificationCompat
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.runBlocking
|
|
|
|
/**
|
|
* AlarmManager implementation for user notifications
|
|
* Implements TTL-at-fire logic and notification delivery
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.1.0
|
|
*/
|
|
class NotifyReceiver : BroadcastReceiver() {
|
|
|
|
companion object {
|
|
private const val TAG = "DNP-NOTIFY"
|
|
private const val CHANNEL_ID = "daily_notifications"
|
|
private const val NOTIFICATION_ID = 1001
|
|
|
|
/**
|
|
* Generate unique request code from trigger time
|
|
* Uses lower 16 bits of timestamp to ensure uniqueness
|
|
*/
|
|
private fun getRequestCode(triggerAtMillis: Long): Int {
|
|
return (triggerAtMillis and 0xFFFF).toInt()
|
|
}
|
|
|
|
/**
|
|
* Get launch intent for the host app
|
|
* Uses package launcher intent to avoid hardcoding MainActivity class name
|
|
* This works across all host apps regardless of their MainActivity package/class
|
|
*
|
|
* @param context Application context
|
|
* @return Intent to launch the app, or null if not available
|
|
*/
|
|
private fun getLaunchIntent(context: Context): Intent? {
|
|
return try {
|
|
// Use package launcher intent - works for any host app
|
|
context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Failed to get launch intent for package: ${context.packageName}", e)
|
|
null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if exact alarm permission is granted
|
|
* On Android 12+ (API 31+), SCHEDULE_EXACT_ALARM must be granted at runtime
|
|
*
|
|
* @param context Application context
|
|
* @return true if exact alarms can be scheduled, false otherwise
|
|
*/
|
|
private fun canScheduleExactAlarms(context: Context): Boolean {
|
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
|
|
alarmManager?.canScheduleExactAlarms() ?: false
|
|
} else {
|
|
// Pre-Android 12: exact alarms are always allowed
|
|
true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule an exact notification using AlarmManager
|
|
* Uses setAlarmClock() for Android 5.0+ for better reliability
|
|
* Falls back to setExactAndAllowWhileIdle for older versions
|
|
*
|
|
* FIX: Uses DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
|
|
* Stores notification content in database and passes notification ID to receiver
|
|
*
|
|
* @param context Application context
|
|
* @param triggerAtMillis When to trigger the notification (UTC milliseconds)
|
|
* @param config Notification configuration
|
|
* @param isStaticReminder Whether this is a static reminder (no content dependency)
|
|
* @param reminderId Optional reminder ID for tracking
|
|
*/
|
|
fun scheduleExactNotification(
|
|
context: Context,
|
|
triggerAtMillis: Long,
|
|
config: UserNotificationConfig,
|
|
isStaticReminder: Boolean = false,
|
|
reminderId: String? = null
|
|
) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
|
|
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
|
|
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
|
|
|
|
// Store notification content in database before scheduling alarm
|
|
// This allows DailyNotificationReceiver to retrieve content via notification ID
|
|
// FIX: Wrap suspend function calls in coroutine
|
|
if (!isStaticReminder) {
|
|
try {
|
|
// Use runBlocking to call suspend function from non-suspend context
|
|
// This is acceptable here because we're not in a UI thread and need to ensure
|
|
// content is stored before scheduling the alarm
|
|
runBlocking {
|
|
val db = DailyNotificationDatabase.getDatabase(context)
|
|
val contentCache = db.contentCacheDao().getLatest()
|
|
|
|
// If we have cached content, create a notification content entity
|
|
if (contentCache != null) {
|
|
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
|
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
|
notificationId,
|
|
"1.0.2", // Plugin version
|
|
null, // timesafariDid - can be set if available
|
|
"daily",
|
|
config.title,
|
|
config.body ?: String(contentCache.payload),
|
|
triggerAtMillis,
|
|
java.time.ZoneId.systemDefault().id
|
|
)
|
|
entity.priority = when (config.priority) {
|
|
"high", "max" -> 2
|
|
"low", "min" -> -1
|
|
else -> 0
|
|
}
|
|
entity.vibrationEnabled = config.vibration ?: true
|
|
entity.soundEnabled = config.sound ?: true
|
|
entity.deliveryStatus = "pending"
|
|
entity.createdAt = System.currentTimeMillis()
|
|
entity.updatedAt = System.currentTimeMillis()
|
|
entity.ttlSeconds = contentCache.ttlSeconds.toLong()
|
|
|
|
// saveNotificationContent returns CompletableFuture, so we need to wait for it
|
|
roomStorage.saveNotificationContent(entity).get()
|
|
Log.d(TAG, "Stored notification content in database: id=$notificationId")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
|
|
}
|
|
}
|
|
|
|
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
|
|
// FIX: Set action to match manifest registration
|
|
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
|
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
|
|
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
|
|
// Also preserve original extras for backward compatibility if needed
|
|
putExtra("title", config.title)
|
|
putExtra("body", config.body)
|
|
putExtra("sound", config.sound ?: true)
|
|
putExtra("vibration", config.vibration ?: true)
|
|
putExtra("priority", config.priority ?: "normal")
|
|
putExtra("is_static_reminder", isStaticReminder)
|
|
putExtra("trigger_time", triggerAtMillis) // Store trigger time for debugging
|
|
if (reminderId != null) {
|
|
putExtra("reminder_id", reminderId)
|
|
}
|
|
}
|
|
|
|
// Use unique request code based on trigger time to prevent PendingIntent conflicts
|
|
val requestCode = getRequestCode(triggerAtMillis)
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context,
|
|
requestCode,
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
val currentTime = System.currentTimeMillis()
|
|
val delayMs = triggerAtMillis - currentTime
|
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
.format(java.util.Date(triggerAtMillis))
|
|
|
|
Log.i(TAG, "Scheduling alarm: triggerTime=$triggerTimeStr, delayMs=$delayMs, requestCode=$requestCode")
|
|
|
|
// Check exact alarm permission before scheduling (Android 12+)
|
|
val canScheduleExact = canScheduleExactAlarms(context)
|
|
if (!canScheduleExact && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
Log.w(TAG, "Exact alarm permission not granted. Cannot schedule exact alarm. User must grant SCHEDULE_EXACT_ALARM permission in settings.")
|
|
// Fall back to inexact alarm
|
|
alarmManager.set(
|
|
AlarmManager.RTC_WAKEUP,
|
|
triggerAtMillis,
|
|
pendingIntent
|
|
)
|
|
Log.i(TAG, "Inexact alarm scheduled (exact permission denied): triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
|
return
|
|
}
|
|
|
|
try {
|
|
// Use setAlarmClock() for Android 5.0+ (API 21+) - most reliable method
|
|
// Shows alarm icon in status bar and is exempt from doze mode
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
// Create show intent for alarm clock (opens app when alarm fires)
|
|
// Use package launcher intent to avoid hardcoding MainActivity class name
|
|
val showIntent = getLaunchIntent(context)
|
|
|
|
val showPendingIntent = if (showIntent != null) {
|
|
PendingIntent.getActivity(
|
|
context,
|
|
requestCode + 1, // Different request code for show intent
|
|
showIntent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
} else {
|
|
null
|
|
}
|
|
|
|
val alarmClockInfo = AlarmClockInfo(triggerAtMillis, showPendingIntent)
|
|
alarmManager.setAlarmClock(alarmClockInfo, pendingIntent)
|
|
Log.i(TAG, "Alarm clock scheduled (setAlarmClock): triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
// Fallback to setExactAndAllowWhileIdle for Android 6.0-4.4
|
|
alarmManager.setExactAndAllowWhileIdle(
|
|
AlarmManager.RTC_WAKEUP,
|
|
triggerAtMillis,
|
|
pendingIntent
|
|
)
|
|
Log.i(TAG, "Exact alarm scheduled (setExactAndAllowWhileIdle): triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
|
} else {
|
|
// Fallback to setExact for older versions
|
|
alarmManager.setExact(
|
|
AlarmManager.RTC_WAKEUP,
|
|
triggerAtMillis,
|
|
pendingIntent
|
|
)
|
|
Log.i(TAG, "Exact alarm scheduled (setExact): triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
|
}
|
|
} catch (e: SecurityException) {
|
|
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e)
|
|
alarmManager.set(
|
|
AlarmManager.RTC_WAKEUP,
|
|
triggerAtMillis,
|
|
pendingIntent
|
|
)
|
|
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel a scheduled notification alarm
|
|
* FIX: Uses DailyNotificationReceiver to match alarm scheduling
|
|
* @param context Application context
|
|
* @param triggerAtMillis The trigger time of the alarm to cancel (required for unique request code)
|
|
*/
|
|
fun cancelNotification(context: Context, triggerAtMillis: Long) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
|
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
|
action = "com.timesafari.daily.NOTIFICATION"
|
|
}
|
|
val requestCode = getRequestCode(triggerAtMillis)
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context,
|
|
requestCode,
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
alarmManager.cancel(pendingIntent)
|
|
Log.i(TAG, "Notification alarm cancelled: triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
|
}
|
|
|
|
/**
|
|
* Check if an alarm is scheduled for the given trigger time
|
|
* FIX: Uses DailyNotificationReceiver to match alarm scheduling
|
|
* @param context Application context
|
|
* @param triggerAtMillis The trigger time to check
|
|
* @return true if alarm is scheduled, false otherwise
|
|
*/
|
|
fun isAlarmScheduled(context: Context, triggerAtMillis: Long): Boolean {
|
|
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
|
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
|
action = "com.timesafari.daily.NOTIFICATION"
|
|
}
|
|
val requestCode = getRequestCode(triggerAtMillis)
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context,
|
|
requestCode,
|
|
intent,
|
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
val isScheduled = pendingIntent != null
|
|
|
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
.format(java.util.Date(triggerAtMillis))
|
|
Log.d(TAG, "Alarm check for $triggerTimeStr: scheduled=$isScheduled, requestCode=$requestCode")
|
|
|
|
return isScheduled
|
|
}
|
|
|
|
/**
|
|
* Get the next scheduled alarm time from AlarmManager
|
|
* @param context Application context
|
|
* @return Next alarm time in milliseconds, or null if no alarm is scheduled
|
|
*/
|
|
fun getNextAlarmTime(context: Context): Long? {
|
|
return try {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
|
val nextAlarm = alarmManager.nextAlarmClock
|
|
if (nextAlarm != null) {
|
|
val triggerTime = nextAlarm.triggerTime
|
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
.format(java.util.Date(triggerTime))
|
|
Log.d(TAG, "Next alarm clock: $triggerTimeStr")
|
|
triggerTime
|
|
} else {
|
|
Log.d(TAG, "No alarm clock scheduled")
|
|
null
|
|
}
|
|
} else {
|
|
Log.d(TAG, "getNextAlarmTime() requires Android 5.0+")
|
|
null
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Error getting next alarm time", e)
|
|
null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test method: Schedule an alarm to fire in a few seconds
|
|
* Useful for verifying alarm delivery works correctly
|
|
* @param context Application context
|
|
* @param secondsFromNow How many seconds from now to fire (default: 5)
|
|
*/
|
|
fun testAlarm(context: Context, secondsFromNow: Int = 5) {
|
|
val triggerTime = System.currentTimeMillis() + (secondsFromNow * 1000L)
|
|
val config = UserNotificationConfig(
|
|
enabled = true,
|
|
schedule = "",
|
|
title = "Test Notification",
|
|
body = "This is a test notification scheduled $secondsFromNow seconds from now",
|
|
sound = true,
|
|
vibration = true,
|
|
priority = "high"
|
|
)
|
|
|
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
.format(java.util.Date(triggerTime))
|
|
Log.i(TAG, "TEST: Scheduling test alarm for $triggerTimeStr (in $secondsFromNow seconds)")
|
|
|
|
scheduleExactNotification(
|
|
context,
|
|
triggerTime,
|
|
config,
|
|
isStaticReminder = true,
|
|
reminderId = "test_${System.currentTimeMillis()}"
|
|
)
|
|
|
|
Log.i(TAG, "TEST: Alarm scheduled. Check logs in $secondsFromNow seconds for 'Notification receiver triggered'")
|
|
}
|
|
}
|
|
|
|
override fun onReceive(context: Context, intent: Intent?) {
|
|
val triggerTime = intent?.getLongExtra("trigger_time", 0L) ?: 0L
|
|
val triggerTimeStr = if (triggerTime > 0) {
|
|
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
.format(java.util.Date(triggerTime))
|
|
} else {
|
|
"unknown"
|
|
}
|
|
val currentTime = System.currentTimeMillis()
|
|
val delayMs = if (triggerTime > 0) currentTime - triggerTime else 0L
|
|
|
|
Log.i(TAG, "Notification receiver triggered: triggerTime=$triggerTimeStr, currentTime=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(currentTime))}, delayMs=$delayMs")
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
// Check if this is a static reminder (no content dependency)
|
|
val isStaticReminder = intent?.getBooleanExtra("is_static_reminder", false) ?: false
|
|
|
|
if (isStaticReminder) {
|
|
// Handle static reminder without content cache
|
|
val title = intent?.getStringExtra("title") ?: "Daily Reminder"
|
|
val body = intent?.getStringExtra("body") ?: "Don't forget your daily check-in!"
|
|
val sound = intent?.getBooleanExtra("sound", true) ?: true
|
|
val vibration = intent?.getBooleanExtra("vibration", true) ?: true
|
|
val priority = intent?.getStringExtra("priority") ?: "normal"
|
|
val reminderId = intent?.getStringExtra("reminder_id") ?: "unknown"
|
|
|
|
showStaticReminderNotification(context, title, body, sound, vibration, priority, reminderId)
|
|
|
|
// Record reminder trigger in database
|
|
recordReminderTrigger(context, reminderId)
|
|
return@launch
|
|
}
|
|
|
|
// Existing cached content logic for regular notifications
|
|
val db = DailyNotificationDatabase.getDatabase(context)
|
|
val latestCache = db.contentCacheDao().getLatest()
|
|
|
|
if (latestCache == null) {
|
|
Log.w(TAG, "No cached content available for notification")
|
|
recordHistory(db, "notify", "no_content")
|
|
return@launch
|
|
}
|
|
|
|
// TTL-at-fire check
|
|
val now = System.currentTimeMillis()
|
|
val ttlExpiry = latestCache.fetchedAt + (latestCache.ttlSeconds * 1000L)
|
|
|
|
if (now > ttlExpiry) {
|
|
Log.i(TAG, "Content TTL expired, skipping notification")
|
|
recordHistory(db, "notify", "skipped_ttl")
|
|
return@launch
|
|
}
|
|
|
|
// Show notification
|
|
val title = intent?.getStringExtra("title") ?: "Daily Notification"
|
|
val body = intent?.getStringExtra("body") ?: String(latestCache.payload)
|
|
val sound = intent?.getBooleanExtra("sound", true) ?: true
|
|
val vibration = intent?.getBooleanExtra("vibration", true) ?: true
|
|
val priority = intent?.getStringExtra("priority") ?: "normal"
|
|
|
|
showNotification(context, title, body, sound, vibration, priority)
|
|
recordHistory(db, "notify", "success")
|
|
|
|
// Fire callbacks
|
|
fireCallbacks(context, db, "onNotifyDelivered", latestCache)
|
|
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Error in notification receiver", e)
|
|
try {
|
|
val db = DailyNotificationDatabase.getDatabase(context)
|
|
recordHistory(db, "notify", "failure", e.message)
|
|
} catch (dbError: Exception) {
|
|
Log.e(TAG, "Failed to record notification failure", dbError)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun showNotification(
|
|
context: Context,
|
|
title: String,
|
|
body: String,
|
|
sound: Boolean,
|
|
vibration: Boolean,
|
|
priority: String
|
|
) {
|
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
// Create notification channel for Android 8.0+
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
CHANNEL_ID,
|
|
"Daily Notifications",
|
|
when (priority) {
|
|
"high" -> NotificationManager.IMPORTANCE_HIGH
|
|
"low" -> NotificationManager.IMPORTANCE_LOW
|
|
else -> NotificationManager.IMPORTANCE_DEFAULT
|
|
}
|
|
).apply {
|
|
enableVibration(vibration)
|
|
if (!sound) {
|
|
setSound(null, null)
|
|
}
|
|
}
|
|
notificationManager.createNotificationChannel(channel)
|
|
}
|
|
|
|
// Create intent to launch app when notification is clicked
|
|
// Use package launcher intent to avoid hardcoding MainActivity class name
|
|
val intent = getLaunchIntent(context) ?: return
|
|
val pendingIntent = PendingIntent.getActivity(
|
|
context,
|
|
0,
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
.setContentTitle(title)
|
|
.setContentText(body)
|
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
|
.setPriority(
|
|
when (priority) {
|
|
"high" -> NotificationCompat.PRIORITY_HIGH
|
|
"low" -> NotificationCompat.PRIORITY_LOW
|
|
else -> NotificationCompat.PRIORITY_DEFAULT
|
|
}
|
|
)
|
|
.setAutoCancel(true) // Dismissible when user swipes it away
|
|
.setContentIntent(pendingIntent) // Launch app when clicked
|
|
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
|
|
.build()
|
|
|
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
|
Log.i(TAG, "Notification displayed: $title")
|
|
}
|
|
|
|
private suspend fun recordHistory(
|
|
db: DailyNotificationDatabase,
|
|
kind: String,
|
|
outcome: String,
|
|
diagJson: String? = null
|
|
) {
|
|
try {
|
|
db.historyDao().insert(
|
|
History(
|
|
refId = "notify_${System.currentTimeMillis()}",
|
|
kind = kind,
|
|
occurredAt = System.currentTimeMillis(),
|
|
outcome = outcome,
|
|
diagJson = diagJson
|
|
)
|
|
)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to record history", e)
|
|
}
|
|
}
|
|
|
|
private suspend fun fireCallbacks(
|
|
context: Context,
|
|
db: DailyNotificationDatabase,
|
|
eventType: String,
|
|
contentCache: ContentCache
|
|
) {
|
|
try {
|
|
val callbacks = db.callbackDao().getEnabled()
|
|
callbacks.forEach { callback ->
|
|
try {
|
|
when (callback.kind) {
|
|
"http" -> fireHttpCallback(callback, eventType, contentCache)
|
|
"local" -> fireLocalCallback(context, callback, eventType, contentCache)
|
|
else -> Log.w(TAG, "Unknown callback kind: ${callback.kind}")
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to fire callback ${callback.id}", e)
|
|
recordHistory(db, "callback", "failure", "{\"callback_id\": \"${callback.id}\", \"error\": \"${e.message}\"}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to fire callbacks", e)
|
|
}
|
|
}
|
|
|
|
private suspend fun fireHttpCallback(
|
|
callback: Callback,
|
|
eventType: String,
|
|
contentCache: ContentCache
|
|
) {
|
|
// HTTP callback implementation would go here
|
|
Log.i(TAG, "HTTP callback fired: ${callback.id} for event: $eventType")
|
|
}
|
|
|
|
private suspend fun fireLocalCallback(
|
|
context: Context,
|
|
callback: Callback,
|
|
eventType: String,
|
|
contentCache: ContentCache
|
|
) {
|
|
// Local callback implementation would go here
|
|
Log.i(TAG, "Local callback fired: ${callback.id} for event: $eventType")
|
|
}
|
|
|
|
// Static Reminder Helper Methods
|
|
private fun showStaticReminderNotification(
|
|
context: Context,
|
|
title: String,
|
|
body: String,
|
|
sound: Boolean,
|
|
vibration: Boolean,
|
|
priority: String,
|
|
reminderId: String
|
|
) {
|
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
// Create notification channel for reminders
|
|
createReminderNotificationChannel(context, notificationManager)
|
|
|
|
// Create intent to launch app when notification is clicked
|
|
// Use package launcher intent to avoid hardcoding MainActivity class name
|
|
val intent = getLaunchIntent(context) ?: return
|
|
val pendingIntent = PendingIntent.getActivity(
|
|
context,
|
|
reminderId.hashCode(),
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
val notification = NotificationCompat.Builder(context, "daily_reminders")
|
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
|
.setContentTitle(title)
|
|
.setContentText(body)
|
|
.setPriority(
|
|
when (priority) {
|
|
"high" -> NotificationCompat.PRIORITY_HIGH
|
|
"low" -> NotificationCompat.PRIORITY_LOW
|
|
else -> NotificationCompat.PRIORITY_DEFAULT
|
|
}
|
|
)
|
|
.setSound(if (sound) null else null) // Use default sound if enabled
|
|
.setAutoCancel(true) // Dismissible when user swipes it away
|
|
.setContentIntent(pendingIntent) // Launch app when clicked
|
|
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
|
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
|
.build()
|
|
|
|
notificationManager.notify(reminderId.hashCode(), notification)
|
|
Log.i(TAG, "Static reminder displayed: $title (ID: $reminderId)")
|
|
}
|
|
|
|
private fun createReminderNotificationChannel(context: Context, notificationManager: NotificationManager) {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val channel = NotificationChannel(
|
|
"daily_reminders",
|
|
"Daily Reminders",
|
|
NotificationManager.IMPORTANCE_DEFAULT
|
|
).apply {
|
|
description = "Daily reminder notifications"
|
|
enableVibration(true)
|
|
setShowBadge(true)
|
|
}
|
|
notificationManager.createNotificationChannel(channel)
|
|
}
|
|
}
|
|
|
|
private fun recordReminderTrigger(context: Context, reminderId: String) {
|
|
try {
|
|
val prefs = context.getSharedPreferences("daily_reminders", Context.MODE_PRIVATE)
|
|
val editor = prefs.edit()
|
|
editor.putLong("${reminderId}_lastTriggered", System.currentTimeMillis())
|
|
editor.apply()
|
|
Log.d(TAG, "Reminder trigger recorded: $reminderId")
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Error recording reminder trigger", e)
|
|
}
|
|
}
|
|
}
|