Files
daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt
Matthew Raymer 5b61f18bd7 feat(android): add exact alarm permission request flow and fix receiver mismatch
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
2025-11-10 05:51:05 +00:00

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)
}
}
}