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