Static reminders scheduled via scheduleDailyNotification() with isStaticReminder=true were being skipped because they don't have content in storage - title/body are in Intent extras. Fixed by: - DailyNotificationReceiver: Extract static reminder extras from Intent and pass to WorkManager as input data - DailyNotificationWorker: Check for static reminder flag in input data and create NotificationContent from input data instead of loading from storage - DailyNotificationWorker: Ensure notification channel exists before displaying (fixes "No Channel found" errors) Also updated prefetch timing from 5 minutes to 2 minutes before notification time in plugin code and web UI.
485 lines
20 KiB
Java
485 lines
20 KiB
Java
/**
|
|
* DailyNotificationReceiver.java
|
|
*
|
|
* Broadcast receiver for handling scheduled notification alarms
|
|
* Displays notifications when scheduled time is reached
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
package com.timesafari.dailynotification;
|
|
|
|
import android.app.NotificationManager;
|
|
import android.app.PendingIntent;
|
|
import android.content.BroadcastReceiver;
|
|
import android.content.Context;
|
|
import android.content.Intent;
|
|
import android.os.Build;
|
|
import android.os.Trace;
|
|
import android.util.Log;
|
|
|
|
import androidx.core.app.NotificationCompat;
|
|
import androidx.work.Data;
|
|
import androidx.work.ExistingWorkPolicy;
|
|
import androidx.work.OneTimeWorkRequest;
|
|
import androidx.work.WorkManager;
|
|
|
|
/**
|
|
* Broadcast receiver for daily notification alarms
|
|
*
|
|
* This receiver is triggered by AlarmManager when it's time to display
|
|
* a notification. It retrieves the notification content from storage
|
|
* and displays it to the user.
|
|
*/
|
|
public class DailyNotificationReceiver extends BroadcastReceiver {
|
|
|
|
private static final String TAG = "DailyNotificationReceiver";
|
|
private static final String CHANNEL_ID = "timesafari.daily";
|
|
private static final String EXTRA_NOTIFICATION_ID = "notification_id";
|
|
|
|
/**
|
|
* Handle broadcast intent when alarm triggers
|
|
*
|
|
* Ultra-lightweight receiver that only parses intent and enqueues work.
|
|
* All heavy operations (storage, JSON, scheduling) are moved to WorkManager.
|
|
*
|
|
* @param context Application context
|
|
* @param intent Broadcast intent
|
|
*/
|
|
@Override
|
|
public void onReceive(Context context, Intent intent) {
|
|
Trace.beginSection("DN:onReceive");
|
|
try {
|
|
Log.d(TAG, "DN|RECEIVE_START action=" + intent.getAction());
|
|
|
|
String action = intent.getAction();
|
|
if (action == null) {
|
|
Log.w(TAG, "DN|RECEIVE_ERR null_action");
|
|
return;
|
|
}
|
|
|
|
if ("com.timesafari.daily.NOTIFICATION".equals(action)) {
|
|
// Parse intent and enqueue work - keep receiver ultra-light
|
|
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
|
if (notificationId == null) {
|
|
Log.w(TAG, "DN|RECEIVE_ERR missing_id");
|
|
return;
|
|
}
|
|
|
|
// Enqueue work immediately - don't block receiver
|
|
// Pass the full intent to extract static reminder extras
|
|
enqueueNotificationWork(context, notificationId, intent);
|
|
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
|
|
|
|
} else if ("com.timesafari.daily.DISMISS".equals(action)) {
|
|
// Handle dismissal - also lightweight
|
|
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
|
if (notificationId != null) {
|
|
enqueueDismissalWork(context, notificationId);
|
|
Log.d(TAG, "DN|DISMISS_OK enqueued=" + notificationId);
|
|
}
|
|
} else {
|
|
Log.w(TAG, "DN|RECEIVE_ERR unknown_action=" + action);
|
|
}
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "DN|RECEIVE_ERR exception=" + e.getMessage(), e);
|
|
} finally {
|
|
Trace.endSection();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enqueue notification processing work to WorkManager with deduplication
|
|
*
|
|
* Uses unique work name based on notification ID to prevent duplicate
|
|
* work items from being enqueued for the same notification. WorkManager's
|
|
* enqueueUniqueWork automatically prevents duplicates when using the same
|
|
* work name.
|
|
*
|
|
* @param context Application context
|
|
* @param notificationId ID of notification to process
|
|
* @param intent Intent containing notification data (may include static reminder extras)
|
|
*/
|
|
private void enqueueNotificationWork(Context context, String notificationId, Intent intent) {
|
|
try {
|
|
// Create unique work name based on notification ID to prevent duplicates
|
|
// WorkManager will automatically skip if work with this name already exists
|
|
String workName = "display_" + notificationId;
|
|
|
|
// Extract static reminder extras from intent if present
|
|
// Static reminders have title/body in Intent extras, not in storage
|
|
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
|
|
String title = intent.getStringExtra("title");
|
|
String body = intent.getStringExtra("body");
|
|
boolean sound = intent.getBooleanExtra("sound", true);
|
|
boolean vibration = intent.getBooleanExtra("vibration", true);
|
|
String priority = intent.getStringExtra("priority");
|
|
if (priority == null) {
|
|
priority = "normal";
|
|
}
|
|
|
|
Data.Builder dataBuilder = new Data.Builder()
|
|
.putString("notification_id", notificationId)
|
|
.putString("action", "display")
|
|
.putBoolean("is_static_reminder", isStaticReminder);
|
|
|
|
// Add static reminder data if present
|
|
if (isStaticReminder && title != null && body != null) {
|
|
dataBuilder.putString("title", title)
|
|
.putString("body", body)
|
|
.putBoolean("sound", sound)
|
|
.putBoolean("vibration", vibration)
|
|
.putString("priority", priority);
|
|
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder id=" + notificationId);
|
|
}
|
|
|
|
Data inputData = dataBuilder.build();
|
|
|
|
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
|
|
.setInputData(inputData)
|
|
.addTag("daily_notification_display")
|
|
.build();
|
|
|
|
// Use unique work name with KEEP policy (don't replace if exists)
|
|
// This prevents duplicate work items from being enqueued even if
|
|
// the receiver is triggered multiple times for the same notification
|
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
workName,
|
|
ExistingWorkPolicy.KEEP,
|
|
workRequest
|
|
);
|
|
|
|
Log.d(TAG, "DN|WORK_ENQUEUE display=" + notificationId + " work_name=" + workName);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "DN|WORK_ENQUEUE_ERR display=" + notificationId + " err=" + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enqueue notification dismissal work to WorkManager with deduplication
|
|
*
|
|
* Uses unique work name based on notification ID to prevent duplicate
|
|
* dismissal work items.
|
|
*
|
|
* @param context Application context
|
|
* @param notificationId ID of notification to dismiss
|
|
*/
|
|
private void enqueueDismissalWork(Context context, String notificationId) {
|
|
try {
|
|
// Create unique work name based on notification ID to prevent duplicates
|
|
String workName = "dismiss_" + notificationId;
|
|
|
|
Data inputData = new Data.Builder()
|
|
.putString("notification_id", notificationId)
|
|
.putString("action", "dismiss")
|
|
.build();
|
|
|
|
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
|
|
.setInputData(inputData)
|
|
.addTag("daily_notification_dismiss")
|
|
.build();
|
|
|
|
// Use unique work name with REPLACE policy (allow new dismissal to replace pending)
|
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
workName,
|
|
ExistingWorkPolicy.REPLACE,
|
|
workRequest
|
|
);
|
|
|
|
Log.d(TAG, "DN|WORK_ENQUEUE dismiss=" + notificationId + " work_name=" + workName);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle notification intent
|
|
*
|
|
* @param context Application context
|
|
* @param intent Intent containing notification data
|
|
*/
|
|
private void handleNotificationIntent(Context context, Intent intent) {
|
|
try {
|
|
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
|
|
|
if (notificationId == null) {
|
|
Log.w(TAG, "Notification ID not found in intent");
|
|
return;
|
|
}
|
|
|
|
Log.d(TAG, "Processing notification: " + notificationId);
|
|
|
|
// Get notification content from storage
|
|
DailyNotificationStorage storage = new DailyNotificationStorage(context);
|
|
NotificationContent content = storage.getNotificationContent(notificationId);
|
|
|
|
if (content == null) {
|
|
Log.w(TAG, "Notification content not found: " + notificationId);
|
|
return;
|
|
}
|
|
|
|
// Check if notification is ready to display
|
|
if (!content.isReadyToDisplay()) {
|
|
Log.d(TAG, "Notification not ready to display yet: " + notificationId);
|
|
return;
|
|
}
|
|
|
|
// JIT Freshness Re-check (Soft TTL)
|
|
content = performJITFreshnessCheck(context, content);
|
|
|
|
// Display the notification
|
|
displayNotification(context, content);
|
|
|
|
// Schedule next notification if this is a recurring daily notification
|
|
scheduleNextNotification(context, content);
|
|
|
|
Log.i(TAG, "Notification processed successfully: " + notificationId);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error handling notification intent", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform JIT (Just-In-Time) freshness re-check for notification content
|
|
*
|
|
* This implements a soft TTL mechanism that attempts to refresh stale content
|
|
* just before displaying the notification. If the refresh fails or content
|
|
* is not stale, the original content is returned.
|
|
*
|
|
* @param context Application context
|
|
* @param content Original notification content
|
|
* @return Updated content if refresh succeeded, original content otherwise
|
|
*/
|
|
private NotificationContent performJITFreshnessCheck(Context context, NotificationContent content) {
|
|
try {
|
|
// Check if content is stale (older than 6 hours for JIT check)
|
|
long currentTime = System.currentTimeMillis();
|
|
long age = currentTime - content.getFetchedAt();
|
|
long staleThreshold = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
|
|
|
|
if (age < staleThreshold) {
|
|
Log.d(TAG, "Content is fresh (age: " + (age / 1000 / 60) + " minutes), skipping JIT refresh");
|
|
return content;
|
|
}
|
|
|
|
Log.i(TAG, "Content is stale (age: " + (age / 1000 / 60) + " minutes), attempting JIT refresh");
|
|
|
|
// Attempt to fetch fresh content
|
|
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(context, new DailyNotificationStorage(context));
|
|
|
|
// Attempt immediate fetch for fresh content
|
|
NotificationContent freshContent = fetcher.fetchContentImmediately();
|
|
|
|
if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) {
|
|
Log.i(TAG, "JIT refresh succeeded, using fresh content");
|
|
|
|
// Update the original content with fresh data while preserving the original ID and scheduled time
|
|
String originalId = content.getId();
|
|
long originalScheduledTime = content.getScheduledTime();
|
|
|
|
content.setTitle(freshContent.getTitle());
|
|
content.setBody(freshContent.getBody());
|
|
content.setSound(freshContent.isSound());
|
|
content.setPriority(freshContent.getPriority());
|
|
content.setUrl(freshContent.getUrl());
|
|
content.setMediaUrl(freshContent.getMediaUrl());
|
|
content.setScheduledTime(originalScheduledTime); // Preserve original scheduled time
|
|
// Note: fetchedAt remains unchanged to preserve original fetch time
|
|
|
|
// Save updated content to storage
|
|
DailyNotificationStorage storage = new DailyNotificationStorage(context);
|
|
storage.saveNotificationContent(content);
|
|
|
|
return content;
|
|
} else {
|
|
Log.w(TAG, "JIT refresh failed or returned empty content, using original content");
|
|
return content;
|
|
}
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error during JIT freshness check", e);
|
|
return content; // Return original content on error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display the notification to the user
|
|
*
|
|
* @param context Application context
|
|
* @param content Notification content to display
|
|
*/
|
|
private void displayNotification(Context context, NotificationContent content) {
|
|
try {
|
|
Log.d(TAG, "Displaying notification: " + content.getId());
|
|
|
|
NotificationManager notificationManager =
|
|
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
|
|
|
if (notificationManager == null) {
|
|
Log.e(TAG, "NotificationManager not available");
|
|
return;
|
|
}
|
|
|
|
// Create notification builder
|
|
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
|
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
|
.setContentTitle(content.getTitle())
|
|
.setContentText(content.getBody())
|
|
.setPriority(getNotificationPriority(content.getPriority()))
|
|
.setAutoCancel(true)
|
|
.setCategory(NotificationCompat.CATEGORY_REMINDER);
|
|
|
|
// Add sound if enabled
|
|
if (content.isSound()) {
|
|
builder.setDefaults(NotificationCompat.DEFAULT_SOUND);
|
|
}
|
|
|
|
// Add click action if URL is available
|
|
if (content.getUrl() != null && !content.getUrl().isEmpty()) {
|
|
Intent clickIntent = new Intent(Intent.ACTION_VIEW);
|
|
clickIntent.setData(android.net.Uri.parse(content.getUrl()));
|
|
|
|
PendingIntent clickPendingIntent = PendingIntent.getActivity(
|
|
context,
|
|
content.getId().hashCode(),
|
|
clickIntent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
|
);
|
|
|
|
builder.setContentIntent(clickPendingIntent);
|
|
}
|
|
|
|
// Add dismiss action
|
|
Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class);
|
|
dismissIntent.setAction("com.timesafari.daily.DISMISS");
|
|
dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
|
|
|
|
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
|
|
context,
|
|
content.getId().hashCode() + 1000, // Different request code
|
|
dismissIntent,
|
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
|
);
|
|
|
|
builder.addAction(
|
|
android.R.drawable.ic_menu_close_clear_cancel,
|
|
"Dismiss",
|
|
dismissPendingIntent
|
|
);
|
|
|
|
// Build and display notification
|
|
int notificationId = content.getId().hashCode();
|
|
notificationManager.notify(notificationId, builder.build());
|
|
|
|
Log.i(TAG, "Notification displayed successfully: " + content.getId());
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error displaying notification", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule the next occurrence of this daily notification
|
|
*
|
|
* @param context Application context
|
|
* @param content Current notification content
|
|
*/
|
|
private void scheduleNextNotification(Context context, NotificationContent content) {
|
|
try {
|
|
Log.d(TAG, "Scheduling next notification for: " + content.getId());
|
|
|
|
// Calculate next occurrence (24 hours from now)
|
|
long nextScheduledTime = content.getScheduledTime() + (24 * 60 * 60 * 1000);
|
|
|
|
// Create new content for next occurrence
|
|
NotificationContent nextContent = new NotificationContent();
|
|
nextContent.setTitle(content.getTitle());
|
|
nextContent.setBody(content.getBody());
|
|
nextContent.setScheduledTime(nextScheduledTime);
|
|
nextContent.setSound(content.isSound());
|
|
nextContent.setPriority(content.getPriority());
|
|
nextContent.setUrl(content.getUrl());
|
|
// fetchedAt is set in constructor, no need to set it again
|
|
|
|
// Save to storage
|
|
DailyNotificationStorage storage = new DailyNotificationStorage(context);
|
|
storage.saveNotificationContent(nextContent);
|
|
|
|
// Schedule the notification
|
|
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
|
|
context,
|
|
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
|
|
);
|
|
|
|
boolean scheduled = scheduler.scheduleNotification(nextContent);
|
|
|
|
if (scheduled) {
|
|
Log.i(TAG, "Next notification scheduled successfully");
|
|
} else {
|
|
Log.e(TAG, "Failed to schedule next notification");
|
|
}
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error scheduling next notification", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get notification priority constant
|
|
*
|
|
* @param priority Priority string from content
|
|
* @return NotificationCompat priority constant
|
|
*/
|
|
private int getNotificationPriority(String priority) {
|
|
if (priority == null) {
|
|
return NotificationCompat.PRIORITY_DEFAULT;
|
|
}
|
|
|
|
switch (priority.toLowerCase()) {
|
|
case "high":
|
|
return NotificationCompat.PRIORITY_HIGH;
|
|
case "low":
|
|
return NotificationCompat.PRIORITY_LOW;
|
|
case "min":
|
|
return NotificationCompat.PRIORITY_MIN;
|
|
case "max":
|
|
return NotificationCompat.PRIORITY_MAX;
|
|
default:
|
|
return NotificationCompat.PRIORITY_DEFAULT;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle notification dismissal
|
|
*
|
|
* @param context Application context
|
|
* @param notificationId ID of dismissed notification
|
|
*/
|
|
private void handleNotificationDismissal(Context context, String notificationId) {
|
|
try {
|
|
Log.d(TAG, "Handling notification dismissal: " + notificationId);
|
|
|
|
// Remove from storage
|
|
DailyNotificationStorage storage = new DailyNotificationStorage(context);
|
|
storage.removeNotification(notificationId);
|
|
|
|
// Cancel any pending alarms
|
|
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
|
|
context,
|
|
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
|
|
);
|
|
scheduler.cancelNotification(notificationId);
|
|
|
|
Log.i(TAG, "Notification dismissed successfully: " + notificationId);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error handling notification dismissal", e);
|
|
}
|
|
}
|
|
}
|