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:
Matthew Raymer
2025-11-06 07:52:40 +00:00
parent 18106e5ba8
commit 9f8e295234
3 changed files with 130 additions and 22 deletions

View File

@@ -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)
}
}
/**

View File

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

View File

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