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() 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()) } 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, 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() .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() .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() val kinds = mutableMapOf() 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 * * *" } } }