diff --git a/android/app/build.gradle b/android/app/build.gradle
index 43b3d51..5811ae8 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -36,6 +36,13 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
+
+ // Daily Notification Plugin Dependencies
+ implementation "androidx.room:room-runtime:2.6.1"
+ implementation "androidx.work:work-runtime-ktx:2.9.0"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
+ annotationProcessor "androidx.room:room-compiler:2.6.1"
+
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4d7ca38..fab80d6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -24,6 +24,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
diff --git a/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt
new file mode 100644
index 0000000..8d19f1c
--- /dev/null
+++ b/android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt
@@ -0,0 +1,153 @@
+package com.timesafari.dailynotification
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+/**
+ * Boot recovery receiver to reschedule notifications after device reboot
+ * Implements RECEIVE_BOOT_COMPLETED functionality
+ *
+ * @author Matthew Raymer
+ * @version 1.1.0
+ */
+class BootReceiver : BroadcastReceiver() {
+
+ companion object {
+ private const val TAG = "DNP-BOOT"
+ }
+
+ override fun onReceive(context: Context, intent: Intent?) {
+ if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
+ Log.i(TAG, "Boot completed, rescheduling notifications")
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ rescheduleNotifications(context)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to reschedule notifications after boot", e)
+ }
+ }
+ }
+ }
+
+ private suspend fun rescheduleNotifications(context: Context) {
+ val db = DailyNotificationDatabase.getDatabase(context)
+ val enabledSchedules = db.scheduleDao().getEnabled()
+
+ Log.i(TAG, "Found ${enabledSchedules.size} enabled schedules to reschedule")
+
+ enabledSchedules.forEach { schedule ->
+ try {
+ when (schedule.kind) {
+ "fetch" -> {
+ // Reschedule WorkManager fetch
+ val config = ContentFetchConfig(
+ enabled = schedule.enabled,
+ schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
+ url = null, // Will use mock content
+ timeout = 30000,
+ retryAttempts = 3,
+ retryDelay = 1000,
+ callbacks = CallbackConfig()
+ )
+ FetchWorker.scheduleFetch(context, config)
+ Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}")
+ }
+ "notify" -> {
+ // Reschedule AlarmManager notification
+ val nextRunTime = calculateNextRunTime(schedule)
+ if (nextRunTime > System.currentTimeMillis()) {
+ val config = UserNotificationConfig(
+ enabled = schedule.enabled,
+ schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
+ title = "Daily Notification",
+ body = "Your daily update is ready",
+ sound = true,
+ vibration = true,
+ priority = "normal"
+ )
+ NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
+ Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
+ }
+ }
+ else -> {
+ Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e)
+ }
+ }
+
+ // Record boot recovery in history
+ try {
+ db.historyDao().insert(
+ History(
+ refId = "boot_recovery_${System.currentTimeMillis()}",
+ kind = "boot_recovery",
+ occurredAt = System.currentTimeMillis(),
+ outcome = "success",
+ diagJson = "{\"schedules_rescheduled\": ${enabledSchedules.size}}"
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to record boot recovery", e)
+ }
+ }
+
+ private fun calculateNextRunTime(schedule: Schedule): Long {
+ val now = System.currentTimeMillis()
+
+ // Simple implementation - for production, use proper cron parsing
+ return when {
+ schedule.cron != null -> {
+ // Parse cron expression and calculate next run
+ // For now, return next day at 9 AM
+ now + (24 * 60 * 60 * 1000L)
+ }
+ schedule.clockTime != null -> {
+ // Parse HH:mm and calculate next run
+ // For now, return next day at specified time
+ now + (24 * 60 * 60 * 1000L)
+ }
+ else -> {
+ // Default to next day at 9 AM
+ now + (24 * 60 * 60 * 1000L)
+ }
+ }
+ }
+}
+
+/**
+ * Data classes for configuration (simplified versions)
+ */
+data class ContentFetchConfig(
+ val enabled: Boolean,
+ val schedule: String,
+ val url: String? = null,
+ val timeout: Int? = null,
+ val retryAttempts: Int? = null,
+ val retryDelay: Int? = null,
+ val callbacks: CallbackConfig
+)
+
+data class UserNotificationConfig(
+ val enabled: Boolean,
+ val schedule: String,
+ val title: String? = null,
+ val body: String? = null,
+ val sound: Boolean? = null,
+ val vibration: Boolean? = null,
+ val priority: String? = null
+)
+
+data class CallbackConfig(
+ val apiService: String? = null,
+ val database: String? = null,
+ val reporting: String? = null
+)
diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
new file mode 100644
index 0000000..a00d2ec
--- /dev/null
+++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
@@ -0,0 +1,294 @@
+package com.timesafari.dailynotification
+
+import android.content.Context
+import android.util.Log
+import com.getcapacitor.JSObject
+import com.getcapacitor.Plugin
+import com.getcapacitor.PluginCall
+import com.getcapacitor.PluginMethod
+import com.getcapacitor.annotation.CapacitorPlugin
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+/**
+ * Main Android implementation of Daily Notification Plugin
+ * Bridges Capacitor calls to native Android functionality
+ *
+ * @author Matthew Raymer
+ * @version 1.1.0
+ */
+@CapacitorPlugin(name = "DailyNotification")
+class DailyNotificationPlugin : Plugin() {
+
+ companion object {
+ private const val TAG = "DNP-PLUGIN"
+ }
+
+ private lateinit var db: DailyNotificationDatabase
+
+ override fun load() {
+ super.load()
+ db = DailyNotificationDatabase.getDatabase(context)
+ Log.i(TAG, "Daily Notification Plugin loaded")
+ }
+
+ @PluginMethod
+ fun configure(call: PluginCall) {
+ try {
+ val options = call.getObject("options")
+ Log.i(TAG, "Configure called with options: $options")
+
+ // Store configuration in database
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // Implementation would store config in database
+ call.resolve()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to configure", e)
+ call.reject("Configuration failed: ${e.message}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Configure error", e)
+ call.reject("Configuration error: ${e.message}")
+ }
+ }
+
+ @PluginMethod
+ fun scheduleContentFetch(call: PluginCall) {
+ try {
+ val configJson = call.getObject("config")
+ val config = parseContentFetchConfig(configJson)
+
+ Log.i(TAG, "Scheduling content fetch")
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // Schedule WorkManager fetch
+ FetchWorker.scheduleFetch(context, config)
+
+ // Store schedule in database
+ val schedule = Schedule(
+ id = "fetch_${System.currentTimeMillis()}",
+ kind = "fetch",
+ cron = config.schedule,
+ enabled = config.enabled,
+ nextRunAt = calculateNextRunTime(config.schedule)
+ )
+ db.scheduleDao().upsert(schedule)
+
+ call.resolve()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to schedule content fetch", e)
+ call.reject("Content fetch scheduling failed: ${e.message}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Schedule content fetch error", e)
+ call.reject("Content fetch error: ${e.message}")
+ }
+ }
+
+ @PluginMethod
+ fun scheduleUserNotification(call: PluginCall) {
+ try {
+ val configJson = call.getObject("config")
+ val config = parseUserNotificationConfig(configJson)
+
+ Log.i(TAG, "Scheduling user notification")
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val nextRunTime = calculateNextRunTime(config.schedule)
+
+ // Schedule AlarmManager notification
+ NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
+
+ // Store schedule in database
+ val schedule = Schedule(
+ id = "notify_${System.currentTimeMillis()}",
+ kind = "notify",
+ cron = config.schedule,
+ enabled = config.enabled,
+ nextRunAt = nextRunTime
+ )
+ db.scheduleDao().upsert(schedule)
+
+ call.resolve()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to schedule user notification", e)
+ call.reject("User notification scheduling failed: ${e.message}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Schedule user notification error", e)
+ call.reject("User notification error: ${e.message}")
+ }
+ }
+
+ @PluginMethod
+ fun scheduleDualNotification(call: PluginCall) {
+ try {
+ val configJson = call.getObject("config")
+ val contentFetchConfig = parseContentFetchConfig(configJson.getObject("contentFetch"))
+ val userNotificationConfig = parseUserNotificationConfig(configJson.getObject("userNotification"))
+
+ Log.i(TAG, "Scheduling dual notification")
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // Schedule both fetch and notification
+ FetchWorker.scheduleFetch(context, contentFetchConfig)
+
+ val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
+ NotifyReceiver.scheduleExactNotification(context, nextRunTime, userNotificationConfig)
+
+ // Store both schedules
+ val fetchSchedule = Schedule(
+ id = "dual_fetch_${System.currentTimeMillis()}",
+ kind = "fetch",
+ cron = contentFetchConfig.schedule,
+ enabled = contentFetchConfig.enabled,
+ nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
+ )
+ val notifySchedule = Schedule(
+ id = "dual_notify_${System.currentTimeMillis()}",
+ kind = "notify",
+ cron = userNotificationConfig.schedule,
+ enabled = userNotificationConfig.enabled,
+ nextRunAt = nextRunTime
+ )
+
+ db.scheduleDao().upsert(fetchSchedule)
+ db.scheduleDao().upsert(notifySchedule)
+
+ call.resolve()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to schedule dual notification", e)
+ call.reject("Dual notification scheduling failed: ${e.message}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Schedule dual notification error", e)
+ call.reject("Dual notification error: ${e.message}")
+ }
+ }
+
+ @PluginMethod
+ fun getDualScheduleStatus(call: PluginCall) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val enabledSchedules = db.scheduleDao().getEnabled()
+ val latestCache = db.contentCacheDao().getLatest()
+ val recentHistory = db.historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
+
+ val status = JSObject().apply {
+ put("nextRuns", enabledSchedules.map { it.nextRunAt })
+ put("lastOutcomes", recentHistory.map { it.outcome })
+ put("cacheAgeMs", latestCache?.let { System.currentTimeMillis() - it.fetchedAt })
+ put("staleArmed", latestCache?.let {
+ System.currentTimeMillis() > (it.fetchedAt + it.ttlSeconds * 1000L)
+ } ?: true)
+ put("queueDepth", recentHistory.size)
+ }
+
+ call.resolve(status)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to get dual schedule status", e)
+ call.reject("Status retrieval failed: ${e.message}")
+ }
+ }
+ }
+
+ @PluginMethod
+ fun registerCallback(call: PluginCall) {
+ try {
+ val name = call.getString("name")
+ val callback = call.getObject("callback")
+
+ Log.i(TAG, "Registering callback: $name")
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val callbackRecord = Callback(
+ id = name,
+ kind = callback.getString("kind", "local"),
+ target = callback.getString("target", ""),
+ headersJson = callback.getString("headers"),
+ enabled = true,
+ createdAt = System.currentTimeMillis()
+ )
+
+ db.callbackDao().upsert(callbackRecord)
+ call.resolve()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to register callback", e)
+ call.reject("Callback registration failed: ${e.message}")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Register callback error", e)
+ call.reject("Callback registration error: ${e.message}")
+ }
+ }
+
+ @PluginMethod
+ fun getContentCache(call: PluginCall) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val latestCache = db.contentCacheDao().getLatest()
+ val result = JSObject()
+
+ if (latestCache != null) {
+ result.put("id", latestCache.id)
+ result.put("fetchedAt", latestCache.fetchedAt)
+ result.put("ttlSeconds", latestCache.ttlSeconds)
+ result.put("payload", String(latestCache.payload))
+ result.put("meta", latestCache.meta)
+ }
+
+ call.resolve(result)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to get content cache", e)
+ call.reject("Content cache retrieval failed: ${e.message}")
+ }
+ }
+ }
+
+ // Helper methods
+ private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
+ return ContentFetchConfig(
+ enabled = configJson.getBoolean("enabled", true),
+ schedule = configJson.getString("schedule", "0 9 * * *"),
+ url = configJson.getString("url"),
+ timeout = configJson.getInt("timeout"),
+ retryAttempts = configJson.getInt("retryAttempts"),
+ retryDelay = configJson.getInt("retryDelay"),
+ callbacks = CallbackConfig(
+ apiService = configJson.getObject("callbacks")?.getString("apiService"),
+ database = configJson.getObject("callbacks")?.getString("database"),
+ reporting = configJson.getObject("callbacks")?.getString("reporting")
+ )
+ )
+ }
+
+ private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig {
+ return UserNotificationConfig(
+ enabled = configJson.getBoolean("enabled", true),
+ schedule = configJson.getString("schedule", "0 9 * * *"),
+ title = configJson.getString("title"),
+ body = configJson.getString("body"),
+ sound = configJson.getBoolean("sound"),
+ vibration = configJson.getBoolean("vibration"),
+ priority = configJson.getString("priority")
+ )
+ }
+
+ private fun calculateNextRunTime(schedule: String): Long {
+ // Simple implementation - for production, use proper cron parsing
+ val now = System.currentTimeMillis()
+ return now + (24 * 60 * 60 * 1000L) // Next day
+ }
+}
diff --git a/android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt b/android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt
new file mode 100644
index 0000000..cda440c
--- /dev/null
+++ b/android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt
@@ -0,0 +1,144 @@
+package com.timesafari.dailynotification
+
+import androidx.room.*
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+/**
+ * SQLite schema for Daily Notification Plugin
+ * Implements TTL-at-fire invariant and rolling window armed design
+ *
+ * @author Matthew Raymer
+ * @version 1.1.0
+ */
+@Entity(tableName = "content_cache")
+data class ContentCache(
+ @PrimaryKey val id: String,
+ val fetchedAt: Long, // epoch ms
+ val ttlSeconds: Int,
+ val payload: ByteArray, // BLOB
+ val meta: String? = null
+)
+
+@Entity(tableName = "schedules")
+data class Schedule(
+ @PrimaryKey val id: String,
+ val kind: String, // 'fetch' or 'notify'
+ val cron: String? = null, // optional cron expression
+ val clockTime: String? = null, // optional HH:mm
+ val enabled: Boolean = true,
+ val lastRunAt: Long? = null,
+ val nextRunAt: Long? = null,
+ val jitterMs: Int = 0,
+ val backoffPolicy: String = "exp",
+ val stateJson: String? = null
+)
+
+@Entity(tableName = "callbacks")
+data class Callback(
+ @PrimaryKey val id: String,
+ val kind: String, // 'http', 'local', 'queue'
+ val target: String, // url_or_local
+ val headersJson: String? = null,
+ val enabled: Boolean = true,
+ val createdAt: Long
+)
+
+@Entity(tableName = "history")
+data class History(
+ @PrimaryKey(autoGenerate = true) val id: Int = 0,
+ val refId: String, // content or schedule id
+ val kind: String, // fetch/notify/callback
+ val occurredAt: Long,
+ val durationMs: Long? = null,
+ val outcome: String, // success|failure|skipped_ttl|circuit_open
+ val diagJson: String? = null
+)
+
+@Database(
+ entities = [ContentCache::class, Schedule::class, Callback::class, History::class],
+ version = 1,
+ exportSchema = false
+)
+@TypeConverters(Converters::class)
+abstract class DailyNotificationDatabase : RoomDatabase() {
+ abstract fun contentCacheDao(): ContentCacheDao
+ abstract fun scheduleDao(): ScheduleDao
+ abstract fun callbackDao(): CallbackDao
+ abstract fun historyDao(): HistoryDao
+}
+
+@Dao
+interface ContentCacheDao {
+ @Query("SELECT * FROM content_cache WHERE id = :id")
+ suspend fun getById(id: String): ContentCache?
+
+ @Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
+ suspend fun getLatest(): ContentCache?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(contentCache: ContentCache)
+
+ @Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime")
+ suspend fun deleteOlderThan(cutoffTime: Long)
+
+ @Query("SELECT COUNT(*) FROM content_cache")
+ suspend fun getCount(): Int
+}
+
+@Dao
+interface ScheduleDao {
+ @Query("SELECT * FROM schedules WHERE enabled = 1")
+ suspend fun getEnabled(): List
+
+ @Query("SELECT * FROM schedules WHERE id = :id")
+ suspend fun getById(id: String): Schedule?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(schedule: Schedule)
+
+ @Query("UPDATE schedules SET enabled = :enabled WHERE id = :id")
+ suspend fun setEnabled(id: String, enabled: Boolean)
+
+ @Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id")
+ suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?)
+}
+
+@Dao
+interface CallbackDao {
+ @Query("SELECT * FROM callbacks WHERE enabled = 1")
+ suspend fun getEnabled(): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(callback: Callback)
+
+ @Query("DELETE FROM callbacks WHERE id = :id")
+ suspend fun deleteById(id: String)
+}
+
+@Dao
+interface HistoryDao {
+ @Insert
+ suspend fun insert(history: History)
+
+ @Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC")
+ suspend fun getSince(since: Long): List
+
+ @Query("DELETE FROM history WHERE occurredAt < :cutoffTime")
+ suspend fun deleteOlderThan(cutoffTime: Long)
+
+ @Query("SELECT COUNT(*) FROM history")
+ suspend fun getCount(): Int
+}
+
+class Converters {
+ @TypeConverter
+ fun fromByteArray(value: ByteArray?): String? {
+ return value?.let { String(it) }
+ }
+
+ @TypeConverter
+ fun toByteArray(value: String?): ByteArray? {
+ return value?.toByteArray()
+ }
+}
diff --git a/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt b/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt
new file mode 100644
index 0000000..79e5273
--- /dev/null
+++ b/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt
@@ -0,0 +1,202 @@
+package com.timesafari.dailynotification
+
+import android.content.Context
+import android.util.Log
+import androidx.work.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.util.concurrent.TimeUnit
+
+/**
+ * WorkManager implementation for content fetching
+ * Implements exponential backoff and network constraints
+ *
+ * @author Matthew Raymer
+ * @version 1.1.0
+ */
+class FetchWorker(
+ appContext: Context,
+ workerParams: WorkerParameters
+) : CoroutineWorker(appContext, workerParams) {
+
+ companion object {
+ private const val TAG = "DNP-FETCH"
+ private const val WORK_NAME = "fetch_content"
+
+ fun scheduleFetch(context: Context, config: ContentFetchConfig) {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ val workRequest = OneTimeWorkRequestBuilder()
+ .setConstraints(constraints)
+ .setBackoffCriteria(
+ BackoffPolicy.EXPONENTIAL,
+ 30,
+ TimeUnit.SECONDS
+ )
+ .setInputData(
+ Data.Builder()
+ .putString("url", config.url)
+ .putString("headers", config.headers?.toString())
+ .putInt("timeout", config.timeout ?: 30000)
+ .putInt("retryAttempts", config.retryAttempts ?: 3)
+ .putInt("retryDelay", config.retryDelay ?: 1000)
+ .build()
+ )
+ .build()
+
+ WorkManager.getInstance(context)
+ .enqueueUniqueWork(
+ WORK_NAME,
+ ExistingWorkPolicy.REPLACE,
+ workRequest
+ )
+ }
+ }
+
+ override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
+ val start = SystemClock.elapsedRealtime()
+ val url = inputData.getString("url")
+ val timeout = inputData.getInt("timeout", 30000)
+ val retryAttempts = inputData.getInt("retryAttempts", 3)
+ val retryDelay = inputData.getInt("retryDelay", 1000)
+
+ try {
+ Log.i(TAG, "Starting content fetch from: $url")
+
+ val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
+ val contentCache = ContentCache(
+ id = generateId(),
+ fetchedAt = System.currentTimeMillis(),
+ ttlSeconds = 3600, // 1 hour default TTL
+ payload = payload,
+ meta = "fetched_by_workmanager"
+ )
+
+ // Store in database
+ val db = DailyNotificationDatabase.getDatabase(applicationContext)
+ db.contentCacheDao().upsert(contentCache)
+
+ // Record success in history
+ db.historyDao().insert(
+ History(
+ refId = contentCache.id,
+ kind = "fetch",
+ occurredAt = System.currentTimeMillis(),
+ durationMs = SystemClock.elapsedRealtime() - start,
+ outcome = "success"
+ )
+ )
+
+ Log.i(TAG, "Content fetch completed successfully")
+ Result.success()
+
+ } catch (e: IOException) {
+ Log.w(TAG, "Network error during fetch", e)
+ recordFailure("network_error", start, e)
+ Result.retry()
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Unexpected error during fetch", e)
+ recordFailure("unexpected_error", start, e)
+ Result.failure()
+ }
+ }
+
+ private suspend fun fetchContent(
+ url: String?,
+ timeout: Int,
+ retryAttempts: Int,
+ retryDelay: Int
+ ): ByteArray {
+ if (url.isNullOrBlank()) {
+ // Generate mock content for testing
+ return generateMockContent()
+ }
+
+ var lastException: Exception? = null
+
+ repeat(retryAttempts) { attempt ->
+ try {
+ val connection = URL(url).openConnection() as HttpURLConnection
+ connection.connectTimeout = timeout
+ connection.readTimeout = timeout
+ connection.requestMethod = "GET"
+
+ val responseCode = connection.responseCode
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ return connection.inputStream.readBytes()
+ } else {
+ throw IOException("HTTP $responseCode: ${connection.responseMessage}")
+ }
+
+ } catch (e: Exception) {
+ lastException = e
+ if (attempt < retryAttempts - 1) {
+ Log.w(TAG, "Fetch attempt ${attempt + 1} failed, retrying in ${retryDelay}ms", e)
+ kotlinx.coroutines.delay(retryDelay.toLong())
+ }
+ }
+ }
+
+ throw lastException ?: IOException("All retry attempts failed")
+ }
+
+ private fun generateMockContent(): ByteArray {
+ val mockData = """
+ {
+ "timestamp": ${System.currentTimeMillis()},
+ "content": "Daily notification content",
+ "source": "mock_generator",
+ "version": "1.1.0"
+ }
+ """.trimIndent()
+ return mockData.toByteArray()
+ }
+
+ private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
+ try {
+ val db = DailyNotificationDatabase.getDatabase(applicationContext)
+ db.historyDao().insert(
+ History(
+ refId = "fetch_${System.currentTimeMillis()}",
+ kind = "fetch",
+ occurredAt = System.currentTimeMillis(),
+ durationMs = SystemClock.elapsedRealtime() - start,
+ outcome = outcome,
+ diagJson = "{\"error\": \"${error.message}\"}"
+ )
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to record failure", e)
+ }
+ }
+
+ private fun generateId(): String {
+ return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
+ }
+}
+
+/**
+ * Database singleton for Room
+ */
+object DailyNotificationDatabase {
+ @Volatile
+ private var INSTANCE: DailyNotificationDatabase? = null
+
+ fun getDatabase(context: Context): DailyNotificationDatabase {
+ return INSTANCE ?: synchronized(this) {
+ val instance = Room.databaseBuilder(
+ context.applicationContext,
+ DailyNotificationDatabase::class.java,
+ "daily_notification_database"
+ ).build()
+ INSTANCE = instance
+ instance
+ }
+ }
+}
diff --git a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt
new file mode 100644
index 0000000..fd4c164
--- /dev/null
+++ b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt
@@ -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")
+ }
+}
diff --git a/doc/directives/0002-Daily-Notification-Plugin-Recommendations.md b/doc/directives/0002-Daily-Notification-Plugin-Recommendations.md
new file mode 100644
index 0000000..0147f51
--- /dev/null
+++ b/doc/directives/0002-Daily-Notification-Plugin-Recommendations.md
@@ -0,0 +1,255 @@
+
+# Daily Notification Plugin — Phase 2 Recommendations (v3)
+
+> This directive assumes Phase 1 (API surface + tests) is complete and aligns with the current codebase. It focuses on **platform implementations**, **storage/TTL**, **callbacks**, **observability**, and **security**.
+
+---
+
+## 1) Milestones & Order of Work
+
+1. **Android Core (Week 1–2)**
+ - Fetch: WorkManager (`Constraints: NETWORK_CONNECTED`, backoff: exponential)
+ - Notify: AlarmManager (or Exact alarms if permitted), NotificationManager
+ - Boot resilience: `RECEIVE_BOOT_COMPLETED` receiver reschedules jobs
+ - Shared SQLite schema + DAO layer (Room recommended)
+2. **Callback Registry (Week 2)** — shared TS interface + native bridges
+3. **Observability & Health (Week 2–3)** — event codes, status endpoints, history compaction
+4. **iOS Parity (Week 3–4)** — BGTaskScheduler + UNUserNotificationCenter
+5. **Web SW/Push (Week 4)** — SW events + IndexedDB (mirror schema), periodic sync fallback
+6. **Docs & Examples (Week 4)** — migration, enterprise callbacks, health dashboards
+
+---
+
+## 2) Storage & TTL — Concrete Schema
+
+> Keep **TTL-at-fire** invariant and **rolling window armed**. Use normalized tables and a minimal DAO.
+
+### SQLite (DDL)
+
+```sql
+CREATE TABLE IF NOT EXISTS content_cache (
+ id TEXT PRIMARY KEY,
+ fetched_at INTEGER NOT NULL, -- epoch ms
+ ttl_seconds INTEGER NOT NULL,
+ payload BLOB NOT NULL,
+ meta TEXT
+);
+
+CREATE TABLE IF NOT EXISTS schedules (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('fetch','notify')),
+ cron TEXT, -- optional: cron expression
+ clock_time TEXT, -- optional: HH:mm
+ enabled INTEGER NOT NULL DEFAULT 1,
+ last_run_at INTEGER,
+ next_run_at INTEGER,
+ jitter_ms INTEGER DEFAULT 0,
+ backoff_policy TEXT DEFAULT 'exp',
+ state_json TEXT
+);
+
+CREATE TABLE IF NOT EXISTS callbacks (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('http','local','queue')),
+ target TEXT NOT NULL, -- url_or_local
+ headers_json TEXT,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS history (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ ref_id TEXT, -- content or schedule id
+ kind TEXT NOT NULL, -- fetch/notify/callback
+ occurred_at INTEGER NOT NULL,
+ duration_ms INTEGER,
+ outcome TEXT NOT NULL, -- success|failure|skipped_ttl|circuit_open
+ diag_json TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_history_time ON history(occurred_at);
+CREATE INDEX IF NOT EXISTS idx_cache_time ON content_cache(fetched_at);
+```
+
+### TTL-at-fire Rule
+
+- On notification fire: `if (now > fetched_at + ttl_seconds) -> skip (record outcome=skipped_ttl)`.
+- Maintain a **prep guarantee**: ensure a fresh cache entry for the next window even after failures (schedule a fetch on next window).
+
+---
+
+## 3) Android Implementation Sketch
+
+### WorkManager for Fetch
+
+```kotlin
+class FetchWorker(
+ appContext: Context,
+ workerParams: WorkerParameters
+) : CoroutineWorker(appContext, workerParams) {
+
+ override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
+ val start = SystemClock.elapsedRealtime()
+ try {
+ val payload = fetchContent() // http call / local generator
+ dao.upsertCache(ContentCache(...))
+ logEvent("DNP-FETCH-SUCCESS", start)
+ Result.success()
+ } catch (e: IOException) {
+ logEvent("DNP-FETCH-FAILURE", start, e)
+ Result.retry()
+ } catch (e: Throwable) {
+ logEvent("DNP-FETCH-FAILURE", start, e)
+ Result.failure()
+ }
+ }
+}
+```
+
+**Constraints**: `Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()`
+**Backoff**: `setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)`
+
+### AlarmManager for Notify
+
+```kotlin
+fun scheduleExactNotification(context: Context, triggerAtMillis: Long) {
+ val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ val pi = PendingIntent.getBroadcast(context, REQ_ID, Intent(context, NotifyReceiver::class.java), FLAG_IMMUTABLE)
+ alarmMgr.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pi)
+}
+
+class NotifyReceiver : BroadcastReceiver() {
+ override fun onReceive(ctx: Context, intent: Intent?) {
+ val cache = dao.latestCache()
+ if (cache == null) return
+ if (System.currentTimeMillis() > cache.fetched_at + cache.ttl_seconds * 1000) {
+ recordHistory("notify", "skipped_ttl"); return
+ }
+ showNotification(ctx, cache)
+ recordHistory("notify", "success")
+ fireCallbacks("onNotifyDelivered")
+ }
+}
+```
+
+### Boot Reschedule
+
+- Manifest: `RECEIVE_BOOT_COMPLETED`
+- On boot: read `schedules.enabled=1` and re-schedule WorkManager/AlarmManager
+
+---
+
+## 4) Callback Registry — Minimal Viable Implementation
+
+### TS Core
+
+```ts
+export type CallbackKind = 'http' | 'local' | 'queue';
+
+export interface CallbackEvent {
+ id: string;
+ at: number;
+ type: 'onFetchStart' | 'onFetchSuccess' | 'onFetchFailure' |
+ 'onNotifyStart' | 'onNotifyDelivered' | 'onNotifySkippedTTL' | 'onNotifyFailure';
+ payload?: unknown;
+}
+
+export type CallbackFunction = (e: CallbackEvent) => Promise | void;
+```
+
+### Delivery Semantics
+
+- **Exactly-once attempt per event**, persisted `history` row
+- **Retry**: exponential backoff with cap; open **circuit** per `callback.id` on repeated failures
+- **Redaction**: apply header/body redaction before persisting `diag_json`
+
+### HTTP Example
+
+```ts
+async function deliverHttpCallback(cb: CallbackRecord, event: CallbackEvent) {
+ const start = performance.now();
+ try {
+ const res = await fetch(cb.target, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...(cb.headers ?? {}) },
+ body: JSON.stringify(event),
+ });
+ recordHistory(cb.id, 'callback', 'success', start, { status: res.status });
+ } catch (err) {
+ scheduleRetry(cb.id, event); // capped exponential
+ recordHistory(cb.id, 'callback', 'failure', start, { error: String(err) });
+ }
+}
+```
+
+---
+
+## 5) Observability & Health
+
+- **Event Codes**: `DNP-FETCH-*`, `DNP-NOTIFY-*`, `DNP-CB-*`
+- **Health API** (TS): `getDualScheduleStatus()` returns `{ nextRuns, lastOutcomes, cacheAgeMs, staleArmed, queueDepth }`
+- **Compaction**: nightly job to prune `history` > 30 days
+- **Device Debug**: Android broadcast to dump status to logcat for field diagnostics
+
+---
+
+## 6) Security & Permissions
+
+- Default **HTTPS-only** callbacks, opt-out via explicit dev flag
+- Android: runtime gate for `POST_NOTIFICATIONS`; show rationale UI for exact alarms (if requested)
+- **PII/Secrets**: redact before persistence; never log tokens
+- **Input Validation**: sanitize HTTP callback targets; enforce allowlist pattern (e.g., `https://*.yourdomain.tld` in prod)
+
+---
+
+## 7) Performance & Battery
+
+- **±Jitter (5m)** for fetch; coalesce same-minute schedules
+- **Retry Caps**: ≤ 5 attempts, upper bound 60 min backoff
+- **Network Guards**: avoid waking when offline; use WorkManager constraints to defer
+- **Back-Pressure**: cap concurrent callbacks; open circuit on sustained failures
+
+---
+
+## 8) Tests You Can Add Now
+
+- **TTL Edge Cases**: past/future timezones, DST cutovers
+- **Retry & Circuit**: force network failures, assert capped retries + circuit open
+- **Boot Reschedule**: instrumentation test to simulate reboot and check re-arming
+- **SW/IndexedDB**: headless test verifying cache write/read + TTL skip
+
+---
+
+## 9) Documentation Tasks
+
+- API reference for new **health** and **callback** semantics
+- Platform guides: Android exact alarm notes, iOS background limits, Web SW lifecycle
+- Migration note: why `scheduleDualNotification` is preferred; compat wrappers policy
+- “Runbook” for QA: how to toggle jitter/backoff; how to inspect `history`
+
+---
+
+## 10) Acceptance Criteria (Phase 2)
+
+- Android end-to-end demo: fetch → cache → TTL check → notify → callback(s) → history
+- Health endpoint returns non-null next run, recent outcomes, and cache age
+- iOS parity path demonstrated on simulator (background fetch + local notif)
+- Web SW functional on Chromium + Firefox with IndexedDB persistence
+- Logs show structured `DNP-*` events; compaction reduces history size as configured
+- Docs updated; examples build and run
+
+---
+
+## 11) Risks & Mitigations
+
+- **Doze/Idle drops alarms** → prefer WorkManager + exact when allowed; add tolerance window
+- **iOS background unpredictability** → encourage scheduled “fetch windows”; document silent-push optionality
+- **Web Push unavailable** → periodic sync + foreground fallback; degrade gracefully
+- **Callback storms** → batch events where possible; per-callback rate limit
+
+---
+
+## 12) Versioning
+
+- Release as `1.1.0` when Android path merges; mark wrappers as **soft-deprecated** in docs
+- Keep zero-padded doc versions in `/doc/` and release notes linking to them
diff --git a/src/callback-registry.ts b/src/callback-registry.ts
new file mode 100644
index 0000000..51d8bc6
--- /dev/null
+++ b/src/callback-registry.ts
@@ -0,0 +1,283 @@
+/**
+ * Callback Registry Implementation
+ * Provides uniform callback lifecycle usable from any platform
+ *
+ * @author Matthew Raymer
+ * @version 1.1.0
+ */
+
+export type CallbackKind = 'http' | 'local' | 'queue';
+
+export interface CallbackEvent {
+ id: string;
+ at: number;
+ type: 'onFetchStart' | 'onFetchSuccess' | 'onFetchFailure' |
+ 'onNotifyStart' | 'onNotifyDelivered' | 'onNotifySkippedTTL' | 'onNotifyFailure';
+ payload?: unknown;
+}
+
+export type CallbackFunction = (e: CallbackEvent) => Promise | void;
+
+export interface CallbackRecord {
+ id: string;
+ kind: CallbackKind;
+ target: string;
+ headers?: Record;
+ enabled: boolean;
+ createdAt: number;
+ retryCount?: number;
+ lastFailure?: number;
+ circuitOpen?: boolean;
+}
+
+export interface CallbackRegistry {
+ register(id: string, callback: CallbackRecord): Promise;
+ unregister(id: string): Promise;
+ fire(event: CallbackEvent): Promise;
+ getRegistered(): Promise;
+ getStatus(): Promise<{
+ total: number;
+ enabled: number;
+ circuitOpen: number;
+ lastActivity: number;
+ }>;
+}
+
+/**
+ * Callback Registry Implementation
+ * Handles callback registration, delivery, and circuit breaker logic
+ */
+export class CallbackRegistryImpl implements CallbackRegistry {
+ private callbacks = new Map();
+ private localCallbacks = new Map();
+ private retryQueue = new Map();
+ private circuitBreakers = new Map();
+
+ constructor() {
+ this.startRetryProcessor();
+ }
+
+ async register(id: string, callback: CallbackRecord): Promise {
+ this.callbacks.set(id, callback);
+
+ // Initialize circuit breaker
+ if (!this.circuitBreakers.has(id)) {
+ this.circuitBreakers.set(id, {
+ failures: 0,
+ lastFailure: 0,
+ open: false
+ });
+ }
+
+ console.log(`DNP-CB-REGISTER: Callback ${id} registered (${callback.kind})`);
+ }
+
+ async unregister(id: string): Promise {
+ this.callbacks.delete(id);
+ this.localCallbacks.delete(id);
+ this.retryQueue.delete(id);
+ this.circuitBreakers.delete(id);
+
+ console.log(`DNP-CB-UNREGISTER: Callback ${id} unregistered`);
+ }
+
+ async fire(event: CallbackEvent): Promise {
+ const enabledCallbacks = Array.from(this.callbacks.values())
+ .filter(cb => cb.enabled);
+
+ console.log(`DNP-CB-FIRE: Firing event ${event.type} to ${enabledCallbacks.length} callbacks`);
+
+ for (const callback of enabledCallbacks) {
+ try {
+ await this.deliverCallback(callback, event);
+ } catch (error) {
+ console.error(`DNP-CB-FIRE-ERROR: Failed to deliver to ${callback.id}`, error);
+ await this.handleCallbackFailure(callback, event, error);
+ }
+ }
+ }
+
+ async getRegistered(): Promise {
+ return Array.from(this.callbacks.values());
+ }
+
+ async getStatus(): Promise<{
+ total: number;
+ enabled: number;
+ circuitOpen: number;
+ lastActivity: number;
+ }> {
+ const callbacks = Array.from(this.callbacks.values());
+ const circuitBreakers = Array.from(this.circuitBreakers.values());
+
+ return {
+ total: callbacks.length,
+ enabled: callbacks.filter(cb => cb.enabled).length,
+ circuitOpen: circuitBreakers.filter(cb => cb.open).length,
+ lastActivity: Math.max(
+ ...callbacks.map(cb => cb.createdAt),
+ ...circuitBreakers.map(cb => cb.lastFailure)
+ )
+ };
+ }
+
+ private async deliverCallback(callback: CallbackRecord, event: CallbackEvent): Promise {
+ const circuitBreaker = this.circuitBreakers.get(callback.id);
+
+ // Check circuit breaker
+ if (circuitBreaker?.open) {
+ console.warn(`DNP-CB-CIRCUIT: Circuit open for ${callback.id}, skipping delivery`);
+ return;
+ }
+
+ const start = performance.now();
+
+ try {
+ switch (callback.kind) {
+ case 'http':
+ await this.deliverHttpCallback(callback, event);
+ break;
+ case 'local':
+ await this.deliverLocalCallback(callback, event);
+ break;
+ case 'queue':
+ await this.deliverQueueCallback(callback, event);
+ break;
+ default:
+ throw new Error(`Unknown callback kind: ${callback.kind}`);
+ }
+
+ // Reset circuit breaker on success
+ if (circuitBreaker) {
+ circuitBreaker.failures = 0;
+ circuitBreaker.open = false;
+ }
+
+ const duration = performance.now() - start;
+ console.log(`DNP-CB-SUCCESS: Delivered to ${callback.id} in ${duration.toFixed(2)}ms`);
+
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ private async deliverHttpCallback(callback: CallbackRecord, event: CallbackEvent): Promise {
+ const response = await fetch(callback.target, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...callback.headers
+ },
+ body: JSON.stringify({
+ ...event,
+ callbackId: callback.id,
+ timestamp: Date.now()
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ }
+
+ private async deliverLocalCallback(callback: CallbackRecord, event: CallbackEvent): Promise {
+ const localCallback = this.localCallbacks.get(callback.id);
+ if (!localCallback) {
+ throw new Error(`Local callback ${callback.id} not found`);
+ }
+
+ await localCallback(event);
+ }
+
+ private async deliverQueueCallback(callback: CallbackRecord, event: CallbackEvent): Promise {
+ // Queue callback implementation would go here
+ // For now, just log the event
+ console.log(`DNP-CB-QUEUE: Queued event ${event.type} for ${callback.id}`);
+ }
+
+ private async handleCallbackFailure(
+ callback: CallbackRecord,
+ event: CallbackEvent,
+ error: unknown
+ ): Promise {
+ const circuitBreaker = this.circuitBreakers.get(callback.id);
+
+ if (circuitBreaker) {
+ circuitBreaker.failures++;
+ circuitBreaker.lastFailure = Date.now();
+
+ // Open circuit after 5 consecutive failures
+ if (circuitBreaker.failures >= 5) {
+ circuitBreaker.open = true;
+ console.error(`DNP-CB-CIRCUIT-OPEN: Circuit opened for ${callback.id} after ${circuitBreaker.failures} failures`);
+ }
+ }
+
+ // Schedule retry with exponential backoff
+ await this.scheduleRetry(callback, event);
+
+ console.error(`DNP-CB-FAILURE: Callback ${callback.id} failed`, error);
+ }
+
+ private async scheduleRetry(callback: CallbackRecord, event: CallbackEvent): Promise {
+ const retryCount = callback.retryCount || 0;
+
+ if (retryCount >= 5) {
+ console.warn(`DNP-CB-RETRY-LIMIT: Max retries reached for ${callback.id}`);
+ return;
+ }
+
+ const backoffMs = Math.min(1000 * Math.pow(2, retryCount), 60000); // Cap at 1 minute
+ const retryEvent = { ...event, retryCount: retryCount + 1 };
+
+ if (!this.retryQueue.has(callback.id)) {
+ this.retryQueue.set(callback.id, []);
+ }
+
+ this.retryQueue.get(callback.id)!.push(retryEvent);
+
+ console.log(`DNP-CB-RETRY: Scheduled retry ${retryCount + 1} for ${callback.id} in ${backoffMs}ms`);
+ }
+
+ private startRetryProcessor(): void {
+ setInterval(async () => {
+ for (const [callbackId, events] of this.retryQueue.entries()) {
+ if (events.length === 0) continue;
+
+ const callback = this.callbacks.get(callbackId);
+ if (!callback) {
+ this.retryQueue.delete(callbackId);
+ continue;
+ }
+
+ const event = events.shift();
+ if (!event) continue;
+
+ try {
+ await this.deliverCallback(callback, event);
+ } catch (error) {
+ console.error(`DNP-CB-RETRY-FAILED: Retry failed for ${callbackId}`, error);
+ }
+ }
+ }, 5000); // Process retries every 5 seconds
+ }
+
+ // Register local callback function
+ registerLocalCallback(id: string, callback: CallbackFunction): void {
+ this.localCallbacks.set(id, callback);
+ console.log(`DNP-CB-LOCAL: Local callback ${id} registered`);
+ }
+
+ // Unregister local callback function
+ unregisterLocalCallback(id: string): void {
+ this.localCallbacks.delete(id);
+ console.log(`DNP-CB-LOCAL: Local callback ${id} unregistered`);
+ }
+}
+
+// Singleton instance
+export const callbackRegistry = new CallbackRegistryImpl();
diff --git a/src/index.ts b/src/index.ts
index ffde750..5792ef3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,10 +6,16 @@
import { registerPlugin } from '@capacitor/core';
import type { DailyNotificationPlugin } from './definitions';
import { DailyNotificationWeb } from './web';
+import { observability, EVENT_CODES } from './observability';
const DailyNotification = registerPlugin('DailyNotification', {
web: async () => new DailyNotificationWeb(),
});
+// Initialize observability
+observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Daily Notification Plugin initialized');
+
export * from './definitions';
+export * from './callback-registry';
+export * from './observability';
export { DailyNotification };
diff --git a/src/observability.ts b/src/observability.ts
new file mode 100644
index 0000000..d87ec41
--- /dev/null
+++ b/src/observability.ts
@@ -0,0 +1,311 @@
+/**
+ * Observability & Health Monitoring Implementation
+ * Provides structured logging, event codes, and health monitoring
+ *
+ * @author Matthew Raymer
+ * @version 1.1.0
+ */
+
+export interface HealthStatus {
+ nextRuns: number[];
+ lastOutcomes: string[];
+ cacheAgeMs: number | null;
+ staleArmed: boolean;
+ queueDepth: number;
+ circuitBreakers: {
+ total: number;
+ open: number;
+ failures: number;
+ };
+ performance: {
+ avgFetchTime: number;
+ avgNotifyTime: number;
+ successRate: number;
+ };
+}
+
+export interface EventLog {
+ id: string;
+ timestamp: number;
+ level: 'INFO' | 'WARN' | 'ERROR';
+ eventCode: string;
+ message: string;
+ data?: Record;
+ duration?: number;
+}
+
+export interface PerformanceMetrics {
+ fetchTimes: number[];
+ notifyTimes: number[];
+ callbackTimes: number[];
+ successCount: number;
+ failureCount: number;
+ lastReset: number;
+}
+
+/**
+ * Observability Manager
+ * Handles structured logging, health monitoring, and performance tracking
+ */
+export class ObservabilityManager {
+ private eventLogs: EventLog[] = [];
+ private performanceMetrics: PerformanceMetrics = {
+ fetchTimes: [],
+ notifyTimes: [],
+ callbackTimes: [],
+ successCount: 0,
+ failureCount: 0,
+ lastReset: Date.now()
+ };
+ private maxLogs = 1000;
+ private maxMetrics = 100;
+
+ /**
+ * Log structured event with event code
+ */
+ logEvent(
+ level: 'INFO' | 'WARN' | 'ERROR',
+ eventCode: string,
+ message: string,
+ data?: Record,
+ duration?: number
+ ): void {
+ const event: EventLog = {
+ id: this.generateEventId(),
+ timestamp: Date.now(),
+ level,
+ eventCode,
+ message,
+ data,
+ duration
+ };
+
+ this.eventLogs.unshift(event);
+
+ // Keep only recent logs
+ if (this.eventLogs.length > this.maxLogs) {
+ this.eventLogs = this.eventLogs.slice(0, this.maxLogs);
+ }
+
+ // Console output with structured format
+ const logMessage = `[${eventCode}] ${message}`;
+ const logData = data ? ` | Data: ${JSON.stringify(data)}` : '';
+ const logDuration = duration ? ` | Duration: ${duration}ms` : '';
+
+ switch (level) {
+ case 'INFO':
+ console.log(logMessage + logData + logDuration);
+ break;
+ case 'WARN':
+ console.warn(logMessage + logData + logDuration);
+ break;
+ case 'ERROR':
+ console.error(logMessage + logData + logDuration);
+ break;
+ }
+ }
+
+ /**
+ * Record performance metrics
+ */
+ recordMetric(type: 'fetch' | 'notify' | 'callback', duration: number, success: boolean): void {
+ switch (type) {
+ case 'fetch':
+ this.performanceMetrics.fetchTimes.push(duration);
+ break;
+ case 'notify':
+ this.performanceMetrics.notifyTimes.push(duration);
+ break;
+ case 'callback':
+ this.performanceMetrics.callbackTimes.push(duration);
+ break;
+ }
+
+ if (success) {
+ this.performanceMetrics.successCount++;
+ } else {
+ this.performanceMetrics.failureCount++;
+ }
+
+ // Keep only recent metrics
+ this.trimMetrics();
+ }
+
+ /**
+ * Get health status
+ */
+ async getHealthStatus(): Promise {
+ const now = Date.now();
+ const recentLogs = this.eventLogs.filter(log => now - log.timestamp < 24 * 60 * 60 * 1000); // Last 24 hours
+
+ // Calculate next runs (mock implementation)
+ const nextRuns = this.calculateNextRuns();
+
+ // Get last outcomes from recent logs
+ const lastOutcomes = recentLogs
+ .filter(log => log.eventCode.startsWith('DNP-FETCH-') || log.eventCode.startsWith('DNP-NOTIFY-'))
+ .slice(0, 10)
+ .map(log => log.eventCode);
+
+ // Calculate cache age (mock implementation)
+ const cacheAgeMs = this.calculateCacheAge();
+
+ // Check if stale armed
+ const staleArmed = cacheAgeMs ? cacheAgeMs > 3600000 : true; // 1 hour
+
+ // Calculate queue depth
+ const queueDepth = recentLogs.filter(log =>
+ log.eventCode.includes('QUEUE') || log.eventCode.includes('RETRY')
+ ).length;
+
+ // Circuit breaker status
+ const circuitBreakers = this.getCircuitBreakerStatus();
+
+ // Performance metrics
+ const performance = this.calculatePerformanceMetrics();
+
+ return {
+ nextRuns,
+ lastOutcomes,
+ cacheAgeMs,
+ staleArmed,
+ queueDepth,
+ circuitBreakers,
+ performance
+ };
+ }
+
+ /**
+ * Get recent event logs
+ */
+ getRecentLogs(limit: number = 50): EventLog[] {
+ return this.eventLogs.slice(0, limit);
+ }
+
+ /**
+ * Get performance metrics
+ */
+ getPerformanceMetrics(): PerformanceMetrics {
+ return { ...this.performanceMetrics };
+ }
+
+ /**
+ * Reset performance metrics
+ */
+ resetMetrics(): void {
+ this.performanceMetrics = {
+ fetchTimes: [],
+ notifyTimes: [],
+ callbackTimes: [],
+ successCount: 0,
+ failureCount: 0,
+ lastReset: Date.now()
+ };
+
+ this.logEvent('INFO', 'DNP-METRICS-RESET', 'Performance metrics reset');
+ }
+
+ /**
+ * Compact old logs (called by cleanup job)
+ */
+ compactLogs(olderThanMs: number = 30 * 24 * 60 * 60 * 1000): number { // 30 days
+ const cutoff = Date.now() - olderThanMs;
+ const initialCount = this.eventLogs.length;
+
+ this.eventLogs = this.eventLogs.filter(log => log.timestamp >= cutoff);
+
+ const removedCount = initialCount - this.eventLogs.length;
+ if (removedCount > 0) {
+ this.logEvent('INFO', 'DNP-LOGS-COMPACTED', `Removed ${removedCount} old logs`);
+ }
+
+ return removedCount;
+ }
+
+ // Private helper methods
+ private generateEventId(): string {
+ return `evt_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ }
+
+ private trimMetrics(): void {
+ if (this.performanceMetrics.fetchTimes.length > this.maxMetrics) {
+ this.performanceMetrics.fetchTimes = this.performanceMetrics.fetchTimes.slice(-this.maxMetrics);
+ }
+ if (this.performanceMetrics.notifyTimes.length > this.maxMetrics) {
+ this.performanceMetrics.notifyTimes = this.performanceMetrics.notifyTimes.slice(-this.maxMetrics);
+ }
+ if (this.performanceMetrics.callbackTimes.length > this.maxMetrics) {
+ this.performanceMetrics.callbackTimes = this.performanceMetrics.callbackTimes.slice(-this.maxMetrics);
+ }
+ }
+
+ private calculateNextRuns(): number[] {
+ // Mock implementation - would calculate from actual schedules
+ const now = Date.now();
+ return [
+ now + (60 * 60 * 1000), // 1 hour from now
+ now + (24 * 60 * 60 * 1000) // 24 hours from now
+ ];
+ }
+
+ private calculateCacheAge(): number | null {
+ // Mock implementation - would get from actual cache
+ return 1800000; // 30 minutes
+ }
+
+ private getCircuitBreakerStatus(): { total: number; open: number; failures: number } {
+ // Mock implementation - would get from actual circuit breakers
+ return {
+ total: 3,
+ open: 1,
+ failures: 5
+ };
+ }
+
+ private calculatePerformanceMetrics(): {
+ avgFetchTime: number;
+ avgNotifyTime: number;
+ successRate: number;
+ } {
+ const fetchTimes = this.performanceMetrics.fetchTimes;
+ const notifyTimes = this.performanceMetrics.notifyTimes;
+ const totalOperations = this.performanceMetrics.successCount + this.performanceMetrics.failureCount;
+
+ return {
+ avgFetchTime: fetchTimes.length > 0 ?
+ fetchTimes.reduce((a, b) => a + b, 0) / fetchTimes.length : 0,
+ avgNotifyTime: notifyTimes.length > 0 ?
+ notifyTimes.reduce((a, b) => a + b, 0) / notifyTimes.length : 0,
+ successRate: totalOperations > 0 ?
+ this.performanceMetrics.successCount / totalOperations : 0
+ };
+ }
+}
+
+// Singleton instance
+export const observability = new ObservabilityManager();
+
+// Event code constants
+export const EVENT_CODES = {
+ FETCH_START: 'DNP-FETCH-START',
+ FETCH_SUCCESS: 'DNP-FETCH-SUCCESS',
+ FETCH_FAILURE: 'DNP-FETCH-FAILURE',
+ FETCH_RETRY: 'DNP-FETCH-RETRY',
+ NOTIFY_START: 'DNP-NOTIFY-START',
+ NOTIFY_SUCCESS: 'DNP-NOTIFY-SUCCESS',
+ NOTIFY_FAILURE: 'DNP-NOTIFY-FAILURE',
+ NOTIFY_SKIPPED_TTL: 'DNP-NOTIFY-SKIPPED-TTL',
+ CALLBACK_START: 'DNP-CB-START',
+ CALLBACK_SUCCESS: 'DNP-CB-SUCCESS',
+ CALLBACK_FAILURE: 'DNP-CB-FAILURE',
+ CALLBACK_RETRY: 'DNP-CB-RETRY',
+ CALLBACK_CIRCUIT_OPEN: 'DNP-CB-CIRCUIT-OPEN',
+ CALLBACK_CIRCUIT_CLOSE: 'DNP-CB-CIRCUIT-CLOSE',
+ BOOT_RECOVERY: 'DNP-BOOT-RECOVERY',
+ SCHEDULE_UPDATE: 'DNP-SCHEDULE-UPDATE',
+ CACHE_HIT: 'DNP-CACHE-HIT',
+ CACHE_MISS: 'DNP-CACHE-MISS',
+ TTL_EXPIRED: 'DNP-TTL-EXPIRED',
+ METRICS_RESET: 'DNP-METRICS-RESET',
+ LOGS_COMPACTED: 'DNP-LOGS-COMPACTED'
+} as const;
diff --git a/src/web.ts b/src/web.ts
index cfdd554..46b81cc 100644
--- a/src/web.ts
+++ b/src/web.ts
@@ -7,10 +7,15 @@
import { WebPlugin } from '@capacitor/core';
import type { DailyNotificationPlugin, NotificationOptions, NotificationSettings, NotificationResponse, NotificationStatus, BatteryStatus, PowerState, PermissionStatus } from './definitions';
+import { callbackRegistry } from './callback-registry';
+import { observability, EVENT_CODES } from './observability';
export class DailyNotificationWeb extends WebPlugin implements DailyNotificationPlugin {
+ private contentCache = new Map();
+ private callbacks = new Map();
+
async configure(_options: any): Promise {
- // Web implementation placeholder
+ observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'Plugin configured on web platform');
console.log('Configure called on web platform');
}
@@ -152,39 +157,101 @@ export class DailyNotificationWeb extends WebPlugin implements DailyNotification
// Dual Scheduling Methods Implementation
async scheduleContentFetch(_config: any): Promise {
- console.log('Schedule content fetch called on web platform');
+ const start = performance.now();
+ observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Content fetch scheduled on web platform');
+
+ try {
+ // Mock content fetch implementation
+ const mockContent = {
+ id: `fetch_${Date.now()}`,
+ timestamp: Date.now(),
+ content: 'Mock daily content',
+ source: 'web_platform'
+ };
+
+ this.contentCache.set(mockContent.id, mockContent);
+
+ const duration = performance.now() - start;
+ observability.recordMetric('fetch', duration, true);
+ observability.logEvent('INFO', EVENT_CODES.FETCH_SUCCESS, 'Content fetch completed', { duration });
+
+ // Fire callbacks
+ await callbackRegistry.fire({
+ id: mockContent.id,
+ at: Date.now(),
+ type: 'onFetchSuccess',
+ payload: mockContent
+ });
+
+ } catch (error) {
+ const duration = performance.now() - start;
+ observability.recordMetric('fetch', duration, false);
+ observability.logEvent('ERROR', EVENT_CODES.FETCH_FAILURE, 'Content fetch failed', { error: String(error) });
+ throw error;
+ }
}
- async scheduleUserNotification(_config: any): Promise {
- console.log('Schedule user notification called on web platform');
+ async scheduleUserNotification(config: any): Promise {
+ const start = performance.now();
+ observability.logEvent('INFO', EVENT_CODES.NOTIFY_START, 'User notification scheduled on web platform');
+
+ try {
+ // Mock notification implementation
+ if ('Notification' in window && Notification.permission === 'granted') {
+ const notification = new Notification(config.title || 'Daily Notification', {
+ body: config.body || 'Your daily update is ready',
+ icon: '/favicon.ico'
+ });
+
+ notification.onclick = () => {
+ observability.logEvent('INFO', EVENT_CODES.NOTIFY_SUCCESS, 'Notification clicked');
+ };
+ }
+
+ const duration = performance.now() - start;
+ observability.recordMetric('notify', duration, true);
+ observability.logEvent('INFO', EVENT_CODES.NOTIFY_SUCCESS, 'User notification displayed', { duration });
+
+ // Fire callbacks
+ await callbackRegistry.fire({
+ id: `notify_${Date.now()}`,
+ at: Date.now(),
+ type: 'onNotifyDelivered',
+ payload: config
+ });
+
+ } catch (error) {
+ const duration = performance.now() - start;
+ observability.recordMetric('notify', duration, false);
+ observability.logEvent('ERROR', EVENT_CODES.NOTIFY_FAILURE, 'User notification failed', { error: String(error) });
+ throw error;
+ }
}
- async scheduleDualNotification(_config: any): Promise {
- console.log('Schedule dual notification called on web platform');
+ async scheduleDualNotification(config: any): Promise {
+ observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'Dual notification scheduled on web platform');
+
+ try {
+ await this.scheduleContentFetch(config.contentFetch);
+ await this.scheduleUserNotification(config.userNotification);
+
+ observability.logEvent('INFO', EVENT_CODES.SCHEDULE_UPDATE, 'Dual notification completed successfully');
+ } catch (error) {
+ observability.logEvent('ERROR', EVENT_CODES.SCHEDULE_UPDATE, 'Dual notification failed', { error: String(error) });
+ throw error;
+ }
}
async getDualScheduleStatus(): Promise {
+ const healthStatus = await observability.getHealthStatus();
return {
- contentFetch: {
- isEnabled: false,
- isScheduled: false,
- pendingFetches: 0
- },
- userNotification: {
- isEnabled: false,
- isScheduled: false,
- pendingNotifications: 0
- },
- relationship: {
- isLinked: false,
- contentAvailable: false
- },
- overall: {
- isActive: false,
- lastActivity: Date.now(),
- errorCount: 0,
- successRate: 1.0
- }
+ nextRuns: healthStatus.nextRuns,
+ lastOutcomes: healthStatus.lastOutcomes,
+ cacheAgeMs: healthStatus.cacheAgeMs,
+ staleArmed: healthStatus.staleArmed,
+ queueDepth: healthStatus.queueDepth,
+ circuitBreakers: healthStatus.circuitBreakers,
+ performance: healthStatus.performance
};
}
@@ -216,15 +283,33 @@ export class DailyNotificationWeb extends WebPlugin implements DailyNotification
return [];
}
- async registerCallback(_name: string, _callback: Function): Promise {
- console.log('Register callback called on web platform');
+ async registerCallback(name: string, callback: Function): Promise {
+ observability.logEvent('INFO', EVENT_CODES.CALLBACK_START, `Callback ${name} registered on web platform`);
+
+ // Register with callback registry
+ await callbackRegistry.register(name, {
+ id: name,
+ kind: 'local',
+ target: '',
+ enabled: true,
+ createdAt: Date.now()
+ });
+
+ // Register local callback function
+ callbackRegistry.registerLocalCallback(name, callback as any);
+ this.callbacks.set(name, callback);
}
- async unregisterCallback(_name: string): Promise {
- console.log('Unregister callback called on web platform');
+ async unregisterCallback(name: string): Promise {
+ observability.logEvent('INFO', EVENT_CODES.CALLBACK_START, `Callback ${name} unregistered on web platform`);
+
+ await callbackRegistry.unregister(name);
+ callbackRegistry.unregisterLocalCallback(name);
+ this.callbacks.delete(name);
}
async getRegisteredCallbacks(): Promise {
- return [];
+ const callbacks = await callbackRegistry.getRegistered();
+ return callbacks.map(cb => cb.id);
}
}
\ No newline at end of file