feat(android)!: implement Phase 2 Android core with WorkManager + AlarmManager + SQLite

- Add complete SQLite schema with Room database (content_cache, schedules, callbacks, history)
- Implement WorkManager FetchWorker with exponential backoff and network constraints
- Add AlarmManager NotifyReceiver with TTL-at-fire logic and notification delivery
- Create BootReceiver for automatic rescheduling after device reboot
- Update AndroidManifest.xml with necessary permissions and receivers
- Add Room, WorkManager, and Kotlin coroutines dependencies to build.gradle

feat(callback-registry)!: implement callback registry with circuit breaker

- Add CallbackRegistryImpl with HTTP, local, and queue callback support
- Implement circuit breaker pattern with exponential backoff retry logic
- Add CallbackEvent interface with structured event types
- Support for exactly-once delivery semantics with retry queue
- Include callback status monitoring and health checks

feat(observability)!: add comprehensive observability and health monitoring

- Implement ObservabilityManager with structured logging and event codes
- Add performance metrics tracking (fetch, notify, callback times)
- Create health status API with circuit breaker monitoring
- Include log compaction and metrics reset functionality
- Support for DNP-* event codes throughout the system

feat(web)!: enhance web implementation with new functionality

- Integrate callback registry and observability into web platform
- Add mock implementations for dual scheduling methods
- Implement performance tracking and structured logging
- Support for local callback registration and management
- Enhanced error handling and event logging

BREAKING CHANGE: New Android dependencies require Room, WorkManager, and Kotlin coroutines
This commit is contained in:
Matthew Raymer
2025-09-22 09:02:04 +00:00
parent 3994b2eb17
commit 0bb5a8d218
12 changed files with 2048 additions and 32 deletions

View File

@@ -0,0 +1,253 @@
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")
}
}