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

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