You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
438 lines
16 KiB
438 lines
16 KiB
/**
|
|
* DailyNotificationTTLEnforcer.java
|
|
*
|
|
* TTL-at-fire enforcement for notification freshness
|
|
* Implements the skip rule: if (T - fetchedAt) > ttlSeconds → skip arming
|
|
*
|
|
* @author Matthew Raymer
|
|
* @version 1.0.0
|
|
*/
|
|
|
|
package com.timesafari.dailynotification;
|
|
|
|
import android.content.Context;
|
|
import android.content.SharedPreferences;
|
|
import android.database.sqlite.SQLiteDatabase;
|
|
import android.util.Log;
|
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
/**
|
|
* Enforces TTL-at-fire rules for notification freshness
|
|
*
|
|
* This class implements the critical freshness enforcement:
|
|
* - Before arming for T, if (T − fetchedAt) > ttlSeconds → skip
|
|
* - Logs TTL violations for debugging
|
|
* - Supports both SQLite and SharedPreferences storage
|
|
* - Provides freshness validation before scheduling
|
|
*/
|
|
public class DailyNotificationTTLEnforcer {
|
|
|
|
private static final String TAG = "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 MIN_TTL_SECONDS = 60; // 1 minute
|
|
private static final long MAX_TTL_SECONDS = 86400; // 24 hours
|
|
|
|
private final Context context;
|
|
private final DailyNotificationDatabase database;
|
|
private final boolean useSharedStorage;
|
|
|
|
/**
|
|
* Constructor
|
|
*
|
|
* @param context Application context
|
|
* @param database SQLite database (null if using SharedPreferences)
|
|
* @param useSharedStorage Whether to use SQLite or SharedPreferences
|
|
*/
|
|
public DailyNotificationTTLEnforcer(Context context, DailyNotificationDatabase database, boolean useSharedStorage) {
|
|
this.context = context;
|
|
this.database = database;
|
|
this.useSharedStorage = useSharedStorage;
|
|
}
|
|
|
|
/**
|
|
* Check if notification content is fresh enough to arm
|
|
*
|
|
* @param slotId Notification slot ID
|
|
* @param scheduledTime T (slot time) - when notification should fire
|
|
* @param fetchedAt When content was fetched
|
|
* @return true if content is fresh enough to arm
|
|
*/
|
|
public boolean isContentFresh(String slotId, long scheduledTime, long fetchedAt) {
|
|
try {
|
|
long ttlSeconds = getTTLSeconds();
|
|
|
|
// Calculate age at fire time
|
|
long ageAtFireTime = scheduledTime - fetchedAt;
|
|
long ageAtFireSeconds = TimeUnit.MILLISECONDS.toSeconds(ageAtFireTime);
|
|
|
|
boolean isFresh = ageAtFireSeconds <= ttlSeconds;
|
|
|
|
if (!isFresh) {
|
|
logTTLViolation(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds);
|
|
}
|
|
|
|
Log.d(TAG, String.format("TTL check for %s: age=%ds, ttl=%ds, fresh=%s",
|
|
slotId, ageAtFireSeconds, ttlSeconds, isFresh));
|
|
|
|
return isFresh;
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error checking content freshness", e);
|
|
// Default to allowing arming if check fails
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if notification content is fresh enough to arm (using stored fetchedAt)
|
|
*
|
|
* @param slotId Notification slot ID
|
|
* @param scheduledTime T (slot time) - when notification should fire
|
|
* @return true if content is fresh enough to arm
|
|
*/
|
|
public boolean isContentFresh(String slotId, long scheduledTime) {
|
|
try {
|
|
long fetchedAt = getFetchedAt(slotId);
|
|
if (fetchedAt == 0) {
|
|
Log.w(TAG, "No fetchedAt found for slot: " + slotId);
|
|
return false;
|
|
}
|
|
|
|
return isContentFresh(slotId, scheduledTime, fetchedAt);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error checking content freshness for slot: " + slotId, e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate freshness before arming notification
|
|
*
|
|
* @param notificationContent Notification content to validate
|
|
* @return true if notification should be armed
|
|
*/
|
|
public boolean validateBeforeArming(NotificationContent notificationContent) {
|
|
try {
|
|
String slotId = notificationContent.getId();
|
|
long scheduledTime = notificationContent.getScheduledTime();
|
|
long fetchedAt = notificationContent.getFetchedAt();
|
|
|
|
Log.d(TAG, String.format("Validating freshness before arming: slot=%s, scheduled=%d, fetched=%d",
|
|
slotId, scheduledTime, fetchedAt));
|
|
|
|
boolean isFresh = isContentFresh(slotId, scheduledTime, fetchedAt);
|
|
|
|
if (!isFresh) {
|
|
Log.w(TAG, "Skipping arming due to TTL violation: " + slotId);
|
|
return false;
|
|
}
|
|
|
|
Log.d(TAG, "Content is fresh, proceeding with arming: " + slotId);
|
|
return true;
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error validating freshness before arming", e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get TTL seconds from configuration
|
|
*
|
|
* @return TTL in seconds
|
|
*/
|
|
private long getTTLSeconds() {
|
|
try {
|
|
if (useSharedStorage && database != null) {
|
|
return getTTLFromSQLite();
|
|
} else {
|
|
return getTTLFromSharedPreferences();
|
|
}
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting TTL seconds", e);
|
|
return DEFAULT_TTL_SECONDS;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get TTL from SQLite database
|
|
*
|
|
* @return TTL in seconds
|
|
*/
|
|
private long getTTLFromSQLite() {
|
|
try {
|
|
SQLiteDatabase db = database.getReadableDatabase();
|
|
android.database.Cursor cursor = db.query(
|
|
DailyNotificationDatabase.TABLE_NOTIF_CONFIG,
|
|
new String[]{DailyNotificationDatabase.COL_CONFIG_V},
|
|
DailyNotificationDatabase.COL_CONFIG_K + " = ?",
|
|
new String[]{"ttlSeconds"},
|
|
null, null, null
|
|
);
|
|
|
|
long ttlSeconds = DEFAULT_TTL_SECONDS;
|
|
if (cursor.moveToFirst()) {
|
|
ttlSeconds = Long.parseLong(cursor.getString(0));
|
|
}
|
|
cursor.close();
|
|
|
|
// Validate TTL range
|
|
ttlSeconds = Math.max(MIN_TTL_SECONDS, Math.min(MAX_TTL_SECONDS, ttlSeconds));
|
|
|
|
return ttlSeconds;
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting TTL from SQLite", e);
|
|
return DEFAULT_TTL_SECONDS;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get TTL from SharedPreferences
|
|
*
|
|
* @return TTL in seconds
|
|
*/
|
|
private long getTTLFromSharedPreferences() {
|
|
try {
|
|
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
|
|
long ttlSeconds = prefs.getLong("ttlSeconds", DEFAULT_TTL_SECONDS);
|
|
|
|
// Validate TTL range
|
|
ttlSeconds = Math.max(MIN_TTL_SECONDS, Math.min(MAX_TTL_SECONDS, ttlSeconds));
|
|
|
|
return ttlSeconds;
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting TTL from SharedPreferences", e);
|
|
return DEFAULT_TTL_SECONDS;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get fetchedAt timestamp for a slot
|
|
*
|
|
* @param slotId Notification slot ID
|
|
* @return FetchedAt timestamp in milliseconds
|
|
*/
|
|
private long getFetchedAt(String slotId) {
|
|
try {
|
|
if (useSharedStorage && database != null) {
|
|
return getFetchedAtFromSQLite(slotId);
|
|
} else {
|
|
return getFetchedAtFromSharedPreferences(slotId);
|
|
}
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting fetchedAt for slot: " + slotId, e);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get fetchedAt from SQLite database
|
|
*
|
|
* @param slotId Notification slot ID
|
|
* @return FetchedAt timestamp in milliseconds
|
|
*/
|
|
private long getFetchedAtFromSQLite(String slotId) {
|
|
try {
|
|
SQLiteDatabase db = database.getReadableDatabase();
|
|
android.database.Cursor cursor = db.query(
|
|
DailyNotificationDatabase.TABLE_NOTIF_CONTENTS,
|
|
new String[]{DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT},
|
|
DailyNotificationDatabase.COL_CONTENTS_SLOT_ID + " = ?",
|
|
new String[]{slotId},
|
|
null, null,
|
|
DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT + " DESC",
|
|
"1"
|
|
);
|
|
|
|
long fetchedAt = 0;
|
|
if (cursor.moveToFirst()) {
|
|
fetchedAt = cursor.getLong(0);
|
|
}
|
|
cursor.close();
|
|
|
|
return fetchedAt;
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting fetchedAt from SQLite", e);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get fetchedAt from SharedPreferences
|
|
*
|
|
* @param slotId Notification slot ID
|
|
* @return FetchedAt timestamp in milliseconds
|
|
*/
|
|
private long getFetchedAtFromSharedPreferences(String slotId) {
|
|
try {
|
|
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
|
|
return prefs.getLong("last_fetch_" + slotId, 0);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting fetchedAt from SharedPreferences", e);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log TTL violation with detailed information
|
|
*
|
|
* @param slotId Notification slot ID
|
|
* @param scheduledTime When notification was scheduled to fire
|
|
* @param fetchedAt When content was fetched
|
|
* @param ageAtFireSeconds Age of content at fire time
|
|
* @param ttlSeconds TTL limit in seconds
|
|
*/
|
|
private void logTTLViolation(String slotId, long scheduledTime, long fetchedAt,
|
|
long ageAtFireSeconds, long ttlSeconds) {
|
|
try {
|
|
String violationMessage = String.format(
|
|
"TTL violation: slot=%s, scheduled=%d, fetched=%d, age=%ds, ttl=%ds",
|
|
slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds
|
|
);
|
|
|
|
Log.w(TAG, LOG_CODE_TTL_VIOLATION + ": " + violationMessage);
|
|
|
|
// Store violation in database or SharedPreferences for analytics
|
|
storeTTLViolation(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error logging TTL violation", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store TTL violation for analytics
|
|
*/
|
|
private void storeTTLViolation(String slotId, long scheduledTime, long fetchedAt,
|
|
long ageAtFireSeconds, long ttlSeconds) {
|
|
try {
|
|
if (useSharedStorage && database != null) {
|
|
storeTTLViolationInSQLite(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds);
|
|
} else {
|
|
storeTTLViolationInSharedPreferences(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds);
|
|
}
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error storing TTL violation", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store TTL violation in SQLite database
|
|
*/
|
|
private void storeTTLViolationInSQLite(String slotId, long scheduledTime, long fetchedAt,
|
|
long ageAtFireSeconds, long ttlSeconds) {
|
|
try {
|
|
SQLiteDatabase db = database.getWritableDatabase();
|
|
|
|
// Insert into notif_deliveries with error status
|
|
android.content.ContentValues values = new android.content.ContentValues();
|
|
values.put(DailyNotificationDatabase.COL_DELIVERIES_SLOT_ID, slotId);
|
|
values.put(DailyNotificationDatabase.COL_DELIVERIES_FIRE_AT, scheduledTime);
|
|
values.put(DailyNotificationDatabase.COL_DELIVERIES_STATUS, DailyNotificationDatabase.STATUS_ERROR);
|
|
values.put(DailyNotificationDatabase.COL_DELIVERIES_ERROR_CODE, LOG_CODE_TTL_VIOLATION);
|
|
values.put(DailyNotificationDatabase.COL_DELIVERIES_ERROR_MESSAGE,
|
|
String.format("Content age %ds exceeds TTL %ds", ageAtFireSeconds, ttlSeconds));
|
|
|
|
db.insert(DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES, null, values);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error storing TTL violation in SQLite", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Store TTL violation in SharedPreferences
|
|
*/
|
|
private void storeTTLViolationInSharedPreferences(String slotId, long scheduledTime, long fetchedAt,
|
|
long ageAtFireSeconds, long ttlSeconds) {
|
|
try {
|
|
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
|
|
SharedPreferences.Editor editor = prefs.edit();
|
|
|
|
String violationKey = "ttl_violation_" + slotId + "_" + scheduledTime;
|
|
String violationValue = String.format("%d,%d,%d,%d", fetchedAt, ageAtFireSeconds, ttlSeconds, System.currentTimeMillis());
|
|
|
|
editor.putString(violationKey, violationValue);
|
|
editor.apply();
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error storing TTL violation in SharedPreferences", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get TTL violation statistics
|
|
*
|
|
* @return Statistics string
|
|
*/
|
|
public String getTTLViolationStats() {
|
|
try {
|
|
if (useSharedStorage && database != null) {
|
|
return getTTLViolationStatsFromSQLite();
|
|
} else {
|
|
return getTTLViolationStatsFromSharedPreferences();
|
|
}
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting TTL violation stats", e);
|
|
return "Error retrieving TTL violation statistics";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get TTL violation statistics from SQLite
|
|
*/
|
|
private String getTTLViolationStatsFromSQLite() {
|
|
try {
|
|
SQLiteDatabase db = database.getReadableDatabase();
|
|
android.database.Cursor cursor = db.rawQuery(
|
|
"SELECT COUNT(*) FROM " + DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES +
|
|
" WHERE " + DailyNotificationDatabase.COL_DELIVERIES_ERROR_CODE + " = ?",
|
|
new String[]{LOG_CODE_TTL_VIOLATION}
|
|
);
|
|
|
|
int violationCount = 0;
|
|
if (cursor.moveToFirst()) {
|
|
violationCount = cursor.getInt(0);
|
|
}
|
|
cursor.close();
|
|
|
|
return String.format("TTL violations: %d", violationCount);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting TTL violation stats from SQLite", e);
|
|
return "Error retrieving TTL violation statistics";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get TTL violation statistics from SharedPreferences
|
|
*/
|
|
private String getTTLViolationStatsFromSharedPreferences() {
|
|
try {
|
|
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
|
|
java.util.Map<String, ?> allPrefs = prefs.getAll();
|
|
|
|
int violationCount = 0;
|
|
for (String key : allPrefs.keySet()) {
|
|
if (key.startsWith("ttl_violation_")) {
|
|
violationCount++;
|
|
}
|
|
}
|
|
|
|
return String.format("TTL violations: %d", violationCount);
|
|
|
|
} catch (Exception e) {
|
|
Log.e(TAG, "Error getting TTL violation stats from SharedPreferences", e);
|
|
return "Error retrieving TTL violation statistics";
|
|
}
|
|
}
|
|
}
|
|
|