You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
253 lines
9.4 KiB
253 lines
9.4 KiB
package com.timesafari.dailynotification
|
|
|
|
import android.app.AlarmManager
|
|
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
|
|
|
|
/**
|
|
* 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
|
|
private const val REQUEST_CODE = 2001
|
|
|
|
fun scheduleExactNotification(
|
|
context: Context,
|
|
triggerAtMillis: Long,
|
|
config: UserNotificationConfig
|
|
) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
val intent = Intent(context, NotifyReceiver::class.java).apply {
|
|
putExtra("title", config.title)
|
|
putExtra("body", config.body)
|
|
putExtra("sound", config.sound ?: true)
|
|
putExtra("vibration", config.vibration ?: true)
|
|
putExtra("priority", config.priority ?: "normal")
|
|
}
|
|
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context,
|
|
REQUEST_CODE,
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
|
|
try {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
alarmManager.setExactAndAllowWhileIdle(
|
|
AlarmManager.RTC_WAKEUP,
|
|
triggerAtMillis,
|
|
pendingIntent
|
|
)
|
|
} else {
|
|
alarmManager.setExact(
|
|
AlarmManager.RTC_WAKEUP,
|
|
triggerAtMillis,
|
|
pendingIntent
|
|
)
|
|
}
|
|
Log.i(TAG, "Exact notification scheduled for: $triggerAtMillis")
|
|
} catch (e: SecurityException) {
|
|
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e)
|
|
alarmManager.set(
|
|
AlarmManager.RTC_WAKEUP,
|
|
triggerAtMillis,
|
|
pendingIntent
|
|
)
|
|
}
|
|
}
|
|
|
|
fun cancelNotification(context: Context) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
val intent = Intent(context, NotifyReceiver::class.java)
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context,
|
|
REQUEST_CODE,
|
|
intent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
alarmManager.cancel(pendingIntent)
|
|
Log.i(TAG, "Notification alarm cancelled")
|
|
}
|
|
}
|
|
|
|
override fun onReceive(context: Context, intent: Intent?) {
|
|
Log.i(TAG, "Notification receiver triggered")
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
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)
|
|
}
|
|
|
|
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)
|
|
.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")
|
|
}
|
|
}
|
|
|