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