Files
daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
Matthew Raymer 1b34f1f34a fix(android): configure native fetcher, use DailyNotificationFetchWorker, and cancel notifications on dismiss
Fix three critical issues in the Android notification system:

1. configureNativeFetcher() now actually calls nativeFetcher.configure() method
   - Previously only stored config in database without configuring fetcher instance
   - Added synchronous configure() call with proper error handling
   - Stores valid but empty config entry if configure() fails to prevent downstream errors
   - Adds FETCHER|CONFIGURE_START and FETCHER|CONFIGURE_COMPLETE instrumentation logs

2. Prefetch operations now use DailyNotificationFetchWorker instead of legacy FetchWorker
   - Replaced FetchWorker.scheduleDelayedFetch() with WorkManager scheduling
   - Uses correct input data format (scheduled_time, fetch_time, retry_count, immediate)
   - Enables native fetcher SPI to be used for prefetch operations
   - Handles both delayed and immediate prefetch scenarios

3. Notification dismiss now cancels notification from NotificationManager
   - Added notification cancellation before removing from storage
   - Uses notificationId.hashCode() to match display notification ID
   - Ensures notification disappears immediately when dismiss button is clicked
   - Adds DN|DISMISS_CANCEL_NOTIF instrumentation log

Version bump: 1.0.8 → 1.0.11
2025-11-11 08:06:59 +00:00

2553 lines
110 KiB
Kotlin

package com.timesafari.dailynotification
import android.Manifest
import android.app.Activity
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.WorkManager
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Data
import java.util.concurrent.TimeUnit
import com.timesafari.dailynotification.DailyNotificationFetchWorker
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.JSONArray
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
private val PERMISSION_REQUEST_CODE = 1001
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
}
}
/**
* Handle app resume - check for pending permission calls
* This is a fallback since Capacitor's Bridge intercepts permission results
* and may not route them to our plugin's handleRequestPermissionsResult
*/
override fun handleOnResume() {
super.handleOnResume()
// Check if we have a pending permission call
val call = savedCall
if (call != null && context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Check if this is a permission request call by checking the method name
val methodName = call.methodName
if (methodName == "requestPermissions" || methodName == "requestNotificationPermissions") {
// Check current permission status
val granted = ContextCompat.checkSelfPermission(
context!!,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
val result = JSObject().apply {
put("status", if (granted) "granted" else "denied")
put("granted", granted)
put("notifications", if (granted) "granted" else "denied")
}
Log.i(TAG, "Resolving pending permission call on resume: granted=$granted")
call.resolve(result)
}
}
}
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}")
}
}
/**
* Check permissions (Capacitor standard format)
* Returns PermissionStatus with notifications field as PermissionState
*/
@PluginMethod
override fun checkPermissions(call: PluginCall) {
try {
if (context == null) {
return call.reject("Context not available")
}
Log.i(TAG, "Checking permissions (Capacitor format)")
var notificationsState = "denied"
var notificationsEnabled = false
// Check POST_NOTIFICATIONS permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
notificationsState = if (granted) "granted" else "prompt"
notificationsEnabled = granted
} else {
// Pre-Android 13: check if notifications are enabled
val enabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
notificationsState = if (enabled) "granted" else "denied"
notificationsEnabled = enabled
}
val result = JSObject().apply {
put("status", notificationsState)
put("granted", notificationsEnabled)
put("notifications", notificationsState)
put("notificationsEnabled", notificationsEnabled)
}
Log.i(TAG, "Permissions check: notifications=$notificationsState, enabled=$notificationsEnabled")
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to check permissions", e)
call.reject("Permission check failed: ${e.message}")
}
}
/**
* Get exact alarm status
* Returns detailed information about exact alarm scheduling capability
*/
@PluginMethod
fun getExactAlarmStatus(call: PluginCall) {
try {
if (context == null) {
return call.reject("Context not available")
}
Log.i(TAG, "Getting exact alarm status")
val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var enabled = false
var canSchedule = false
val fallbackWindow = "15 minutes" // Standard fallback window for inexact alarms
if (supported) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
enabled = alarmManager?.canScheduleExactAlarms() ?: false
canSchedule = enabled
} else {
// Pre-Android 12: exact alarms are always allowed
enabled = true
canSchedule = true
}
val result = JSObject().apply {
put("supported", supported)
put("enabled", enabled)
put("canSchedule", canSchedule)
put("fallbackWindow", fallbackWindow)
}
Log.i(TAG, "Exact alarm status: supported=$supported, enabled=$enabled, canSchedule=$canSchedule")
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to get exact alarm status", e)
call.reject("Exact alarm status check failed: ${e.message}")
}
}
/**
* Update starred plan IDs
* Stores plan IDs in SharedPreferences for native fetcher to use
*/
@PluginMethod
fun updateStarredPlans(call: PluginCall) {
try {
if (context == null) {
return call.reject("Context not available")
}
val options = call.data ?: return call.reject("Options are required")
// Extract planIds array from options
// Capacitor passes arrays as JSONArray in JSObject
val planIdsValue = options.get("planIds")
val planIds = mutableListOf<String>()
when (planIdsValue) {
is JSONArray -> {
// Direct JSONArray
for (i in 0 until planIdsValue.length()) {
planIds.add(planIdsValue.getString(i))
}
}
is List<*> -> {
// List from JSObject conversion
planIds.addAll(planIdsValue.filterIsInstance<String>())
}
is String -> {
// Single string (unlikely but handle it)
planIds.add(planIdsValue)
}
else -> {
// Try to get as JSObject and extract array
val planIdsObj = options.getJSObject("planIds")
if (planIdsObj != null) {
val array = planIdsObj.get("planIds")
if (array is JSONArray) {
for (i in 0 until array.length()) {
planIds.add(array.getString(i))
}
}
} else {
return call.reject("planIds array is required")
}
}
}
Log.i(TAG, "Updating starred plans: count=${planIds.size}")
// Store in SharedPreferences (matching TestNativeFetcher expectations)
val prefsName = "daily_notification_timesafari"
val keyStarredPlanIds = "starredPlanIds"
val prefs: SharedPreferences = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
val editor = prefs.edit()
// Convert planIds list to JSON array string
val jsonArray = JSONArray()
planIds.forEach { planId ->
jsonArray.put(planId)
}
editor.putString(keyStarredPlanIds, jsonArray.toString())
editor.apply()
val result = JSObject().apply {
put("success", true)
put("planIdsCount", planIds.size)
put("updatedAt", System.currentTimeMillis())
}
Log.i(TAG, "Starred plans updated: count=${planIds.size}")
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to update starred plans", e)
call.reject("Failed to update starred plans: ${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 {
// Save the call using Capacitor's mechanism so it can be retrieved later
saveCall(call)
// Request permission - result will be handled in handleRequestPermissionsResult
// Note: Capacitor's Bridge intercepts permission results, so we also check
// permission status when the app resumes as a fallback
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
PERMISSION_REQUEST_CODE
)
Log.i(TAG, "Permission dialog shown, waiting for user response (requestCode=$PERMISSION_REQUEST_CODE)")
// Don't resolve here - wait for handleRequestPermissionsResult
}
} 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}")
}
}
/**
* Request permissions (alias for requestNotificationPermissions)
* Delegates to requestNotificationPermissions for consistency
*/
@PluginMethod
override fun requestPermissions(call: PluginCall) {
requestNotificationPermissions(call)
}
/**
* Handle permission request results
* Called by Capacitor when user responds to permission dialog
*/
override fun handleRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
Log.i(TAG, "handleRequestPermissionsResult called: requestCode=$requestCode, permissions=${permissions.contentToString()}")
if (requestCode == PERMISSION_REQUEST_CODE) {
// Retrieve the saved call
val call = savedCall
if (call != null) {
val granted = grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED
val result = JSObject().apply {
put("status", if (granted) "granted" else "denied")
put("granted", granted)
put("notifications", if (granted) "granted" else "denied")
}
Log.i(TAG, "Permission request result: granted=$granted, resolving call")
call.resolve(result)
return
} else {
Log.w(TAG, "No saved call found for permission request code $requestCode")
}
}
// Not handled by this plugin, let parent handle it
super.handleRequestPermissionsResult(requestCode, permissions, grantResults)
}
@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 FIRST
// This configures the fetcher instance with API credentials for background operations
var configureSuccess = false
var configureError: Exception? = null
try {
Log.d(TAG, "FETCHER|CONFIGURE_START apiBaseUrl=$apiBaseUrl, activeDid=${activeDid.take(30)}...")
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken)
configureSuccess = true
Log.i(TAG, "FETCHER|CONFIGURE_COMPLETE success=true")
} catch (e: Exception) {
configureError = e
Log.e(TAG, "FETCHER|CONFIGURE_COMPLETE success=false error=${e.message}", e)
// Continue to store empty config entry - don't fail the entire operation
}
// Store configuration in database for persistence across app restarts
// If configure() failed, store a valid but empty entry that won't cause errors
val configId = "native_fetcher_config"
val configValue = if (configureSuccess) {
// Store actual configuration values
JSONObject().apply {
put("apiBaseUrl", apiBaseUrl)
put("activeDid", activeDid)
put("jwtToken", jwtToken)
}.toString()
} else {
// Store valid but empty entry to prevent errors in code that reads this config
JSONObject().apply {
put("apiBaseUrl", "")
put("activeDid", "")
put("jwtToken", "")
put("configureError", configureError?.message ?: "Unknown error")
}.toString()
}
CoroutineScope(Dispatchers.IO).launch {
try {
val config = com.timesafari.dailynotification.entities.NotificationConfigEntity(
configId, null, "native_fetcher", "config", configValue, "json"
)
getDatabase().notificationConfigDao().insertConfig(config)
if (configureSuccess) {
call.resolve()
} else {
// Configure failed but we stored a valid entry - reject with error details
call.reject("Native fetcher configure() failed: ${configureError?.message}")
}
} 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, "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}")
}
}
}
/**
* Cancel all scheduled notifications
*
* This method:
* 1. Cancels all AlarmManager alarms (both exact and inexact)
* 2. Cancels all WorkManager prefetch jobs
* 3. Clears notification schedules from database
* 4. Updates plugin state to reflect cancellation
*
* The method is idempotent - safe to call multiple times even if nothing is scheduled.
*/
@PluginMethod
fun cancelAllNotifications(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
if (context == null) {
return@launch call.reject("Context not available")
}
Log.i(TAG, "Cancelling all notifications")
// 1. Get all scheduled notifications from database
val schedules = getDatabase().scheduleDao().getAll()
val notifySchedules = schedules.filter { it.kind == "notify" && it.enabled }
// 2. Cancel all AlarmManager alarms
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
if (alarmManager != null) {
var cancelledAlarms = 0
notifySchedules.forEach { schedule ->
try {
// Cancel alarm using the scheduled time (used for request code)
val nextRunAt = schedule.nextRunAt
if (nextRunAt != null && nextRunAt > 0) {
NotifyReceiver.cancelNotification(context, nextRunAt)
cancelledAlarms++
}
} catch (e: Exception) {
// Log but don't fail - alarm might not exist
Log.w(TAG, "Failed to cancel alarm for schedule ${schedule.id}", e)
}
}
// Also try to cancel any alarms that might not be in database
// Cancel by attempting to cancel with a generic intent
// FIX: Use DailyNotificationReceiver to match alarm scheduling
try {
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
}
// Try cancelling with common request codes (0-65535)
// This is a fallback for any orphaned alarms
for (requestCode in 0..100 step 10) {
try {
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
if (pendingIntent != null) {
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
} catch (e: Exception) {
// Ignore - this is a best-effort cleanup
}
}
} catch (e: Exception) {
Log.w(TAG, "Error during fallback alarm cancellation", e)
}
Log.i(TAG, "Cancelled $cancelledAlarms alarm(s)")
} else {
Log.w(TAG, "AlarmManager not available")
}
// 3. Cancel all WorkManager jobs
try {
val workManager = WorkManager.getInstance(context)
// Cancel all prefetch jobs
workManager.cancelAllWorkByTag("prefetch")
// Cancel fetch jobs (if using DailyNotificationFetcher tags)
workManager.cancelAllWorkByTag("daily_notification_fetch")
workManager.cancelAllWorkByTag("daily_notification_maintenance")
workManager.cancelAllWorkByTag("soft_refetch")
workManager.cancelAllWorkByTag("daily_notification_display")
workManager.cancelAllWorkByTag("daily_notification_dismiss")
// Cancel unique work by name pattern (prefetch_*)
// Note: WorkManager doesn't support wildcard cancellation, so we cancel by tag
// The unique work names will be replaced when new work is scheduled
Log.i(TAG, "Cancelled all WorkManager jobs")
} catch (e: Exception) {
Log.w(TAG, "Failed to cancel WorkManager jobs", e)
// Don't fail - continue with database cleanup
}
// 4. Clear database state - disable all notification schedules
try {
notifySchedules.forEach { schedule ->
getDatabase().scheduleDao().setEnabled(schedule.id, false)
}
// Also clear any fetch schedules
val fetchSchedules = schedules.filter { it.kind == "fetch" && it.enabled }
fetchSchedules.forEach { schedule ->
getDatabase().scheduleDao().setEnabled(schedule.id, false)
}
Log.i(TAG, "Disabled ${notifySchedules.size} notification schedule(s) and ${fetchSchedules.size} fetch schedule(s)")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear database state", e)
// Continue - alarms and jobs are already cancelled
}
Log.i(TAG, "All notifications cancelled successfully")
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "Failed to cancel all notifications", e)
call.reject("Failed to cancel notifications: ${e.message}")
}
}
}
@PluginMethod
fun scheduleDailyReminder(call: PluginCall) {
// Alias for scheduleDailyNotification for backward compatibility
// scheduleDailyReminder accepts same parameters as scheduleDailyNotification
try {
if (context == null) {
return call.reject("Context not available")
}
// Check if exact alarms can be scheduled
if (!canScheduleExactAlarms(context)) {
// Permission not granted - request it
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
call.reject(
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
"EXACT_ALARM_PERMISSION_REQUIRED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
return
}
} else {
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
call.reject(
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
call.reject("Failed to open app settings: ${e.message}")
return
}
}
}
// Permission granted - proceed with scheduling
// 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}")
}
}
/**
* Check if exact alarms can be scheduled
* Helper method for internal use
*
* @param context Application context
* @return true if exact alarms can be scheduled, false otherwise
*/
private fun canScheduleExactAlarms(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
alarmManager?.canScheduleExactAlarms() ?: false
} else {
// Pre-Android 12: exact alarms are always allowed
true
}
}
/**
* Check if exact alarm permission can be requested
* Helper method that handles API level differences
*
* On Android 12 (API 31-32): Permission can always be requested
* On Android 13+ (API 33+): Uses reflection to call Settings.canRequestScheduleExactAlarms()
* If reflection fails, falls back to heuristic: if exact alarms are not currently allowed,
* we assume they can be requested (safe default).
*
* @param context Application context
* @return true if permission can be requested, false if permanently denied
*/
private fun canRequestExactAlarmPermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12+ (API 31+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Android 13+ (API 33+) - try reflection to call canRequestScheduleExactAlarms
try {
// Try getMethod first (for public static methods)
val method = Settings::class.java.getMethod(
"canRequestScheduleExactAlarms",
Context::class.java
)
val result = method.invoke(null, context) as Boolean
Log.d(TAG, "canRequestScheduleExactAlarms() returned: $result")
return result
} catch (e: NoSuchMethodException) {
// Method not found - try getDeclaredMethod as fallback
try {
val method = Settings::class.java.getDeclaredMethod(
"canRequestScheduleExactAlarms",
Context::class.java
)
method.isAccessible = true
val result = method.invoke(null, context) as Boolean
Log.d(TAG, "canRequestScheduleExactAlarms() (via getDeclaredMethod) returned: $result")
return result
} catch (e2: Exception) {
Log.w(TAG, "Failed to check exact alarm permission using reflection, using heuristic", e2)
// Fallback heuristic: if exact alarms are not currently allowed,
// assume we can request them (safe default)
// Only case where we can't request is if permanently denied, which is rare
return !canScheduleExactAlarms(context)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to invoke canRequestScheduleExactAlarms(), using heuristic", e)
// Fallback heuristic: if exact alarms are not currently allowed,
// assume we can request them (safe default)
return !canScheduleExactAlarms(context)
}
} else {
// Android 12 (API 31-32) - permission can always be requested
// (user hasn't permanently denied it yet)
true
}
} else {
// Android 11 and below - permission not needed
true
}
}
/**
* Check exact alarm permission status
* Returns detailed information about permission status and whether it can be requested
*
* @param call Plugin call with no parameters
*/
@PluginMethod
fun checkExactAlarmPermission(call: PluginCall) {
try {
if (context == null) {
return call.reject("Context not available")
}
val canSchedule = canScheduleExactAlarms(context)
val canRequest = canRequestExactAlarmPermission(context)
val result = JSObject().apply {
put("canSchedule", canSchedule)
put("canRequest", canRequest)
put("required", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
}
Log.i(TAG, "Exact alarm permission check: canSchedule=$canSchedule, canRequest=$canRequest")
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to check exact alarm permission", e)
call.reject("Permission check failed: ${e.message}")
}
}
/**
* Request exact alarm permission
* Opens Settings intent to let user grant the permission
*
* On Android 12+ (API 31+), SCHEDULE_EXACT_ALARM is a special permission that
* cannot be requested through the normal permission request flow. Users must
* grant it manually in Settings.
*
* @param call Plugin call with no parameters
*/
@PluginMethod
fun requestExactAlarmPermission(call: PluginCall) {
try {
if (context == null) {
return call.reject("Context not available")
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
// Android 11 and below don't need this permission
Log.i(TAG, "Exact alarm permission not required on Android ${Build.VERSION.SDK_INT}")
call.resolve(JSObject().apply {
put("success", true)
put("message", "Exact alarm permission not required on this Android version")
})
return
}
if (canScheduleExactAlarms(context)) {
// Permission already granted
Log.i(TAG, "Exact alarm permission already granted")
call.resolve(JSObject().apply {
put("success", true)
put("message", "Exact alarm permission already granted")
})
return
}
// Check if app can request the permission
if (canRequestExactAlarmPermission(context)) {
// Open Settings to let user grant permission
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.i(TAG, "Opened exact alarm permission settings")
call.resolve(JSObject().apply {
put("success", true)
put("message", "Please grant 'Alarms & reminders' permission in Settings")
})
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
}
} else {
// User has already denied or permission is permanently denied
// Direct user to app settings
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Permission denied. Directing user to app settings")
call.reject(
"Permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
)
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
call.reject("Failed to open app settings: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to request exact alarm permission", e)
call.reject("Permission request failed: ${e.message}")
}
}
/**
* Open exact alarm settings (legacy method, kept for backward compatibility)
* Use requestExactAlarmPermission() for better error handling
*
* @param call Plugin call with no parameters
*/
@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).apply {
data = android.net.Uri.parse("package:${context?.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity?.startActivity(intent) ?: context?.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 {
if (context == null) {
return call.reject("Context not available")
}
// Check if exact alarms can be scheduled
if (!canScheduleExactAlarms(context)) {
// Permission not granted - request it
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
// Open Settings to let user grant permission
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
call.reject(
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
"EXACT_ALARM_PERMISSION_REQUIRED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
return
}
} else {
// Permission permanently denied - direct to app settings
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
call.reject(
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
call.reject("Failed to open app settings: ${e.message}")
return
}
}
}
// Permission granted - proceed with exact alarm scheduling
// 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 as static reminder
// (doesn't require cached content)
val scheduleId = "daily_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
isStaticReminder = true,
reminderId = scheduleId
)
// Always schedule prefetch 5 minutes before notification
// (URL is optional - native fetcher will be used if registered)
val fetchTime = nextRunTime - (5 * 60 * 1000L) // 5 minutes before
val delayMs = fetchTime - System.currentTimeMillis()
if (delayMs > 0) {
// Schedule delayed prefetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", fetchTime)
.putInt("retry_count", 0)
.putBoolean("immediate", false)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs, using native fetcher")
} else {
// Fetch time is in the past, schedule immediate fetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", System.currentTimeMillis())
.putInt("retry_count", 0)
.putBoolean("immediate", true)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i(TAG, "Immediate prefetch scheduled: notificationTime=$nextRunTime, using native fetcher")
}
// Store schedule in database
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 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}")
}
}
/**
* Check if an alarm is scheduled for a given trigger time
*/
@PluginMethod
fun isAlarmScheduled(call: PluginCall) {
try {
val options = call.data ?: return call.reject("Options are required")
val triggerAtMillis = options.getLong("triggerAtMillis") ?: return call.reject("triggerAtMillis is required")
val context = context ?: return call.reject("Context not available")
val isScheduled = NotifyReceiver.isAlarmScheduled(context, triggerAtMillis)
val result = JSObject().apply {
put("scheduled", isScheduled)
put("triggerAtMillis", triggerAtMillis)
}
Log.i(TAG, "Checking alarm status: scheduled=$isScheduled, triggerAt=$triggerAtMillis")
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to check alarm status", e)
call.reject("Failed to check alarm status: ${e.message}")
}
}
/**
* Get the next scheduled alarm time from AlarmManager
*/
@PluginMethod
fun getNextAlarmTime(call: PluginCall) {
try {
val context = context ?: return call.reject("Context not available")
val nextAlarmTime = NotifyReceiver.getNextAlarmTime(context)
val result = JSObject().apply {
if (nextAlarmTime != null) {
put("scheduled", true)
put("triggerAtMillis", nextAlarmTime)
} else {
put("scheduled", false)
}
}
Log.i(TAG, "Getting next alarm time: ${if (nextAlarmTime != null) nextAlarmTime else "none"}")
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to get next alarm time", e)
call.reject("Failed to get next alarm time: ${e.message}")
}
}
/**
* Test method: Schedule an alarm to fire in a few seconds
* Useful for verifying alarm delivery works correctly
*/
@PluginMethod
fun testAlarm(call: PluginCall) {
try {
val options = call.data
val secondsFromNow = options?.getInt("secondsFromNow") ?: 5
val context = context ?: return call.reject("Context not available")
Log.i(TAG, "TEST: Scheduling test alarm in $secondsFromNow seconds")
NotifyReceiver.testAlarm(context, secondsFromNow)
val result = JSObject().apply {
put("scheduled", true)
put("secondsFromNow", secondsFromNow)
put("triggerAtMillis", System.currentTimeMillis() + (secondsFromNow * 1000L))
}
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule test alarm", e)
call.reject("Failed to schedule test alarm: ${e.message}")
}
}
@PluginMethod
fun scheduleUserNotification(call: PluginCall) {
try {
if (context == null) {
return call.reject("Context not available")
}
// Check if exact alarms can be scheduled
if (!canScheduleExactAlarms(context)) {
// Permission not granted - request it
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
call.reject(
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
"EXACT_ALARM_PERMISSION_REQUIRED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
return
}
} else {
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
call.reject(
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
call.reject("Failed to open app settings: ${e.message}")
return
}
}
}
// Permission granted - proceed with scheduling
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 {
if (context == null) {
return call.reject("Context not available")
}
// Check if exact alarms can be scheduled
if (!canScheduleExactAlarms(context)) {
// Permission not granted - request it
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
call.reject(
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
"EXACT_ALARM_PERMISSION_REQUIRED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
return
}
} else {
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
call.reject(
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
call.reject("Failed to open app settings: ${e.message}")
return
}
}
}
// Permission granted - proceed with scheduling
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 {
// Parse cron expression: "minute hour * * *" (daily schedule)
// Example: "9 7 * * *" = 07:09 daily
try {
val parts = schedule.trim().split("\\s+".toRegex())
if (parts.size < 2) {
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
val minute = parts[0].toIntOrNull() ?: 0
val hour = parts[1].toIntOrNull() ?: 9
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
// Calculate next occurrence of this time
val calendar = java.util.Calendar.getInstance()
val now = calendar.timeInMillis
// Set to today at the specified time
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
calendar.set(java.util.Calendar.MINUTE, minute)
calendar.set(java.util.Calendar.SECOND, 0)
calendar.set(java.util.Calendar.MILLISECOND, 0)
var nextRun = calendar.timeInMillis
// If the time has already passed today, schedule for tomorrow
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
nextRun = calendar.timeInMillis
}
Log.d(TAG, "Calculated next run time: cron=$schedule, nextRun=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(nextRun))}")
return nextRun
} catch (e: Exception) {
Log.e(TAG, "Error calculating next run time for schedule: $schedule", e)
// Fallback: 24 hours from now
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
}
/**
* 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 * * *"
}
}
}