diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt index 79884c3..10a6fcf 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt @@ -8,6 +8,7 @@ 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 @@ -609,8 +610,11 @@ open class DailyNotificationPlugin : Plugin() { // 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, NotifyReceiver::class.java) + 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) { @@ -695,6 +699,53 @@ open class DailyNotificationPlugin : Plugin() { // 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") @@ -751,13 +802,185 @@ open class DailyNotificationPlugin : Plugin() { } } + /** + * 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 + * + * Uses reflection to call Settings.canRequestScheduleExactAlarms() on Android 13+ + * to avoid compilation issues with newer APIs. + * + * @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+) - use reflection to call canRequestScheduleExactAlarms + try { + val method = Settings::class.java.getMethod( + "canRequestScheduleExactAlarms", + Context::class.java + ) + method.invoke(null, context) as Boolean + } catch (e: Exception) { + Log.e(TAG, "Failed to check exact alarm permission using reflection", e) + // Fallback to allowing request (safe default) + true + } + } 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) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - activity?.startActivity(intent) + 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+") @@ -920,6 +1143,55 @@ open class DailyNotificationPlugin : Plugin() { @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") @@ -1076,6 +1348,53 @@ open class DailyNotificationPlugin : Plugin() { @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) @@ -1113,6 +1432,53 @@ open class DailyNotificationPlugin : Plugin() { @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") diff --git a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt index b2ce9c0..e675656 100644 --- a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt +++ b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt @@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking /** * AlarmManager implementation for user notifications @@ -79,6 +80,9 @@ class NotifyReceiver : BroadcastReceiver() { * Uses setAlarmClock() for Android 5.0+ for better reliability * Falls back to setExactAndAllowWhileIdle for older versions * + * FIX: Uses DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver + * Stores notification content in database and passes notification ID to receiver + * * @param context Application context * @param triggerAtMillis When to trigger the notification (UTC milliseconds) * @param config Notification configuration @@ -93,7 +97,63 @@ class NotifyReceiver : BroadcastReceiver() { reminderId: String? = null ) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val intent = Intent(context, NotifyReceiver::class.java).apply { + + // Generate notification ID (use reminderId if provided, otherwise generate from trigger time) + val notificationId = reminderId ?: "notify_${triggerAtMillis}" + + // Store notification content in database before scheduling alarm + // This allows DailyNotificationReceiver to retrieve content via notification ID + // FIX: Wrap suspend function calls in coroutine + if (!isStaticReminder) { + try { + // Use runBlocking to call suspend function from non-suspend context + // This is acceptable here because we're not in a UI thread and need to ensure + // content is stored before scheduling the alarm + runBlocking { + val db = DailyNotificationDatabase.getDatabase(context) + val contentCache = db.contentCacheDao().getLatest() + + // If we have cached content, create a notification content entity + if (contentCache != null) { + val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context) + val entity = com.timesafari.dailynotification.entities.NotificationContentEntity( + notificationId, + "1.0.2", // Plugin version + null, // timesafariDid - can be set if available + "daily", + config.title, + config.body ?: String(contentCache.payload), + triggerAtMillis, + java.time.ZoneId.systemDefault().id + ) + entity.priority = when (config.priority) { + "high", "max" -> 2 + "low", "min" -> -1 + else -> 0 + } + entity.vibrationEnabled = config.vibration ?: true + entity.soundEnabled = config.sound ?: true + entity.deliveryStatus = "pending" + entity.createdAt = System.currentTimeMillis() + entity.updatedAt = System.currentTimeMillis() + entity.ttlSeconds = contentCache.ttlSeconds.toLong() + + // saveNotificationContent returns CompletableFuture, so we need to wait for it + roomStorage.saveNotificationContent(entity).get() + Log.d(TAG, "Stored notification content in database: id=$notificationId") + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e) + } + } + + // FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver + // FIX: Set action to match manifest registration + val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { + action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action + putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra + // Also preserve original extras for backward compatibility if needed putExtra("title", config.title) putExtra("body", config.body) putExtra("sound", config.sound ?: true) @@ -188,12 +248,16 @@ class NotifyReceiver : BroadcastReceiver() { /** * Cancel a scheduled notification alarm + * FIX: Uses DailyNotificationReceiver to match alarm scheduling * @param context Application context * @param triggerAtMillis The trigger time of the alarm to cancel (required for unique request code) */ fun cancelNotification(context: Context, triggerAtMillis: Long) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val intent = Intent(context, NotifyReceiver::class.java) + // FIX: Use DailyNotificationReceiver to match what was scheduled + val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { + action = "com.timesafari.daily.NOTIFICATION" + } val requestCode = getRequestCode(triggerAtMillis) val pendingIntent = PendingIntent.getBroadcast( context, @@ -207,12 +271,16 @@ class NotifyReceiver : BroadcastReceiver() { /** * Check if an alarm is scheduled for the given trigger time + * FIX: Uses DailyNotificationReceiver to match alarm scheduling * @param context Application context * @param triggerAtMillis The trigger time to check * @return true if alarm is scheduled, false otherwise */ fun isAlarmScheduled(context: Context, triggerAtMillis: Long): Boolean { - val intent = Intent(context, NotifyReceiver::class.java) + // FIX: Use DailyNotificationReceiver to match what was scheduled + val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { + action = "com.timesafari.daily.NOTIFICATION" + } val requestCode = getRequestCode(triggerAtMillis) val pendingIntent = PendingIntent.getBroadcast( context, diff --git a/package.json b/package.json index 4c45ac6..c8e2e28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@timesafari/daily-notification-plugin", - "version": "1.0.3", + "version": "1.0.8", "description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms", "main": "dist/plugin.js", "module": "dist/esm/index.js",