feat(android): add exact alarm permission request flow and fix receiver mismatch

Add comprehensive exact alarm permission handling for Android 12+ (API 31+)
and fix critical bugs preventing notifications from triggering.

Features:
- Add checkExactAlarmPermission() and requestExactAlarmPermission() plugin methods
- Add canScheduleExactAlarms() and canRequestExactAlarmPermission() helper methods
- Update all scheduling methods to check/request permission before scheduling
- Use reflection for canRequestScheduleExactAlarms() to avoid compilation issues

Bug Fixes:
- Fix receiver mismatch: change alarm intents from NotifyReceiver to DailyNotificationReceiver
- Fix coroutine compilation error: wrap getLatest() suspend call in runBlocking
- Store notification content in database before scheduling alarms
- Update intent action to match manifest registration

The permission request flow opens Settings intent when SCHEDULE_EXACT_ALARM
permission is not granted, providing clear user guidance. All scheduling
methods now check permission status and request it if needed before proceeding.

Version bumped to 1.0.8
This commit is contained in:
Matthew Raymer
2025-11-10 05:51:05 +00:00
parent f31bae1563
commit 5b61f18bd7
3 changed files with 442 additions and 8 deletions

View File

@@ -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")

View File

@@ -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,