feat(android): add complete DailyNotification plugin implementation

- Add full DailyNotificationPlugin with @CapacitorPlugin annotation
- Implement echo method for testing plugin connectivity
- Add comprehensive notification functionality with offline-first approach
- Include performance optimization and error handling classes
- Add WorkManager integration for background content fetching
- Plugin now ready for testing with Capacitor 6 registration
This commit is contained in:
Matthew Raymer
2025-10-13 10:50:23 +00:00
parent d3433aabbf
commit 31f5adcfd1
24 changed files with 11405 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
/**
* DailyNotificationDatabaseTest.java
*
* Unit tests for SQLite database functionality
* Tests schema creation, WAL mode, and basic operations
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.test.AndroidTestCase;
import android.test.mock.MockContext;
import java.io.File;
/**
* Unit tests for DailyNotificationDatabase
*
* Tests the core SQLite functionality including:
* - Database creation and schema
* - WAL mode configuration
* - Table and index creation
* - Schema version management
*/
public class DailyNotificationDatabaseTest extends AndroidTestCase {
private DailyNotificationDatabase database;
private Context mockContext;
@Override
protected void setUp() throws Exception {
super.setUp();
// Create mock context
mockContext = new MockContext() {
@Override
public File getDatabasePath(String name) {
return new File(getContext().getCacheDir(), name);
}
};
// Create database instance
database = new DailyNotificationDatabase(mockContext);
}
@Override
protected void tearDown() throws Exception {
if (database != null) {
database.close();
}
super.tearDown();
}
/**
* Test database creation and schema
*/
public void testDatabaseCreation() {
assertNotNull("Database should not be null", database);
SQLiteDatabase db = database.getReadableDatabase();
assertNotNull("Readable database should not be null", db);
assertTrue("Database should be open", db.isOpen());
db.close();
}
/**
* Test WAL mode configuration
*/
public void testWALModeConfiguration() {
SQLiteDatabase db = database.getWritableDatabase();
// Check journal mode
android.database.Cursor cursor = db.rawQuery("PRAGMA journal_mode", null);
assertTrue("Should have journal mode result", cursor.moveToFirst());
String journalMode = cursor.getString(0);
assertEquals("Journal mode should be WAL", "wal", journalMode.toLowerCase());
cursor.close();
// Check synchronous mode
cursor = db.rawQuery("PRAGMA synchronous", null);
assertTrue("Should have synchronous result", cursor.moveToFirst());
int synchronous = cursor.getInt(0);
assertEquals("Synchronous mode should be NORMAL", 1, synchronous);
cursor.close();
// Check foreign keys
cursor = db.rawQuery("PRAGMA foreign_keys", null);
assertTrue("Should have foreign_keys result", cursor.moveToFirst());
int foreignKeys = cursor.getInt(0);
assertEquals("Foreign keys should be enabled", 1, foreignKeys);
cursor.close();
db.close();
}
/**
* Test table creation
*/
public void testTableCreation() {
SQLiteDatabase db = database.getWritableDatabase();
// Check if tables exist
assertTrue("notif_contents table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONTENTS));
assertTrue("notif_deliveries table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES));
assertTrue("notif_config table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONFIG));
db.close();
}
/**
* Test index creation
*/
public void testIndexCreation() {
SQLiteDatabase db = database.getWritableDatabase();
// Check if indexes exist
assertTrue("notif_idx_contents_slot_time index should exist",
indexExists(db, "notif_idx_contents_slot_time"));
assertTrue("notif_idx_deliveries_slot index should exist",
indexExists(db, "notif_idx_deliveries_slot"));
db.close();
}
/**
* Test schema version management
*/
public void testSchemaVersion() {
SQLiteDatabase db = database.getWritableDatabase();
// Check user_version
android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null);
assertTrue("Should have user_version result", cursor.moveToFirst());
int userVersion = cursor.getInt(0);
assertEquals("User version should match database version",
DailyNotificationDatabase.DATABASE_VERSION, userVersion);
cursor.close();
db.close();
}
/**
* Test basic insert operations
*/
public void testBasicInsertOperations() {
SQLiteDatabase db = database.getWritableDatabase();
// Test inserting into notif_contents
android.content.ContentValues values = new android.content.ContentValues();
values.put(DailyNotificationDatabase.COL_CONTENTS_SLOT_ID, "test_slot_1");
values.put(DailyNotificationDatabase.COL_CONTENTS_PAYLOAD_JSON, "{\"title\":\"Test\"}");
values.put(DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT, System.currentTimeMillis());
long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null, values);
assertTrue("Insert should succeed", rowId > 0);
// Test inserting into notif_config
values.clear();
values.put(DailyNotificationDatabase.COL_CONFIG_K, "test_key");
values.put(DailyNotificationDatabase.COL_CONFIG_V, "test_value");
rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values);
assertTrue("Config insert should succeed", rowId > 0);
db.close();
}
/**
* Test database file operations
*/
public void testDatabaseFileOperations() {
String dbPath = database.getDatabasePath();
assertNotNull("Database path should not be null", dbPath);
assertTrue("Database path should not be empty", !dbPath.isEmpty());
// Database should exist after creation
assertTrue("Database file should exist", database.databaseExists());
// Database size should be greater than 0
long size = database.getDatabaseSize();
assertTrue("Database size should be greater than 0", size > 0);
}
/**
* Helper method to check if table exists
*/
private boolean tableExists(SQLiteDatabase db, String tableName) {
android.database.Cursor cursor = db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
new String[]{tableName});
boolean exists = cursor.moveToFirst();
cursor.close();
return exists;
}
/**
* Helper method to check if index exists
*/
private boolean indexExists(SQLiteDatabase db, String indexName) {
android.database.Cursor cursor = db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
new String[]{indexName});
boolean exists = cursor.moveToFirst();
cursor.close();
return exists;
}
}

View File

@@ -0,0 +1,193 @@
/**
* DailyNotificationRollingWindowTest.java
*
* Unit tests for rolling window safety functionality
* Tests window maintenance, capacity management, and platform-specific limits
*
* @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 DailyNotificationRollingWindow
*
* Tests the rolling window safety functionality including:
* - Window maintenance and state updates
* - Capacity limit enforcement
* - Platform-specific behavior (iOS vs Android)
* - Statistics and maintenance timing
*/
public class DailyNotificationRollingWindowTest extends AndroidTestCase {
private DailyNotificationRollingWindow rollingWindow;
private Context mockContext;
private DailyNotificationScheduler mockScheduler;
private DailyNotificationTTLEnforcer mockTTLEnforcer;
private DailyNotificationStorage mockStorage;
@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 mock components
mockScheduler = new MockDailyNotificationScheduler();
mockTTLEnforcer = new MockDailyNotificationTTLEnforcer();
mockStorage = new MockDailyNotificationStorage();
// Create rolling window for Android platform
rollingWindow = new DailyNotificationRollingWindow(
mockContext,
mockScheduler,
mockTTLEnforcer,
mockStorage,
false // Android platform
);
}
@Override
protected void tearDown() throws Exception {
super.tearDown();
}
/**
* Test rolling window initialization
*/
public void testRollingWindowInitialization() {
assertNotNull("Rolling window should be initialized", rollingWindow);
// Test Android platform limits
String stats = rollingWindow.getRollingWindowStats();
assertNotNull("Stats should not be null", stats);
assertTrue("Stats should contain Android platform info", stats.contains("Android"));
}
/**
* Test rolling window maintenance
*/
public void testRollingWindowMaintenance() {
// Test that maintenance can be forced
rollingWindow.forceMaintenance();
// Test maintenance timing
assertFalse("Maintenance should not be needed immediately after forcing",
rollingWindow.isMaintenanceNeeded());
// Test time until next maintenance
long timeUntilNext = rollingWindow.getTimeUntilNextMaintenance();
assertTrue("Time until next maintenance should be positive", timeUntilNext > 0);
}
/**
* Test iOS platform behavior
*/
public void testIOSPlatformBehavior() {
// Create rolling window for iOS platform
DailyNotificationRollingWindow iosRollingWindow = new DailyNotificationRollingWindow(
mockContext,
mockScheduler,
mockTTLEnforcer,
mockStorage,
true // iOS platform
);
String stats = iosRollingWindow.getRollingWindowStats();
assertNotNull("iOS stats should not be null", stats);
assertTrue("Stats should contain iOS platform info", stats.contains("iOS"));
}
/**
* Test maintenance timing
*/
public void testMaintenanceTiming() {
// Initially, maintenance should not be needed
assertFalse("Maintenance should not be needed initially",
rollingWindow.isMaintenanceNeeded());
// Force maintenance
rollingWindow.forceMaintenance();
// Should not be needed immediately after
assertFalse("Maintenance should not be needed after forcing",
rollingWindow.isMaintenanceNeeded());
}
/**
* Test statistics retrieval
*/
public void testStatisticsRetrieval() {
String stats = rollingWindow.getRollingWindowStats();
assertNotNull("Statistics should not be null", stats);
assertTrue("Statistics should contain pending count", stats.contains("pending"));
assertTrue("Statistics should contain daily count", stats.contains("daily"));
assertTrue("Statistics should contain platform info", stats.contains("platform"));
}
/**
* Test error handling
*/
public void testErrorHandling() {
// Test with null components (should not crash)
try {
DailyNotificationRollingWindow errorWindow = new DailyNotificationRollingWindow(
null, null, null, null, false
);
// Should not crash during construction
} catch (Exception e) {
// Expected to handle gracefully
}
}
/**
* Mock DailyNotificationScheduler for testing
*/
private static class MockDailyNotificationScheduler extends DailyNotificationScheduler {
public MockDailyNotificationScheduler() {
super(null, null);
}
@Override
public boolean scheduleNotification(NotificationContent content) {
return true; // Always succeed for testing
}
}
/**
* Mock DailyNotificationTTLEnforcer for testing
*/
private static class MockDailyNotificationTTLEnforcer extends DailyNotificationTTLEnforcer {
public MockDailyNotificationTTLEnforcer() {
super(null, null, false);
}
@Override
public boolean validateBeforeArming(NotificationContent content) {
return true; // Always pass validation for testing
}
}
/**
* Mock DailyNotificationStorage for testing
*/
private static class MockDailyNotificationStorage extends DailyNotificationStorage {
public MockDailyNotificationStorage() {
super(null);
}
}
}

View 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);
}
}