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)
|
||||
|
||||
// Schedule AlarmManager notification
|
||||
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
|
||||
// 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
|
||||
)
|
||||
|
||||
// Schedule prefetch 5 minutes before notification (if URL provided)
|
||||
if (url != null) {
|
||||
val fetchTime = nextRunTime - (5 * 60 * 1000L) // 5 minutes before
|
||||
FetchWorker.scheduleDelayedFetch(
|
||||
context,
|
||||
fetchTime,
|
||||
nextRunTime,
|
||||
url
|
||||
)
|
||||
Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime")
|
||||
}
|
||||
// Always schedule prefetch 5 minutes before notification
|
||||
// (URL is optional - generates mock content if not provided)
|
||||
val fetchTime = nextRunTime - (5 * 60 * 1000L) // 5 minutes before
|
||||
FetchWorker.scheduleDelayedFetch(
|
||||
context,
|
||||
fetchTime,
|
||||
nextRunTime,
|
||||
url // Can be null - FetchWorker will generate mock content
|
||||
)
|
||||
Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, url=${url ?: "none (will generate mock)"}")
|
||||
|
||||
// Store schedule in database
|
||||
val schedule = Schedule(
|
||||
id = "daily_${System.currentTimeMillis()}",
|
||||
id = scheduleId,
|
||||
kind = "notify",
|
||||
cron = cronExpression,
|
||||
clockTime = time,
|
||||
@@ -1563,9 +1570,48 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
|
||||
private fun calculateNextRunTime(schedule: String): Long {
|
||||
// Simple implementation - for production, use proper cron parsing
|
||||
val now = System.currentTimeMillis()
|
||||
return now + (24 * 60 * 60 * 1000L) // Next day
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -82,8 +82,16 @@ class FetchWorker(
|
||||
return
|
||||
}
|
||||
|
||||
// Only require network if URL is provided (mock content doesn't need network)
|
||||
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()
|
||||
|
||||
// Create unique work name based on notification time to prevent duplicate fetches
|
||||
@@ -129,8 +137,16 @@ class FetchWorker(
|
||||
notificationTime: Long,
|
||||
url: String? = null
|
||||
) {
|
||||
// Only require network if URL is provided (mock content doesn't need network)
|
||||
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()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||
|
||||
@@ -32,7 +32,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
fun scheduleExactNotification(
|
||||
context: Context,
|
||||
triggerAtMillis: Long,
|
||||
config: UserNotificationConfig
|
||||
config: UserNotificationConfig,
|
||||
isStaticReminder: Boolean = false,
|
||||
reminderId: String? = null
|
||||
) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = Intent(context, NotifyReceiver::class.java).apply {
|
||||
@@ -41,6 +43,10 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
putExtra("sound", config.sound ?: true)
|
||||
putExtra("vibration", config.vibration ?: true)
|
||||
putExtra("priority", config.priority ?: "normal")
|
||||
putExtra("is_static_reminder", isStaticReminder)
|
||||
if (reminderId != null) {
|
||||
putExtra("reminder_id", reminderId)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
@@ -187,6 +193,25 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
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)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
@@ -198,7 +223,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
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)
|
||||
.build()
|
||||
|
||||
@@ -286,6 +312,25 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
// Create notification channel for reminders
|
||||
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")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
@@ -298,7 +343,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
)
|
||||
.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)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
Reference in New Issue
Block a user