fix(android): single rollover alarm, user content, no main-thread DB
- Receiver: stop reading Room on main thread; pass schedule_id to Worker so title/body are resolved on a background thread (fixes db_fallback_failed / "Cannot access database on the main thread"). - Worker: use stable schedule_id for rollover so one alarm per reminder and reschedule cancels it; resolve user title/body by schedule_id when Intent lacks them; skip prefetch for static reminders to avoid a second alarm. - ScheduleHelper: persist NotificationContentEntity for scheduleId when scheduling daily notification so rollover and post-reboot show user text. Refs: plugin-feedback-android-rollover-double-fire-and-user-content
This commit is contained in:
@@ -2700,6 +2700,34 @@ object ScheduleHelper {
|
||||
)
|
||||
database.scheduleDao().upsert(schedule)
|
||||
|
||||
// Persist title/body for this scheduleId so rollover and post-reboot resolve user content
|
||||
// (see plugin-feedback-android-rollover-double-fire-and-user-content)
|
||||
try {
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
scheduleId,
|
||||
"1.1.9",
|
||||
null,
|
||||
"daily",
|
||||
config.title ?: "Daily Notification",
|
||||
config.body ?: "",
|
||||
nextRunTime,
|
||||
java.time.ZoneId.systemDefault().id
|
||||
)
|
||||
entity.soundEnabled = config.sound ?: true
|
||||
entity.vibrationEnabled = config.vibration ?: true
|
||||
entity.priority = when (config.priority) {
|
||||
"high", "max" -> 2
|
||||
"low", "min" -> -1
|
||||
else -> 0
|
||||
}
|
||||
entity.createdAt = System.currentTimeMillis()
|
||||
entity.updatedAt = System.currentTimeMillis()
|
||||
database.notificationContentDao().insertNotification(entity)
|
||||
Log.d("ScheduleHelper", "Persisted title/body for scheduleId=$scheduleId (rollover/post-reboot)")
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
|
||||
}
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
|
||||
|
||||
@@ -25,8 +25,6 @@ import androidx.work.ExistingWorkPolicy;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
|
||||
/**
|
||||
* Broadcast receiver for daily notification alarms
|
||||
*
|
||||
@@ -111,8 +109,9 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
String workName = "display_" + notificationId;
|
||||
|
||||
// Extract static reminder extras from intent if present
|
||||
// Static reminders have title/body in Intent extras, not in storage
|
||||
// After reboot, Intent extras may be missing; restore from DB when possible
|
||||
// Static reminders have title/body in Intent extras, not in storage.
|
||||
// Do NOT access DB on main thread here (Room disallows it); Worker will resolve
|
||||
// missing title/body by schedule_id on a background thread (see plugin-feedback-android-rollover-double-fire).
|
||||
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
|
||||
String title = intent.getStringExtra("title");
|
||||
String body = intent.getStringExtra("body");
|
||||
@@ -122,35 +121,17 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
if (priority == null) {
|
||||
priority = "normal";
|
||||
}
|
||||
|
||||
// Post-reboot fallback: if we have notification_id but missing title/body, load from DB
|
||||
// so the worker can display user-set text instead of fallback (see plugin-feedback-android-post-reboot-fallback-text)
|
||||
if ((title == null || title.isEmpty() || body == null || body.isEmpty()) && notificationId != null) {
|
||||
try {
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(context);
|
||||
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
|
||||
if (entity != null && entity.title != null && !entity.title.isEmpty()
|
||||
&& entity.body != null && !entity.body.isEmpty()) {
|
||||
title = entity.title;
|
||||
body = entity.body;
|
||||
isStaticReminder = true;
|
||||
sound = entity.soundEnabled;
|
||||
vibration = entity.vibrationEnabled;
|
||||
priority = mapPriorityFromInt(entity.priority);
|
||||
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder_from_db id=" + notificationId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "DN|WORK_ENQUEUE db_fallback_failed id=" + notificationId + " err=" + e.getMessage());
|
||||
}
|
||||
}
|
||||
String scheduleId = intent.getStringExtra("schedule_id");
|
||||
|
||||
Data.Builder dataBuilder = new Data.Builder()
|
||||
.putString("notification_id", notificationId)
|
||||
.putString("action", "display")
|
||||
.putBoolean("is_static_reminder", isStaticReminder);
|
||||
if (scheduleId != null && !scheduleId.isEmpty()) {
|
||||
dataBuilder.putString("schedule_id", scheduleId);
|
||||
}
|
||||
|
||||
// Add static reminder data if present
|
||||
// Add static reminder data when present (from Intent; Worker resolves from DB by schedule_id if missing)
|
||||
if (isStaticReminder && title != null && body != null) {
|
||||
dataBuilder.putString("title", title)
|
||||
.putString("body", body)
|
||||
@@ -221,15 +202,6 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Room priority int to string for Worker input.
|
||||
*/
|
||||
private static String mapPriorityFromInt(int p) {
|
||||
if (p >= 2) return "high";
|
||||
if (p <= -1) return "low";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notification intent
|
||||
*
|
||||
|
||||
@@ -133,7 +133,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
NotificationContent content;
|
||||
|
||||
if (isStaticReminder) {
|
||||
// Static reminder: create NotificationContent from input data
|
||||
// Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
|
||||
String title = inputData.getString("title");
|
||||
String body = inputData.getString("body");
|
||||
boolean sound = inputData.getBoolean("sound", true);
|
||||
@@ -142,7 +142,18 @@ public class DailyNotificationWorker extends Worker {
|
||||
if (priority == null) {
|
||||
priority = "normal";
|
||||
}
|
||||
|
||||
// Post-reboot/rollover: Intent may lack title/body; resolve from DB by canonical schedule_id
|
||||
String scheduleId = inputData.getString("schedule_id");
|
||||
if ((title == null || title.isEmpty() || body == null || body.isEmpty()) && scheduleId != null) {
|
||||
NotificationContent canonical = getContentByScheduleId(scheduleId);
|
||||
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
|
||||
title = canonical.getTitle();
|
||||
body = canonical.getBody();
|
||||
sound = canonical.isSound();
|
||||
priority = canonical.getPriority() != null ? canonical.getPriority() : "normal";
|
||||
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER_FROM_DB id=" + notificationId + " schedule_id=" + scheduleId);
|
||||
}
|
||||
}
|
||||
if (title == null || body == null) {
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP static_reminder_missing_data id=" + notificationId);
|
||||
return Result.success();
|
||||
@@ -160,25 +171,28 @@ public class DailyNotificationWorker extends Worker {
|
||||
|
||||
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title);
|
||||
} else {
|
||||
// Regular notification: load from storage
|
||||
// Prefer Room storage; fallback to legacy SharedPreferences storage
|
||||
// Regular notification: load from storage (by notification_id, then by schedule_id for rollover/user content)
|
||||
content = getContentFromRoomOrLegacy(notificationId);
|
||||
|
||||
if (content == null) {
|
||||
// Content not found - likely removed during deduplication or cleanup
|
||||
// Return success instead of failure to prevent retries for intentionally removed notifications
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
||||
return Result.success(); // Success prevents retry loops for removed notifications
|
||||
}
|
||||
|
||||
// Check if notification is ready to display
|
||||
if (!content.isReadyToDisplay()) {
|
||||
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// Rollover/notify_* runs: prefer canonical reminder content by schedule_id so user text is shown
|
||||
String scheduleId = inputData.getString("schedule_id");
|
||||
if (scheduleId != null && (content == null || content.getTitle() == null || content.getTitle().isEmpty()
|
||||
|| content.getBody() == null || content.getBody().isEmpty())) {
|
||||
NotificationContent canonical = getContentByScheduleId(scheduleId);
|
||||
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
|
||||
content = canonical;
|
||||
content.setId(notificationId); // keep run id for display/dismiss
|
||||
Log.d(TAG, "DN|DISPLAY_USE_CANONICAL id=" + notificationId + " schedule_id=" + scheduleId);
|
||||
}
|
||||
}
|
||||
if (content == null) {
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
||||
return Result.success();
|
||||
}
|
||||
if (!content.isReadyToDisplay()) {
|
||||
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
// JIT Freshness Re-check (Soft TTL) - skip when content has title/body from Room
|
||||
// (e.g. post-reboot static reminder restored from DB so we don't overwrite with fetcher fallback)
|
||||
boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
|
||||
&& content.getBody() != null && !content.getBody().isEmpty();
|
||||
if (!hasTitleBody) {
|
||||
@@ -547,22 +561,22 @@ public class DailyNotificationWorker extends Worker {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve static reminder semantics across rollover so title/body don't revert to fallback
|
||||
// Preserve static reminder semantics across rollover; use stable schedule_id so reschedule cancels this alarm
|
||||
Data inputData = getInputData();
|
||||
boolean preserveStaticReminder = inputData.getBoolean("is_static_reminder", false);
|
||||
|
||||
// Extract scheduleId from notificationId pattern or use fallback
|
||||
// For static reminders, keep stable scheduleId across days
|
||||
String scheduleId = null;
|
||||
String scheduleId = inputData.getString("schedule_id");
|
||||
if (scheduleId == null || scheduleId.isEmpty()) {
|
||||
String notificationId = content.getId();
|
||||
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
||||
scheduleId = notificationId;
|
||||
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleId = notificationId;
|
||||
} else {
|
||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||
}
|
||||
}
|
||||
String cronExpression = null;
|
||||
String notificationId = content.getId();
|
||||
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
||||
scheduleId = notificationId;
|
||||
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleId = notificationId;
|
||||
} else {
|
||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Calculate cron from current scheduled time (extract hour:minute)
|
||||
try {
|
||||
@@ -608,34 +622,31 @@ public class DailyNotificationWorker extends Worker {
|
||||
String nextTimeStr = formatScheduledTime(nextScheduledTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
|
||||
|
||||
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
||||
try {
|
||||
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
|
||||
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
|
||||
getApplicationContext(),
|
||||
storageForFetcher,
|
||||
roomStorageForFetcher
|
||||
);
|
||||
|
||||
// Calculate fetch time (5 minutes before notification)
|
||||
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
if (fetchTime > currentTime) {
|
||||
fetcher.scheduleFetch(fetchTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() +
|
||||
" next_fetch=" + fetchTime +
|
||||
" next_notification=" + nextScheduledTime);
|
||||
} else {
|
||||
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
|
||||
" fetch_time=" + fetchTime +
|
||||
" current=" + currentTime);
|
||||
fetcher.scheduleImmediateFetch();
|
||||
// Do not schedule prefetch for static reminders (single NotifyReceiver alarm is enough; avoids second alarm)
|
||||
if (preserveStaticReminder) {
|
||||
Log.d(TAG, "DN|RESCHEDULE_SKIP_PREFETCH static_reminder scheduleId=" + scheduleId);
|
||||
} else {
|
||||
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
||||
try {
|
||||
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
|
||||
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
|
||||
getApplicationContext(),
|
||||
storageForFetcher,
|
||||
roomStorageForFetcher
|
||||
);
|
||||
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
if (fetchTime > currentTime) {
|
||||
fetcher.scheduleFetch(fetchTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() + " next_fetch=" + fetchTime + " next_notification=" + nextScheduledTime);
|
||||
} else {
|
||||
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + " fetch_time=" + fetchTime + " current=" + currentTime);
|
||||
fetcher.scheduleImmediateFetch();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + " error scheduling prefetch", e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
|
||||
" error scheduling prefetch", e);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -645,6 +656,28 @@ public class DailyNotificationWorker extends Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load notification content by canonical schedule id (for static reminder / rollover user text).
|
||||
* Tries id then "daily_" + id to match getTitleBodyForSchedule / BootReceiver.
|
||||
*/
|
||||
private NotificationContent getContentByScheduleId(String scheduleId) {
|
||||
if (scheduleId == null || scheduleId.isEmpty()) return null;
|
||||
try {
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(scheduleId);
|
||||
if (entity == null) {
|
||||
entity = db.notificationContentDao().getNotificationById("daily_" + scheduleId);
|
||||
}
|
||||
if (entity != null) {
|
||||
return mapEntityToContent(entity);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "DN|CANONICAL_READ_FAIL schedule_id=" + scheduleId + " err=" + t.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load content from Room; fallback to legacy storage
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user