Browse Source

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.
master
Matthew Raymer 1 week ago
parent
commit
92c843b07e
  1. 6
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java
  2. 6
      android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationTTLEnforcer.java
  3. 84
      android/plugin/src/main/java/com/timesafari/dailynotification/NotificationContent.java

6
android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationStorage.java

@ -59,7 +59,10 @@ public class DailyNotificationStorage {
public DailyNotificationStorage(Context context) { public DailyNotificationStorage(Context context) {
this.context = context; this.context = context;
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); 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.notificationCache = new ConcurrentHashMap<>();
this.notificationList = Collections.synchronizedList(new ArrayList<>()); this.notificationList = Collections.synchronizedList(new ArrayList<>());
@ -375,6 +378,7 @@ public class DailyNotificationStorage {
private void loadNotificationsFromStorage() { private void loadNotificationsFromStorage() {
try { try {
String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]"); String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]");
Log.d(TAG, "Loading notifications from storage: " + notificationsJson);
Type type = new TypeToken<ArrayList<NotificationContent>>(){}.getType(); Type type = new TypeToken<ArrayList<NotificationContent>>(){}.getType();
List<NotificationContent> notifications = gson.fromJson(notificationsJson, type); List<NotificationContent> notifications = gson.fromJson(notificationsJson, type);

6
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"; private static final String LOG_CODE_TTL_VIOLATION = "TTL_VIOLATION";
// Default TTL values // 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 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 Context context;
private final DailyNotificationDatabase database; private final DailyNotificationDatabase database;
@ -120,7 +120,7 @@ public class DailyNotificationTTLEnforcer {
try { try {
String slotId = notificationContent.getId(); String slotId = notificationContent.getId();
long scheduledTime = notificationContent.getScheduledTime(); 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", Log.d(TAG, String.format("Validating freshness before arming: slot=%s, scheduled=%d, fetched=%d",
slotId, scheduledTime, fetchedAt)); slotId, scheduledTime, fetchedAt));

84
android/plugin/src/main/java/com/timesafari/dailynotification/NotificationContent.java

@ -10,6 +10,7 @@
package com.timesafari.dailynotification; package com.timesafari.dailynotification;
import android.util.Log;
import java.util.UUID; import java.util.UUID;
/** /**
@ -30,7 +31,39 @@ public class NotificationContent {
private String body; private String body;
private long scheduledTime; private long scheduledTime;
private String mediaUrl; 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<NotificationContent> {
@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 boolean sound;
private String priority; private String priority;
private String url; private String url;
@ -40,9 +73,11 @@ public class NotificationContent {
*/ */
public NotificationContent() { public NotificationContent() {
this.id = UUID.randomUUID().toString(); this.id = UUID.randomUUID().toString();
this.fetchTime = System.currentTimeMillis(); this.fetchedAt = System.currentTimeMillis();
this.scheduledAt = System.currentTimeMillis();
this.sound = true; this.sound = true;
this.priority = "default"; 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 * @return Timestamp in milliseconds
*/ */
public long getFetchTime() { public long getScheduledAt() {
return fetchTime; 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) { public void setScheduledAt(long scheduledAt) {
this.fetchTime = fetchTime; 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() { public boolean isStale() {
long currentTime = System.currentTimeMillis(); long currentTime = System.currentTimeMillis();
long age = currentTime - fetchTime; long age = currentTime - fetchedAt;
return age > 24 * 60 * 60 * 1000; // 24 hours in milliseconds 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 * @return Age in milliseconds
*/ */
public long getAge() { 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 + '\'' + ", body='" + body + '\'' +
", scheduledTime=" + scheduledTime + ", scheduledTime=" + scheduledTime +
", mediaUrl='" + mediaUrl + '\'' + ", mediaUrl='" + mediaUrl + '\'' +
", fetchTime=" + fetchTime + ", fetchedAt=" + fetchedAt +
", scheduledAt=" + scheduledAt +
", sound=" + sound + ", sound=" + sound +
", priority='" + priority + '\'' + ", priority='" + priority + '\'' +
", url='" + url + '\'' + ", url='" + url + '\'' +

Loading…
Cancel
Save