Files
daily-notification-plugin/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
Jose Olarte III b8d9b6247d chore(release): bump plugin version to 1.3.3
Sync version in package.json, package-lock.json, Android/Kotlin sources,
iOS Info.plist, and ios/DailyNotificationPlugin.podspec.
2026-03-09 20:32:02 +08:00

3181 lines
131 KiB
Kotlin

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.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
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.3.3
*/
@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
// Service instances for delegation
private var statusChecker: NotificationStatusChecker? = null
private var permissionManager: PermissionManager? = null
private var exactAlarmManager: DailyNotificationExactAlarmManager? = null
private var channelManager: ChannelManager? = null
private var scheduler: DailyNotificationScheduler? = null
private var integrationManager: TimeSafariIntegrationManager? = null
// Pending permission request tracking (prevents wrong-call resolution)
private var pendingPermissionRequest: PendingPermissionRequest? = null
override fun load() {
super.load()
try {
if (context == null) {
Log.e(TAG, "Context is null, cannot initialize database")
return
}
db = DailyNotificationDatabase.getDatabase(context)
statusChecker = NotificationStatusChecker(context)
channelManager = ChannelManager(context)
permissionManager = PermissionManager(context, channelManager)
// Note: exactAlarmManager requires AlarmManager and DailyNotificationScheduler
// For now, we'll initialize it lazily when needed, or create a simpler version
// This is a known limitation - exactAlarmManager initialization needs refactoring
exactAlarmManager = null // Will be initialized on-demand if needed
// Note: TimeSafariIntegrationManager requires many dependencies (Storage, Scheduler, ETagManager, JWTManager, Fetcher, TTLEnforcer, Logger)
// Initialization deferred to future integration work when all dependencies are available
integrationManager = null
Log.i(TAG, "Daily Notification Plugin loaded successfully")
// Phase 1: Perform app launch recovery (cold start only)
// Runs asynchronously, non-blocking, with timeout
val reactivationManager = ReactivationManager(context)
reactivationManager.performRecovery()
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
// Don't throw - allow plugin to load even if recovery fails
}
}
/**
* Handle app resume - check if we need to resolve a pending permission call
* Uses pendingPermissionRequest token to prevent wrong-call resolution
*/
override fun handleOnResume() {
super.handleOnResume()
// Only resolve if we have a valid pending request token
val pending = pendingPermissionRequest
val call = savedCall
if (pending != null && call != null && context != null) {
val now = System.currentTimeMillis()
val ageMs = now - pending.requestedAtMs
// Only auto-resolve if request is recent (< 2 minutes) and matches expected state
if (ageMs < 120_000) { // 2 minutes
when (pending.requestType) {
PermissionRequestType.POST_NOTIFICATIONS -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
context!!,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
// Only resolve if permission state changed (granted)
if (granted) {
val result = JSObject().apply {
put("status", "granted")
put("granted", true)
put("notifications", "granted")
}
Log.i(TAG, "Resolving pending POST_NOTIFICATIONS request on resume: granted=true")
call.resolve(result)
pendingPermissionRequest = null // Clear pending request
} else {
Log.d(TAG, "POST_NOTIFICATIONS still not granted, not auto-resolving")
}
}
}
PermissionRequestType.EXACT_ALARM -> {
// For exact alarm, check if permission was granted
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = context!!.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
val granted = alarmManager?.canScheduleExactAlarms() ?: false
if (granted) {
val result = JSObject().apply {
put("success", true)
put("message", "Exact alarm permission granted")
}
Log.i(TAG, "Resolving pending EXACT_ALARM request on resume: granted=true")
call.resolve(result)
pendingPermissionRequest = null
}
}
}
PermissionRequestType.BATTERY_OPTIMIZATION -> {
// Battery optimization doesn't have a simple granted/denied state
// Don't auto-resolve - let user action complete naturally
Log.d(TAG, "Battery optimization request - not auto-resolving on resume")
}
}
} else {
// Request is stale - clear it
Log.w(TAG, "Pending permission request expired (age: ${ageMs}ms), clearing")
pendingPermissionRequest = null
}
}
}
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")
// Delegate to TimeSafariIntegrationManager if available
// For now, this is a placeholder - configuration will be handled by integration manager
// when it's initialized. This method maintains API compatibility.
CoroutineScope(Dispatchers.IO).launch {
try {
// Delegate to TimeSafariIntegrationManager if available
integrationManager?.configure(options)?.let {
call.resolve()
} ?: run {
// Fallback: just resolve to maintain API compatibility
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")
}
// Delegate to PermissionManager
permissionManager?.checkPermissionStatus(call)
} 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
* Delegates to PermissionManager for single source of truth
*/
@PluginMethod
override fun checkPermissions(call: PluginCall) {
try {
if (context == null) {
return call.reject("Context not available")
}
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Get unified permission status from PermissionManager
val status = permissionManager!!.getPermissionStatus()
// Format for Capacitor standard format
val notificationsState = when {
status.postNotificationsGranted && status.notificationsEnabledAtOsLevel -> "granted"
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> "prompt"
else -> if (status.notificationsEnabledAtOsLevel) "granted" else "denied"
}
val result = JSObject().apply {
put("status", notificationsState)
put("granted", status.canScheduleNow)
put("notifications", notificationsState)
put("notificationsEnabled", status.postNotificationsGranted && status.notificationsEnabledAtOsLevel)
// Include full status for debugging
put("postNotificationsGranted", status.postNotificationsGranted)
put("exactAlarmGranted", status.exactAlarmGranted)
put("notificationsEnabledAtOsLevel", status.notificationsEnabledAtOsLevel)
}
Log.i(TAG, "Permissions check: notifications=$notificationsState, canSchedule=${status.canScheduleNow}")
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")
}
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Delegate to DailyNotificationExactAlarmManager if available, otherwise use PermissionManager
if (exactAlarmManager != null) {
val status = exactAlarmManager!!.getExactAlarmStatus()
val result = JSObject().apply {
put("supported", status.supported)
put("enabled", status.enabled)
put("canSchedule", status.canSchedule)
put("fallbackWindow", JSObject().apply {
put("startMs", status.fallbackWindow.startMs)
put("lengthMs", status.fallbackWindow.lengthMs)
})
}
Log.i(TAG, "Exact alarm status retrieved: ${status.toString()}")
call.resolve(result)
} else {
// Fallback: Use PermissionManager's checkExactAlarmPermission
permissionManager!!.checkExactAlarmPermission(call)
}
} 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
*
* Input contract: { planIds: string[] }
* Rejects any other shape with clear error message
*/
@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")
// Enforce single input shape: planIds must be string[]
val planIdsValue = options.get("planIds")
if (planIdsValue == null) {
return call.reject("planIds is required and must be a string array")
}
val planIds = mutableListOf<String>()
// Primary path: JSONArray (Capacitor's standard format)
if (planIdsValue is JSONArray) {
for (i in 0 until planIdsValue.length()) {
val item = planIdsValue.get(i)
if (item is String) {
planIds.add(item)
} else {
return call.reject("planIds must be an array of strings (found non-string at index $i)")
}
}
}
// Fallback: List<String> (from JSObject conversion)
else if (planIdsValue is List<*>) {
planIdsValue.forEachIndexed { index, item ->
if (item is String) {
planIds.add(item)
} else {
return call.reject("planIds must be an array of strings (found non-string at index $index)")
}
}
}
// Legacy support: Single string (normalize to array immediately)
else if (planIdsValue is String) {
Log.w(TAG, "updateStarredPlans: Received single string instead of array (legacy support)")
planIds.add(planIdsValue)
}
// Reject all other shapes
else {
return call.reject("planIds must be a string array, got: ${planIdsValue.javaClass.simpleName}")
}
// Delegate to ScheduleHelper
val success = ScheduleHelper.updateStarredPlans(context, planIds)
if (success) {
val result = JSObject().apply {
put("success", true)
put("planIdsCount", planIds.size)
put("updatedAt", System.currentTimeMillis())
}
call.resolve(result)
} else {
call.reject("Failed to update starred plans")
}
} 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")
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Save the call using Capacitor's mechanism so it can be retrieved later
// (needed for handleRequestPermissionsResult to resolve the call)
saveCall(call)
// Create pending request token to track this permission request
val requestNonce = java.util.UUID.randomUUID().toString()
pendingPermissionRequest = PendingPermissionRequest(
requestNonce = requestNonce,
requestType = PermissionRequestType.POST_NOTIFICATIONS,
requestedAtMs = System.currentTimeMillis()
)
Log.d(TAG, "Created pending permission request: nonce=$requestNonce, type=POST_NOTIFICATIONS")
// Delegate to PermissionManager.requestNotificationPermissions()
permissionManager!!.requestNotificationPermissions(call, activity)
} 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) {
// Delegate to requestNotificationPermissions (which delegates to PermissionManager)
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 == DailyNotificationConstants.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)
pendingPermissionRequest = null // Clear pending request after resolution
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
}
// SECURITY WARNING: JWT token storage
// By default, tokens are NOT persisted to prevent storing bearer credentials at rest
// If persistence is required, caller must explicitly opt-in with persistToken: true
// Use optBoolean() to avoid JSONException when key doesn't exist
val persistToken = options.optBoolean("persistToken", false)
if (persistToken) {
Log.w(TAG, "SECURITY WARNING: JWT token will be persisted to unencrypted database. " +
"This is a security risk. Consider using short-lived tokens or encrypted storage.")
} else {
Log.d(TAG, "JWT token will NOT be persisted (default secure behavior)")
}
// 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)
// Only store JWT token if explicitly opted-in
if (persistToken) {
put("jwtToken", jwtToken)
Log.w(TAG, "JWT token stored in database (persistToken=true). " +
"Database is NOT encrypted - token is stored in plain text.")
} else {
put("jwtToken", "") // Empty string - token not persisted
put("tokenNotPersisted", true) // Flag indicating token was not stored
}
}.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 {
if (context == null) {
return@launch call.reject("Context not available")
}
val database = getDatabase()
if (statusChecker == null) {
statusChecker = NotificationStatusChecker(context)
}
// Delegate to NotificationStatusChecker.getNotificationStatus()
// (which internally uses NotificationStatusHelper)
val result = statusChecker!!.getNotificationStatus(database)
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 (delegate to ScheduleHelper)
// Only cancel alarms we can prove we scheduled (from database)
// This prevents accidental cancellation of other alarms and false confidence
// If alarms exist outside the database, they should be tracked or ignored
val cancelledAlarms = ScheduleHelper.cancelAlarmsForSchedules(context, notifySchedules)
if (cancelledAlarms > 0) {
Log.i(TAG, "Cancelled $cancelledAlarms alarm(s) from database schedules")
} else {
Log.d(TAG, "No alarms found in database to cancel")
}
// 3. Cancel all WorkManager jobs (delegate to ScheduleHelper)
val workCancelled = ScheduleHelper.cancelAllWorkManagerJobs(context)
if (workCancelled) {
Log.i(TAG, "Cancelled all WorkManager jobs")
} else {
Log.w(TAG, "Failed to cancel some WorkManager jobs, continuing with cleanup")
}
// 4. Clear database state - disable all notification and fetch schedules
try {
// Delegate to ScheduleHelper
val disabledNotify = ScheduleHelper.disableAllSchedulesByKind(getDatabase(), "notify")
val disabledFetch = ScheduleHelper.disableAllSchedulesByKind(getDatabase(), "fetch")
Log.i(TAG, "Disabled $disabledNotify notification schedule(s) and $disabledFetch 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
// This ensures both method names work the same way
scheduleDailyNotification(call)
}
@PluginMethod
fun cancelDailyReminder(call: PluginCall) {
try {
val reminderId = call.getString("reminderId")
?: call.getString("id")
?: call.getString("reminder_id")
?: call.getString("scheduleId")
if (reminderId.isNullOrBlank()) {
call.reject("cancelDailyReminder: missing reminderId")
return
}
NotifyReceiver.cancelNotification(context, scheduleId = reminderId)
try {
kotlinx.coroutines.runBlocking {
val db = getDatabase()
db.scheduleDao().setEnabled(reminderId, false)
db.scheduleDao().updateRunTimes(reminderId, null, null)
}
} catch (dbErr: Exception) {
Log.w(TAG, "cancelDailyReminder: failed DB update for $reminderId", dbErr)
}
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "cancelDailyReminder failed", e)
call.reject("cancelDailyReminder failed: ${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
*/
/**
* Check if exact alarms can be scheduled
* Helper method for internal use
* Delegates to PermissionManager for single source of truth
*
* @param context Application context
* @return true if exact alarms can be scheduled, false otherwise
*/
private fun canScheduleExactAlarms(context: Context): Boolean {
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Use unified permission status
val status = permissionManager!!.getPermissionStatus()
return status.exactAlarmGranted
}
/**
* Check if exact alarm permission can be requested
* Delegates to PermissionManager.canRequestExactAlarmPermission()
*
* @param context Application context
* @return true if permission can be requested, false if permanently denied
*/
private fun canRequestExactAlarmPermission(context: Context): Boolean {
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Delegate to PermissionManager.checkExactAlarmPermission and extract canRequest
// Note: This is a synchronous check, so we need to check directly
val canSchedule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
alarmManager?.canScheduleExactAlarms() ?: false
} else {
true // Android 11 and below don't need this permission
}
// Check if permission can be requested (Android 13+)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Try reflection to call Settings.canRequestScheduleExactAlarms()
try {
val method = Settings::class.java.getMethod("canRequestScheduleExactAlarms", Context::class.java)
method.invoke(null, context) as Boolean
} catch (e: Exception) {
// Fallback heuristic: if exact alarms are not currently allowed, assume we can request them
!canSchedule
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12 (API 31-32) - permission can always be requested
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")
}
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Delegate to PermissionManager.checkExactAlarmPermission()
permissionManager!!.checkExactAlarmPermission(call)
} 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")
}
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Save the call for resume handling
saveCall(call)
// Create pending request token to track this exact alarm permission request
val requestNonce = java.util.UUID.randomUUID().toString()
pendingPermissionRequest = PendingPermissionRequest(
requestNonce = requestNonce,
requestType = PermissionRequestType.EXACT_ALARM,
requestedAtMs = System.currentTimeMillis()
)
Log.d(TAG, "Created pending permission request: nonce=$requestNonce, type=EXACT_ALARM")
// Delegate to PermissionManager.requestExactAlarmPermission()
permissionManager!!.requestExactAlarmPermission(call)
} 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 (context == null) {
return call.reject("Context not available")
}
// Ensure permissionManager is initialized
if (permissionManager == null) {
if (channelManager == null) {
channelManager = ChannelManager(context)
}
permissionManager = PermissionManager(context, channelManager)
}
// Delegate to PermissionManager.openExactAlarmSettings()
permissionManager!!.openExactAlarmSettings(call)
} 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 {
if (context == null) {
return call.reject("Context not available")
}
// Ensure channelManager is initialized
if (channelManager == null) {
channelManager = ChannelManager(context)
}
// Use the default channel ID (ChannelManager only supports default channel)
val requestedChannelId = call.getString("channelId")
val channelId = channelManager!!.getDefaultChannelId()
if (requestedChannelId != null && requestedChannelId != channelId) {
Log.w(TAG, "Requested channelId '$requestedChannelId' differs from default '$channelId', using default")
}
// Ensure channel exists (creates if needed)
channelManager!!.ensureChannelExists()
// Delegate to ChannelManager for channel status
val channelEnabled = channelManager!!.isChannelEnabled()
val importance = channelManager!!.getChannelImportance()
// Check app-level notifications (this is app-level, not channel-level)
val appNotificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
// Final enabled state: both app and channel must be enabled
val finalEnabled = appNotificationsEnabled && channelEnabled
Log.i(TAG, "Channel status check complete: channelId=$channelId, appNotificationsEnabled=$appNotificationsEnabled, channelEnabled=$channelEnabled, importance=$importance, finalEnabled=$finalEnabled")
val result = JSObject().apply {
// Channel is enabled if both app notifications are enabled AND channel importance is not NONE
put("enabled", finalEnabled as Boolean)
put("channelId", channelId as String)
put("importance", (if (importance >= 0) importance else android.app.NotificationManager.IMPORTANCE_DEFAULT) as Int)
put("appNotificationsEnabled", appNotificationsEnabled as Boolean)
put("channelBlocked", (importance == android.app.NotificationManager.IMPORTANCE_NONE) as Boolean)
}
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 {
if (context == null) {
return call.reject("Context not available")
}
// Ensure channelManager is initialized
if (channelManager == null) {
channelManager = ChannelManager(context)
}
// Use the actual channel ID that matches what's used in notifications
val channelId = call.getString("channelId") ?: channelManager!!.getDefaultChannelId()
// Delegate to ChannelManager.openChannelSettings()
val opened = channelManager!!.openChannelSettings(channelId)
val result = JSObject().apply {
put("opened", opened)
put("channelId", channelId)
if (!opened) {
put("error", "Failed to open channel settings")
}
}
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 - delegate to NotificationStatusChecker
try {
if (context == null) {
return call.reject("Context not available")
}
if (statusChecker == null) {
statusChecker = NotificationStatusChecker(context)
}
val result = statusChecker!!.getComprehensiveStatus()
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")
}
// Do not open Settings or reject when exact alarms are not granted.
// Proceed with scheduling; underlying layer uses inexact/windowed alarms when exact is unavailable.
// Apps that want to prompt for exact alarm can use openExactAlarmSettings() or requestExactAlarmPermission().
if (!canScheduleExactAlarms(context)) {
Log.i(TAG, "Exact alarm permission not granted; scheduling will use inexact/windowed fallback.")
}
// Proceed with scheduling (exact when granted, otherwise inexact/windowed)
// 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 (not used in helper yet)
val rolloverIntervalMinutes = try {
(options.getInt("rolloverIntervalMinutes") ?: 0).takeIf { it > 0 }
} catch (_: Exception) {
null
}
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title, rolloverIntervalMinutes=$rolloverIntervalMinutes")
// Convert HH:mm time to cron expression (daily at specified time)
val cronExpression = convertTimeToCron(time)
CoroutineScope(Dispatchers.IO).launch {
try {
// Use stable scheduleId for daily notifications to ensure "one per day" semantics
// If user provides an ID, use it; otherwise use stable "daily_notification"
val scheduleId = options.getString("id") ?: "daily_notification"
Log.i(TAG, "scheduleDailyNotification: START - time=$time, scheduleId=$scheduleId")
// CRITICAL: Cancel and delete all existing notification schedules before creating new one
// This ensures "one per day" semantics - only one daily notification schedule exists
// Delegate cleanup to ScheduleHelper
val cleanedCount = ScheduleHelper.cleanupExistingNotificationSchedules(
context,
getDatabase(),
excludeScheduleId = scheduleId
)
if (cleanedCount > 0) {
Log.i(TAG, "scheduleDailyNotification: ✅ Cleaned up $cleanedCount existing notification schedule(s) before creating new one")
} else {
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
}
// Cancel only fetch-related WorkManager jobs so they cannot create a second (UUID) alarm
// with fallback or placeholder text. Does not cancel display/dismiss; future fetched-content
// flows should use distinct tags so they are not affected.
val workCancelled = ScheduleHelper.cancelFetchRelatedWorkManagerJobs(context)
if (workCancelled) {
Log.i(TAG, "scheduleDailyNotification: Cancelled pending prefetch/fetch WorkManager jobs")
}
val config = UserNotificationConfig(
enabled = true,
schedule = cronExpression,
title = title,
body = body,
sound = sound,
vibration = true,
priority = priority
)
// Delegate to ScheduleHelper
val success = ScheduleHelper.scheduleDailyNotification(
context,
getDatabase(),
scheduleId,
config,
time,
::calculateNextRunTime,
rolloverIntervalMinutes
)
if (success) {
call.resolve()
} else {
call.reject("Daily notification scheduling failed")
}
} 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 {
if (context == null) {
return call.reject("Context not available")
}
val options = call.data ?: return call.reject("Options are required")
val triggerAtMillis = options.getLong("triggerAtMillis") ?: return call.reject("triggerAtMillis is required")
// Initialize scheduler if needed (requires AlarmManager)
if (scheduler == null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
?: return call.reject("AlarmManager not available")
scheduler = DailyNotificationScheduler(context, alarmManager)
}
// Delegate to DailyNotificationScheduler.isScheduled()
val isScheduled = scheduler!!.isScheduled(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 {
if (context == null) {
return call.reject("Context not available")
}
// Initialize scheduler if needed (requires AlarmManager)
if (scheduler == null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
?: return call.reject("AlarmManager not available")
scheduler = DailyNotificationScheduler(context, alarmManager)
}
// Delegate to DailyNotificationScheduler.getNextAlarmTime()
val nextAlarmTime = scheduler!!.getNextAlarmTime()
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")
// Initialize scheduler if needed (lazy initialization)
if (scheduler == null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
?: return call.reject("AlarmManager not available")
scheduler = DailyNotificationScheduler(context, alarmManager)
}
// Delegate to DailyNotificationScheduler.testAlarm()
scheduler!!.testAlarm(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}")
}
}
/**
* Test method: Inject invalid data into database for recovery testing
*
* This method is used by TEST 4 to verify that recovery handles invalid
* data gracefully (empty IDs, null nextRunAt, etc.) without crashing.
*
* @param call Plugin call with optional parameters:
* - injectEmptyScheduleId: boolean (default: true) - inject schedule with empty ID
* - injectNullNextRunAt: boolean (default: true) - inject schedule with null nextRunAt
* - injectEmptyNotificationId: boolean (default: true) - inject notification with empty ID
*/
@PluginMethod
fun injectInvalidTestData(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
val options = call.data
val injectEmptyScheduleId = options?.getBoolean("injectEmptyScheduleId") ?: true
val injectNullNextRunAt = options?.getBoolean("injectNullNextRunAt") ?: true
val injectEmptyNotificationId = options?.getBoolean("injectEmptyNotificationId") ?: true
val db = getDatabase()
val injected = mutableListOf<String>()
// Delegate schedule injection to TestDataHelper
val scheduleInjected = TestDataHelper.injectInvalidScheduleData(
database = db,
injectEmptyScheduleId = injectEmptyScheduleId,
injectNullNextRunAt = injectNullNextRunAt
)
injected.addAll(scheduleInjected)
// Inject notification with empty ID (if requested)
// Note: Room's @NonNull constraint may prevent this, but we try anyway
if (injectEmptyNotificationId) {
val notificationInjected = TestDataHelper.injectInvalidNotificationData(db)
if (notificationInjected) {
injected.add("empty_notification_id")
Log.i(TAG, "TEST: Injected notification with empty ID")
} else {
Log.w(TAG, "TEST: Failed to inject empty notification ID (Room @NonNull constraint may prevent this)")
Log.i(TAG, "TEST: Other invalid data types (null nextRunAt, empty schedule ID) will still test recovery")
}
}
val result = JSObject().apply {
put("success", true)
put("injected", JSONArray(injected))
put("message", "Invalid test data injected: ${injected.joinToString(", ")}")
}
call.resolve(result)
} catch (e: Exception) {
Log.e(TAG, "Failed to inject invalid test data", e)
call.reject("Failed to inject invalid test data: ${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)) {
// Delegate to PermissionManager to handle exact alarm permission request
val activity = activity
if (activity == null) {
return call.reject("Activity not available")
}
permissionManager!!.requestExactAlarmPermission(call)
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 {
// Delegate to ScheduleHelper
val scheduleId = ScheduleHelper.scheduleUserNotification(
context,
getDatabase(),
config,
::calculateNextRunTime
)
if (scheduleId != null) {
call.resolve()
} else {
call.reject("User notification scheduling failed")
}
} 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)) {
// Delegate to PermissionManager to handle exact alarm permission request
val activity = activity
if (activity == null) {
return call.reject("Activity not available")
}
permissionManager!!.requestExactAlarmPermission(call)
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 {
// Delegate to ScheduleHelper
val success = ScheduleHelper.scheduleDualNotification(
context,
getDatabase(),
contentFetchConfig,
userNotificationConfig,
FetchWorker::scheduleFetch,
::calculateNextRunTime
)
if (success) {
call.resolve()
} else {
call.reject("Dual notification scheduling failed")
}
} 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()
)
// Delegate to CallbackHelper
CallbackHelper.registerCallback(getDatabase(), 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 database = getDatabase()
// Delegate to ContentCacheHelper
val latestCache = ContentCacheHelper.getLatest(database)
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}")
}
}
}
/**
* Get all schedules with their AlarmManager status
* Returns schedules from database with isActuallyScheduled flag for each
*/
@PluginMethod
fun getSchedulesWithStatus(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
val options = call.getObject("options")
val kind = options?.getString("kind")
val enabled = options?.getBoolean("enabled")
val context = context ?: return@launch call.reject("Context not available")
// Get schedules from database (same logic as getSchedules())
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()
}
// Delegate to ScheduleHelper to combine with AlarmManager status
val schedulesArray = ScheduleHelper.getSchedulesWithStatus(context, schedules) { schedule ->
scheduleToJson(schedule)
}
call.resolve(JSObject().apply {
put("schedules", schedulesArray)
})
} catch (e: Exception) {
Log.e(TAG, "Failed to get schedules with status", e)
call.reject("Failed to get schedules with status: ${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")
)
// Delegate to ScheduleHelper
val created = ScheduleHelper.createSchedule(getDatabase(), schedule)
call.resolve(scheduleToJson(created))
} 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")
// Delegate to ScheduleHelper
val updated = ScheduleHelper.updateSchedule(
database = getDatabase(),
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"),
lastRunAt = updates.getLong("lastRunAt"),
nextRunAt = updates.getLong("nextRunAt")
)
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")
// Delegate to ScheduleHelper
ScheduleHelper.deleteSchedule(getDatabase(), 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
// Delegate to ScheduleHelper
ScheduleHelper.enableSchedule(getDatabase(), 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")
Log.d(TAG, "DNP-CONFIG: Loading config from database: key=$key, timesafariDid=${timesafariDid?.take(20)}...")
val entity = if (timesafariDid != null) {
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
} else {
getDatabase().notificationConfigDao().getConfigByKey(key)
}
if (entity != null) {
Log.i(TAG, "DNP-CONFIG: Configuration restored from database: key=$key, configType=${entity.configType}, hasValue=${entity.configValue.isNotEmpty()}")
call.resolve(configToJson(entity))
} else {
Log.d(TAG, "DNP-CONFIG: Configuration not found in database: key=$key")
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 * * *"
}
}
}
/**
* Helper object for test data operations
* Provides functions for injecting test data into the database
*/
object TestDataHelper {
/**
* Inject invalid schedule data for recovery testing
*
* @param database Database instance
* @param injectEmptyScheduleId Whether to inject schedule with empty ID
* @param injectNullNextRunAt Whether to inject schedule with null nextRunAt
* @return List of injected test data types
*/
suspend fun injectInvalidScheduleData(
database: DailyNotificationDatabase,
injectEmptyScheduleId: Boolean = true,
injectNullNextRunAt: Boolean = true
): List<String> {
val injected = mutableListOf<String>()
// Inject schedule with empty ID
if (injectEmptyScheduleId) {
try {
val invalidSchedule = Schedule(
id = "", // Empty ID - should be skipped by recovery
kind = "notify",
cron = "0 9 * * *",
clockTime = "09:00",
enabled = true,
nextRunAt = System.currentTimeMillis() + 86400000L
)
database.scheduleDao().upsert(invalidSchedule)
injected.add("empty_schedule_id")
} catch (e: Exception) {
// Log but continue - may fail due to constraints
}
}
// Inject schedule with null nextRunAt
if (injectNullNextRunAt) {
try {
val invalidSchedule = Schedule(
id = "test_null_nextrunat",
kind = "notify",
cron = "0 9 * * *",
clockTime = "09:00",
enabled = true,
nextRunAt = null // Null nextRunAt - should be skipped by recovery
)
database.scheduleDao().upsert(invalidSchedule)
injected.add("null_nextrunat")
} catch (e: Exception) {
// Log but continue
}
}
return injected
}
/**
* Inject invalid notification data for recovery testing
*
* @param database Database instance
* @return true if injection succeeded, false otherwise
*/
suspend fun injectInvalidNotificationData(database: DailyNotificationDatabase): Boolean {
return try {
val invalidNotification =
com.timesafari.dailynotification.entities.NotificationContentEntity()
invalidNotification.id = "" // Empty ID - should be skipped by recovery
invalidNotification.title = "Test Invalid Notification"
invalidNotification.body = "This has an empty ID"
invalidNotification.scheduledTime = System.currentTimeMillis() - 3600000L // 1 hour ago
invalidNotification.deliveryStatus = "pending"
invalidNotification.deliveryAttempts = 0
invalidNotification.lastDeliveryAttempt = 0
invalidNotification.userInteractionCount = 0
invalidNotification.lastUserInteraction = 0
invalidNotification.ttlSeconds = 86400L
invalidNotification.createdAt = System.currentTimeMillis()
invalidNotification.updatedAt = System.currentTimeMillis()
database.notificationContentDao().insertNotification(invalidNotification)
true
} catch (e: Exception) {
// Room's @NonNull constraint may prevent this - this is expected
false
}
}
}
/**
* Helper object for schedule operations
* Provides functions for managing schedules in the database
*/
object ScheduleHelper {
/**
* Update starred plan IDs in SharedPreferences
*
* @param context Application context
* @param planIds List of plan IDs to star
* @return true if update was successful
*/
fun updateStarredPlans(context: Context, planIds: List<String>): Boolean {
return try {
// Validate all plan IDs are non-empty strings
planIds.forEachIndexed { index, planId ->
if (planId.isBlank()) {
throw IllegalArgumentException("planIds[$index] must be a non-empty string")
}
}
// Store in SharedPreferences (matching TestNativeFetcher expectations)
val prefsName = DailyNotificationConstants.PREFS_NAME
val keyStarredPlanIds = DailyNotificationConstants.PREFS_KEY_STARRED_PLAN_IDS
val prefs = 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()
Log.i("ScheduleHelper", "Starred plans updated: count=${planIds.size}")
true
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to update starred plans", e)
false
}
}
/**
* Disable all schedules of a specific kind
*
* @param database Database instance
* @param kind Schedule kind ("notify" or "fetch")
* @return Number of schedules disabled
*/
suspend fun disableAllSchedulesByKind(
database: DailyNotificationDatabase,
kind: String
): Int {
val schedules = database.scheduleDao().getByKind(kind)
val enabledSchedules = schedules.filter { it.enabled }
enabledSchedules.forEach { schedule ->
database.scheduleDao().setEnabled(schedule.id, false)
}
return enabledSchedules.size
}
/**
* Cancel alarms for a list of schedules
*
* @param context Application context
* @param schedules List of schedules to cancel alarms for
* @return Number of alarms cancelled
*/
suspend fun cancelAlarmsForSchedules(
context: Context,
schedules: List<Schedule>
): Int {
var cancelledCount = 0
schedules.forEach { schedule ->
try {
val nextRunAt = schedule.nextRunAt
if (nextRunAt != null && nextRunAt > 0) {
NotifyReceiver.cancelNotification(context, scheduleId = schedule.id, triggerAtMillis = nextRunAt)
cancelledCount++
}
} catch (e: Exception) {
// Log but don't fail - alarm might not exist
Log.w("ScheduleHelper", "Failed to cancel alarm for schedule ${schedule.id}", e)
}
}
return cancelledCount
}
/**
* Get schedules with AlarmManager status
*
* Combines database schedules with AlarmManager status checks.
*
* @param context Application context
* @param schedules List of schedules from database
* @return JSONArray of schedules with isActuallyScheduled field added
*/
fun getSchedulesWithStatus(context: Context, schedules: List<Schedule>, scheduleToJson: (Schedule) -> org.json.JSONObject): org.json.JSONArray {
val schedulesArray = org.json.JSONArray()
schedules.forEach { schedule ->
val scheduleJson = scheduleToJson(schedule)
// Only check AlarmManager status for "notify" schedules with nextRunAt
if (schedule.kind == "notify" && schedule.nextRunAt != null) {
val isScheduled = NotifyReceiver.isAlarmScheduled(context, scheduleId = schedule.id, triggerAtMillis = schedule.nextRunAt!!)
scheduleJson.put("isActuallyScheduled", isScheduled)
} else {
scheduleJson.put("isActuallyScheduled", false)
}
schedulesArray.put(scheduleJson)
}
return schedulesArray
}
/**
* Schedule daily notification (alarm + prefetch + database)
*
* Orchestrates scheduling a daily notification with prefetch WorkManager job.
*
* @param context Application context
* @param database Database instance
* @param scheduleId Schedule ID (stable for "one per day" semantics)
* @param config User notification configuration
* @param clockTime Original HH:mm time string
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @param rolloverIntervalMinutes When > 0, next occurrence is this many minutes after trigger (dev/testing). Null/0 = 24h.
* @return true if successful, false otherwise
*/
suspend fun scheduleDailyNotification(
context: Context,
database: DailyNotificationDatabase,
scheduleId: String,
config: UserNotificationConfig,
clockTime: String,
calculateNextRunTime: (String) -> Long,
rolloverIntervalMinutes: Int? = null
): Boolean {
return try {
val nextRunTime = calculateNextRunTime(config.schedule)
// CRITICAL: Cancel any existing alarm for this scheduleId BEFORE scheduling new one
// This ensures "one per day" semantics - when updating schedule time, old alarm is canceled
// The cleanupExistingNotificationSchedules() above only cancels OTHER schedules, not the current one
NotifyReceiver.cancelNotification(context, scheduleId)
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
// Schedule AlarmManager notification as static reminder
// (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
// this scheduleId and Android may still return the cancelled PendingIntent from cache.
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
isStaticReminder = true,
reminderId = scheduleId,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP,
skipPendingIntentIdempotence = true
)
// Do not enqueue prefetch for static reminders: display is already in the NotifyReceiver
// alarm. Prefetch is for "fetch content then show"; for static reminders there is nothing
// to fetch. Enqueueing prefetch would cause the worker to use fallback content and
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
// notifications at fire time.
// Store schedule in database (include rollover interval for dev/testing; survives reboot)
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = config.schedule,
clockTime = clockTime,
enabled = true,
nextRunAt = nextRunTime,
rolloverIntervalMinutes = rolloverIntervalMinutes
)
database.scheduleDao().upsert(schedule)
// Persist title/body for this scheduleId so rollover and post-reboot resolve user content
// (see plugin-feedback-android-rollover-double-fire-and-user-content)
try {
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
scheduleId,
"1.3.1",
null,
"daily",
config.title ?: "Daily Notification",
config.body ?: "",
nextRunTime,
java.time.ZoneId.systemDefault().id
)
entity.soundEnabled = config.sound ?: true
entity.vibrationEnabled = config.vibration ?: true
entity.priority = when (config.priority) {
"high", "max" -> 2
"low", "min" -> -1
else -> 0
}
entity.createdAt = System.currentTimeMillis()
entity.updatedAt = System.currentTimeMillis()
database.notificationContentDao().insertNotification(entity)
Log.d("ScheduleHelper", "Persisted title/body for scheduleId=$scheduleId (rollover/post-reboot)")
} catch (e: Exception) {
Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
}
true
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
false
}
}
/**
* Blocking load of a schedule by id (for use from Java Worker / rollover path).
*/
@JvmStatic
fun getScheduleBlocking(context: Context, scheduleId: String): Schedule? {
return kotlinx.coroutines.runBlocking {
try {
DailyNotificationDatabase.getDatabase(context).scheduleDao().getById(scheduleId)
} catch (e: Exception) {
Log.w("ScheduleHelper", "getScheduleBlocking failed: $scheduleId", e)
null
}
}
}
/**
* Blocking: first enabled notify schedule with rolloverIntervalMinutes > 0 (canonical for rollover chain).
* Used when the firing run has schedule_id = daily_rollover_* so we can still apply the interval.
*/
@JvmStatic
fun getCanonicalRolloverScheduleBlocking(context: Context): Schedule? {
return kotlinx.coroutines.runBlocking {
try {
DailyNotificationDatabase.getDatabase(context).scheduleDao()
.getByKindAndEnabled("notify", true)
.firstOrNull { it.rolloverIntervalMinutes != null && it.rolloverIntervalMinutes > 0 }
} catch (e: Exception) {
Log.w("ScheduleHelper", "getCanonicalRolloverScheduleBlocking failed", e)
null
}
}
}
/**
* Blocking update of schedule next run time (for use from Java Worker after rollover).
*/
@JvmStatic
fun updateScheduleNextRunTimeBlocking(context: Context, scheduleId: String, lastRunAt: Long?, nextRunAt: Long) {
kotlinx.coroutines.runBlocking {
try {
DailyNotificationDatabase.getDatabase(context).scheduleDao().updateRunTimes(scheduleId, lastRunAt, nextRunAt)
} catch (e: Exception) {
Log.w("ScheduleHelper", "updateScheduleNextRunTimeBlocking failed: $scheduleId", e)
}
}
}
/**
* Schedule dual notification (fetch + notify)
*
* Orchestrates scheduling both content fetch and user notification.
*
* @param context Application context
* @param database Database instance
* @param contentFetchConfig Content fetch configuration
* @param userNotificationConfig User notification configuration
* @param scheduleFetch Function to schedule fetch (FetchWorker.scheduleFetch)
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @return true if successful, false otherwise
*/
suspend fun scheduleDualNotification(
context: Context,
database: DailyNotificationDatabase,
contentFetchConfig: ContentFetchConfig,
userNotificationConfig: UserNotificationConfig,
scheduleFetch: (Context, ContentFetchConfig) -> Unit,
calculateNextRunTime: (String) -> Long
): Boolean {
return try {
// Schedule fetch
scheduleFetch(context, contentFetchConfig)
// Schedule notification
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
val scheduleId = "notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
userNotificationConfig,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// 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
)
database.scheduleDao().upsert(fetchSchedule)
database.scheduleDao().upsert(notifySchedule)
true
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to schedule dual notification", e)
false
}
}
/**
* Schedule user notification (alarm + database)
*
* Orchestrates scheduling a user notification via NotifyReceiver and storing in database.
*
* @param context Application context
* @param database Database instance
* @param config User notification configuration
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @return Schedule ID if successful, null otherwise
*/
suspend fun scheduleUserNotification(
context: Context,
database: DailyNotificationDatabase,
config: UserNotificationConfig,
calculateNextRunTime: (String) -> Long
): String? {
return try {
val nextRunTime = calculateNextRunTime(config.schedule)
// Generate scheduleId before scheduling (needed for stable requestCode)
val scheduleId = "notify_${System.currentTimeMillis()}"
// Schedule AlarmManager notification
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
config,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
// Store schedule in database
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = config.schedule,
enabled = config.enabled,
nextRunAt = nextRunTime
)
createSchedule(database, schedule)
scheduleId
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to schedule user notification", e)
null
}
}
/**
* Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
* prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.
* Use this when handling scheduleDailyNotification so pending prefetch does not run and create
* a duplicate alarm; future fetched-content flows should use distinct tags so they are not affected.
*
* @param context Application context
* @return true if cancellation was successful
*/
suspend fun cancelFetchRelatedWorkManagerJobs(context: Context): Boolean {
return try {
val workManager = WorkManager.getInstance(context)
workManager.cancelAllWorkByTag("prefetch")
workManager.cancelAllWorkByTag("daily_notification_fetch")
true
} catch (e: Exception) {
Log.w("ScheduleHelper", "Failed to cancel fetch-related WorkManager jobs", e)
false
}
}
/**
* Cancel all WorkManager jobs by tags
*
* @param context Application context
* @return true if cancellation was successful
*/
suspend fun cancelAllWorkManagerJobs(context: Context): Boolean {
return 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")
// Note: WorkManager doesn't support wildcard cancellation, so we cancel by tag
// The unique work names will be replaced when new work is scheduled
true
} catch (e: Exception) {
Log.w("ScheduleHelper", "Failed to cancel WorkManager jobs", e)
false
}
}
/**
* Clean up existing notification schedules (cancel alarms and delete from database)
* Used to ensure "one per day" semantics for daily notifications
*
* @param context Application context
* @param database Database instance
* @param excludeScheduleId Schedule ID to exclude from cleanup (will be updated/created)
* @return Number of schedules cleaned up
*/
suspend fun cleanupExistingNotificationSchedules(
context: Context,
database: DailyNotificationDatabase,
excludeScheduleId: String? = null
): Int {
val existingSchedules = database.scheduleDao().getByKind("notify")
var cleanedCount = 0
existingSchedules.forEach { existingSchedule ->
try {
// Skip if this is the same schedule we're about to create (will be upserted anyway)
if (existingSchedule.id == excludeScheduleId) {
return@forEach
}
// Cancel the alarm in AlarmManager
NotifyReceiver.cancelNotification(context, existingSchedule.id)
// Delete from database
database.scheduleDao().deleteById(existingSchedule.id)
cleanedCount++
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to cancel/delete existing schedule: ${existingSchedule.id}", e)
// Continue with other schedules - don't fail entire operation
}
}
return cleanedCount
}
/**
* Create a new schedule
*
* @param database Database instance
* @param schedule Schedule entity to create
* @return Created schedule
*/
suspend fun createSchedule(database: DailyNotificationDatabase, schedule: Schedule): Schedule {
database.scheduleDao().upsert(schedule)
return schedule
}
/**
* Update an existing schedule
*
* @param database Database instance
* @param id Schedule ID
* @param enabled Optional enabled flag
* @param cron Optional cron expression
* @param clockTime Optional clock time
* @param jitterMs Optional jitter milliseconds
* @param backoffPolicy Optional backoff policy
* @param stateJson Optional state JSON
* @param lastRunAt Optional last run time
* @param nextRunAt Optional next run time
* @return Updated schedule
*/
suspend fun updateSchedule(
database: DailyNotificationDatabase,
id: String,
enabled: Boolean? = null,
cron: String? = null,
clockTime: String? = null,
jitterMs: Int? = null,
backoffPolicy: String? = null,
stateJson: String? = null,
lastRunAt: Long? = null,
nextRunAt: Long? = null
): Schedule {
// Check if schedule exists
val existing = database.scheduleDao().getById(id)
?: throw IllegalArgumentException("Schedule not found: $id")
// Update fields
database.scheduleDao().update(
id = id,
enabled = enabled,
cron = cron,
clockTime = clockTime,
jitterMs = jitterMs,
backoffPolicy = backoffPolicy,
stateJson = stateJson
)
// Update run times if provided
if (lastRunAt != null || nextRunAt != null) {
database.scheduleDao().updateRunTimes(id, lastRunAt, nextRunAt)
}
return database.scheduleDao().getById(id)!!
}
/**
* Delete a schedule by ID
*
* @param database Database instance
* @param id Schedule ID to delete
*/
suspend fun deleteSchedule(database: DailyNotificationDatabase, id: String) {
database.scheduleDao().deleteById(id)
}
/**
* Enable or disable a schedule
*
* @param database Database instance
* @param id Schedule ID
* @param enabled Enabled flag
*/
suspend fun enableSchedule(database: DailyNotificationDatabase, id: String, enabled: Boolean) {
database.scheduleDao().setEnabled(id, enabled)
}
}
/**
* Helper object for callback operations
* Provides functions for managing callbacks in the database
*/
object CallbackHelper {
/**
* Register a new callback
*
* @param database Database instance
* @param callback Callback entity to register
* @return Created callback
*/
suspend fun registerCallback(database: DailyNotificationDatabase, callback: Callback): Callback {
database.callbackDao().upsert(callback)
return callback
}
/**
* Get callback by ID
*
* @param database Database instance
* @param id Callback ID
* @return Callback or null if not found
*/
suspend fun getCallback(database: DailyNotificationDatabase, id: String): Callback? {
return database.callbackDao().getById(id)
}
/**
* Get all callbacks
*
* @param database Database instance
* @return List of all callbacks
*/
suspend fun getAllCallbacks(database: DailyNotificationDatabase): List<Callback> {
return database.callbackDao().getAll()
}
/**
* Get enabled callbacks
*
* @param database Database instance
* @param enabled Enabled flag
* @return List of callbacks matching enabled status
*/
suspend fun getCallbacksByEnabled(database: DailyNotificationDatabase, enabled: Boolean): List<Callback> {
return database.callbackDao().getByEnabled(enabled)
}
}
/**
* Helper object for content cache operations
* Provides functions for accessing ContentCache from the database
*/
object ContentCacheHelper {
/**
* Get the latest content cache entry
*
* This is a suspend function that queries the database for the latest
* content cache entry.
*
* @param database Database instance for querying content cache
* @return ContentCache entry or null if none exists
*/
suspend fun getLatest(database: DailyNotificationDatabase): ContentCache? {
return database.contentCacheDao().getLatest()
}
/**
* Get content cache by ID
*
* @param database Database instance for querying content cache
* @param id Content cache ID
* @return ContentCache entry or null if not found
*/
suspend fun getById(database: DailyNotificationDatabase, id: String): ContentCache? {
return database.contentCacheDao().getById(id)
}
}
/**
* Helper object for notification status operations
* Provides functions that can be called from both Java and Kotlin code
*/
object NotificationStatusHelper {
/**
* Get notification status information (schedules and history)
*
* This is a suspend function that queries the database for notification
* schedules and history, then builds a status object.
*
* @param database Database instance for querying schedules and history
* @return JSObject containing notification status
*/
suspend fun getNotificationStatus(database: DailyNotificationDatabase): JSObject {
val schedules = database.scheduleDao().getAll()
val notifySchedules = schedules.filter { it.kind == "notify" && it.enabled }
// Get last notification time from history
val history = database.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
return 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)
})
}
}
/**
* Java-compatible wrapper that uses runBlocking to call the suspend function
*
* @param database Database instance for querying schedules and history
* @return JSObject containing notification status
*/
@JvmStatic
fun getNotificationStatusBlocking(database: DailyNotificationDatabase): JSObject {
return kotlinx.coroutines.runBlocking {
getNotificationStatus(database)
}
}
}