fix(android): improve notification scheduling and UX
- Fix cron parsing to correctly calculate next run time based on hour/minute - Always schedule prefetch 5 minutes before notification (even without URL) - Make notifications dismissable with setAutoCancel(true) - Add click action to launch app when notification is tapped - Conditionally require network only when URL is provided for prefetch - Generate mock content when no URL is specified These changes ensure notifications fire at the correct time, are user-friendly (dismissable and clickable), and prefetch works reliably even without a content URL.
This commit is contained in:
@@ -570,24 +570,31 @@ open class DailyNotificationPlugin : Plugin() {
|
|||||||
|
|
||||||
val nextRunTime = calculateNextRunTime(cronExpression)
|
val nextRunTime = calculateNextRunTime(cronExpression)
|
||||||
|
|
||||||
// Schedule AlarmManager notification
|
// Schedule AlarmManager notification as static reminder
|
||||||
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
|
// (doesn't require cached content)
|
||||||
|
val scheduleId = "daily_${System.currentTimeMillis()}"
|
||||||
|
NotifyReceiver.scheduleExactNotification(
|
||||||
|
context,
|
||||||
|
nextRunTime,
|
||||||
|
config,
|
||||||
|
isStaticReminder = true,
|
||||||
|
reminderId = scheduleId
|
||||||
|
)
|
||||||
|
|
||||||
// Schedule prefetch 5 minutes before notification (if URL provided)
|
// Always schedule prefetch 5 minutes before notification
|
||||||
if (url != null) {
|
// (URL is optional - generates mock content if not provided)
|
||||||
val fetchTime = nextRunTime - (5 * 60 * 1000L) // 5 minutes before
|
val fetchTime = nextRunTime - (5 * 60 * 1000L) // 5 minutes before
|
||||||
FetchWorker.scheduleDelayedFetch(
|
FetchWorker.scheduleDelayedFetch(
|
||||||
context,
|
context,
|
||||||
fetchTime,
|
fetchTime,
|
||||||
nextRunTime,
|
nextRunTime,
|
||||||
url
|
url // Can be null - FetchWorker will generate mock content
|
||||||
)
|
)
|
||||||
Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime")
|
Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, url=${url ?: "none (will generate mock)"}")
|
||||||
}
|
|
||||||
|
|
||||||
// Store schedule in database
|
// Store schedule in database
|
||||||
val schedule = Schedule(
|
val schedule = Schedule(
|
||||||
id = "daily_${System.currentTimeMillis()}",
|
id = scheduleId,
|
||||||
kind = "notify",
|
kind = "notify",
|
||||||
cron = cronExpression,
|
cron = cronExpression,
|
||||||
clockTime = time,
|
clockTime = time,
|
||||||
@@ -1563,9 +1570,48 @@ open class DailyNotificationPlugin : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateNextRunTime(schedule: String): Long {
|
private fun calculateNextRunTime(schedule: String): Long {
|
||||||
// Simple implementation - for production, use proper cron parsing
|
// Parse cron expression: "minute hour * * *" (daily schedule)
|
||||||
val now = System.currentTimeMillis()
|
// Example: "9 7 * * *" = 07:09 daily
|
||||||
return now + (24 * 60 * 60 * 1000L) // Next day
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -82,8 +82,16 @@ class FetchWorker(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only require network if URL is provided (mock content doesn't need network)
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.apply {
|
||||||
|
if (url != null) {
|
||||||
|
setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
} else {
|
||||||
|
// No network required for mock content generation
|
||||||
|
setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Create unique work name based on notification time to prevent duplicate fetches
|
// Create unique work name based on notification time to prevent duplicate fetches
|
||||||
@@ -129,8 +137,16 @@ class FetchWorker(
|
|||||||
notificationTime: Long,
|
notificationTime: Long,
|
||||||
url: String? = null
|
url: String? = null
|
||||||
) {
|
) {
|
||||||
|
// Only require network if URL is provided (mock content doesn't need network)
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.apply {
|
||||||
|
if (url != null) {
|
||||||
|
setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
} else {
|
||||||
|
// No network required for mock content generation
|
||||||
|
setRequiredNetworkType(NetworkType.NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
fun scheduleExactNotification(
|
fun scheduleExactNotification(
|
||||||
context: Context,
|
context: Context,
|
||||||
triggerAtMillis: Long,
|
triggerAtMillis: Long,
|
||||||
config: UserNotificationConfig
|
config: UserNotificationConfig,
|
||||||
|
isStaticReminder: Boolean = false,
|
||||||
|
reminderId: String? = null
|
||||||
) {
|
) {
|
||||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
val intent = Intent(context, NotifyReceiver::class.java).apply {
|
val intent = Intent(context, NotifyReceiver::class.java).apply {
|
||||||
@@ -41,6 +43,10 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
putExtra("sound", config.sound ?: true)
|
putExtra("sound", config.sound ?: true)
|
||||||
putExtra("vibration", config.vibration ?: true)
|
putExtra("vibration", config.vibration ?: true)
|
||||||
putExtra("priority", config.priority ?: "normal")
|
putExtra("priority", config.priority ?: "normal")
|
||||||
|
putExtra("is_static_reminder", isStaticReminder)
|
||||||
|
if (reminderId != null) {
|
||||||
|
putExtra("reminder_id", reminderId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
@@ -187,6 +193,25 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
notificationManager.createNotificationChannel(channel)
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create intent to launch app when notification is clicked
|
||||||
|
val intent = try {
|
||||||
|
Intent(context, Class.forName("com.timesafari.dailynotification.MainActivity")).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
} catch (e: ClassNotFoundException) {
|
||||||
|
Log.w(TAG, "MainActivity not found, using package launcher", e)
|
||||||
|
// Fallback: launch app by package name
|
||||||
|
context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
} ?: return
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
@@ -198,7 +223,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
else -> NotificationCompat.PRIORITY_DEFAULT
|
else -> NotificationCompat.PRIORITY_DEFAULT
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true) // Dismissible when user swipes it away
|
||||||
|
.setContentIntent(pendingIntent) // Launch app when clicked
|
||||||
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
|
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -286,6 +312,25 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
// Create notification channel for reminders
|
// Create notification channel for reminders
|
||||||
createReminderNotificationChannel(context, notificationManager)
|
createReminderNotificationChannel(context, notificationManager)
|
||||||
|
|
||||||
|
// Create intent to launch app when notification is clicked
|
||||||
|
val intent = try {
|
||||||
|
Intent(context, Class.forName("com.timesafari.dailynotification.MainActivity")).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
}
|
||||||
|
} catch (e: ClassNotFoundException) {
|
||||||
|
Log.w(TAG, "MainActivity not found, using package launcher", e)
|
||||||
|
// Fallback: launch app by package name
|
||||||
|
context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
} ?: return
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
reminderId.hashCode(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, "daily_reminders")
|
val notification = NotificationCompat.Builder(context, "daily_reminders")
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
@@ -298,7 +343,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.setSound(if (sound) null else null) // Use default sound if enabled
|
.setSound(if (sound) null else null) // Use default sound if enabled
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true) // Dismissible when user swipes it away
|
||||||
|
.setContentIntent(pendingIntent) // Launch app when clicked
|
||||||
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
|
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
|
||||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
Reference in New Issue
Block a user