/** * DailyNotificationPlugin.java * * Android implementation of the Daily Notification Plugin for Capacitor * Implements offline-first daily notifications with prefetch → cache → schedule → display pipeline * * @author Matthew Raymer * @version 1.0.0 */ package com.timesafari.dailynotification; import android.Manifest; import android.app.AlarmManager; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.database.sqlite.SQLiteDatabase; import android.os.Build; import android.os.PowerManager; import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.work.WorkManager; import com.getcapacitor.JSObject; import com.getcapacitor.Plugin; import com.getcapacitor.PluginCall; import com.getcapacitor.PluginMethod; import com.getcapacitor.annotation.CapacitorPlugin; import com.getcapacitor.annotation.Permission; import java.util.Calendar; import java.util.concurrent.TimeUnit; /** * Main plugin class for handling daily notifications on Android * * This plugin provides functionality for scheduling and managing daily notifications * with offline-first approach, background content fetching, and reliable delivery. */ @CapacitorPlugin( name = "DailyNotification", permissions = { @Permission( alias = "notifications", strings = { Manifest.permission.POST_NOTIFICATIONS, Manifest.permission.SCHEDULE_EXACT_ALARM, Manifest.permission.WAKE_LOCK, Manifest.permission.INTERNET } ) } ) public class DailyNotificationPlugin extends Plugin { private static final String TAG = "DailyNotificationPlugin"; private static final String CHANNEL_ID = "timesafari.daily"; private static final String CHANNEL_NAME = "Daily Notifications"; private static final String CHANNEL_DESCRIPTION = "Daily notification updates from TimeSafari"; private NotificationManager notificationManager; private AlarmManager alarmManager; private WorkManager workManager; private PowerManager powerManager; private DailyNotificationStorage storage; private DailyNotificationScheduler scheduler; private DailyNotificationFetcher fetcher; // SQLite database components private DailyNotificationDatabase database; private DailyNotificationMigration migration; private String databasePath; private boolean useSharedStorage = false; // Rolling window management private DailyNotificationRollingWindow rollingWindow; // Exact alarm management private DailyNotificationExactAlarmManager exactAlarmManager; // Reboot recovery management private DailyNotificationRebootRecoveryManager rebootRecoveryManager; /** * Initialize the plugin and create notification channel */ @Override public void load() { super.load(); try { // Initialize system services notificationManager = (NotificationManager) getContext() .getSystemService(Context.NOTIFICATION_SERVICE); alarmManager = (AlarmManager) getContext() .getSystemService(Context.ALARM_SERVICE); workManager = WorkManager.getInstance(getContext()); powerManager = (PowerManager) getContext() .getSystemService(Context.POWER_SERVICE); // Initialize components storage = new DailyNotificationStorage(getContext()); scheduler = new DailyNotificationScheduler(getContext(), alarmManager); fetcher = new DailyNotificationFetcher(getContext(), storage); // Initialize TTL enforcer and connect to scheduler initializeTTLEnforcer(); // Create notification channel createNotificationChannel(); // Schedule next maintenance scheduleMaintenance(); Log.i(TAG, "DailyNotificationPlugin initialized successfully"); } catch (Exception e) { Log.e(TAG, "Failed to initialize DailyNotificationPlugin", e); } } /** * Configure the plugin with database and storage options * * @param call Plugin call containing configuration parameters */ @PluginMethod public void configure(PluginCall call) { try { Log.d(TAG, "Configuring plugin with new options"); // Get configuration options String dbPath = call.getString("dbPath"); String storageMode = call.getString("storage", "tiered"); Integer ttlSeconds = call.getInt("ttlSeconds"); Integer prefetchLeadMinutes = call.getInt("prefetchLeadMinutes"); Integer maxNotificationsPerDay = call.getInt("maxNotificationsPerDay"); Integer retentionDays = call.getInt("retentionDays"); // Update storage mode useSharedStorage = "shared".equals(storageMode); // Set database path if (dbPath != null && !dbPath.isEmpty()) { databasePath = dbPath; Log.d(TAG, "Database path set to: " + databasePath); } else { // Use default database path databasePath = getContext().getDatabasePath("daily_notifications.db").getAbsolutePath(); Log.d(TAG, "Using default database path: " + databasePath); } // Initialize SQLite database if using shared storage if (useSharedStorage) { initializeSQLiteDatabase(); } // Store configuration in database or SharedPreferences storeConfiguration(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); Log.i(TAG, "Plugin configuration completed successfully"); call.resolve(); } catch (Exception e) { Log.e(TAG, "Error configuring plugin", e); call.reject("Configuration failed: " + e.getMessage()); } } /** * Initialize SQLite database with migration */ private void initializeSQLiteDatabase() { try { Log.d(TAG, "Initializing SQLite database"); // Create database instance database = new DailyNotificationDatabase(getContext(), databasePath); // Initialize migration utility migration = new DailyNotificationMigration(getContext(), database); // Perform migration if needed if (migration.migrateToSQLite()) { Log.i(TAG, "Migration completed successfully"); // Validate migration if (migration.validateMigration()) { Log.i(TAG, "Migration validation successful"); Log.i(TAG, migration.getMigrationStats()); } else { Log.w(TAG, "Migration validation failed"); } } else { Log.w(TAG, "Migration failed or not needed"); } } catch (Exception e) { Log.e(TAG, "Error initializing SQLite database", e); throw new RuntimeException("SQLite initialization failed", e); } } /** * Store configuration values */ private void storeConfiguration(Integer ttlSeconds, Integer prefetchLeadMinutes, Integer maxNotificationsPerDay, Integer retentionDays) { try { if (useSharedStorage && database != null) { // Store in SQLite storeConfigurationInSQLite(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); } else { // Store in SharedPreferences storeConfigurationInSharedPreferences(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); } } catch (Exception e) { Log.e(TAG, "Error storing configuration", e); } } /** * Store configuration in SQLite database */ private void storeConfigurationInSQLite(Integer ttlSeconds, Integer prefetchLeadMinutes, Integer maxNotificationsPerDay, Integer retentionDays) { try { SQLiteDatabase db = database.getWritableDatabase(); // Store each configuration value if (ttlSeconds != null) { storeConfigValue(db, "ttlSeconds", String.valueOf(ttlSeconds)); } if (prefetchLeadMinutes != null) { storeConfigValue(db, "prefetchLeadMinutes", String.valueOf(prefetchLeadMinutes)); } if (maxNotificationsPerDay != null) { storeConfigValue(db, "maxNotificationsPerDay", String.valueOf(maxNotificationsPerDay)); } if (retentionDays != null) { storeConfigValue(db, "retentionDays", String.valueOf(retentionDays)); } Log.d(TAG, "Configuration stored in SQLite"); } catch (Exception e) { Log.e(TAG, "Error storing configuration in SQLite", e); } } /** * Store a single configuration value in SQLite */ private void storeConfigValue(SQLiteDatabase db, String key, String value) { ContentValues values = new ContentValues(); values.put(DailyNotificationDatabase.COL_CONFIG_K, key); values.put(DailyNotificationDatabase.COL_CONFIG_V, value); // Use INSERT OR REPLACE to handle updates db.replace(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values); } /** * Store configuration in SharedPreferences */ private void storeConfigurationInSharedPreferences(Integer ttlSeconds, Integer prefetchLeadMinutes, Integer maxNotificationsPerDay, Integer retentionDays) { try { SharedPreferences prefs = getContext().getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); if (ttlSeconds != null) { editor.putInt("ttlSeconds", ttlSeconds); } if (prefetchLeadMinutes != null) { editor.putInt("prefetchLeadMinutes", prefetchLeadMinutes); } if (maxNotificationsPerDay != null) { editor.putInt("maxNotificationsPerDay", maxNotificationsPerDay); } if (retentionDays != null) { editor.putInt("retentionDays", retentionDays); } editor.apply(); Log.d(TAG, "Configuration stored in SharedPreferences"); } catch (Exception e) { Log.e(TAG, "Error storing configuration in SharedPreferences", e); } } /** * 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); // Initialize rolling window initializeRollingWindow(ttlEnforcer); Log.i(TAG, "TTL enforcer initialized and connected to scheduler"); } catch (Exception e) { Log.e(TAG, "Error initializing TTL enforcer", e); } } /** * Initialize rolling window manager */ private void initializeRollingWindow(DailyNotificationTTLEnforcer ttlEnforcer) { try { Log.d(TAG, "Initializing rolling window manager"); // Detect platform (Android vs iOS) boolean isIOSPlatform = false; // TODO: Implement platform detection // Create rolling window manager rollingWindow = new DailyNotificationRollingWindow( getContext(), scheduler, ttlEnforcer, storage, isIOSPlatform ); // Initialize exact alarm manager initializeExactAlarmManager(); // Initialize reboot recovery manager initializeRebootRecoveryManager(); // Start initial window maintenance rollingWindow.maintainRollingWindow(); Log.i(TAG, "Rolling window manager initialized"); } catch (Exception e) { Log.e(TAG, "Error initializing rolling window manager", e); } } /** * Initialize exact alarm manager */ private void initializeExactAlarmManager() { try { Log.d(TAG, "Initializing exact alarm manager"); // Create exact alarm manager exactAlarmManager = new DailyNotificationExactAlarmManager( getContext(), alarmManager, scheduler ); // Connect to scheduler scheduler.setExactAlarmManager(exactAlarmManager); Log.i(TAG, "Exact alarm manager initialized"); } catch (Exception e) { Log.e(TAG, "Error initializing exact alarm manager", e); } } /** * Initialize reboot recovery manager */ private void initializeRebootRecoveryManager() { try { Log.d(TAG, "Initializing reboot recovery manager"); // Create reboot recovery manager rebootRecoveryManager = new DailyNotificationRebootRecoveryManager( getContext(), scheduler, exactAlarmManager, rollingWindow ); // Register broadcast receivers rebootRecoveryManager.registerReceivers(); Log.i(TAG, "Reboot recovery manager initialized"); } catch (Exception e) { Log.e(TAG, "Error initializing reboot recovery manager", e); } } /** * Schedule a daily notification with the specified options * * @param call Plugin call containing notification parameters */ @PluginMethod public void scheduleDailyNotification(PluginCall call) { try { Log.d(TAG, "Scheduling daily notification"); // Validate required parameters String time = call.getString("time"); if (time == null || time.isEmpty()) { call.reject("Time parameter is required"); return; } // Parse time (HH:mm format) String[] timeParts = time.split(":"); if (timeParts.length != 2) { call.reject("Invalid time format. Use HH:mm"); return; } int hour, minute; try { hour = Integer.parseInt(timeParts[0]); minute = Integer.parseInt(timeParts[1]); } catch (NumberFormatException e) { call.reject("Invalid time format. Use HH:mm"); return; } if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { call.reject("Invalid time values"); return; } // Extract other parameters String title = call.getString("title", "Daily Update"); String body = call.getString("body", "Your daily notification is ready"); boolean sound = call.getBoolean("sound", true); String priority = call.getString("priority", "default"); String url = call.getString("url", ""); // Create notification content NotificationContent content = new NotificationContent(); content.setTitle(title); content.setBody(body); content.setSound(sound); content.setPriority(priority); content.setUrl(url); content.setScheduledTime(calculateNextScheduledTime(hour, minute)); // Store notification content storage.saveNotificationContent(content); // Schedule the notification boolean scheduled = scheduler.scheduleNotification(content); if (scheduled) { // Schedule background fetch for next day scheduleBackgroundFetch(content.getScheduledTime()); Log.i(TAG, "Daily notification scheduled successfully for " + time); call.resolve(); } else { call.reject("Failed to schedule notification"); } } catch (Exception e) { Log.e(TAG, "Error scheduling daily notification", e); call.reject("Internal error: " + e.getMessage()); } } /** * Get the last notification that was delivered * * @param call Plugin call */ @PluginMethod public void getLastNotification(PluginCall call) { try { Log.d(TAG, "Getting last notification"); NotificationContent lastNotification = storage.getLastNotification(); if (lastNotification != null) { JSObject result = new JSObject(); result.put("id", lastNotification.getId()); result.put("title", lastNotification.getTitle()); result.put("body", lastNotification.getBody()); result.put("timestamp", lastNotification.getScheduledTime()); result.put("url", lastNotification.getUrl()); call.resolve(result); } else { call.resolve(null); } } catch (Exception e) { Log.e(TAG, "Error getting last notification", e); call.reject("Internal error: " + e.getMessage()); } } /** * Cancel all scheduled notifications * * @param call Plugin call */ @PluginMethod public void cancelAllNotifications(PluginCall call) { try { Log.d(TAG, "Cancelling all notifications"); scheduler.cancelAllNotifications(); storage.clearAllNotifications(); Log.i(TAG, "All notifications cancelled successfully"); call.resolve(); } catch (Exception e) { Log.e(TAG, "Error cancelling notifications", e); call.reject("Internal error: " + e.getMessage()); } } /** * Get the current status of notifications * * @param call Plugin call */ @PluginMethod public void getNotificationStatus(PluginCall call) { try { Log.d(TAG, "Getting notification status"); JSObject result = new JSObject(); // Check if notifications are enabled boolean notificationsEnabled = areNotificationsEnabled(); result.put("isEnabled", notificationsEnabled); // Get next notification time long nextNotificationTime = scheduler.getNextNotificationTime(); result.put("nextNotificationTime", nextNotificationTime); // Get current settings JSObject settings = new JSObject(); settings.put("sound", true); settings.put("priority", "default"); settings.put("timezone", "UTC"); result.put("settings", settings); // Get pending notifications count int pendingCount = scheduler.getPendingNotificationsCount(); result.put("pending", pendingCount); call.resolve(result); } catch (Exception e) { Log.e(TAG, "Error getting notification status", e); call.reject("Internal error: " + e.getMessage()); } } /** * Update notification settings * * @param call Plugin call containing new settings */ @PluginMethod public void updateSettings(PluginCall call) { try { Log.d(TAG, "Updating notification settings"); // Extract settings Boolean sound = call.getBoolean("sound"); String priority = call.getString("priority"); String timezone = call.getString("timezone"); // Update settings in storage if (sound != null) { storage.setSoundEnabled(sound); } if (priority != null) { storage.setPriority(priority); } if (timezone != null) { storage.setTimezone(timezone); } // Update existing notifications with new settings scheduler.updateNotificationSettings(); Log.i(TAG, "Notification settings updated successfully"); call.resolve(); } catch (Exception e) { Log.e(TAG, "Error updating notification settings", e); call.reject("Internal error: " + e.getMessage()); } } /** * Get battery status information * * @param call Plugin call */ @PluginMethod public void getBatteryStatus(PluginCall call) { try { Log.d(TAG, "Getting battery status"); JSObject result = new JSObject(); // Get battery level (simplified - would need BatteryManager in real implementation) result.put("level", 100); // Placeholder result.put("isCharging", false); // Placeholder result.put("powerState", 0); // Placeholder result.put("isOptimizationExempt", false); // Placeholder call.resolve(result); } catch (Exception e) { Log.e(TAG, "Error getting battery status", e); call.reject("Internal error: " + e.getMessage()); } } /** * Request battery optimization exemption * * @param call Plugin call */ @PluginMethod public void requestBatteryOptimizationExemption(PluginCall call) { try { Log.d(TAG, "Requesting battery optimization exemption"); // This would typically open system settings // For now, just log the request Log.i(TAG, "Battery optimization exemption requested"); call.resolve(); } catch (Exception e) { Log.e(TAG, "Error requesting battery optimization exemption", e); call.reject("Internal error: " + e.getMessage()); } } /** * Set adaptive scheduling based on device state * * @param call Plugin call containing enabled flag */ @PluginMethod public void setAdaptiveScheduling(PluginCall call) { try { Log.d(TAG, "Setting adaptive scheduling"); boolean enabled = call.getBoolean("enabled", true); storage.setAdaptiveSchedulingEnabled(enabled); if (enabled) { scheduler.enableAdaptiveScheduling(); } else { scheduler.disableAdaptiveScheduling(); } Log.i(TAG, "Adaptive scheduling " + (enabled ? "enabled" : "disabled")); call.resolve(); } catch (Exception e) { Log.e(TAG, "Error setting adaptive scheduling", e); call.reject("Internal error: " + e.getMessage()); } } /** * Get current power state information * * @param call Plugin call */ @PluginMethod public void getPowerState(PluginCall call) { try { Log.d(TAG, "Getting power state"); JSObject result = new JSObject(); result.put("powerState", 0); // Placeholder result.put("isOptimizationExempt", false); // Placeholder call.resolve(result); } catch (Exception e) { Log.e(TAG, "Error getting power state", e); call.reject("Internal error: " + e.getMessage()); } } /** * Create the notification channel for Android 8.0+ */ private void createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH ); channel.setDescription(CHANNEL_DESCRIPTION); channel.enableLights(true); channel.enableVibration(true); notificationManager.createNotificationChannel(channel); Log.d(TAG, "Notification channel created: " + CHANNEL_ID); } } /** * Calculate the next scheduled time for the notification * * @param hour Hour of day (0-23) * @param minute Minute of hour (0-59) * @return Timestamp in milliseconds */ private long calculateNextScheduledTime(int hour, int minute) { Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, hour); calendar.set(Calendar.MINUTE, minute); calendar.set(Calendar.SECOND, 0); calendar.set(Calendar.MILLISECOND, 0); // If time has passed today, schedule for tomorrow if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { calendar.add(Calendar.DAY_OF_YEAR, 1); } return calendar.getTimeInMillis(); } /** * Schedule background fetch for content * * @param scheduledTime When the notification is scheduled for */ private void scheduleBackgroundFetch(long scheduledTime) { try { // Schedule fetch 1 hour before notification long fetchTime = scheduledTime - TimeUnit.HOURS.toMillis(1); if (fetchTime > System.currentTimeMillis()) { fetcher.scheduleFetch(fetchTime); Log.d(TAG, "Background fetch scheduled for " + fetchTime); } } catch (Exception e) { Log.e(TAG, "Error scheduling background fetch", e); } } /** * Schedule maintenance tasks */ private void scheduleMaintenance() { try { // Schedule daily maintenance at 2 AM Calendar calendar = Calendar.getInstance(); calendar.set(Calendar.HOUR_OF_DAY, 2); calendar.set(Calendar.MINUTE, 0); calendar.set(Calendar.SECOND, 0); if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { calendar.add(Calendar.DAY_OF_YEAR, 1); } // This would typically use WorkManager for maintenance Log.d(TAG, "Maintenance scheduled for " + calendar.getTimeInMillis()); } catch (Exception e) { Log.e(TAG, "Error scheduling maintenance", e); } } /** * Check if notifications are enabled * * @return true if notifications are enabled */ private boolean areNotificationsEnabled() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED; } return NotificationManagerCompat.from(getContext()).areNotificationsEnabled(); } /** * Maintain rolling window (for testing or manual triggers) * * @param call Plugin call */ @PluginMethod public void maintainRollingWindow(PluginCall call) { try { Log.d(TAG, "Manual rolling window maintenance requested"); if (rollingWindow != null) { rollingWindow.forceMaintenance(); call.resolve(); } else { call.reject("Rolling window not initialized"); } } catch (Exception e) { Log.e(TAG, "Error during manual rolling window maintenance", e); call.reject("Error maintaining rolling window: " + e.getMessage()); } } /** * Get rolling window statistics * * @param call Plugin call */ @PluginMethod public void getRollingWindowStats(PluginCall call) { try { Log.d(TAG, "Rolling window stats requested"); if (rollingWindow != null) { String stats = rollingWindow.getRollingWindowStats(); JSObject result = new JSObject(); result.put("stats", stats); result.put("maintenanceNeeded", rollingWindow.isMaintenanceNeeded()); result.put("timeUntilNextMaintenance", rollingWindow.getTimeUntilNextMaintenance()); call.resolve(result); } else { call.reject("Rolling window not initialized"); } } catch (Exception e) { Log.e(TAG, "Error getting rolling window stats", e); call.reject("Error getting rolling window stats: " + e.getMessage()); } } /** * Get exact alarm status * * @param call Plugin call */ @PluginMethod public void getExactAlarmStatus(PluginCall call) { try { Log.d(TAG, "Exact alarm status requested"); if (exactAlarmManager != null) { DailyNotificationExactAlarmManager.ExactAlarmStatus status = exactAlarmManager.getExactAlarmStatus(); JSObject result = new JSObject(); result.put("supported", status.supported); result.put("enabled", status.enabled); result.put("canSchedule", status.canSchedule); result.put("fallbackWindow", status.fallbackWindow.description); call.resolve(result); } else { call.reject("Exact alarm manager not initialized"); } } catch (Exception e) { Log.e(TAG, "Error getting exact alarm status", e); call.reject("Error getting exact alarm status: " + e.getMessage()); } } /** * Request exact alarm permission * * @param call Plugin call */ @PluginMethod public void requestExactAlarmPermission(PluginCall call) { try { Log.d(TAG, "Exact alarm permission request"); if (exactAlarmManager != null) { boolean success = exactAlarmManager.requestExactAlarmPermission(); if (success) { call.resolve(); } else { call.reject("Failed to request exact alarm permission"); } } else { call.reject("Exact alarm manager not initialized"); } } catch (Exception e) { Log.e(TAG, "Error requesting exact alarm permission", e); call.reject("Error requesting exact alarm permission: " + e.getMessage()); } } /** * Open exact alarm settings * * @param call Plugin call */ @PluginMethod public void openExactAlarmSettings(PluginCall call) { try { Log.d(TAG, "Opening exact alarm settings"); if (exactAlarmManager != null) { boolean success = exactAlarmManager.openExactAlarmSettings(); if (success) { call.resolve(); } else { call.reject("Failed to open exact alarm settings"); } } else { call.reject("Exact alarm manager not initialized"); } } catch (Exception e) { Log.e(TAG, "Error opening exact alarm settings", e); call.reject("Error opening exact alarm settings: " + e.getMessage()); } } /** * Get reboot recovery status * * @param call Plugin call */ @PluginMethod public void getRebootRecoveryStatus(PluginCall call) { try { Log.d(TAG, "Reboot recovery status requested"); if (rebootRecoveryManager != null) { DailyNotificationRebootRecoveryManager.RecoveryStatus status = rebootRecoveryManager.getRecoveryStatus(); JSObject result = new JSObject(); result.put("inProgress", status.inProgress); result.put("lastRecoveryTime", status.lastRecoveryTime); result.put("timeSinceLastRecovery", status.timeSinceLastRecovery); result.put("recoveryNeeded", rebootRecoveryManager.isRecoveryNeeded()); call.resolve(result); } else { call.reject("Reboot recovery manager not initialized"); } } catch (Exception e) { Log.e(TAG, "Error getting reboot recovery status", e); call.reject("Error getting reboot recovery status: " + e.getMessage()); } } }