Sync version in package.json, package-lock.json, Android/Kotlin sources, iOS Info.plist, and ios/DailyNotificationPlugin.podspec.
3181 lines
131 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|