feat(android): implement Phase 1.2 TTL-at-fire enforcement
- Add DailyNotificationTTLEnforcer with freshness validation logic - Add TTL validation to scheduling path before arming notifications - Implement skip rule: if (T - fetchedAt) > ttlSeconds → skip arming - Add TTL violation logging with TTL_VIOLATION code - Add comprehensive unit tests for TTL enforcement - Add TTL enforcer integration to DailyNotificationPlugin - Add phase1-2-ttl-enforcement.ts usage examples This implements the critical Phase 1.2 gate for content freshness: - Notifications with stale content are automatically skipped - TTL violations are logged and tracked for analytics - Freshness validation prevents delivery of outdated content - Configurable TTL settings support different use cases - Integration with existing scheduling infrastructure Files: 5 changed, 878 insertions(+)
This commit is contained in:
173
examples/phase1-2-ttl-enforcement.ts
Normal file
173
examples/phase1-2-ttl-enforcement.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Phase 1.2 TTL-at-Fire Enforcement Usage Example
|
||||
*
|
||||
* Demonstrates TTL enforcement functionality
|
||||
* Shows how stale notifications are automatically skipped
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { DailyNotification } from '@timesafari/daily-notification-plugin';
|
||||
|
||||
/**
|
||||
* Example: Configure TTL enforcement
|
||||
*/
|
||||
async function configureTTLEnforcement() {
|
||||
try {
|
||||
console.log('Configuring TTL enforcement...');
|
||||
|
||||
// Configure with TTL settings
|
||||
await DailyNotification.configure({
|
||||
storage: 'shared',
|
||||
ttlSeconds: 1800, // 30 minutes TTL
|
||||
prefetchLeadMinutes: 15
|
||||
});
|
||||
|
||||
console.log('✅ TTL enforcement configured (30 minutes)');
|
||||
|
||||
// Now the plugin will automatically skip notifications with stale content
|
||||
// Content older than 30 minutes at fire time will not be armed
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TTL configuration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Schedule notification with TTL enforcement
|
||||
*/
|
||||
async function scheduleWithTTLEnforcement() {
|
||||
try {
|
||||
// Configure TTL enforcement first
|
||||
await configureTTLEnforcement();
|
||||
|
||||
// Schedule a notification
|
||||
await DailyNotification.scheduleDailyNotification({
|
||||
url: 'https://api.example.com/daily-content',
|
||||
time: '09:00',
|
||||
title: 'Daily Update',
|
||||
body: 'Your daily notification is ready',
|
||||
sound: true
|
||||
});
|
||||
|
||||
console.log('✅ Notification scheduled with TTL enforcement');
|
||||
|
||||
// The plugin will now:
|
||||
// 1. Check content freshness before arming
|
||||
// 2. Skip notifications with stale content
|
||||
// 3. Log TTL violations for debugging
|
||||
// 4. Store violation statistics
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Scheduling with TTL enforcement failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Demonstrate TTL violation scenario
|
||||
*/
|
||||
async function demonstrateTTLViolation() {
|
||||
try {
|
||||
console.log('Demonstrating TTL violation scenario...');
|
||||
|
||||
// Configure with short TTL for demonstration
|
||||
await DailyNotification.configure({
|
||||
storage: 'shared',
|
||||
ttlSeconds: 300, // 5 minutes TTL (very short for demo)
|
||||
prefetchLeadMinutes: 2
|
||||
});
|
||||
|
||||
// Schedule notification
|
||||
await DailyNotification.scheduleDailyNotification({
|
||||
url: 'https://api.example.com/daily-content',
|
||||
time: '09:00',
|
||||
title: 'Daily Update',
|
||||
body: 'Your daily notification is ready'
|
||||
});
|
||||
|
||||
console.log('✅ Notification scheduled with 5-minute TTL');
|
||||
|
||||
// If content is fetched more than 5 minutes before 09:00,
|
||||
// the notification will be skipped due to TTL violation
|
||||
|
||||
console.log('ℹ️ If content is older than 5 minutes at 09:00, notification will be skipped');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TTL violation demonstration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Check TTL violation statistics
|
||||
*/
|
||||
async function checkTTLStats() {
|
||||
try {
|
||||
console.log('Checking TTL violation statistics...');
|
||||
|
||||
// Configure TTL enforcement
|
||||
await DailyNotification.configure({
|
||||
storage: 'shared',
|
||||
ttlSeconds: 1800, // 30 minutes
|
||||
prefetchLeadMinutes: 15
|
||||
});
|
||||
|
||||
// The plugin automatically tracks TTL violations
|
||||
// You can check the logs for TTL_VIOLATION entries
|
||||
// or implement a method to retrieve violation statistics
|
||||
|
||||
console.log('✅ TTL enforcement active - violations will be logged');
|
||||
console.log('ℹ️ Check logs for "TTL_VIOLATION" entries');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TTL stats check failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Different TTL configurations for different use cases
|
||||
*/
|
||||
async function configureDifferentTTLScenarios() {
|
||||
try {
|
||||
console.log('Configuring different TTL scenarios...');
|
||||
|
||||
// Scenario 1: Real-time notifications (short TTL)
|
||||
await DailyNotification.configure({
|
||||
storage: 'shared',
|
||||
ttlSeconds: 300, // 5 minutes
|
||||
prefetchLeadMinutes: 2
|
||||
});
|
||||
|
||||
console.log('✅ Real-time notifications: 5-minute TTL');
|
||||
|
||||
// Scenario 2: Daily digest (longer TTL)
|
||||
await DailyNotification.configure({
|
||||
storage: 'shared',
|
||||
ttlSeconds: 7200, // 2 hours
|
||||
prefetchLeadMinutes: 30
|
||||
});
|
||||
|
||||
console.log('✅ Daily digest: 2-hour TTL');
|
||||
|
||||
// Scenario 3: Weekly summary (very long TTL)
|
||||
await DailyNotification.configure({
|
||||
storage: 'shared',
|
||||
ttlSeconds: 86400, // 24 hours
|
||||
prefetchLeadMinutes: 60
|
||||
});
|
||||
|
||||
console.log('✅ Weekly summary: 24-hour TTL');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TTL scenario configuration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export examples for use
|
||||
export {
|
||||
configureTTLEnforcement,
|
||||
scheduleWithTTLEnforcement,
|
||||
demonstrateTTLViolation,
|
||||
checkTTLStats,
|
||||
configureDifferentTTLScenarios
|
||||
};
|
||||
@@ -101,6 +101,9 @@ public class DailyNotificationPlugin extends Plugin {
|
||||
scheduler = new DailyNotificationScheduler(getContext(), alarmManager);
|
||||
fetcher = new DailyNotificationFetcher(getContext(), storage);
|
||||
|
||||
// Initialize TTL enforcer and connect to scheduler
|
||||
initializeTTLEnforcer();
|
||||
|
||||
// Create notification channel
|
||||
createNotificationChannel();
|
||||
|
||||
@@ -285,6 +288,30 @@ public class DailyNotificationPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize TTL enforcer and connect to scheduler
|
||||
*/
|
||||
private void initializeTTLEnforcer() {
|
||||
try {
|
||||
Log.d(TAG, "Initializing TTL enforcer");
|
||||
|
||||
// Create TTL enforcer with current storage mode
|
||||
DailyNotificationTTLEnforcer ttlEnforcer = new DailyNotificationTTLEnforcer(
|
||||
getContext(),
|
||||
database,
|
||||
useSharedStorage
|
||||
);
|
||||
|
||||
// Connect to scheduler
|
||||
scheduler.setTTLEnforcer(ttlEnforcer);
|
||||
|
||||
Log.i(TAG, "TTL enforcer initialized and connected to scheduler");
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error initializing TTL enforcer", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a daily notification with the specified options
|
||||
*
|
||||
|
||||
@@ -36,6 +36,9 @@ public class DailyNotificationScheduler {
|
||||
private final AlarmManager alarmManager;
|
||||
private final ConcurrentHashMap<String, PendingIntent> scheduledAlarms;
|
||||
|
||||
// TTL enforcement
|
||||
private DailyNotificationTTLEnforcer ttlEnforcer;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
@@ -48,6 +51,16 @@ public class DailyNotificationScheduler {
|
||||
this.scheduledAlarms = new ConcurrentHashMap<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TTL enforcer for freshness validation
|
||||
*
|
||||
* @param ttlEnforcer TTL enforcement instance
|
||||
*/
|
||||
public void setTTLEnforcer(DailyNotificationTTLEnforcer ttlEnforcer) {
|
||||
this.ttlEnforcer = ttlEnforcer;
|
||||
Log.d(TAG, "TTL enforcer set for freshness validation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a notification for delivery
|
||||
*
|
||||
@@ -58,6 +71,16 @@ public class DailyNotificationScheduler {
|
||||
try {
|
||||
Log.d(TAG, "Scheduling notification: " + content.getId());
|
||||
|
||||
// TTL validation before arming
|
||||
if (ttlEnforcer != null) {
|
||||
if (!ttlEnforcer.validateBeforeArming(content)) {
|
||||
Log.w(TAG, "Skipping notification due to TTL violation: " + content.getId());
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "TTL enforcer not set, proceeding without freshness validation");
|
||||
}
|
||||
|
||||
// Cancel any existing alarm for this notification
|
||||
cancelNotification(content.getId());
|
||||
|
||||
|
||||
438
src/android/DailyNotificationTTLEnforcer.java
Normal file
438
src/android/DailyNotificationTTLEnforcer.java
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
217
src/android/DailyNotificationTTLEnforcerTest.java
Normal file
217
src/android/DailyNotificationTTLEnforcerTest.java
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* DailyNotificationTTLEnforcerTest.java
|
||||
*
|
||||
* Unit tests for TTL-at-fire enforcement functionality
|
||||
* Tests freshness validation, TTL violation logging, and skip logic
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.test.AndroidTestCase;
|
||||
import android.test.mock.MockContext;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Unit tests for DailyNotificationTTLEnforcer
|
||||
*
|
||||
* Tests the core TTL enforcement functionality including:
|
||||
* - Freshness validation before arming
|
||||
* - TTL violation detection and logging
|
||||
* - Skip logic for stale content
|
||||
* - Configuration retrieval from storage
|
||||
*/
|
||||
public class DailyNotificationTTLEnforcerTest extends AndroidTestCase {
|
||||
|
||||
private DailyNotificationTTLEnforcer ttlEnforcer;
|
||||
private Context mockContext;
|
||||
private DailyNotificationDatabase database;
|
||||
|
||||
@Override
|
||||
protected void setUp() throws Exception {
|
||||
super.setUp();
|
||||
|
||||
// Create mock context
|
||||
mockContext = new MockContext() {
|
||||
@Override
|
||||
public android.content.SharedPreferences getSharedPreferences(String name, int mode) {
|
||||
return getContext().getSharedPreferences(name, mode);
|
||||
}
|
||||
};
|
||||
|
||||
// Create database instance
|
||||
database = new DailyNotificationDatabase(mockContext);
|
||||
|
||||
// Create TTL enforcer with SQLite storage
|
||||
ttlEnforcer = new DailyNotificationTTLEnforcer(mockContext, database, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void tearDown() throws Exception {
|
||||
if (database != null) {
|
||||
database.close();
|
||||
}
|
||||
super.tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test freshness validation with fresh content
|
||||
*/
|
||||
public void testFreshContentValidation() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
|
||||
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5); // 5 minutes ago
|
||||
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_1", scheduledTime, fetchedAt);
|
||||
|
||||
assertTrue("Content should be fresh (5 min old, scheduled 30 min from now)", isFresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test freshness validation with stale content
|
||||
*/
|
||||
public void testStaleContentValidation() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
|
||||
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_2", scheduledTime, fetchedAt);
|
||||
|
||||
assertFalse("Content should be stale (2 hours old, exceeds 1 hour TTL)", isFresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TTL violation detection
|
||||
*/
|
||||
public void testTTLViolationDetection() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
|
||||
|
||||
// This should trigger a TTL violation
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_3", scheduledTime, fetchedAt);
|
||||
|
||||
assertFalse("Should detect TTL violation", isFresh);
|
||||
|
||||
// Check that violation was logged (we can't easily test the actual logging,
|
||||
// but we can verify the method returns false as expected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test validateBeforeArming with fresh content
|
||||
*/
|
||||
public void testValidateBeforeArmingFresh() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
|
||||
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5);
|
||||
|
||||
NotificationContent content = new NotificationContent();
|
||||
content.setId("test_slot_4");
|
||||
content.setScheduledTime(scheduledTime);
|
||||
content.setFetchedAt(fetchedAt);
|
||||
content.setTitle("Test Notification");
|
||||
content.setBody("Test body");
|
||||
|
||||
boolean shouldArm = ttlEnforcer.validateBeforeArming(content);
|
||||
|
||||
assertTrue("Should arm fresh content", shouldArm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test validateBeforeArming with stale content
|
||||
*/
|
||||
public void testValidateBeforeArmingStale() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2);
|
||||
|
||||
NotificationContent content = new NotificationContent();
|
||||
content.setId("test_slot_5");
|
||||
content.setScheduledTime(scheduledTime);
|
||||
content.setFetchedAt(fetchedAt);
|
||||
content.setTitle("Test Notification");
|
||||
content.setBody("Test body");
|
||||
|
||||
boolean shouldArm = ttlEnforcer.validateBeforeArming(content);
|
||||
|
||||
assertFalse("Should not arm stale content", shouldArm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test edge case: content fetched exactly at TTL limit
|
||||
*/
|
||||
public void testTTLBoundaryCase() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1); // Exactly 1 hour ago (TTL limit)
|
||||
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_6", scheduledTime, fetchedAt);
|
||||
|
||||
assertTrue("Content at TTL boundary should be considered fresh", isFresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test edge case: content fetched just over TTL limit
|
||||
*/
|
||||
public void testTTLBoundaryCaseOver() {
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1) - TimeUnit.SECONDS.toMillis(1); // 1 hour + 1 second ago
|
||||
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_7", scheduledTime, fetchedAt);
|
||||
|
||||
assertFalse("Content just over TTL limit should be considered stale", isFresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TTL violation statistics
|
||||
*/
|
||||
public void testTTLViolationStats() {
|
||||
// Generate some TTL violations
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
|
||||
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2);
|
||||
|
||||
// Trigger TTL violations
|
||||
ttlEnforcer.isContentFresh("test_slot_8", scheduledTime, fetchedAt);
|
||||
ttlEnforcer.isContentFresh("test_slot_9", scheduledTime, fetchedAt);
|
||||
|
||||
String stats = ttlEnforcer.getTTLViolationStats();
|
||||
|
||||
assertNotNull("TTL violation stats should not be null", stats);
|
||||
assertTrue("Stats should contain violation count", stats.contains("violations"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test error handling with invalid parameters
|
||||
*/
|
||||
public void testErrorHandling() {
|
||||
// Test with null slot ID
|
||||
boolean result = ttlEnforcer.isContentFresh(null, System.currentTimeMillis(), System.currentTimeMillis());
|
||||
assertFalse("Should handle null slot ID gracefully", result);
|
||||
|
||||
// Test with invalid timestamps
|
||||
result = ttlEnforcer.isContentFresh("test_slot_10", 0, 0);
|
||||
assertTrue("Should handle invalid timestamps gracefully", result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test TTL configuration retrieval
|
||||
*/
|
||||
public void testTTLConfiguration() {
|
||||
// Test that TTL enforcer can retrieve configuration
|
||||
// This is indirectly tested through the freshness checks
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
|
||||
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(30); // 30 minutes ago
|
||||
|
||||
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_11", scheduledTime, fetchedAt);
|
||||
|
||||
// Should be fresh (30 min < 1 hour TTL)
|
||||
assertTrue("Should retrieve TTL configuration correctly", isFresh);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user