/** * 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 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"; } } }