package com.timesafari.dailynotification import android.Manifest import android.app.Activity import android.app.AlarmManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.PowerManager import android.util.Log import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat 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") open class DailyNotificationPlugin : Plugin() { companion object { private const val TAG = "DNP-PLUGIN" /** * Static registry for native content fetcher * Thread-safe: Volatile ensures visibility across threads */ @Volatile private var nativeFetcher: NativeNotificationContentFetcher? = null /** * Get the registered native fetcher (called from Java code) * * @return Registered NativeNotificationContentFetcher or null if not registered */ @JvmStatic fun getNativeFetcherStatic(): NativeNotificationContentFetcher? { return nativeFetcher } /** * Register a native content fetcher * * @param fetcher The native fetcher implementation to register */ @JvmStatic fun registerNativeFetcher(fetcher: NativeNotificationContentFetcher?) { nativeFetcher = fetcher Log.i(TAG, "Native fetcher ${if (fetcher != null) "registered" else "unregistered"}") } /** * Set the native content fetcher (alias for registerNativeFetcher) * * @param fetcher The native fetcher implementation to register */ @JvmStatic fun setNativeFetcher(fetcher: NativeNotificationContentFetcher?) { registerNativeFetcher(fetcher) } } private var db: DailyNotificationDatabase? = null override fun load() { super.load() try { if (context == null) { Log.e(TAG, "Context is null, cannot initialize database") return } db = DailyNotificationDatabase.getDatabase(context) Log.i(TAG, "Daily Notification Plugin loaded successfully") } catch (e: Exception) { Log.e(TAG, "Failed to initialize Daily Notification Plugin", e) // Don't throw - allow plugin to load but database operations will fail gracefully } } private fun getDatabase(): DailyNotificationDatabase { if (db == null) { if (context == null) { throw IllegalStateException("Plugin not initialized: context is null") } db = DailyNotificationDatabase.getDatabase(context) } return db!! } @PluginMethod fun configure(call: PluginCall) { try { // Capacitor passes the object directly via call.data val options = call.data 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 checkPermissionStatus(call: PluginCall) { try { if (context == null) { return call.reject("Context not available") } Log.i(TAG, "Checking permission status") var notificationsEnabled = false var exactAlarmEnabled = false var wakeLockEnabled = false // Check POST_NOTIFICATIONS permission if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { notificationsEnabled = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } else { notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() } // Check exact alarm permission if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager exactAlarmEnabled = alarmManager?.canScheduleExactAlarms() ?: false } else { exactAlarmEnabled = true // Pre-Android 12, exact alarms are always allowed } // Check wake lock permission (usually granted by default) val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager wakeLockEnabled = powerManager != null val allPermissionsGranted = notificationsEnabled && exactAlarmEnabled && wakeLockEnabled val result = JSObject().apply { put("notificationsEnabled", notificationsEnabled) put("exactAlarmEnabled", exactAlarmEnabled) put("wakeLockEnabled", wakeLockEnabled) put("allPermissionsGranted", allPermissionsGranted) } Log.i(TAG, "Permission status: notifications=$notificationsEnabled, exactAlarm=$exactAlarmEnabled, wakeLock=$wakeLockEnabled, all=$allPermissionsGranted") call.resolve(result) } catch (e: Exception) { Log.e(TAG, "Failed to check permission status", e) call.reject("Permission check failed: ${e.message}") } } @PluginMethod fun requestNotificationPermissions(call: PluginCall) { try { val activity = activity ?: return call.reject("Activity not available") val context = context ?: return call.reject("Context not available") Log.i(TAG, "Requesting notification permissions") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // For Android 13+, request POST_NOTIFICATIONS permission if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { // Already granted val result = JSObject().apply { put("status", "granted") put("granted", true) put("notifications", "granted") } call.resolve(result) } else { // Request permission ActivityCompat.requestPermissions( activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001 // Request code ) // Note: Permission result will be handled by onRequestPermissionsResult // For now, resolve with pending status val result = JSObject().apply { put("status", "prompt") put("granted", false) put("notifications", "prompt") } call.resolve(result) } } else { // For older versions, permissions are granted at install time val result = JSObject().apply { put("status", "granted") put("granted", true) put("notifications", "granted") } call.resolve(result) } } catch (e: Exception) { Log.e(TAG, "Failed to request notification permissions", e) call.reject("Permission request failed: ${e.message}") } } @PluginMethod fun configureNativeFetcher(call: PluginCall) { try { // Capacitor passes the object directly via call.data val options = call.data ?: return call.reject("Options are required") // Support both jwtToken and jwtSecret for backward compatibility val apiBaseUrl = options.getString("apiBaseUrl") ?: return call.reject("apiBaseUrl is required") val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required") val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required") val nativeFetcher = getNativeFetcherStatic() if (nativeFetcher == null) { return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.") } Log.i(TAG, "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid") // Call the native fetcher's configure method // Note: This assumes the native fetcher has a configure method // If the native fetcher interface doesn't have configure, we'll need to handle it differently try { // Store configuration in database for later use val configId = "native_fetcher_config" val configValue = JSONObject().apply { put("apiBaseUrl", apiBaseUrl) put("activeDid", activeDid) put("jwtToken", jwtToken) }.toString() CoroutineScope(Dispatchers.IO).launch { try { val config = com.timesafari.dailynotification.entities.NotificationConfigEntity( configId, null, "native_fetcher", "config", configValue, "json" ) getDatabase().notificationConfigDao().insertConfig(config) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to store native fetcher config", e) call.reject("Failed to store configuration: ${e.message}") } } } catch (e: Exception) { Log.e(TAG, "Native fetcher configuration failed", e) call.reject("Native fetcher configuration failed: ${e.message}") } } catch (e: Exception) { Log.e(TAG, "Configure native fetcher error", e) call.reject("Configuration error: ${e.message}") } } @PluginMethod fun getNotificationStatus(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val schedules = getDatabase().scheduleDao().getAll() val notifySchedules = schedules.filter { it.kind == "notify" && it.enabled } // Get last notification time from history val history = getDatabase().historyDao().getRecent(100) // Get last 100 entries val lastNotification = history .filter { it.kind == "notify" && it.outcome == "success" } .maxByOrNull { it.occurredAt } val lastNotificationTime = lastNotification?.occurredAt ?: 0 val result = JSObject().apply { put("isEnabled", notifySchedules.isNotEmpty()) put("isScheduled", notifySchedules.isNotEmpty()) put("lastNotificationTime", lastNotificationTime) put("nextNotificationTime", notifySchedules.minOfOrNull { it.nextRunAt ?: Long.MAX_VALUE } ?: 0) put("scheduledCount", notifySchedules.size) put("pending", notifySchedules.size) // Alias for scheduledCount put("settings", JSObject().apply { put("enabled", notifySchedules.isNotEmpty()) put("count", notifySchedules.size) }) } call.resolve(result) } catch (e: Exception) { Log.e(TAG, "Failed to get notification status", e) call.reject("Failed to get notification status: ${e.message}") } } } @PluginMethod fun scheduleDailyReminder(call: PluginCall) { // Alias for scheduleDailyNotification for backward compatibility // scheduleDailyReminder accepts same parameters as scheduleDailyNotification try { // Capacitor passes the object directly via call.data val options = call.data ?: return call.reject("Options are required") // Extract required fields, with defaults val time = options.getString("time") ?: return call.reject("Time is required") val title = options.getString("title") ?: "Daily Reminder" val body = options.getString("body") ?: "" val sound = options.getBoolean("sound") ?: true val priority = options.getString("priority") ?: "default" Log.i(TAG, "Scheduling daily reminder: time=$time, title=$title") // Convert HH:mm time to cron expression (daily at specified time) val cronExpression = convertTimeToCron(time) CoroutineScope(Dispatchers.IO).launch { try { val config = UserNotificationConfig( enabled = true, schedule = cronExpression, title = title, body = body, sound = sound, vibration = options.getBoolean("vibration") ?: true, priority = priority ) val nextRunTime = calculateNextRunTime(cronExpression) // Schedule AlarmManager notification NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) // Store schedule in database val scheduleId = options.getString("id") ?: "daily_reminder_${System.currentTimeMillis()}" val schedule = Schedule( id = scheduleId, kind = "notify", cron = cronExpression, clockTime = time, enabled = true, nextRunAt = nextRunTime ) getDatabase().scheduleDao().upsert(schedule) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to schedule daily reminder", e) call.reject("Daily reminder scheduling failed: ${e.message}") } } } catch (e: Exception) { Log.e(TAG, "Schedule daily reminder error", e) call.reject("Daily reminder error: ${e.message}") } } @PluginMethod fun openExactAlarmSettings(call: PluginCall) { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val intent = Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) activity?.startActivity(intent) call.resolve() } else { call.reject("Exact alarm settings are only available on Android 12+") } } catch (e: Exception) { Log.e(TAG, "Failed to open exact alarm settings", e) call.reject("Failed to open exact alarm settings: ${e.message}") } } @PluginMethod fun isChannelEnabled(call: PluginCall) { try { val channelId = call.getString("channelId") ?: "daily_notification_channel" val enabled = NotificationManagerCompat.from(context).areNotificationsEnabled() // Get notification channel importance if available var importance = 0 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notificationManager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager? val channel = notificationManager?.getNotificationChannel(channelId) importance = channel?.importance ?: android.app.NotificationManager.IMPORTANCE_DEFAULT } val result = JSObject().apply { put("enabled", enabled) put("channelId", channelId) put("importance", importance) } call.resolve(result) } catch (e: Exception) { Log.e(TAG, "Failed to check channel status", e) call.reject("Failed to check channel status: ${e.message}") } } @PluginMethod fun openChannelSettings(call: PluginCall) { try { val channelId = call.getString("channelId") ?: "daily_notification_channel" val intent = Intent(android.provider.Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context?.packageName) putExtra(android.provider.Settings.EXTRA_CHANNEL_ID, channelId) } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { activity?.startActivity(intent) val result = JSObject().apply { put("opened", true) put("channelId", channelId) } call.resolve(result) } catch (e: Exception) { Log.e(TAG, "Failed to start activity", e) val result = JSObject().apply { put("opened", false) put("channelId", channelId) put("error", e.message) } call.resolve(result) } } catch (e: Exception) { Log.e(TAG, "Failed to open channel settings", e) call.reject("Failed to open channel settings: ${e.message}") } } @PluginMethod fun checkStatus(call: PluginCall) { // Comprehensive status check try { if (context == null) { return call.reject("Context not available") } var postNotificationsGranted = false var channelEnabled = false var exactAlarmsGranted = false var channelImportance = 0 val channelId = "daily_notification_channel" // Check POST_NOTIFICATIONS permission if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED } else { postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled() } // Check exact alarms permission if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager exactAlarmsGranted = alarmManager.canScheduleExactAlarms() } else { exactAlarmsGranted = true // Always available on older Android versions } // Check channel status channelEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager? val channel = notificationManager?.getNotificationChannel(channelId) channelImportance = channel?.importance ?: android.app.NotificationManager.IMPORTANCE_DEFAULT channelEnabled = channel?.importance != android.app.NotificationManager.IMPORTANCE_NONE } val canScheduleNow = postNotificationsGranted && channelEnabled && exactAlarmsGranted val result = JSObject().apply { put("canScheduleNow", canScheduleNow) put("postNotificationsGranted", postNotificationsGranted) put("channelEnabled", channelEnabled) put("exactAlarmsGranted", exactAlarmsGranted) put("channelImportance", channelImportance) put("channelId", channelId) } call.resolve(result) } catch (e: Exception) { Log.e(TAG, "Failed to check status", e) call.reject("Failed to check status: ${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) ) getDatabase().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 scheduleDailyNotification(call: PluginCall) { try { // Capacitor passes the object directly via call.data val options = call.data ?: return call.reject("Options are required") val time = options.getString("time") ?: return call.reject("Time is required") val title = options.getString("title") ?: "Daily Notification" val body = options.getString("body") ?: "" val sound = options.getBoolean("sound") ?: true val priority = options.getString("priority") ?: "default" val url = options.getString("url") // Optional URL for prefetch Log.i(TAG, "Scheduling daily notification: time=$time, title=$title") // Convert HH:mm time to cron expression (daily at specified time) val cronExpression = convertTimeToCron(time) CoroutineScope(Dispatchers.IO).launch { try { val config = UserNotificationConfig( enabled = true, schedule = cronExpression, title = title, body = body, sound = sound, vibration = true, priority = priority ) val nextRunTime = calculateNextRunTime(cronExpression) // Schedule AlarmManager notification NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) // Schedule prefetch 5 minutes before notification (if URL provided) if (url != null) { val fetchTime = nextRunTime - (5 * 60 * 1000L) // 5 minutes before FetchWorker.scheduleDelayedFetch( context, fetchTime, nextRunTime, url ) Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime") } // Store schedule in database val schedule = Schedule( id = "daily_${System.currentTimeMillis()}", kind = "notify", cron = cronExpression, clockTime = time, enabled = true, nextRunAt = nextRunTime ) getDatabase().scheduleDao().upsert(schedule) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to schedule daily notification", e) call.reject("Daily notification scheduling failed: ${e.message}") } } } catch (e: Exception) { Log.e(TAG, "Schedule daily notification error", e) call.reject("Daily notification 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 ) getDatabase().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") ?: return call.reject("Config is required") val contentFetchObj = configJson.getJSObject("contentFetch") ?: return call.reject("contentFetch config is required") val userNotificationObj = configJson.getJSObject("userNotification") ?: return call.reject("userNotification config is required") val contentFetchConfig = parseContentFetchConfig(contentFetchObj) val userNotificationConfig = parseUserNotificationConfig(userNotificationObj) 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 ) getDatabase().scheduleDao().upsert(fetchSchedule) getDatabase().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 = getDatabase().scheduleDao().getEnabled() val latestCache = getDatabase().contentCacheDao().getLatest() val recentHistory = getDatabase().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") ?: return call.reject("Callback name is required") val callback = call.getObject("callback") ?: return call.reject("Callback data is required") 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() ) getDatabase().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 = getDatabase().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}") } } } // ============================================================================ // DATABASE ACCESS METHODS // ============================================================================ // These methods provide TypeScript/JavaScript access to the plugin's internal // SQLite database. All operations run on background threads for thread safety. // ============================================================================ // SCHEDULES MANAGEMENT @PluginMethod fun getSchedules(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val options = call.getObject("options") val kind = options?.getString("kind") val enabled = options?.getBoolean("enabled") val schedules = when { kind != null && enabled != null -> getDatabase().scheduleDao().getByKindAndEnabled(kind, enabled) kind != null -> getDatabase().scheduleDao().getByKind(kind) enabled != null -> if (enabled) getDatabase().scheduleDao().getEnabled() else getDatabase().scheduleDao().getAll().filter { !it.enabled } else -> getDatabase().scheduleDao().getAll() } // Return array wrapped in JSObject - Capacitor will serialize correctly val schedulesArray = org.json.JSONArray() schedules.forEach { schedulesArray.put(scheduleToJson(it)) } call.resolve(JSObject().apply { put("schedules", schedulesArray) }) } catch (e: Exception) { Log.e(TAG, "Failed to get schedules", e) call.reject("Failed to get schedules: ${e.message}") } } } @PluginMethod fun getSchedule(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Schedule ID is required") val schedule = getDatabase().scheduleDao().getById(id) if (schedule != null) { call.resolve(scheduleToJson(schedule)) } else { call.resolve(JSObject().apply { put("schedule", null) }) } } catch (e: Exception) { Log.e(TAG, "Failed to get schedule", e) call.reject("Failed to get schedule: ${e.message}") } } } @PluginMethod fun createSchedule(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val scheduleJson = call.getObject("schedule") ?: return@launch call.reject("Schedule data is required") val kindStr = scheduleJson.getString("kind") ?: return@launch call.reject("Schedule kind is required") val id = scheduleJson.getString("id") ?: "${kindStr}_${System.currentTimeMillis()}" val schedule = Schedule( id = id, kind = kindStr, cron = scheduleJson.getString("cron"), clockTime = scheduleJson.getString("clockTime"), enabled = scheduleJson.getBoolean("enabled") ?: true, jitterMs = scheduleJson.getInt("jitterMs") ?: 0, backoffPolicy = scheduleJson.getString("backoffPolicy") ?: "exp", stateJson = scheduleJson.getString("stateJson") ) getDatabase().scheduleDao().upsert(schedule) call.resolve(scheduleToJson(schedule)) } catch (e: Exception) { Log.e(TAG, "Failed to create schedule", e) call.reject("Failed to create schedule: ${e.message}") } } } @PluginMethod fun updateSchedule(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Schedule ID is required") val updates = call.getObject("updates") ?: return@launch call.reject("Updates are required") val existing = getDatabase().scheduleDao().getById(id) ?: return@launch call.reject("Schedule not found: $id") // Update fields getDatabase().scheduleDao().update( id = id, enabled = updates.getBoolean("enabled")?.let { it }, cron = updates.getString("cron"), clockTime = updates.getString("clockTime"), jitterMs = updates.getInt("jitterMs")?.let { it }, backoffPolicy = updates.getString("backoffPolicy"), stateJson = updates.getString("stateJson") ) // Update run times if provided val lastRunAt = updates.getLong("lastRunAt") val nextRunAt = updates.getLong("nextRunAt") if (lastRunAt != null || nextRunAt != null) { getDatabase().scheduleDao().updateRunTimes(id, lastRunAt, nextRunAt) } val updated = getDatabase().scheduleDao().getById(id) call.resolve(scheduleToJson(updated!!)) } catch (e: Exception) { Log.e(TAG, "Failed to update schedule", e) call.reject("Failed to update schedule: ${e.message}") } } } @PluginMethod fun deleteSchedule(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Schedule ID is required") getDatabase().scheduleDao().deleteById(id) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to delete schedule", e) call.reject("Failed to delete schedule: ${e.message}") } } } @PluginMethod fun enableSchedule(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Schedule ID is required") val enabled = call.getBoolean("enabled") ?: true getDatabase().scheduleDao().setEnabled(id, enabled) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to enable/disable schedule", e) call.reject("Failed to update schedule: ${e.message}") } } } @PluginMethod fun calculateNextRunTime(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val schedule = call.getString("schedule") ?: return@launch call.reject("Schedule expression is required") val nextRun = calculateNextRunTime(schedule) call.resolve(JSObject().apply { put("nextRunAt", nextRun) }) } catch (e: Exception) { Log.e(TAG, "Failed to calculate next run time", e) call.reject("Failed to calculate next run time: ${e.message}") } } } // CONTENT CACHE MANAGEMENT @PluginMethod fun getContentCacheById(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val options = call.getObject("options") val id = options?.getString("id") val cache = if (id != null) { getDatabase().contentCacheDao().getById(id) } else { getDatabase().contentCacheDao().getLatest() } if (cache != null) { call.resolve(contentCacheToJson(cache)) } else { call.resolve(JSObject().apply { put("contentCache", null) }) } } catch (e: Exception) { Log.e(TAG, "Failed to get content cache", e) call.reject("Failed to get content cache: ${e.message}") } } } @PluginMethod fun getLatestContentCache(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val cache = getDatabase().contentCacheDao().getLatest() if (cache != null) { call.resolve(contentCacheToJson(cache)) } else { call.resolve(JSObject().apply { put("contentCache", null) }) } } catch (e: Exception) { Log.e(TAG, "Failed to get latest content cache", e) call.reject("Failed to get latest content cache: ${e.message}") } } } @PluginMethod fun getContentCacheHistory(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val limit = call.getInt("limit") ?: 10 val history = getDatabase().contentCacheDao().getHistory(limit) val historyArray = org.json.JSONArray() history.forEach { historyArray.put(contentCacheToJson(it)) } call.resolve(JSObject().apply { put("history", historyArray) }) } catch (e: Exception) { Log.e(TAG, "Failed to get content cache history", e) call.reject("Failed to get content cache history: ${e.message}") } } } @PluginMethod fun saveContentCache(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val contentJson = call.getObject("content") ?: return@launch call.reject("Content data is required") val id = contentJson.getString("id") ?: "cache_${System.currentTimeMillis()}" val payload = contentJson.getString("payload") ?: return@launch call.reject("Payload is required") val ttlSeconds = contentJson.getInt("ttlSeconds") ?: return@launch call.reject("TTL seconds is required") val cache = ContentCache( id = id, fetchedAt = System.currentTimeMillis(), ttlSeconds = ttlSeconds, payload = payload.toByteArray(), meta = contentJson.getString("meta") ) getDatabase().contentCacheDao().upsert(cache) call.resolve(contentCacheToJson(cache)) } catch (e: Exception) { Log.e(TAG, "Failed to save content cache", e) call.reject("Failed to save content cache: ${e.message}") } } } @PluginMethod fun clearContentCacheEntries(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val options = call.getObject("options") val olderThan = options?.getLong("olderThan") if (olderThan != null) { getDatabase().contentCacheDao().deleteOlderThan(olderThan) } else { getDatabase().contentCacheDao().deleteAll() } call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to clear content cache", e) call.reject("Failed to clear content cache: ${e.message}") } } } // CALLBACKS MANAGEMENT @PluginMethod fun getCallbacks(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val options = call.getObject("options") val enabled = options?.getBoolean("enabled") val callbacks = if (enabled != null) { getDatabase().callbackDao().getByEnabled(enabled) } else { getDatabase().callbackDao().getAll() } val callbacksArray = org.json.JSONArray() callbacks.forEach { callbacksArray.put(callbackToJson(it)) } call.resolve(JSObject().apply { put("callbacks", callbacksArray) }) } catch (e: Exception) { Log.e(TAG, "Failed to get callbacks", e) call.reject("Failed to get callbacks: ${e.message}") } } } @PluginMethod fun getCallback(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Callback ID is required") val callback = getDatabase().callbackDao().getById(id) if (callback != null) { call.resolve(callbackToJson(callback)) } else { call.resolve(JSObject().apply { put("callback", null) }) } } catch (e: Exception) { Log.e(TAG, "Failed to get callback", e) call.reject("Failed to get callback: ${e.message}") } } } @PluginMethod fun registerCallbackConfig(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val callbackJson = call.getObject("callback") ?: return@launch call.reject("Callback data is required") val id = callbackJson.getString("id") ?: return@launch call.reject("Callback ID is required") val kindStr = callbackJson.getString("kind") ?: return@launch call.reject("Callback kind is required") val targetStr = callbackJson.getString("target") ?: return@launch call.reject("Callback target is required") val callback = Callback( id = id, kind = kindStr, target = targetStr, headersJson = callbackJson.getString("headersJson"), enabled = callbackJson.getBoolean("enabled") ?: true, createdAt = System.currentTimeMillis() ) getDatabase().callbackDao().upsert(callback) call.resolve(callbackToJson(callback)) } catch (e: Exception) { Log.e(TAG, "Failed to register callback", e) call.reject("Failed to register callback: ${e.message}") } } } @PluginMethod fun updateCallback(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Callback ID is required") val updates = call.getObject("updates") ?: return@launch call.reject("Updates are required") getDatabase().callbackDao().update( id = id, kind = updates.getString("kind"), target = updates.getString("target"), headersJson = updates.getString("headersJson"), enabled = updates.getBoolean("enabled")?.let { it } ) val updated = getDatabase().callbackDao().getById(id) if (updated != null) { call.resolve(callbackToJson(updated)) } else { call.reject("Callback not found after update") } } catch (e: Exception) { Log.e(TAG, "Failed to update callback", e) call.reject("Failed to update callback: ${e.message}") } } } @PluginMethod fun deleteCallback(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Callback ID is required") getDatabase().callbackDao().deleteById(id) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to delete callback", e) call.reject("Failed to delete callback: ${e.message}") } } } @PluginMethod fun enableCallback(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val id = call.getString("id") ?: return@launch call.reject("Callback ID is required") val enabled = call.getBoolean("enabled") ?: true getDatabase().callbackDao().setEnabled(id, enabled) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to enable/disable callback", e) call.reject("Failed to update callback: ${e.message}") } } } // HISTORY MANAGEMENT @PluginMethod fun getHistory(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val options = call.getObject("options") val since = options?.getLong("since") val kind = options?.getString("kind") val limit = options?.getInt("limit") ?: 50 val history = when { since != null && kind != null -> getDatabase().historyDao().getSinceByKind(since, kind, limit) since != null -> getDatabase().historyDao().getSince(since).take(limit) else -> getDatabase().historyDao().getRecent(limit) } val historyArray = org.json.JSONArray() history.forEach { historyArray.put(historyToJson(it)) } call.resolve(JSObject().apply { put("history", historyArray) }) } catch (e: Exception) { Log.e(TAG, "Failed to get history", e) call.reject("Failed to get history: ${e.message}") } } } @PluginMethod fun getHistoryStats(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val allHistory = getDatabase().historyDao().getRecent(Int.MAX_VALUE) val outcomes = mutableMapOf() val kinds = mutableMapOf() var mostRecent: Long? = null var oldest: Long? = null allHistory.forEach { entry -> outcomes[entry.outcome] = (outcomes[entry.outcome] ?: 0) + 1 kinds[entry.kind] = (kinds[entry.kind] ?: 0) + 1 if (mostRecent == null || entry.occurredAt > mostRecent!!) { mostRecent = entry.occurredAt } if (oldest == null || entry.occurredAt < oldest!!) { oldest = entry.occurredAt } } call.resolve(JSObject().apply { put("totalCount", allHistory.size) put("outcomes", JSObject().apply { outcomes.forEach { (k, v) -> put(k, v) } }) put("kinds", JSObject().apply { kinds.forEach { (k, v) -> put(k, v) } }) put("mostRecent", mostRecent) put("oldest", oldest) }) } catch (e: Exception) { Log.e(TAG, "Failed to get history stats", e) call.reject("Failed to get history stats: ${e.message}") } } } // CONFIGURATION MANAGEMENT @PluginMethod fun getConfig(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val key = call.getString("key") ?: return@launch call.reject("Config key is required") val options = call.getObject("options") val timesafariDid = options?.getString("timesafariDid") val entity = if (timesafariDid != null) { getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid) } else { getDatabase().notificationConfigDao().getConfigByKey(key) } if (entity != null) { call.resolve(configToJson(entity)) } else { call.resolve(JSObject().apply { put("config", null) }) } } catch (e: Exception) { Log.e(TAG, "Failed to get config", e) call.reject("Failed to get config: ${e.message}") } } } @PluginMethod fun getAllConfigs(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val options = call.getObject("options") val timesafariDid = options?.getString("timesafariDid") val configType = options?.getString("configType") val configs = when { timesafariDid != null && configType != null -> { getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid) .filter { it.configType == configType } } timesafariDid != null -> { getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid) } configType != null -> { getDatabase().notificationConfigDao().getConfigsByType(configType) } else -> { getDatabase().notificationConfigDao().getAllConfigs() } } val configsArray = org.json.JSONArray() configs.forEach { configsArray.put(configToJson(it)) } call.resolve(JSObject().apply { put("configs", configsArray) }) } catch (e: Exception) { Log.e(TAG, "Failed to get all configs", e) call.reject("Failed to get configs: ${e.message}") } } } @PluginMethod fun setConfig(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val configJson = call.getObject("config") ?: return@launch call.reject("Config data is required") val id = configJson.getString("id") ?: "config_${System.currentTimeMillis()}" val timesafariDid = configJson.getString("timesafariDid") val configType = configJson.getString("configType") ?: return@launch call.reject("Config type is required") val configKey = configJson.getString("configKey") ?: return@launch call.reject("Config key is required") val configValue = configJson.getString("configValue") ?: return@launch call.reject("Config value is required") val configDataType = configJson.getString("configDataType", "string") val entity = com.timesafari.dailynotification.entities.NotificationConfigEntity( id, timesafariDid, configType, configKey, configValue, configDataType ) // Set optional fields configJson.getString("metadata")?.let { entity.metadata = it } configJson.getBoolean("isEncrypted", false)?.let { entity.isEncrypted = it configJson.getString("encryptionKeyId")?.let { entity.encryptionKeyId = it } } configJson.getLong("ttlSeconds")?.let { entity.ttlSeconds = it } configJson.getBoolean("isActive", true)?.let { entity.isActive = it } getDatabase().notificationConfigDao().insertConfig(entity) call.resolve(configToJson(entity)) } catch (e: Exception) { Log.e(TAG, "Failed to set config", e) call.reject("Failed to set config: ${e.message}") } } } @PluginMethod fun updateConfig(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val key = call.getString("key") ?: return@launch call.reject("Config key is required") val value = call.getString("value") ?: return@launch call.reject("Config value is required") val options = call.getObject("options") val timesafariDid = options?.getString("timesafariDid") val entity = if (timesafariDid != null) { getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid) } else { getDatabase().notificationConfigDao().getConfigByKey(key) } if (entity == null) { return@launch call.reject("Config not found") } entity.updateValue(value) getDatabase().notificationConfigDao().updateConfig(entity) call.resolve(configToJson(entity)) } catch (e: Exception) { Log.e(TAG, "Failed to update config", e) call.reject("Failed to update config: ${e.message}") } } } @PluginMethod fun deleteConfig(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { val key = call.getString("key") ?: return@launch call.reject("Config key is required") val options = call.getObject("options") val timesafariDid = options?.getString("timesafariDid") val entity = if (timesafariDid != null) { getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid) } else { getDatabase().notificationConfigDao().getConfigByKey(key) } if (entity == null) { return@launch call.reject("Config not found") } getDatabase().notificationConfigDao().deleteConfig(entity.id) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to delete config", e) call.reject("Failed to delete config: ${e.message}") } } } // Helper methods to convert entities to JSON private fun scheduleToJson(schedule: Schedule): JSObject { return JSObject().apply { put("id", schedule.id) put("kind", schedule.kind) put("cron", schedule.cron) put("clockTime", schedule.clockTime) put("enabled", schedule.enabled) put("lastRunAt", schedule.lastRunAt) put("nextRunAt", schedule.nextRunAt) put("jitterMs", schedule.jitterMs) put("backoffPolicy", schedule.backoffPolicy) put("stateJson", schedule.stateJson) } } private fun contentCacheToJson(cache: ContentCache): JSObject { return JSObject().apply { put("id", cache.id) put("fetchedAt", cache.fetchedAt) put("ttlSeconds", cache.ttlSeconds) put("payload", String(cache.payload)) put("meta", cache.meta) } } private fun callbackToJson(callback: Callback): JSObject { return JSObject().apply { put("id", callback.id) put("kind", callback.kind) put("target", callback.target) put("headersJson", callback.headersJson) put("enabled", callback.enabled) put("createdAt", callback.createdAt) } } private fun historyToJson(history: History): JSObject { return JSObject().apply { put("id", history.id) put("refId", history.refId) put("kind", history.kind) put("occurredAt", history.occurredAt) put("durationMs", history.durationMs) put("outcome", history.outcome) put("diagJson", history.diagJson) } } private fun configToJson(config: com.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject { return JSObject().apply { put("id", config.id) put("timesafariDid", config.timesafariDid) put("configType", config.configType) put("configKey", config.configKey) put("configValue", config.configValue) put("configDataType", config.configDataType) put("isEncrypted", config.isEncrypted) put("encryptionKeyId", config.encryptionKeyId) put("createdAt", config.createdAt) put("updatedAt", config.updatedAt) put("ttlSeconds", config.ttlSeconds) put("isActive", config.isActive) put("metadata", config.metadata) } } // Helper methods private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig { val callbacksObj = configJson.getJSObject("callbacks") 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 = callbacksObj?.getString("apiService"), database = callbacksObj?.getString("database"), reporting = callbacksObj?.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 } /** * Convert HH:mm time string to cron expression (daily at specified time) * Example: "09:30" -> "30 9 * * *" */ private fun convertTimeToCron(time: String): String { try { val parts = time.split(":") if (parts.size != 2) { throw IllegalArgumentException("Invalid time format: $time. Expected HH:mm") } val hour = parts[0].toInt() val minute = parts[1].toInt() if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { throw IllegalArgumentException("Invalid time values: hour=$hour, minute=$minute") } // Cron format: minute hour day month day-of-week // Daily at specified time: "minute hour * * *" return "$minute $hour * * *" } catch (e: Exception) { Log.e(TAG, "Failed to convert time to cron: $time", e) // Default to 9:00 AM if conversion fails return "0 9 * * *" } } }