You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1597 lines
66 KiB

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<String, Int>()
val kinds = mutableMapOf<String, Int>()
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 * * *"
}
}
}