Fix duplicate alarm bug where updating schedule time created multiple schedules in database, violating "one notification per day" contract. Plugin Changes: - Use stable scheduleId "daily_notification" instead of timestamp-based IDs - Delete all existing notification schedules before creating new one - Cancel alarms in AlarmManager before database deletion - Add detailed logging for cleanup operations - Make scheduleDailyReminder delegate to scheduleDailyNotification Test Harness Changes: - Make TEST 2 fail when alarm count > 1 after schedule update - Make TEST 2 fail when alarm count > 1 after recovery - Add clear failure messages explaining "one per day" violation - Add final verdict section with detailed failure summary Results: - Before: 2-3 alarms, 2 schedules in DB, "Pending: 2" in UI - After: 1 alarm, 1 schedule in DB, "Pending: 1" in UI - TEST 2 now correctly passes with proper validation This ensures that updating schedule time maintains exactly one alarm per day, preventing duplicate notifications and database bloat.
2677 lines
118 KiB
Kotlin
2677 lines
118 KiB
Kotlin
package com.timesafari.dailynotification
|
|
|
|
import android.Manifest
|
|
import android.app.Activity
|
|
import android.app.AlarmManager
|
|
import android.app.PendingIntent
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.SharedPreferences
|
|
import android.content.pm.PackageManager
|
|
import android.net.Uri
|
|
import android.os.Build
|
|
import android.os.PowerManager
|
|
import android.provider.Settings
|
|
import android.util.Log
|
|
import androidx.core.app.ActivityCompat
|
|
import androidx.core.app.NotificationManagerCompat
|
|
import androidx.core.content.ContextCompat
|
|
import androidx.work.WorkManager
|
|
import androidx.work.OneTimeWorkRequestBuilder
|
|
import androidx.work.Data
|
|
import java.util.concurrent.TimeUnit
|
|
import com.timesafari.dailynotification.DailyNotificationFetchWorker
|
|
import com.getcapacitor.JSObject
|
|
import com.getcapacitor.Plugin
|
|
import com.getcapacitor.PluginCall
|
|
import com.getcapacitor.PluginMethod
|
|
import com.getcapacitor.annotation.CapacitorPlugin
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.launch
|
|
import org.json.JSONArray
|
|
import org.json.JSONObject
|
|
|
|
/**
|
|
* Main Android implementation of Daily Notification Plugin
|
|
* Bridges Capacitor calls to native Android functionality
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.1.0
|
|
*/
|
|
@CapacitorPlugin(name = "DailyNotification")
|
|
open class DailyNotificationPlugin : Plugin() {
|
|
|
|
companion object {
|
|
private const val TAG = "DNP-PLUGIN"
|
|
|
|
/**
|
|
* Static registry for native content fetcher
|
|
* Thread-safe: Volatile ensures visibility across threads
|
|
*/
|
|
@Volatile
|
|
private var nativeFetcher: NativeNotificationContentFetcher? = null
|
|
|
|
/**
|
|
* Get the registered native fetcher (called from Java code)
|
|
*
|
|
* @return Registered NativeNotificationContentFetcher or null if not registered
|
|
*/
|
|
@JvmStatic
|
|
fun getNativeFetcherStatic(): NativeNotificationContentFetcher? {
|
|
return nativeFetcher
|
|
}
|
|
|
|
/**
|
|
* Register a native content fetcher
|
|
*
|
|
* @param fetcher The native fetcher implementation to register
|
|
*/
|
|
@JvmStatic
|
|
fun registerNativeFetcher(fetcher: NativeNotificationContentFetcher?) {
|
|
nativeFetcher = fetcher
|
|
Log.i(TAG, "Native fetcher ${if (fetcher != null) "registered" else "unregistered"}")
|
|
}
|
|
|
|
/**
|
|
* Set the native content fetcher (alias for registerNativeFetcher)
|
|
*
|
|
* @param fetcher The native fetcher implementation to register
|
|
*/
|
|
@JvmStatic
|
|
fun setNativeFetcher(fetcher: NativeNotificationContentFetcher?) {
|
|
registerNativeFetcher(fetcher)
|
|
}
|
|
}
|
|
|
|
private var db: DailyNotificationDatabase? = null
|
|
|
|
private val PERMISSION_REQUEST_CODE = 1001
|
|
|
|
override fun load() {
|
|
super.load()
|
|
try {
|
|
if (context == null) {
|
|
Log.e(TAG, "Context is null, cannot initialize database")
|
|
return
|
|
}
|
|
db = DailyNotificationDatabase.getDatabase(context)
|
|
Log.i(TAG, "Daily Notification Plugin loaded successfully")
|
|
|
|
// 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 for pending permission calls
|
|
* This is a fallback since Capacitor's Bridge intercepts permission results
|
|
* and may not route them to our plugin's handleRequestPermissionsResult
|
|
*/
|
|
override fun handleOnResume() {
|
|
super.handleOnResume()
|
|
|
|
// Check if we have a pending permission call
|
|
val call = savedCall
|
|
if (call != null && context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
// Check if this is a permission request call by checking the method name
|
|
val methodName = call.methodName
|
|
if (methodName == "requestPermissions" || methodName == "requestNotificationPermissions") {
|
|
// Check current permission status
|
|
val granted = ContextCompat.checkSelfPermission(
|
|
context!!,
|
|
Manifest.permission.POST_NOTIFICATIONS
|
|
) == PackageManager.PERMISSION_GRANTED
|
|
|
|
val result = JSObject().apply {
|
|
put("status", if (granted) "granted" else "denied")
|
|
put("granted", granted)
|
|
put("notifications", if (granted) "granted" else "denied")
|
|
}
|
|
|
|
Log.i(TAG, "Resolving pending permission call on resume: granted=$granted")
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun getDatabase(): DailyNotificationDatabase {
|
|
if (db == null) {
|
|
if (context == null) {
|
|
throw IllegalStateException("Plugin not initialized: context is null")
|
|
}
|
|
db = DailyNotificationDatabase.getDatabase(context)
|
|
}
|
|
return db!!
|
|
}
|
|
|
|
@PluginMethod
|
|
fun configure(call: PluginCall) {
|
|
try {
|
|
// Capacitor passes the object directly via call.data
|
|
val options = call.data
|
|
Log.i(TAG, "Configure called with options: $options")
|
|
|
|
// Store configuration in database
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
// Implementation would store config in database
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to configure", e)
|
|
call.reject("Configuration failed: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Configure error", e)
|
|
call.reject("Configuration error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun checkPermissionStatus(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
Log.i(TAG, "Checking permission status")
|
|
|
|
var notificationsEnabled = false
|
|
var exactAlarmEnabled = false
|
|
var wakeLockEnabled = false
|
|
|
|
// Check POST_NOTIFICATIONS permission
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
notificationsEnabled = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
|
} else {
|
|
notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
|
}
|
|
|
|
// Check exact alarm permission
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
|
|
exactAlarmEnabled = alarmManager?.canScheduleExactAlarms() ?: false
|
|
} else {
|
|
exactAlarmEnabled = true // Pre-Android 12, exact alarms are always allowed
|
|
}
|
|
|
|
// Check wake lock permission (usually granted by default)
|
|
val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
|
wakeLockEnabled = powerManager != null
|
|
|
|
val allPermissionsGranted = notificationsEnabled && exactAlarmEnabled && wakeLockEnabled
|
|
|
|
val result = JSObject().apply {
|
|
put("notificationsEnabled", notificationsEnabled)
|
|
put("exactAlarmEnabled", exactAlarmEnabled)
|
|
put("wakeLockEnabled", wakeLockEnabled)
|
|
put("allPermissionsGranted", allPermissionsGranted)
|
|
}
|
|
|
|
Log.i(TAG, "Permission status: notifications=$notificationsEnabled, exactAlarm=$exactAlarmEnabled, wakeLock=$wakeLockEnabled, all=$allPermissionsGranted")
|
|
call.resolve(result)
|
|
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to check permission status", e)
|
|
call.reject("Permission check failed: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check permissions (Capacitor standard format)
|
|
* Returns PermissionStatus with notifications field as PermissionState
|
|
*/
|
|
@PluginMethod
|
|
override fun checkPermissions(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
Log.i(TAG, "Checking permissions (Capacitor format)")
|
|
|
|
var notificationsState = "denied"
|
|
var notificationsEnabled = false
|
|
|
|
// Check POST_NOTIFICATIONS permission
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
val granted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
|
notificationsState = if (granted) "granted" else "prompt"
|
|
notificationsEnabled = granted
|
|
} else {
|
|
// Pre-Android 13: check if notifications are enabled
|
|
val enabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
|
notificationsState = if (enabled) "granted" else "denied"
|
|
notificationsEnabled = enabled
|
|
}
|
|
|
|
val result = JSObject().apply {
|
|
put("status", notificationsState)
|
|
put("granted", notificationsEnabled)
|
|
put("notifications", notificationsState)
|
|
put("notificationsEnabled", notificationsEnabled)
|
|
}
|
|
|
|
Log.i(TAG, "Permissions check: notifications=$notificationsState, enabled=$notificationsEnabled")
|
|
call.resolve(result)
|
|
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to check permissions", e)
|
|
call.reject("Permission check failed: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get exact alarm status
|
|
* Returns detailed information about exact alarm scheduling capability
|
|
*/
|
|
@PluginMethod
|
|
fun getExactAlarmStatus(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
Log.i(TAG, "Getting exact alarm status")
|
|
|
|
val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
|
var enabled = false
|
|
var canSchedule = false
|
|
val fallbackWindow = "15 minutes" // Standard fallback window for inexact alarms
|
|
|
|
if (supported) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
|
|
enabled = alarmManager?.canScheduleExactAlarms() ?: false
|
|
canSchedule = enabled
|
|
} else {
|
|
// Pre-Android 12: exact alarms are always allowed
|
|
enabled = true
|
|
canSchedule = true
|
|
}
|
|
|
|
val result = JSObject().apply {
|
|
put("supported", supported)
|
|
put("enabled", enabled)
|
|
put("canSchedule", canSchedule)
|
|
put("fallbackWindow", fallbackWindow)
|
|
}
|
|
|
|
Log.i(TAG, "Exact alarm status: supported=$supported, enabled=$enabled, canSchedule=$canSchedule")
|
|
call.resolve(result)
|
|
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get exact alarm status", e)
|
|
call.reject("Exact alarm status check failed: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update starred plan IDs
|
|
* Stores plan IDs in SharedPreferences for native fetcher to use
|
|
*/
|
|
@PluginMethod
|
|
fun updateStarredPlans(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
val options = call.data ?: return call.reject("Options are required")
|
|
|
|
// Extract planIds array from options
|
|
// Capacitor passes arrays as JSONArray in JSObject
|
|
val planIdsValue = options.get("planIds")
|
|
val planIds = mutableListOf<String>()
|
|
|
|
when (planIdsValue) {
|
|
is JSONArray -> {
|
|
// Direct JSONArray
|
|
for (i in 0 until planIdsValue.length()) {
|
|
planIds.add(planIdsValue.getString(i))
|
|
}
|
|
}
|
|
is List<*> -> {
|
|
// List from JSObject conversion
|
|
planIds.addAll(planIdsValue.filterIsInstance<String>())
|
|
}
|
|
is String -> {
|
|
// Single string (unlikely but handle it)
|
|
planIds.add(planIdsValue)
|
|
}
|
|
else -> {
|
|
// Try to get as JSObject and extract array
|
|
val planIdsObj = options.getJSObject("planIds")
|
|
if (planIdsObj != null) {
|
|
val array = planIdsObj.get("planIds")
|
|
if (array is JSONArray) {
|
|
for (i in 0 until array.length()) {
|
|
planIds.add(array.getString(i))
|
|
}
|
|
}
|
|
} else {
|
|
return call.reject("planIds array is required")
|
|
}
|
|
}
|
|
}
|
|
|
|
Log.i(TAG, "Updating starred plans: count=${planIds.size}")
|
|
|
|
// Store in SharedPreferences (matching TestNativeFetcher expectations)
|
|
val prefsName = "daily_notification_timesafari"
|
|
val keyStarredPlanIds = "starredPlanIds"
|
|
|
|
val prefs: SharedPreferences = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
|
|
val editor = prefs.edit()
|
|
|
|
// Convert planIds list to JSON array string
|
|
val jsonArray = JSONArray()
|
|
planIds.forEach { planId ->
|
|
jsonArray.put(planId)
|
|
}
|
|
editor.putString(keyStarredPlanIds, jsonArray.toString())
|
|
editor.apply()
|
|
|
|
val result = JSObject().apply {
|
|
put("success", true)
|
|
put("planIdsCount", planIds.size)
|
|
put("updatedAt", System.currentTimeMillis())
|
|
}
|
|
|
|
Log.i(TAG, "Starred plans updated: count=${planIds.size}")
|
|
call.resolve(result)
|
|
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to update starred plans", e)
|
|
call.reject("Failed to update starred plans: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun requestNotificationPermissions(call: PluginCall) {
|
|
try {
|
|
val activity = activity ?: return call.reject("Activity not available")
|
|
val context = context ?: return call.reject("Context not available")
|
|
|
|
Log.i(TAG, "Requesting notification permissions")
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
// For Android 13+, request POST_NOTIFICATIONS permission
|
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
|
== PackageManager.PERMISSION_GRANTED) {
|
|
// Already granted
|
|
val result = JSObject().apply {
|
|
put("status", "granted")
|
|
put("granted", true)
|
|
put("notifications", "granted")
|
|
}
|
|
call.resolve(result)
|
|
} else {
|
|
// Save the call using Capacitor's mechanism so it can be retrieved later
|
|
saveCall(call)
|
|
|
|
// Request permission - result will be handled in handleRequestPermissionsResult
|
|
// Note: Capacitor's Bridge intercepts permission results, so we also check
|
|
// permission status when the app resumes as a fallback
|
|
ActivityCompat.requestPermissions(
|
|
activity,
|
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
PERMISSION_REQUEST_CODE
|
|
)
|
|
|
|
Log.i(TAG, "Permission dialog shown, waiting for user response (requestCode=$PERMISSION_REQUEST_CODE)")
|
|
// Don't resolve here - wait for handleRequestPermissionsResult
|
|
}
|
|
} else {
|
|
// For older versions, permissions are granted at install time
|
|
val result = JSObject().apply {
|
|
put("status", "granted")
|
|
put("granted", true)
|
|
put("notifications", "granted")
|
|
}
|
|
call.resolve(result)
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to request notification permissions", e)
|
|
call.reject("Permission request failed: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request permissions (alias for requestNotificationPermissions)
|
|
* Delegates to requestNotificationPermissions for consistency
|
|
*/
|
|
@PluginMethod
|
|
override fun requestPermissions(call: PluginCall) {
|
|
requestNotificationPermissions(call)
|
|
}
|
|
|
|
/**
|
|
* Handle permission request results
|
|
* Called by Capacitor when user responds to permission dialog
|
|
*/
|
|
override fun handleRequestPermissionsResult(
|
|
requestCode: Int,
|
|
permissions: Array<out String>,
|
|
grantResults: IntArray
|
|
) {
|
|
Log.i(TAG, "handleRequestPermissionsResult called: requestCode=$requestCode, permissions=${permissions.contentToString()}")
|
|
|
|
if (requestCode == PERMISSION_REQUEST_CODE) {
|
|
// Retrieve the saved call
|
|
val call = savedCall
|
|
if (call != null) {
|
|
val granted = grantResults.isNotEmpty() &&
|
|
grantResults[0] == PackageManager.PERMISSION_GRANTED
|
|
|
|
val result = JSObject().apply {
|
|
put("status", if (granted) "granted" else "denied")
|
|
put("granted", granted)
|
|
put("notifications", if (granted) "granted" else "denied")
|
|
}
|
|
|
|
Log.i(TAG, "Permission request result: granted=$granted, resolving call")
|
|
call.resolve(result)
|
|
return
|
|
} else {
|
|
Log.w(TAG, "No saved call found for permission request code $requestCode")
|
|
}
|
|
}
|
|
|
|
// Not handled by this plugin, let parent handle it
|
|
super.handleRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
}
|
|
|
|
@PluginMethod
|
|
fun configureNativeFetcher(call: PluginCall) {
|
|
try {
|
|
// Capacitor passes the object directly via call.data
|
|
val options = call.data ?: return call.reject("Options are required")
|
|
|
|
// Support both jwtToken and jwtSecret for backward compatibility
|
|
val apiBaseUrl = options.getString("apiBaseUrl") ?: return call.reject("apiBaseUrl is required")
|
|
val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required")
|
|
val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required")
|
|
|
|
val nativeFetcher = getNativeFetcherStatic()
|
|
if (nativeFetcher == null) {
|
|
return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.")
|
|
}
|
|
|
|
Log.i(TAG, "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid")
|
|
|
|
// Call the native fetcher's configure method FIRST
|
|
// This configures the fetcher instance with API credentials for background operations
|
|
var configureSuccess = false
|
|
var configureError: Exception? = null
|
|
|
|
try {
|
|
Log.d(TAG, "FETCHER|CONFIGURE_START apiBaseUrl=$apiBaseUrl, activeDid=${activeDid.take(30)}...")
|
|
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken)
|
|
configureSuccess = true
|
|
Log.i(TAG, "FETCHER|CONFIGURE_COMPLETE success=true")
|
|
} catch (e: Exception) {
|
|
configureError = e
|
|
Log.e(TAG, "FETCHER|CONFIGURE_COMPLETE success=false error=${e.message}", e)
|
|
// Continue to store empty config entry - don't fail the entire operation
|
|
}
|
|
|
|
// Store configuration in database for persistence across app restarts
|
|
// If configure() failed, store a valid but empty entry that won't cause errors
|
|
val configId = "native_fetcher_config"
|
|
val configValue = if (configureSuccess) {
|
|
// Store actual configuration values
|
|
JSONObject().apply {
|
|
put("apiBaseUrl", apiBaseUrl)
|
|
put("activeDid", activeDid)
|
|
put("jwtToken", jwtToken)
|
|
}.toString()
|
|
} else {
|
|
// Store valid but empty entry to prevent errors in code that reads this config
|
|
JSONObject().apply {
|
|
put("apiBaseUrl", "")
|
|
put("activeDid", "")
|
|
put("jwtToken", "")
|
|
put("configureError", configureError?.message ?: "Unknown error")
|
|
}.toString()
|
|
}
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val config = com.timesafari.dailynotification.entities.NotificationConfigEntity(
|
|
configId, null, "native_fetcher", "config", configValue, "json"
|
|
)
|
|
getDatabase().notificationConfigDao().insertConfig(config)
|
|
|
|
if (configureSuccess) {
|
|
call.resolve()
|
|
} else {
|
|
// Configure failed but we stored a valid entry - reject with error details
|
|
call.reject("Native fetcher configure() failed: ${configureError?.message}")
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to store native fetcher config", e)
|
|
call.reject("Failed to store configuration: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Configure native fetcher error", e)
|
|
call.reject("Configuration error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getNotificationStatus(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val schedules = getDatabase().scheduleDao().getAll()
|
|
val notifySchedules = schedules.filter { it.kind == "notify" && it.enabled }
|
|
|
|
// Get last notification time from history
|
|
val history = getDatabase().historyDao().getRecent(100) // Get last 100 entries
|
|
val lastNotification = history
|
|
.filter { it.kind == "notify" && it.outcome == "success" }
|
|
.maxByOrNull { it.occurredAt }
|
|
val lastNotificationTime = lastNotification?.occurredAt ?: 0
|
|
|
|
val result = JSObject().apply {
|
|
put("isEnabled", notifySchedules.isNotEmpty())
|
|
put("isScheduled", notifySchedules.isNotEmpty())
|
|
put("lastNotificationTime", lastNotificationTime)
|
|
put("nextNotificationTime", notifySchedules.minOfOrNull { it.nextRunAt ?: Long.MAX_VALUE } ?: 0)
|
|
put("scheduledCount", notifySchedules.size)
|
|
put("pending", notifySchedules.size) // Alias for scheduledCount
|
|
put("settings", JSObject().apply {
|
|
put("enabled", notifySchedules.isNotEmpty())
|
|
put("count", notifySchedules.size)
|
|
})
|
|
}
|
|
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get notification status", e)
|
|
call.reject("Failed to get notification status: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel all scheduled notifications
|
|
*
|
|
* This method:
|
|
* 1. Cancels all AlarmManager alarms (both exact and inexact)
|
|
* 2. Cancels all WorkManager prefetch jobs
|
|
* 3. Clears notification schedules from database
|
|
* 4. Updates plugin state to reflect cancellation
|
|
*
|
|
* The method is idempotent - safe to call multiple times even if nothing is scheduled.
|
|
*/
|
|
@PluginMethod
|
|
fun cancelAllNotifications(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
if (context == null) {
|
|
return@launch call.reject("Context not available")
|
|
}
|
|
|
|
Log.i(TAG, "Cancelling all notifications")
|
|
|
|
// 1. Get all scheduled notifications from database
|
|
val schedules = getDatabase().scheduleDao().getAll()
|
|
val notifySchedules = schedules.filter { it.kind == "notify" && it.enabled }
|
|
|
|
// 2. Cancel all AlarmManager alarms
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
|
|
if (alarmManager != null) {
|
|
var cancelledAlarms = 0
|
|
notifySchedules.forEach { schedule ->
|
|
try {
|
|
// Cancel alarm using the scheduled time (used for request code)
|
|
val nextRunAt = schedule.nextRunAt
|
|
if (nextRunAt != null && nextRunAt > 0) {
|
|
NotifyReceiver.cancelNotification(context, scheduleId = schedule.id, triggerAtMillis = nextRunAt)
|
|
cancelledAlarms++
|
|
}
|
|
} catch (e: Exception) {
|
|
// Log but don't fail - alarm might not exist
|
|
Log.w(TAG, "Failed to cancel alarm for schedule ${schedule.id}", e)
|
|
}
|
|
}
|
|
|
|
// Also try to cancel any alarms that might not be in database
|
|
// Cancel by attempting to cancel with a generic intent
|
|
// FIX: Use DailyNotificationReceiver to match alarm scheduling
|
|
try {
|
|
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
|
action = "com.timesafari.daily.NOTIFICATION"
|
|
}
|
|
// Try cancelling with common request codes (0-65535)
|
|
// This is a fallback for any orphaned alarms
|
|
for (requestCode in 0..100 step 10) {
|
|
try {
|
|
val pendingIntent = PendingIntent.getBroadcast(
|
|
context,
|
|
requestCode,
|
|
intent,
|
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
|
)
|
|
if (pendingIntent != null) {
|
|
alarmManager.cancel(pendingIntent)
|
|
pendingIntent.cancel()
|
|
}
|
|
} catch (e: Exception) {
|
|
// Ignore - this is a best-effort cleanup
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Error during fallback alarm cancellation", e)
|
|
}
|
|
|
|
Log.i(TAG, "Cancelled $cancelledAlarms alarm(s)")
|
|
} else {
|
|
Log.w(TAG, "AlarmManager not available")
|
|
}
|
|
|
|
// 3. Cancel all WorkManager jobs
|
|
try {
|
|
val workManager = WorkManager.getInstance(context)
|
|
|
|
// Cancel all prefetch jobs
|
|
workManager.cancelAllWorkByTag("prefetch")
|
|
|
|
// Cancel fetch jobs (if using DailyNotificationFetcher tags)
|
|
workManager.cancelAllWorkByTag("daily_notification_fetch")
|
|
workManager.cancelAllWorkByTag("daily_notification_maintenance")
|
|
workManager.cancelAllWorkByTag("soft_refetch")
|
|
workManager.cancelAllWorkByTag("daily_notification_display")
|
|
workManager.cancelAllWorkByTag("daily_notification_dismiss")
|
|
|
|
// Cancel unique work by name pattern (prefetch_*)
|
|
// Note: WorkManager doesn't support wildcard cancellation, so we cancel by tag
|
|
// The unique work names will be replaced when new work is scheduled
|
|
|
|
Log.i(TAG, "Cancelled all WorkManager jobs")
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Failed to cancel WorkManager jobs", e)
|
|
// Don't fail - continue with database cleanup
|
|
}
|
|
|
|
// 4. Clear database state - disable all notification schedules
|
|
try {
|
|
notifySchedules.forEach { schedule ->
|
|
getDatabase().scheduleDao().setEnabled(schedule.id, false)
|
|
}
|
|
|
|
// Also clear any fetch schedules
|
|
val fetchSchedules = schedules.filter { it.kind == "fetch" && it.enabled }
|
|
fetchSchedules.forEach { schedule ->
|
|
getDatabase().scheduleDao().setEnabled(schedule.id, false)
|
|
}
|
|
|
|
Log.i(TAG, "Disabled ${notifySchedules.size} notification schedule(s) and ${fetchSchedules.size} fetch schedule(s)")
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to clear database state", e)
|
|
// Continue - alarms and jobs are already cancelled
|
|
}
|
|
|
|
Log.i(TAG, "All notifications cancelled successfully")
|
|
call.resolve()
|
|
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to cancel all notifications", e)
|
|
call.reject("Failed to cancel notifications: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun scheduleDailyReminder(call: PluginCall) {
|
|
// Alias for scheduleDailyNotification for backward compatibility
|
|
// This ensures both method names work the same way
|
|
scheduleDailyNotification(call)
|
|
}
|
|
|
|
/**
|
|
* Check if exact alarms can be scheduled
|
|
* Helper method for internal use
|
|
*
|
|
* @param context Application context
|
|
* @return true if exact alarms can be scheduled, false otherwise
|
|
*/
|
|
private fun canScheduleExactAlarms(context: Context): Boolean {
|
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
|
|
alarmManager?.canScheduleExactAlarms() ?: false
|
|
} else {
|
|
// Pre-Android 12: exact alarms are always allowed
|
|
true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if exact alarm permission can be requested
|
|
* Helper method that handles API level differences
|
|
*
|
|
* On Android 12 (API 31-32): Permission can always be requested
|
|
* On Android 13+ (API 33+): Uses reflection to call Settings.canRequestScheduleExactAlarms()
|
|
* If reflection fails, falls back to heuristic: if exact alarms are not currently allowed,
|
|
* we assume they can be requested (safe default).
|
|
*
|
|
* @param context Application context
|
|
* @return true if permission can be requested, false if permanently denied
|
|
*/
|
|
private fun canRequestExactAlarmPermission(context: Context): Boolean {
|
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
// Android 12+ (API 31+)
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
// Android 13+ (API 33+) - try reflection to call canRequestScheduleExactAlarms
|
|
try {
|
|
// Try getMethod first (for public static methods)
|
|
val method = Settings::class.java.getMethod(
|
|
"canRequestScheduleExactAlarms",
|
|
Context::class.java
|
|
)
|
|
val result = method.invoke(null, context) as Boolean
|
|
Log.d(TAG, "canRequestScheduleExactAlarms() returned: $result")
|
|
return result
|
|
} catch (e: NoSuchMethodException) {
|
|
// Method not found - try getDeclaredMethod as fallback
|
|
try {
|
|
val method = Settings::class.java.getDeclaredMethod(
|
|
"canRequestScheduleExactAlarms",
|
|
Context::class.java
|
|
)
|
|
method.isAccessible = true
|
|
val result = method.invoke(null, context) as Boolean
|
|
Log.d(TAG, "canRequestScheduleExactAlarms() (via getDeclaredMethod) returned: $result")
|
|
return result
|
|
} catch (e2: Exception) {
|
|
Log.w(TAG, "Failed to check exact alarm permission using reflection, using heuristic", e2)
|
|
// Fallback heuristic: if exact alarms are not currently allowed,
|
|
// assume we can request them (safe default)
|
|
// Only case where we can't request is if permanently denied, which is rare
|
|
return !canScheduleExactAlarms(context)
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.w(TAG, "Failed to invoke canRequestScheduleExactAlarms(), using heuristic", e)
|
|
// Fallback heuristic: if exact alarms are not currently allowed,
|
|
// assume we can request them (safe default)
|
|
return !canScheduleExactAlarms(context)
|
|
}
|
|
} else {
|
|
// Android 12 (API 31-32) - permission can always be requested
|
|
// (user hasn't permanently denied it yet)
|
|
true
|
|
}
|
|
} else {
|
|
// Android 11 and below - permission not needed
|
|
true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check exact alarm permission status
|
|
* Returns detailed information about permission status and whether it can be requested
|
|
*
|
|
* @param call Plugin call with no parameters
|
|
*/
|
|
@PluginMethod
|
|
fun checkExactAlarmPermission(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
val canSchedule = canScheduleExactAlarms(context)
|
|
val canRequest = canRequestExactAlarmPermission(context)
|
|
|
|
val result = JSObject().apply {
|
|
put("canSchedule", canSchedule)
|
|
put("canRequest", canRequest)
|
|
put("required", Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
|
}
|
|
|
|
Log.i(TAG, "Exact alarm permission check: canSchedule=$canSchedule, canRequest=$canRequest")
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to check exact alarm permission", e)
|
|
call.reject("Permission check failed: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request exact alarm permission
|
|
* Opens Settings intent to let user grant the permission
|
|
*
|
|
* On Android 12+ (API 31+), SCHEDULE_EXACT_ALARM is a special permission that
|
|
* cannot be requested through the normal permission request flow. Users must
|
|
* grant it manually in Settings.
|
|
*
|
|
* @param call Plugin call with no parameters
|
|
*/
|
|
@PluginMethod
|
|
fun requestExactAlarmPermission(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
|
// Android 11 and below don't need this permission
|
|
Log.i(TAG, "Exact alarm permission not required on Android ${Build.VERSION.SDK_INT}")
|
|
call.resolve(JSObject().apply {
|
|
put("success", true)
|
|
put("message", "Exact alarm permission not required on this Android version")
|
|
})
|
|
return
|
|
}
|
|
|
|
if (canScheduleExactAlarms(context)) {
|
|
// Permission already granted
|
|
Log.i(TAG, "Exact alarm permission already granted")
|
|
call.resolve(JSObject().apply {
|
|
put("success", true)
|
|
put("message", "Exact alarm permission already granted")
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if app can request the permission
|
|
if (canRequestExactAlarmPermission(context)) {
|
|
// Open Settings to let user grant permission
|
|
try {
|
|
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.i(TAG, "Opened exact alarm permission settings")
|
|
call.resolve(JSObject().apply {
|
|
put("success", true)
|
|
put("message", "Please grant 'Alarms & reminders' permission in Settings")
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open exact alarm settings", e)
|
|
call.reject("Failed to open exact alarm settings: ${e.message}")
|
|
}
|
|
} else {
|
|
// User has already denied or permission is permanently denied
|
|
// Direct user to app settings
|
|
try {
|
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.w(TAG, "Permission denied. Directing user to app settings")
|
|
call.reject(
|
|
"Permission denied. Please enable 'Alarms & reminders' in app settings.",
|
|
"PERMISSION_DENIED"
|
|
)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open app settings", e)
|
|
call.reject("Failed to open app settings: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to request exact alarm permission", e)
|
|
call.reject("Permission request failed: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open exact alarm settings (legacy method, kept for backward compatibility)
|
|
* Use requestExactAlarmPermission() for better error handling
|
|
*
|
|
* @param call Plugin call with no parameters
|
|
*/
|
|
@PluginMethod
|
|
fun openExactAlarmSettings(call: PluginCall) {
|
|
try {
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
val intent = Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
|
data = android.net.Uri.parse("package:${context?.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
activity?.startActivity(intent) ?: context?.startActivity(intent)
|
|
call.resolve()
|
|
} else {
|
|
call.reject("Exact alarm settings are only available on Android 12+")
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open exact alarm settings", e)
|
|
call.reject("Failed to open exact alarm settings: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun isChannelEnabled(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
// Use the actual channel ID that matches what's used in notifications
|
|
val channelId = call.getString("channelId") ?: "timesafari.daily"
|
|
|
|
// Check app-level notifications first
|
|
val appNotificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
|
|
|
// Get notification channel importance if available
|
|
var importance = 0
|
|
var channelEnabled = false
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager
|
|
var channel = notificationManager?.getNotificationChannel(channelId)
|
|
|
|
if (channel == null) {
|
|
// Channel doesn't exist - create it first (same as ChannelManager does)
|
|
Log.i(TAG, "Channel $channelId doesn't exist, creating it")
|
|
val newChannel = android.app.NotificationChannel(
|
|
channelId,
|
|
"Daily Notifications",
|
|
android.app.NotificationManager.IMPORTANCE_HIGH
|
|
).apply {
|
|
description = "Daily notifications from TimeSafari"
|
|
enableLights(true)
|
|
enableVibration(true)
|
|
setShowBadge(true)
|
|
}
|
|
notificationManager?.createNotificationChannel(newChannel)
|
|
Log.i(TAG, "Channel $channelId created with HIGH importance")
|
|
|
|
// Re-fetch the channel from the system to get actual state
|
|
// (in case it was previously blocked by user)
|
|
channel = notificationManager?.getNotificationChannel(channelId)
|
|
}
|
|
|
|
// Now check the channel (re-fetched from system to get actual state)
|
|
if (channel != null) {
|
|
importance = channel.importance
|
|
// Channel is enabled if importance is not IMPORTANCE_NONE
|
|
// IMPORTANCE_NONE = 0 means blocked/disabled
|
|
channelEnabled = importance != android.app.NotificationManager.IMPORTANCE_NONE
|
|
Log.d(TAG, "Channel $channelId status: importance=$importance, enabled=$channelEnabled")
|
|
} else {
|
|
// Channel still doesn't exist after creation attempt - should not happen
|
|
Log.w(TAG, "Channel $channelId still doesn't exist after creation attempt")
|
|
importance = android.app.NotificationManager.IMPORTANCE_NONE
|
|
channelEnabled = false
|
|
}
|
|
} else {
|
|
// Pre-Oreo: channels don't exist, use app-level check
|
|
channelEnabled = appNotificationsEnabled
|
|
importance = android.app.NotificationManager.IMPORTANCE_DEFAULT
|
|
}
|
|
|
|
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)
|
|
put("channelId", channelId)
|
|
put("importance", importance)
|
|
put("appNotificationsEnabled", appNotificationsEnabled)
|
|
put("channelBlocked", importance == android.app.NotificationManager.IMPORTANCE_NONE)
|
|
}
|
|
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")
|
|
}
|
|
|
|
// Use the actual channel ID that matches what's used in notifications
|
|
val channelId = call.getString("channelId") ?: "timesafari.daily"
|
|
|
|
// Ensure channel exists before trying to open settings
|
|
// This ensures the channel-specific settings page can be opened
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? android.app.NotificationManager
|
|
val channel = notificationManager?.getNotificationChannel(channelId)
|
|
|
|
if (channel == null) {
|
|
// Channel doesn't exist - create it first
|
|
Log.i(TAG, "Channel $channelId doesn't exist, creating it")
|
|
val newChannel = android.app.NotificationChannel(
|
|
channelId,
|
|
"Daily Notifications",
|
|
android.app.NotificationManager.IMPORTANCE_HIGH
|
|
).apply {
|
|
description = "Daily notifications from TimeSafari"
|
|
enableLights(true)
|
|
enableVibration(true)
|
|
setShowBadge(true)
|
|
}
|
|
notificationManager?.createNotificationChannel(newChannel)
|
|
Log.i(TAG, "Channel $channelId created")
|
|
}
|
|
}
|
|
|
|
// Try to open channel-specific settings first
|
|
try {
|
|
val intent = Intent(android.provider.Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
|
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
|
|
putExtra(android.provider.Settings.EXTRA_CHANNEL_ID, channelId)
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
|
|
activity?.startActivity(intent) ?: context.startActivity(intent)
|
|
Log.i(TAG, "Channel settings opened for channel: $channelId")
|
|
|
|
val result = JSObject().apply {
|
|
put("opened", true)
|
|
put("channelId", channelId)
|
|
}
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
// Fallback to general app notification settings if channel-specific fails
|
|
Log.w(TAG, "Failed to open channel-specific settings, trying app notification settings", e)
|
|
try {
|
|
val fallbackIntent = Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
|
putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
activity?.startActivity(fallbackIntent) ?: context.startActivity(fallbackIntent)
|
|
Log.i(TAG, "App notification settings opened (fallback)")
|
|
|
|
val result = JSObject().apply {
|
|
put("opened", true)
|
|
put("channelId", channelId)
|
|
put("fallback", true)
|
|
put("message", "Opened app notification settings (channel-specific unavailable)")
|
|
}
|
|
call.resolve(result)
|
|
} catch (e2: Exception) {
|
|
Log.e(TAG, "Failed to open notification settings", e2)
|
|
val result = JSObject().apply {
|
|
put("opened", false)
|
|
put("channelId", channelId)
|
|
put("error", e2.message)
|
|
}
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open channel settings", e)
|
|
call.reject("Failed to open channel settings: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun checkStatus(call: PluginCall) {
|
|
// Comprehensive status check
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
var postNotificationsGranted = false
|
|
var channelEnabled = false
|
|
var exactAlarmsGranted = false
|
|
var channelImportance = 0
|
|
val channelId = "daily_notification_channel"
|
|
|
|
// Check POST_NOTIFICATIONS permission
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
|
} else {
|
|
postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
|
}
|
|
|
|
// Check exact alarms permission
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
exactAlarmsGranted = alarmManager.canScheduleExactAlarms()
|
|
} else {
|
|
exactAlarmsGranted = true // Always available on older Android versions
|
|
}
|
|
|
|
// Check channel status
|
|
channelEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager?
|
|
val channel = notificationManager?.getNotificationChannel(channelId)
|
|
channelImportance = channel?.importance ?: android.app.NotificationManager.IMPORTANCE_DEFAULT
|
|
channelEnabled = channel?.importance != android.app.NotificationManager.IMPORTANCE_NONE
|
|
}
|
|
|
|
val canScheduleNow = postNotificationsGranted && channelEnabled && exactAlarmsGranted
|
|
|
|
val result = JSObject().apply {
|
|
put("canScheduleNow", canScheduleNow)
|
|
put("postNotificationsGranted", postNotificationsGranted)
|
|
put("channelEnabled", channelEnabled)
|
|
put("exactAlarmsGranted", exactAlarmsGranted)
|
|
put("channelImportance", channelImportance)
|
|
put("channelId", channelId)
|
|
}
|
|
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to check status", e)
|
|
call.reject("Failed to check status: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun scheduleContentFetch(call: PluginCall) {
|
|
try {
|
|
val configJson = call.getObject("config")
|
|
val config = parseContentFetchConfig(configJson)
|
|
|
|
Log.i(TAG, "Scheduling content fetch")
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
// Schedule WorkManager fetch
|
|
FetchWorker.scheduleFetch(context, config)
|
|
|
|
// Store schedule in database
|
|
val schedule = Schedule(
|
|
id = "fetch_${System.currentTimeMillis()}",
|
|
kind = "fetch",
|
|
cron = config.schedule,
|
|
enabled = config.enabled,
|
|
nextRunAt = calculateNextRunTime(config.schedule)
|
|
)
|
|
getDatabase().scheduleDao().upsert(schedule)
|
|
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to schedule content fetch", e)
|
|
call.reject("Content fetch scheduling failed: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Schedule content fetch error", e)
|
|
call.reject("Content fetch error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun scheduleDailyNotification(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
// Check if exact alarms can be scheduled
|
|
if (!canScheduleExactAlarms(context)) {
|
|
// Permission not granted - request it
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
|
|
// Open Settings to let user grant permission
|
|
try {
|
|
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
|
|
call.reject(
|
|
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
|
|
"EXACT_ALARM_PERMISSION_REQUIRED"
|
|
)
|
|
return
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open exact alarm settings", e)
|
|
call.reject("Failed to open exact alarm settings: ${e.message}")
|
|
return
|
|
}
|
|
} else {
|
|
// Permission permanently denied - direct to app settings
|
|
try {
|
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
|
|
call.reject(
|
|
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
|
|
"PERMISSION_DENIED"
|
|
)
|
|
return
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open app settings", e)
|
|
call.reject("Failed to open app settings: ${e.message}")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Permission granted - proceed with exact alarm scheduling
|
|
// Capacitor passes the object directly via call.data
|
|
val options = call.data ?: return call.reject("Options are required")
|
|
|
|
val time = options.getString("time") ?: return call.reject("Time is required")
|
|
val title = options.getString("title") ?: "Daily Notification"
|
|
val body = options.getString("body") ?: ""
|
|
val sound = options.getBoolean("sound") ?: true
|
|
val priority = options.getString("priority") ?: "default"
|
|
val url = options.getString("url") // Optional URL for prefetch
|
|
|
|
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title")
|
|
|
|
// Convert HH:mm time to cron expression (daily at specified time)
|
|
val cronExpression = convertTimeToCron(time)
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
// 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
|
|
// This cleanup runs regardless of whether user provided an ID or not
|
|
val existingSchedules = getDatabase().scheduleDao().getByKind("notify")
|
|
Log.i(TAG, "scheduleDailyNotification: Found ${existingSchedules.size} existing notification schedule(s) in database")
|
|
|
|
if (existingSchedules.isNotEmpty()) {
|
|
Log.i(TAG, "scheduleDailyNotification: Existing schedule IDs: ${existingSchedules.map { it.id }.joinToString(", ")}")
|
|
}
|
|
|
|
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 == scheduleId) {
|
|
Log.i(TAG, "scheduleDailyNotification: Skipping cleanup of schedule with same ID (will be updated): ${existingSchedule.id}")
|
|
return@forEach
|
|
}
|
|
|
|
Log.i(TAG, "scheduleDailyNotification: Cleaning up existing schedule: id=${existingSchedule.id}, nextRunAt=${existingSchedule.nextRunAt}, enabled=${existingSchedule.enabled}")
|
|
|
|
// Cancel the alarm in AlarmManager
|
|
NotifyReceiver.cancelNotification(context, existingSchedule.id)
|
|
Log.i(TAG, "scheduleDailyNotification: Cancelled alarm for schedule: ${existingSchedule.id}")
|
|
|
|
// Delete from database
|
|
getDatabase().scheduleDao().deleteById(existingSchedule.id)
|
|
Log.i(TAG, "scheduleDailyNotification: Deleted schedule from database: ${existingSchedule.id}")
|
|
cleanedCount++
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "scheduleDailyNotification: Failed to cancel/delete existing schedule: ${existingSchedule.id}", e)
|
|
// Continue with other schedules - don't fail entire operation
|
|
}
|
|
}
|
|
|
|
if (cleanedCount > 0) {
|
|
Log.i(TAG, "scheduleDailyNotification: ✅ Cleaned up $cleanedCount existing notification schedule(s) before creating new one (total found: ${existingSchedules.size})")
|
|
} else if (existingSchedules.isNotEmpty()) {
|
|
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
|
|
} else {
|
|
Log.i(TAG, "scheduleDailyNotification: No existing schedules found - creating first notification schedule")
|
|
}
|
|
|
|
val config = UserNotificationConfig(
|
|
enabled = true,
|
|
schedule = cronExpression,
|
|
title = title,
|
|
body = body,
|
|
sound = sound,
|
|
vibration = true,
|
|
priority = priority
|
|
)
|
|
|
|
val nextRunTime = calculateNextRunTime(cronExpression)
|
|
|
|
// Schedule AlarmManager notification as static reminder
|
|
// (doesn't require cached content)
|
|
NotifyReceiver.scheduleExactNotification(
|
|
context,
|
|
nextRunTime,
|
|
config,
|
|
isStaticReminder = true,
|
|
reminderId = scheduleId,
|
|
scheduleId = scheduleId,
|
|
source = ScheduleSource.INITIAL_SETUP
|
|
)
|
|
|
|
// Always schedule prefetch 2 minutes before notification
|
|
// (URL is optional - native fetcher will be used if registered)
|
|
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before
|
|
val delayMs = fetchTime - System.currentTimeMillis()
|
|
|
|
if (delayMs > 0) {
|
|
// Schedule delayed prefetch
|
|
val inputData = Data.Builder()
|
|
.putLong("scheduled_time", nextRunTime)
|
|
.putLong("fetch_time", fetchTime)
|
|
.putInt("retry_count", 0)
|
|
.putBoolean("immediate", false)
|
|
.build()
|
|
|
|
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
|
|
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
|
|
.setInputData(inputData)
|
|
.addTag("prefetch")
|
|
.build()
|
|
|
|
WorkManager.getInstance(context).enqueue(workRequest)
|
|
|
|
Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs, using native fetcher")
|
|
} else {
|
|
// Fetch time is in the past, schedule immediate fetch
|
|
val inputData = Data.Builder()
|
|
.putLong("scheduled_time", nextRunTime)
|
|
.putLong("fetch_time", System.currentTimeMillis())
|
|
.putInt("retry_count", 0)
|
|
.putBoolean("immediate", true)
|
|
.build()
|
|
|
|
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
|
|
.setInputData(inputData)
|
|
.addTag("prefetch")
|
|
.build()
|
|
|
|
WorkManager.getInstance(context).enqueue(workRequest)
|
|
|
|
Log.i(TAG, "Immediate prefetch scheduled: notificationTime=$nextRunTime, using native fetcher")
|
|
}
|
|
|
|
// Store schedule in database
|
|
val schedule = Schedule(
|
|
id = scheduleId,
|
|
kind = "notify",
|
|
cron = cronExpression,
|
|
clockTime = time,
|
|
enabled = true,
|
|
nextRunAt = nextRunTime
|
|
)
|
|
getDatabase().scheduleDao().upsert(schedule)
|
|
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to schedule daily notification", e)
|
|
call.reject("Daily notification scheduling failed: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Schedule daily notification error", e)
|
|
call.reject("Daily notification error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if an alarm is scheduled for a given trigger time
|
|
*/
|
|
@PluginMethod
|
|
fun isAlarmScheduled(call: PluginCall) {
|
|
try {
|
|
val options = call.data ?: return call.reject("Options are required")
|
|
val triggerAtMillis = options.getLong("triggerAtMillis") ?: return call.reject("triggerAtMillis is required")
|
|
|
|
val context = context ?: return call.reject("Context not available")
|
|
val isScheduled = NotifyReceiver.isAlarmScheduled(context, triggerAtMillis = triggerAtMillis)
|
|
|
|
val result = JSObject().apply {
|
|
put("scheduled", isScheduled)
|
|
put("triggerAtMillis", triggerAtMillis)
|
|
}
|
|
|
|
Log.i(TAG, "Checking alarm status: scheduled=$isScheduled, triggerAt=$triggerAtMillis")
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to check alarm status", e)
|
|
call.reject("Failed to check alarm status: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the next scheduled alarm time from AlarmManager
|
|
*/
|
|
@PluginMethod
|
|
fun getNextAlarmTime(call: PluginCall) {
|
|
try {
|
|
val context = context ?: return call.reject("Context not available")
|
|
val nextAlarmTime = NotifyReceiver.getNextAlarmTime(context)
|
|
|
|
val result = JSObject().apply {
|
|
if (nextAlarmTime != null) {
|
|
put("scheduled", true)
|
|
put("triggerAtMillis", nextAlarmTime)
|
|
} else {
|
|
put("scheduled", false)
|
|
}
|
|
}
|
|
|
|
Log.i(TAG, "Getting next alarm time: ${if (nextAlarmTime != null) nextAlarmTime else "none"}")
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get next alarm time", e)
|
|
call.reject("Failed to get next alarm time: ${e.message}")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test method: Schedule an alarm to fire in a few seconds
|
|
* Useful for verifying alarm delivery works correctly
|
|
*/
|
|
@PluginMethod
|
|
fun testAlarm(call: PluginCall) {
|
|
try {
|
|
val options = call.data
|
|
val secondsFromNow = options?.getInt("secondsFromNow") ?: 5
|
|
|
|
val context = context ?: return call.reject("Context not available")
|
|
|
|
Log.i(TAG, "TEST: Scheduling test alarm in $secondsFromNow seconds")
|
|
NotifyReceiver.testAlarm(context, secondsFromNow)
|
|
|
|
val result = JSObject().apply {
|
|
put("scheduled", true)
|
|
put("secondsFromNow", secondsFromNow)
|
|
put("triggerAtMillis", System.currentTimeMillis() + (secondsFromNow * 1000L))
|
|
}
|
|
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to schedule test alarm", e)
|
|
call.reject("Failed to schedule test alarm: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun scheduleUserNotification(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
// Check if exact alarms can be scheduled
|
|
if (!canScheduleExactAlarms(context)) {
|
|
// Permission not granted - request it
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
|
|
try {
|
|
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
|
|
call.reject(
|
|
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
|
|
"EXACT_ALARM_PERMISSION_REQUIRED"
|
|
)
|
|
return
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open exact alarm settings", e)
|
|
call.reject("Failed to open exact alarm settings: ${e.message}")
|
|
return
|
|
}
|
|
} else {
|
|
try {
|
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
|
|
call.reject(
|
|
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
|
|
"PERMISSION_DENIED"
|
|
)
|
|
return
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open app settings", e)
|
|
call.reject("Failed to open app settings: ${e.message}")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Permission granted - proceed with scheduling
|
|
val configJson = call.getObject("config")
|
|
val config = parseUserNotificationConfig(configJson)
|
|
|
|
Log.i(TAG, "Scheduling user notification")
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val nextRunTime = calculateNextRunTime(config.schedule)
|
|
|
|
// 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
|
|
)
|
|
getDatabase().scheduleDao().upsert(schedule)
|
|
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to schedule user notification", e)
|
|
call.reject("User notification scheduling failed: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Schedule user notification error", e)
|
|
call.reject("User notification error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun scheduleDualNotification(call: PluginCall) {
|
|
try {
|
|
if (context == null) {
|
|
return call.reject("Context not available")
|
|
}
|
|
|
|
// Check if exact alarms can be scheduled
|
|
if (!canScheduleExactAlarms(context)) {
|
|
// Permission not granted - request it
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
|
|
try {
|
|
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
|
|
call.reject(
|
|
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
|
|
"EXACT_ALARM_PERMISSION_REQUIRED"
|
|
)
|
|
return
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open exact alarm settings", e)
|
|
call.reject("Failed to open exact alarm settings: ${e.message}")
|
|
return
|
|
}
|
|
} else {
|
|
try {
|
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
data = android.net.Uri.parse("package:${context.packageName}")
|
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
}
|
|
context.startActivity(intent)
|
|
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
|
|
call.reject(
|
|
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
|
|
"PERMISSION_DENIED"
|
|
)
|
|
return
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to open app settings", e)
|
|
call.reject("Failed to open app settings: ${e.message}")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Permission granted - proceed with scheduling
|
|
val configJson = call.getObject("config") ?: return call.reject("Config is required")
|
|
val contentFetchObj = configJson.getJSObject("contentFetch") ?: return call.reject("contentFetch config is required")
|
|
val userNotificationObj = configJson.getJSObject("userNotification") ?: return call.reject("userNotification config is required")
|
|
val contentFetchConfig = parseContentFetchConfig(contentFetchObj)
|
|
val userNotificationConfig = parseUserNotificationConfig(userNotificationObj)
|
|
|
|
Log.i(TAG, "Scheduling dual notification")
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
// Schedule both fetch and notification
|
|
FetchWorker.scheduleFetch(context, contentFetchConfig)
|
|
|
|
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
|
|
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
|
|
)
|
|
|
|
getDatabase().scheduleDao().upsert(fetchSchedule)
|
|
getDatabase().scheduleDao().upsert(notifySchedule)
|
|
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to schedule dual notification", e)
|
|
call.reject("Dual notification scheduling failed: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Schedule dual notification error", e)
|
|
call.reject("Dual notification error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getDualScheduleStatus(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val enabledSchedules = getDatabase().scheduleDao().getEnabled()
|
|
val latestCache = getDatabase().contentCacheDao().getLatest()
|
|
val recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
|
|
|
|
val status = JSObject().apply {
|
|
put("nextRuns", enabledSchedules.map { it.nextRunAt })
|
|
put("lastOutcomes", recentHistory.map { it.outcome })
|
|
put("cacheAgeMs", latestCache?.let { System.currentTimeMillis() - it.fetchedAt })
|
|
put("staleArmed", latestCache?.let {
|
|
System.currentTimeMillis() > (it.fetchedAt + it.ttlSeconds * 1000L)
|
|
} ?: true)
|
|
put("queueDepth", recentHistory.size)
|
|
}
|
|
|
|
call.resolve(status)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get dual schedule status", e)
|
|
call.reject("Status retrieval failed: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun registerCallback(call: PluginCall) {
|
|
try {
|
|
val name = call.getString("name") ?: return call.reject("Callback name is required")
|
|
val callback = call.getObject("callback") ?: return call.reject("Callback data is required")
|
|
|
|
Log.i(TAG, "Registering callback: $name")
|
|
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val callbackRecord = Callback(
|
|
id = name,
|
|
kind = callback.getString("kind") ?: "local",
|
|
target = callback.getString("target") ?: "",
|
|
headersJson = callback.getString("headers"),
|
|
enabled = true,
|
|
createdAt = System.currentTimeMillis()
|
|
)
|
|
|
|
getDatabase().callbackDao().upsert(callbackRecord)
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to register callback", e)
|
|
call.reject("Callback registration failed: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Register callback error", e)
|
|
call.reject("Callback registration error: ${e.message}")
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getContentCache(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val latestCache = getDatabase().contentCacheDao().getLatest()
|
|
val result = JSObject()
|
|
|
|
if (latestCache != null) {
|
|
result.put("id", latestCache.id)
|
|
result.put("fetchedAt", latestCache.fetchedAt)
|
|
result.put("ttlSeconds", latestCache.ttlSeconds)
|
|
result.put("payload", String(latestCache.payload))
|
|
result.put("meta", latestCache.meta)
|
|
}
|
|
|
|
call.resolve(result)
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get content cache", e)
|
|
call.reject("Content cache retrieval failed: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// DATABASE ACCESS METHODS
|
|
// ============================================================================
|
|
// These methods provide TypeScript/JavaScript access to the plugin's internal
|
|
// SQLite database. All operations run on background threads for thread safety.
|
|
// ============================================================================
|
|
|
|
// SCHEDULES MANAGEMENT
|
|
|
|
@PluginMethod
|
|
fun getSchedules(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val options = call.getObject("options")
|
|
val kind = options?.getString("kind")
|
|
val enabled = options?.getBoolean("enabled")
|
|
|
|
val schedules = when {
|
|
kind != null && enabled != null ->
|
|
getDatabase().scheduleDao().getByKindAndEnabled(kind, enabled)
|
|
kind != null ->
|
|
getDatabase().scheduleDao().getByKind(kind)
|
|
enabled != null ->
|
|
if (enabled) getDatabase().scheduleDao().getEnabled() else getDatabase().scheduleDao().getAll().filter { !it.enabled }
|
|
else ->
|
|
getDatabase().scheduleDao().getAll()
|
|
}
|
|
|
|
// Return array wrapped in JSObject - Capacitor will serialize correctly
|
|
val schedulesArray = org.json.JSONArray()
|
|
schedules.forEach { schedulesArray.put(scheduleToJson(it)) }
|
|
|
|
call.resolve(JSObject().apply {
|
|
put("schedules", schedulesArray)
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get schedules", e)
|
|
call.reject("Failed to get schedules: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getSchedule(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Schedule ID is required")
|
|
|
|
val schedule = getDatabase().scheduleDao().getById(id)
|
|
|
|
if (schedule != null) {
|
|
call.resolve(scheduleToJson(schedule))
|
|
} else {
|
|
call.resolve(JSObject().apply { put("schedule", null) })
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get schedule", e)
|
|
call.reject("Failed to get schedule: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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")
|
|
|
|
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()
|
|
}
|
|
|
|
// For each schedule, check if it's actually scheduled in AlarmManager
|
|
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)
|
|
}
|
|
|
|
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")
|
|
)
|
|
|
|
getDatabase().scheduleDao().upsert(schedule)
|
|
call.resolve(scheduleToJson(schedule))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to create schedule", e)
|
|
call.reject("Failed to create schedule: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun updateSchedule(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Schedule ID is required")
|
|
|
|
val updates = call.getObject("updates")
|
|
?: return@launch call.reject("Updates are required")
|
|
|
|
val existing = getDatabase().scheduleDao().getById(id)
|
|
?: return@launch call.reject("Schedule not found: $id")
|
|
|
|
// Update fields
|
|
getDatabase().scheduleDao().update(
|
|
id = id,
|
|
enabled = updates.getBoolean("enabled")?.let { it },
|
|
cron = updates.getString("cron"),
|
|
clockTime = updates.getString("clockTime"),
|
|
jitterMs = updates.getInt("jitterMs")?.let { it },
|
|
backoffPolicy = updates.getString("backoffPolicy"),
|
|
stateJson = updates.getString("stateJson")
|
|
)
|
|
|
|
// Update run times if provided
|
|
val lastRunAt = updates.getLong("lastRunAt")
|
|
val nextRunAt = updates.getLong("nextRunAt")
|
|
if (lastRunAt != null || nextRunAt != null) {
|
|
getDatabase().scheduleDao().updateRunTimes(id, lastRunAt, nextRunAt)
|
|
}
|
|
|
|
val updated = getDatabase().scheduleDao().getById(id)
|
|
call.resolve(scheduleToJson(updated!!))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to update schedule", e)
|
|
call.reject("Failed to update schedule: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun deleteSchedule(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Schedule ID is required")
|
|
|
|
getDatabase().scheduleDao().deleteById(id)
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to delete schedule", e)
|
|
call.reject("Failed to delete schedule: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun enableSchedule(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Schedule ID is required")
|
|
|
|
val enabled = call.getBoolean("enabled") ?: true
|
|
|
|
getDatabase().scheduleDao().setEnabled(id, enabled)
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to enable/disable schedule", e)
|
|
call.reject("Failed to update schedule: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun calculateNextRunTime(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val schedule = call.getString("schedule")
|
|
?: return@launch call.reject("Schedule expression is required")
|
|
|
|
val nextRun = calculateNextRunTime(schedule)
|
|
|
|
call.resolve(JSObject().apply {
|
|
put("nextRunAt", nextRun)
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to calculate next run time", e)
|
|
call.reject("Failed to calculate next run time: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// CONTENT CACHE MANAGEMENT
|
|
|
|
@PluginMethod
|
|
fun getContentCacheById(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val options = call.getObject("options")
|
|
val id = options?.getString("id")
|
|
|
|
val cache = if (id != null) {
|
|
getDatabase().contentCacheDao().getById(id)
|
|
} else {
|
|
getDatabase().contentCacheDao().getLatest()
|
|
}
|
|
|
|
if (cache != null) {
|
|
call.resolve(contentCacheToJson(cache))
|
|
} else {
|
|
call.resolve(JSObject().apply { put("contentCache", null) })
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get content cache", e)
|
|
call.reject("Failed to get content cache: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getLatestContentCache(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val cache = getDatabase().contentCacheDao().getLatest()
|
|
|
|
if (cache != null) {
|
|
call.resolve(contentCacheToJson(cache))
|
|
} else {
|
|
call.resolve(JSObject().apply { put("contentCache", null) })
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get latest content cache", e)
|
|
call.reject("Failed to get latest content cache: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getContentCacheHistory(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val limit = call.getInt("limit") ?: 10
|
|
|
|
val history = getDatabase().contentCacheDao().getHistory(limit)
|
|
|
|
val historyArray = org.json.JSONArray()
|
|
history.forEach { historyArray.put(contentCacheToJson(it)) }
|
|
|
|
call.resolve(JSObject().apply {
|
|
put("history", historyArray)
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get content cache history", e)
|
|
call.reject("Failed to get content cache history: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun saveContentCache(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val contentJson = call.getObject("content")
|
|
?: return@launch call.reject("Content data is required")
|
|
|
|
val id = contentJson.getString("id") ?: "cache_${System.currentTimeMillis()}"
|
|
val payload = contentJson.getString("payload")
|
|
?: return@launch call.reject("Payload is required")
|
|
val ttlSeconds = contentJson.getInt("ttlSeconds")
|
|
?: return@launch call.reject("TTL seconds is required")
|
|
|
|
val cache = ContentCache(
|
|
id = id,
|
|
fetchedAt = System.currentTimeMillis(),
|
|
ttlSeconds = ttlSeconds,
|
|
payload = payload.toByteArray(),
|
|
meta = contentJson.getString("meta")
|
|
)
|
|
|
|
getDatabase().contentCacheDao().upsert(cache)
|
|
call.resolve(contentCacheToJson(cache))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to save content cache", e)
|
|
call.reject("Failed to save content cache: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun clearContentCacheEntries(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val options = call.getObject("options")
|
|
val olderThan = options?.getLong("olderThan")
|
|
|
|
if (olderThan != null) {
|
|
getDatabase().contentCacheDao().deleteOlderThan(olderThan)
|
|
} else {
|
|
getDatabase().contentCacheDao().deleteAll()
|
|
}
|
|
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to clear content cache", e)
|
|
call.reject("Failed to clear content cache: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// CALLBACKS MANAGEMENT
|
|
|
|
@PluginMethod
|
|
fun getCallbacks(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val options = call.getObject("options")
|
|
val enabled = options?.getBoolean("enabled")
|
|
|
|
val callbacks = if (enabled != null) {
|
|
getDatabase().callbackDao().getByEnabled(enabled)
|
|
} else {
|
|
getDatabase().callbackDao().getAll()
|
|
}
|
|
|
|
val callbacksArray = org.json.JSONArray()
|
|
callbacks.forEach { callbacksArray.put(callbackToJson(it)) }
|
|
|
|
call.resolve(JSObject().apply {
|
|
put("callbacks", callbacksArray)
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get callbacks", e)
|
|
call.reject("Failed to get callbacks: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getCallback(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Callback ID is required")
|
|
|
|
val callback = getDatabase().callbackDao().getById(id)
|
|
|
|
if (callback != null) {
|
|
call.resolve(callbackToJson(callback))
|
|
} else {
|
|
call.resolve(JSObject().apply { put("callback", null) })
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get callback", e)
|
|
call.reject("Failed to get callback: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun registerCallbackConfig(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val callbackJson = call.getObject("callback")
|
|
?: return@launch call.reject("Callback data is required")
|
|
|
|
val id = callbackJson.getString("id")
|
|
?: return@launch call.reject("Callback ID is required")
|
|
val kindStr = callbackJson.getString("kind")
|
|
?: return@launch call.reject("Callback kind is required")
|
|
val targetStr = callbackJson.getString("target")
|
|
?: return@launch call.reject("Callback target is required")
|
|
|
|
val callback = Callback(
|
|
id = id,
|
|
kind = kindStr,
|
|
target = targetStr,
|
|
headersJson = callbackJson.getString("headersJson"),
|
|
enabled = callbackJson.getBoolean("enabled") ?: true,
|
|
createdAt = System.currentTimeMillis()
|
|
)
|
|
|
|
getDatabase().callbackDao().upsert(callback)
|
|
call.resolve(callbackToJson(callback))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to register callback", e)
|
|
call.reject("Failed to register callback: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun updateCallback(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Callback ID is required")
|
|
|
|
val updates = call.getObject("updates")
|
|
?: return@launch call.reject("Updates are required")
|
|
|
|
getDatabase().callbackDao().update(
|
|
id = id,
|
|
kind = updates.getString("kind"),
|
|
target = updates.getString("target"),
|
|
headersJson = updates.getString("headersJson"),
|
|
enabled = updates.getBoolean("enabled")?.let { it }
|
|
)
|
|
|
|
val updated = getDatabase().callbackDao().getById(id)
|
|
if (updated != null) {
|
|
call.resolve(callbackToJson(updated))
|
|
} else {
|
|
call.reject("Callback not found after update")
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to update callback", e)
|
|
call.reject("Failed to update callback: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun deleteCallback(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Callback ID is required")
|
|
|
|
getDatabase().callbackDao().deleteById(id)
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to delete callback", e)
|
|
call.reject("Failed to delete callback: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun enableCallback(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val id = call.getString("id")
|
|
?: return@launch call.reject("Callback ID is required")
|
|
|
|
val enabled = call.getBoolean("enabled") ?: true
|
|
|
|
getDatabase().callbackDao().setEnabled(id, enabled)
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to enable/disable callback", e)
|
|
call.reject("Failed to update callback: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// HISTORY MANAGEMENT
|
|
|
|
@PluginMethod
|
|
fun getHistory(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val options = call.getObject("options")
|
|
val since = options?.getLong("since")
|
|
val kind = options?.getString("kind")
|
|
val limit = options?.getInt("limit") ?: 50
|
|
|
|
val history = when {
|
|
since != null && kind != null ->
|
|
getDatabase().historyDao().getSinceByKind(since, kind, limit)
|
|
since != null ->
|
|
getDatabase().historyDao().getSince(since).take(limit)
|
|
else ->
|
|
getDatabase().historyDao().getRecent(limit)
|
|
}
|
|
|
|
val historyArray = org.json.JSONArray()
|
|
history.forEach { historyArray.put(historyToJson(it)) }
|
|
|
|
call.resolve(JSObject().apply {
|
|
put("history", historyArray)
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get history", e)
|
|
call.reject("Failed to get history: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getHistoryStats(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val allHistory = getDatabase().historyDao().getRecent(Int.MAX_VALUE)
|
|
|
|
val outcomes = mutableMapOf<String, Int>()
|
|
val kinds = mutableMapOf<String, Int>()
|
|
var mostRecent: Long? = null
|
|
var oldest: Long? = null
|
|
|
|
allHistory.forEach { entry ->
|
|
outcomes[entry.outcome] = (outcomes[entry.outcome] ?: 0) + 1
|
|
kinds[entry.kind] = (kinds[entry.kind] ?: 0) + 1
|
|
|
|
if (mostRecent == null || entry.occurredAt > mostRecent!!) {
|
|
mostRecent = entry.occurredAt
|
|
}
|
|
if (oldest == null || entry.occurredAt < oldest!!) {
|
|
oldest = entry.occurredAt
|
|
}
|
|
}
|
|
|
|
call.resolve(JSObject().apply {
|
|
put("totalCount", allHistory.size)
|
|
put("outcomes", JSObject().apply {
|
|
outcomes.forEach { (k, v) -> put(k, v) }
|
|
})
|
|
put("kinds", JSObject().apply {
|
|
kinds.forEach { (k, v) -> put(k, v) }
|
|
})
|
|
put("mostRecent", mostRecent)
|
|
put("oldest", oldest)
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get history stats", e)
|
|
call.reject("Failed to get history stats: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// CONFIGURATION MANAGEMENT
|
|
|
|
@PluginMethod
|
|
fun getConfig(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val key = call.getString("key")
|
|
?: return@launch call.reject("Config key is required")
|
|
|
|
val options = call.getObject("options")
|
|
val timesafariDid = options?.getString("timesafariDid")
|
|
|
|
val entity = if (timesafariDid != null) {
|
|
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
|
|
} else {
|
|
getDatabase().notificationConfigDao().getConfigByKey(key)
|
|
}
|
|
|
|
if (entity != null) {
|
|
call.resolve(configToJson(entity))
|
|
} else {
|
|
call.resolve(JSObject().apply { put("config", null) })
|
|
}
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get config", e)
|
|
call.reject("Failed to get config: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun getAllConfigs(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val options = call.getObject("options")
|
|
val timesafariDid = options?.getString("timesafariDid")
|
|
val configType = options?.getString("configType")
|
|
|
|
val configs = when {
|
|
timesafariDid != null && configType != null -> {
|
|
getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid)
|
|
.filter { it.configType == configType }
|
|
}
|
|
timesafariDid != null -> {
|
|
getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid)
|
|
}
|
|
configType != null -> {
|
|
getDatabase().notificationConfigDao().getConfigsByType(configType)
|
|
}
|
|
else -> {
|
|
getDatabase().notificationConfigDao().getAllConfigs()
|
|
}
|
|
}
|
|
|
|
val configsArray = org.json.JSONArray()
|
|
configs.forEach { configsArray.put(configToJson(it)) }
|
|
|
|
call.resolve(JSObject().apply {
|
|
put("configs", configsArray)
|
|
})
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to get all configs", e)
|
|
call.reject("Failed to get configs: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun setConfig(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val configJson = call.getObject("config")
|
|
?: return@launch call.reject("Config data is required")
|
|
|
|
val id = configJson.getString("id") ?: "config_${System.currentTimeMillis()}"
|
|
val timesafariDid = configJson.getString("timesafariDid")
|
|
val configType = configJson.getString("configType")
|
|
?: return@launch call.reject("Config type is required")
|
|
val configKey = configJson.getString("configKey")
|
|
?: return@launch call.reject("Config key is required")
|
|
val configValue = configJson.getString("configValue")
|
|
?: return@launch call.reject("Config value is required")
|
|
val configDataType = configJson.getString("configDataType", "string")
|
|
|
|
val entity = com.timesafari.dailynotification.entities.NotificationConfigEntity(
|
|
id, timesafariDid, configType, configKey, configValue, configDataType
|
|
)
|
|
|
|
// Set optional fields
|
|
configJson.getString("metadata")?.let { entity.metadata = it }
|
|
configJson.getBoolean("isEncrypted", false)?.let {
|
|
entity.isEncrypted = it
|
|
configJson.getString("encryptionKeyId")?.let { entity.encryptionKeyId = it }
|
|
}
|
|
configJson.getLong("ttlSeconds")?.let { entity.ttlSeconds = it }
|
|
configJson.getBoolean("isActive", true)?.let { entity.isActive = it }
|
|
|
|
getDatabase().notificationConfigDao().insertConfig(entity)
|
|
call.resolve(configToJson(entity))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to set config", e)
|
|
call.reject("Failed to set config: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun updateConfig(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val key = call.getString("key")
|
|
?: return@launch call.reject("Config key is required")
|
|
val value = call.getString("value")
|
|
?: return@launch call.reject("Config value is required")
|
|
|
|
val options = call.getObject("options")
|
|
val timesafariDid = options?.getString("timesafariDid")
|
|
|
|
val entity = if (timesafariDid != null) {
|
|
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
|
|
} else {
|
|
getDatabase().notificationConfigDao().getConfigByKey(key)
|
|
}
|
|
|
|
if (entity == null) {
|
|
return@launch call.reject("Config not found")
|
|
}
|
|
|
|
entity.updateValue(value)
|
|
getDatabase().notificationConfigDao().updateConfig(entity)
|
|
call.resolve(configToJson(entity))
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to update config", e)
|
|
call.reject("Failed to update config: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
@PluginMethod
|
|
fun deleteConfig(call: PluginCall) {
|
|
CoroutineScope(Dispatchers.IO).launch {
|
|
try {
|
|
val key = call.getString("key")
|
|
?: return@launch call.reject("Config key is required")
|
|
|
|
val options = call.getObject("options")
|
|
val timesafariDid = options?.getString("timesafariDid")
|
|
|
|
val entity = if (timesafariDid != null) {
|
|
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
|
|
} else {
|
|
getDatabase().notificationConfigDao().getConfigByKey(key)
|
|
}
|
|
|
|
if (entity == null) {
|
|
return@launch call.reject("Config not found")
|
|
}
|
|
|
|
getDatabase().notificationConfigDao().deleteConfig(entity.id)
|
|
call.resolve()
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to delete config", e)
|
|
call.reject("Failed to delete config: ${e.message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper methods to convert entities to JSON
|
|
private fun scheduleToJson(schedule: Schedule): JSObject {
|
|
return JSObject().apply {
|
|
put("id", schedule.id)
|
|
put("kind", schedule.kind)
|
|
put("cron", schedule.cron)
|
|
put("clockTime", schedule.clockTime)
|
|
put("enabled", schedule.enabled)
|
|
put("lastRunAt", schedule.lastRunAt)
|
|
put("nextRunAt", schedule.nextRunAt)
|
|
put("jitterMs", schedule.jitterMs)
|
|
put("backoffPolicy", schedule.backoffPolicy)
|
|
put("stateJson", schedule.stateJson)
|
|
}
|
|
}
|
|
|
|
private fun contentCacheToJson(cache: ContentCache): JSObject {
|
|
return JSObject().apply {
|
|
put("id", cache.id)
|
|
put("fetchedAt", cache.fetchedAt)
|
|
put("ttlSeconds", cache.ttlSeconds)
|
|
put("payload", String(cache.payload))
|
|
put("meta", cache.meta)
|
|
}
|
|
}
|
|
|
|
private fun callbackToJson(callback: Callback): JSObject {
|
|
return JSObject().apply {
|
|
put("id", callback.id)
|
|
put("kind", callback.kind)
|
|
put("target", callback.target)
|
|
put("headersJson", callback.headersJson)
|
|
put("enabled", callback.enabled)
|
|
put("createdAt", callback.createdAt)
|
|
}
|
|
}
|
|
|
|
private fun historyToJson(history: History): JSObject {
|
|
return JSObject().apply {
|
|
put("id", history.id)
|
|
put("refId", history.refId)
|
|
put("kind", history.kind)
|
|
put("occurredAt", history.occurredAt)
|
|
put("durationMs", history.durationMs)
|
|
put("outcome", history.outcome)
|
|
put("diagJson", history.diagJson)
|
|
}
|
|
}
|
|
|
|
private fun configToJson(config: com.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
|
|
return JSObject().apply {
|
|
put("id", config.id)
|
|
put("timesafariDid", config.timesafariDid)
|
|
put("configType", config.configType)
|
|
put("configKey", config.configKey)
|
|
put("configValue", config.configValue)
|
|
put("configDataType", config.configDataType)
|
|
put("isEncrypted", config.isEncrypted)
|
|
put("encryptionKeyId", config.encryptionKeyId)
|
|
put("createdAt", config.createdAt)
|
|
put("updatedAt", config.updatedAt)
|
|
put("ttlSeconds", config.ttlSeconds)
|
|
put("isActive", config.isActive)
|
|
put("metadata", config.metadata)
|
|
}
|
|
}
|
|
|
|
// Helper methods
|
|
private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
|
|
val callbacksObj = configJson.getJSObject("callbacks")
|
|
return ContentFetchConfig(
|
|
enabled = configJson.getBoolean("enabled") ?: true,
|
|
schedule = configJson.getString("schedule") ?: "0 9 * * *",
|
|
url = configJson.getString("url"),
|
|
timeout = configJson.getInt("timeout"),
|
|
retryAttempts = configJson.getInt("retryAttempts"),
|
|
retryDelay = configJson.getInt("retryDelay"),
|
|
callbacks = CallbackConfig(
|
|
apiService = callbacksObj?.getString("apiService"),
|
|
database = callbacksObj?.getString("database"),
|
|
reporting = callbacksObj?.getString("reporting")
|
|
)
|
|
)
|
|
}
|
|
|
|
private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig {
|
|
return UserNotificationConfig(
|
|
enabled = configJson.getBoolean("enabled") ?: true,
|
|
schedule = configJson.getString("schedule") ?: "0 9 * * *",
|
|
title = configJson.getString("title"),
|
|
body = configJson.getString("body"),
|
|
sound = configJson.getBoolean("sound"),
|
|
vibration = configJson.getBoolean("vibration"),
|
|
priority = configJson.getString("priority")
|
|
)
|
|
}
|
|
|
|
private fun calculateNextRunTime(schedule: String): Long {
|
|
// Parse cron expression: "minute hour * * *" (daily schedule)
|
|
// Example: "9 7 * * *" = 07:09 daily
|
|
try {
|
|
val parts = schedule.trim().split("\\s+".toRegex())
|
|
if (parts.size < 2) {
|
|
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
|
|
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
|
}
|
|
|
|
val minute = parts[0].toIntOrNull() ?: 0
|
|
val hour = parts[1].toIntOrNull() ?: 9
|
|
|
|
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
|
|
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
|
|
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
|
}
|
|
|
|
// Calculate next occurrence of this time
|
|
val calendar = java.util.Calendar.getInstance()
|
|
val now = calendar.timeInMillis
|
|
|
|
// Set to today at the specified time
|
|
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
|
|
calendar.set(java.util.Calendar.MINUTE, minute)
|
|
calendar.set(java.util.Calendar.SECOND, 0)
|
|
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
|
|
|
var nextRun = calendar.timeInMillis
|
|
|
|
// If the time has already passed today, schedule for tomorrow
|
|
if (nextRun <= now) {
|
|
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
|
nextRun = calendar.timeInMillis
|
|
}
|
|
|
|
Log.d(TAG, "Calculated next run time: cron=$schedule, nextRun=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(nextRun))}")
|
|
return nextRun
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Error calculating next run time for schedule: $schedule", e)
|
|
// Fallback: 24 hours from now
|
|
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert HH:mm time string to cron expression (daily at specified time)
|
|
* Example: "09:30" -> "30 9 * * *"
|
|
*/
|
|
private fun convertTimeToCron(time: String): String {
|
|
try {
|
|
val parts = time.split(":")
|
|
if (parts.size != 2) {
|
|
throw IllegalArgumentException("Invalid time format: $time. Expected HH:mm")
|
|
}
|
|
val hour = parts[0].toInt()
|
|
val minute = parts[1].toInt()
|
|
|
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
throw IllegalArgumentException("Invalid time values: hour=$hour, minute=$minute")
|
|
}
|
|
|
|
// Cron format: minute hour day month day-of-week
|
|
// Daily at specified time: "minute hour * * *"
|
|
return "$minute $hour * * *"
|
|
} catch (e: Exception) {
|
|
Log.e(TAG, "Failed to convert time to cron: $time", e)
|
|
// Default to 9:00 AM if conversion fails
|
|
return "0 9 * * *"
|
|
}
|
|
}
|
|
}
|