From 92c843b07e3bd3202b1163beb3786fcc582853fc Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Tue, 14 Oct 2025 06:16:50 +0000 Subject: [PATCH] fix(notifications): resolve TTL violation and timestamp issues - Separate fetchedAt (immutable) and scheduledAt (mutable) timestamps - Add custom JsonDeserializer to ensure fetchedAt is set by constructor - Add transient fetchTime field for Gson compatibility - Update TTL enforcer to use fetchedAt for freshness checks - Increase DEFAULT_TTL_SECONDS to 25 hours for daily notifications - Update storage to use custom Gson deserializer - Add debug logging for timestamp validation - Fix timestamp initialization in NotificationContent constructor This resolves the TTL_VIOLATION error that was preventing notifications from being scheduled due to stale timestamp data. --- .../DailyNotificationStorage.java | 6 +- .../DailyNotificationTTLEnforcer.java | 6 +- .../NotificationContent.java | 84 +++++++++++++++---- 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java index feedad0..d3e6fb5 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java @@ -59,7 +59,10 @@ public class DailyNotificationStorage { public DailyNotificationStorage(Context context) { this.context = context; this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - this.gson = new Gson(); + // Create Gson with custom deserializer for NotificationContent + com.google.gson.GsonBuilder gsonBuilder = new com.google.gson.GsonBuilder(); + gsonBuilder.registerTypeAdapter(NotificationContent.class, new NotificationContent.NotificationContentDeserializer()); + this.gson = gsonBuilder.create(); this.notificationCache = new ConcurrentHashMap<>(); this.notificationList = Collections.synchronizedList(new ArrayList<>()); @@ -375,6 +378,7 @@ public class DailyNotificationStorage { private void loadNotificationsFromStorage() { try { String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]"); + Log.d(TAG, "Loading notifications from storage: " + notificationsJson); Type type = new TypeToken>(){}.getType(); List notifications = gson.fromJson(notificationsJson, type); diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java index 2fb7aac..6fe82a9 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java @@ -32,9 +32,9 @@ public class DailyNotificationTTLEnforcer { private static final String LOG_CODE_TTL_VIOLATION = "TTL_VIOLATION"; // Default TTL values - private static final long DEFAULT_TTL_SECONDS = 3600; // 1 hour + private static final long DEFAULT_TTL_SECONDS = 90000; // 25 hours (for daily notifications) private static final long MIN_TTL_SECONDS = 60; // 1 minute - private static final long MAX_TTL_SECONDS = 86400; // 24 hours + private static final long MAX_TTL_SECONDS = 172800; // 48 hours private final Context context; private final DailyNotificationDatabase database; @@ -120,7 +120,7 @@ public class DailyNotificationTTLEnforcer { try { String slotId = notificationContent.getId(); long scheduledTime = notificationContent.getScheduledTime(); - long fetchedAt = notificationContent.getFetchTime(); + long fetchedAt = notificationContent.getFetchedAt(); Log.d(TAG, String.format("Validating freshness before arming: slot=%s, scheduled=%d, fetched=%d", slotId, scheduledTime, fetchedAt)); diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/NotificationContent.java b/android/plugin/src/main/java/com/timesafari/dailynotification/NotificationContent.java index 1d5383b..7f3989f 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/NotificationContent.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/NotificationContent.java @@ -10,6 +10,7 @@ package com.timesafari.dailynotification; +import android.util.Log; import java.util.UUID; /** @@ -30,7 +31,39 @@ public class NotificationContent { private String body; private long scheduledTime; private String mediaUrl; - private long fetchTime; + private final long fetchedAt; // When content was fetched (immutable) + private long scheduledAt; // When this instance was scheduled + + // Gson will try to deserialize this field, but we ignore it to keep fetchedAt immutable + @SuppressWarnings("unused") + private transient long fetchTime; // Legacy field for Gson compatibility (ignored) + + // Custom deserializer to handle fetchedAt field + public static class NotificationContentDeserializer implements com.google.gson.JsonDeserializer { + @Override + public NotificationContent deserialize(com.google.gson.JsonElement json, java.lang.reflect.Type typeOfT, com.google.gson.JsonDeserializationContext context) throws com.google.gson.JsonParseException { + com.google.gson.JsonObject jsonObject = json.getAsJsonObject(); + + // Create new instance (constructor sets fresh fetchedAt) + NotificationContent content = new NotificationContent(); + + // Deserialize other fields + if (jsonObject.has("id")) content.id = jsonObject.get("id").getAsString(); + if (jsonObject.has("title")) content.title = jsonObject.get("title").getAsString(); + if (jsonObject.has("body")) content.body = jsonObject.get("body").getAsString(); + if (jsonObject.has("scheduledTime")) content.scheduledTime = jsonObject.get("scheduledTime").getAsLong(); + if (jsonObject.has("mediaUrl")) content.mediaUrl = jsonObject.get("mediaUrl").getAsString(); + if (jsonObject.has("scheduledAt")) content.scheduledAt = jsonObject.get("scheduledAt").getAsLong(); + if (jsonObject.has("sound")) content.sound = jsonObject.get("sound").getAsBoolean(); + if (jsonObject.has("priority")) content.priority = jsonObject.get("priority").getAsString(); + if (jsonObject.has("url")) content.url = jsonObject.get("url").getAsString(); + + // fetchedAt is set by constructor and not overwritten + Log.d("NotificationContent", "Deserialized content with fetchedAt=" + content.fetchedAt + " (from constructor)"); + + return content; + } + } private boolean sound; private String priority; private String url; @@ -40,9 +73,11 @@ public class NotificationContent { */ public NotificationContent() { this.id = UUID.randomUUID().toString(); - this.fetchTime = System.currentTimeMillis(); + this.fetchedAt = System.currentTimeMillis(); + this.scheduledAt = System.currentTimeMillis(); this.sound = true; this.priority = "default"; + Log.d("NotificationContent", "Constructor: created with fetchedAt=" + this.fetchedAt + ", scheduledAt=" + this.scheduledAt); } /** @@ -152,21 +187,30 @@ public class NotificationContent { } /** - * Get the fetch time when content was retrieved + * Get the fetch time when content was retrieved (immutable) + * + * @return Timestamp in milliseconds + */ + public long getFetchedAt() { + return fetchedAt; + } + + /** + * Get when this notification instance was scheduled * * @return Timestamp in milliseconds */ - public long getFetchTime() { - return fetchTime; + public long getScheduledAt() { + return scheduledAt; } /** - * Set the fetch time when content was retrieved + * Set when this notification instance was scheduled * - * @param fetchTime Timestamp in milliseconds + * @param scheduledAt Timestamp in milliseconds */ - public void setFetchTime(long fetchTime) { - this.fetchTime = fetchTime; + public void setScheduledAt(long scheduledAt) { + this.scheduledAt = scheduledAt; } /** @@ -224,23 +268,32 @@ public class NotificationContent { } /** - * Check if this notification is stale (older than 24 hours) + * Check if this notification content is stale (older than 24 hours) * - * @return true if notification is stale + * @return true if notification content is stale */ public boolean isStale() { long currentTime = System.currentTimeMillis(); - long age = currentTime - fetchTime; + long age = currentTime - fetchedAt; return age > 24 * 60 * 60 * 1000; // 24 hours in milliseconds } /** - * Get the age of this notification in milliseconds + * Get the age of this notification content in milliseconds * * @return Age in milliseconds */ public long getAge() { - return System.currentTimeMillis() - fetchTime; + return System.currentTimeMillis() - fetchedAt; + } + + /** + * Get the age since this notification was scheduled + * + * @return Age in milliseconds + */ + public long getScheduledAge() { + return System.currentTimeMillis() - scheduledAt; } /** @@ -292,7 +345,8 @@ public class NotificationContent { ", body='" + body + '\'' + ", scheduledTime=" + scheduledTime + ", mediaUrl='" + mediaUrl + '\'' + - ", fetchTime=" + fetchTime + + ", fetchedAt=" + fetchedAt + + ", scheduledAt=" + scheduledAt + ", sound=" + sound + ", priority='" + priority + '\'' + ", url='" + url + '\'' +