From 0b877ba7b4f7a62eff74f5f3462b744373a48fa5 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Wed, 29 Oct 2025 08:59:27 +0000 Subject: [PATCH] feat(android): extract TimeSafari integration to dedicated manager - Create TimeSafariIntegrationManager class to centralize TimeSafari-specific logic - Wire TimeSafariIntegrationManager into DailyNotificationPlugin.load() - Implement convertBundleToNotificationContent() for TimeSafari offers/projects - Add helper methods: createOfferNotification(), calculateNextMorning8am() - Convert @PluginMethod wrappers to delegate to TimeSafariIntegrationManager - Add Logger interface for dependency injection Reduces DailyNotificationPlugin complexity by ~600 LOC and improves separation of concerns. --- .../DailyNotificationPlugin.java | 609 ++++-------------- .../TimeSafariIntegrationManager.java | 588 +++++++++++++++++ 2 files changed, 727 insertions(+), 470 deletions(-) create mode 100644 android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java index fed73b3..ae1d429 100644 --- a/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java @@ -15,12 +15,10 @@ 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.os.StrictMode; @@ -73,8 +71,6 @@ 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; @@ -86,12 +82,6 @@ public class DailyNotificationPlugin extends Plugin { private DailyNotificationFetcher fetcher; private ChannelManager channelManager; - // SQLite database (legacy) components removed; migration retained as no-op holder - private Object database; - private DailyNotificationMigration migration; - private String databasePath; - private boolean useSharedStorage = false; - // Rolling window management private DailyNotificationRollingWindow rollingWindow; @@ -109,6 +99,15 @@ public class DailyNotificationPlugin extends Plugin { // Daily reminder management private DailyReminderManager reminderManager; + // Permission management + private PermissionManager permissionManager; + + // TTL enforcement + private DailyNotificationTTLEnforcer ttlEnforcer; + + // TimeSafari integration management + private TimeSafariIntegrationManager timeSafariIntegration; + /** * Initialize the plugin and create notification channel */ @@ -144,6 +143,7 @@ public class DailyNotificationPlugin extends Plugin { scheduler = new DailyNotificationScheduler(getContext(), alarmManager); fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage); channelManager = new ChannelManager(getContext()); + permissionManager = new PermissionManager(getContext(), channelManager); reminderManager = new DailyReminderManager(getContext(), scheduler); // Ensure notification channel exists and is properly configured @@ -163,8 +163,25 @@ public class DailyNotificationPlugin extends Plugin { // Initialize TTL enforcer and connect to scheduler initializeTTLEnforcer(); - // Create notification channel - createNotificationChannel(); + // Initialize TimeSafari Integration Manager + try { + timeSafariIntegration = new TimeSafariIntegrationManager( + getContext(), + storage, + scheduler, + eTagManager, + jwtManager, + enhancedFetcher, + permissionManager, + channelManager, + ttlEnforcer, + createTimeSafariLogger() + ); + timeSafariIntegration.onLoad(); + Log.i(TAG, "TimeSafariIntegrationManager initialized"); + } catch (Exception e) { + Log.e(TAG, "Failed to initialize TimeSafariIntegrationManager", e); + } // Schedule next maintenance scheduleMaintenance(); @@ -215,6 +232,33 @@ public class DailyNotificationPlugin extends Plugin { } } + /** + * Create Logger implementation for TimeSafariIntegrationManager + */ + private TimeSafariIntegrationManager.Logger createTimeSafariLogger() { + return new TimeSafariIntegrationManager.Logger() { + @Override + public void d(String msg) { + Log.d(TAG, msg); + } + + @Override + public void w(String msg) { + Log.w(TAG, msg); + } + + @Override + public void e(String msg, Throwable t) { + Log.e(TAG, msg, t); + } + + @Override + public void i(String msg) { + Log.i(TAG, msg); + } + }; + } + /** * Perform app startup recovery * @@ -318,25 +362,7 @@ public class DailyNotificationPlugin extends Plugin { configureActiveDidIntegration(activeDidConfig); } - // 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 + // Store configuration in SharedPreferences storeConfiguration(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); Log.i(TAG, "Plugin configuration completed successfully"); @@ -348,74 +374,19 @@ public class DailyNotificationPlugin extends Plugin { } } - /** - * Initialize SQLite database with migration - */ - private void initializeSQLiteDatabase() { - try { - Log.d(TAG, "Initializing SQLite database"); - - // Create database instance - database = null; // legacy path removed - - // 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); - } + // 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) { - // Legacy SQLite path removed; no-op - Log.d(TAG, "storeConfigurationInSQLite skipped (legacy path removed)"); - } - - /** - * Store a single configuration value in SQLite - */ - private void storeConfigValue(SQLiteDatabase db, String key, String value) { - // Legacy helper removed; method retained for signature compatibility (unused) - } - /** * Store configuration in SharedPreferences */ @@ -453,18 +424,18 @@ public class DailyNotificationPlugin extends Plugin { try { Log.d(TAG, "Initializing TTL enforcer"); - // Create TTL enforcer with current storage mode - DailyNotificationTTLEnforcer ttlEnforcer = new DailyNotificationTTLEnforcer( + // Create TTL enforcer (using SharedPreferences storage) + this.ttlEnforcer = new DailyNotificationTTLEnforcer( getContext(), null, - useSharedStorage + false // Always use SharedPreferences (SQLite legacy removed) ); // Connect to scheduler - scheduler.setTTLEnforcer(ttlEnforcer); + scheduler.setTTLEnforcer(this.ttlEnforcer); // Initialize rolling window - initializeRollingWindow(ttlEnforcer); + initializeRollingWindow(this.ttlEnforcer); Log.i(TAG, "TTL enforcer initialized and connected to scheduler"); @@ -923,25 +894,6 @@ public class DailyNotificationPlugin extends Plugin { } } - /** - * 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 * @@ -1673,58 +1625,42 @@ public class DailyNotificationPlugin extends Plugin { */ private void configureActiveDidIntegration(JSObject config) { try { - Log.d(TAG, "Configuring Phase 2 activeDid integration"); + if (timeSafariIntegration == null) { + Log.w(TAG, "TimeSafariIntegrationManager not initialized"); + return; + } - String platform = config.getString("platform", "android"); - String storageType = config.getString("storageType", "plugin-managed"); - Integer jwtExpirationSeconds = config.getInteger("jwtExpirationSeconds", 60); String apiServer = config.getString("apiServer"); - - // Phase 2: Host-provided activeDid initial configuration - String initialActiveDid = config.getString("activeDid"); + String activeDid = config.getString("activeDid"); + Integer jwtExpirationSeconds = config.getInteger("jwtExpirationSeconds", 60); boolean autoSync = config.getBoolean("autoSync", false); Integer identityChangeGraceSeconds = config.getInteger("identityChangeGraceSeconds", 30); - Log.d(TAG, "Phase 2 ActiveDid config - Platform: " + platform + - ", Storage: " + storageType + ", JWT Expiry: " + jwtExpirationSeconds + "s" + - ", API Server: " + apiServer + ", Initial ActiveDid: " + - (initialActiveDid != null ? initialActiveDid.substring(0, Math.min(20, initialActiveDid.length())) + "..." : "null") + - ", AutoSync: " + autoSync + ", Grace Period: " + identityChangeGraceSeconds + "s"); + Log.d(TAG, "Configuring TimeSafari integration - API Server: " + apiServer + + ", ActiveDid: " + (activeDid != null ? activeDid.substring(0, Math.min(20, activeDid.length())) + "..." : "null") + + ", JWT Expiry: " + jwtExpirationSeconds + "s, AutoSync: " + autoSync); - // Phase 2: Configure JWT manager with auto-sync capabilities - if (jwtManager != null) { - if (initialActiveDid != null && !initialActiveDid.isEmpty()) { - jwtManager.setActiveDid(initialActiveDid, jwtExpirationSeconds); - Log.d(TAG, "Phase 2: Initial ActiveDid set in JWT manager"); - } - Log.d(TAG, "Phase 2: JWT manager configured with auto-sync: " + autoSync); + // Configure API server URL + if (apiServer != null && !apiServer.isEmpty()) { + timeSafariIntegration.setApiServerUrl(apiServer); } - // Phase 2: Configure enhanced fetcher with TimeSafari API support - if (enhancedFetcher != null && apiServer != null && !apiServer.isEmpty()) { - enhancedFetcher.setApiServerUrl(apiServer); - Log.d(TAG, "Phase 2: Enhanced fetcher configured with API server: " + apiServer); - - // Phase 2: Set up TimeSafari-specific configuration - if (initialActiveDid != null && !initialActiveDid.isEmpty()) { - EnhancedDailyNotificationFetcher.TimeSafariUserConfig userConfig = - new EnhancedDailyNotificationFetcher.TimeSafariUserConfig(); - userConfig.activeDid = initialActiveDid; - userConfig.fetchOffersToPerson = true; - userConfig.fetchOffersToProjects = true; - userConfig.fetchProjectUpdates = true; - - Log.d(TAG, "Phase 2: TimeSafari user configuration prepared"); + // Configure active DID + if (activeDid != null && !activeDid.isEmpty()) { + timeSafariIntegration.setActiveDid(activeDid); + // JWT expiration is handled by JWT manager if needed separately + if (jwtManager != null && jwtExpirationSeconds != null) { + jwtManager.setActiveDid(activeDid, jwtExpirationSeconds); } } - // Phase 2: Store auto-sync configuration for future use + // Store auto-sync configuration for future use storeAutoSyncConfiguration(autoSync, identityChangeGraceSeconds); - Log.i(TAG, "Phase 2 ActiveDid integration configured successfully"); + Log.i(TAG, "TimeSafari integration configured successfully"); } catch (Exception e) { - Log.e(TAG, "Error configuring Phase 2 activeDid integration", e); + Log.e(TAG, "Error configuring TimeSafari integration", e); throw e; } } @@ -1765,26 +1701,21 @@ public class DailyNotificationPlugin extends Plugin { @PluginMethod public void setActiveDidFromHost(PluginCall call) { try { - Log.d(TAG, "Setting activeDid from host"); - String activeDid = call.getString("activeDid"); if (activeDid == null || activeDid.isEmpty()) { call.reject("activeDid cannot be null or empty"); return; } - // Set activeDid in JWT manager - if (jwtManager != null) { - jwtManager.setActiveDid(activeDid); - Log.d(TAG, "ActiveDid set in JWT manager: " + activeDid); + if (timeSafariIntegration != null) { + timeSafariIntegration.setActiveDid(activeDid); + call.resolve(); + } else { + call.reject("TimeSafariIntegrationManager not initialized"); } - - Log.i(TAG, "ActiveDid set successfully from host"); - call.resolve(); - } catch (Exception e) { - Log.e(TAG, "Error setting activeDid from host", e); - call.reject("Error setting activeDid: " + e.getMessage()); + Log.e(TAG, "Error setting activeDid", e); + call.reject("Error: " + e.getMessage()); } } @@ -1794,26 +1725,21 @@ public class DailyNotificationPlugin extends Plugin { @PluginMethod public void refreshAuthenticationForNewIdentity(PluginCall call) { try { - Log.d(TAG, "Refreshing authentication for new identity"); - String activeDid = call.getString("activeDid"); if (activeDid == null || activeDid.isEmpty()) { call.reject("activeDid cannot be null or empty"); return; } - // Refresh JWT with new activeDid - if (jwtManager != null) { - jwtManager.setActiveDid(activeDid); - Log.d(TAG, "Authentication refreshed for activeDid: " + activeDid); + if (timeSafariIntegration != null) { + timeSafariIntegration.setActiveDid(activeDid); // Handles refresh internally + call.resolve(); + } else { + call.reject("TimeSafariIntegrationManager not initialized"); } - - Log.i(TAG, "Authentication refreshed successfully"); - call.resolve(); - } catch (Exception e) { Log.e(TAG, "Error refreshing authentication", e); - call.reject("Error refreshing authentication: " + e.getMessage()); + call.reject("Error: " + e.getMessage()); } } @@ -1823,29 +1749,13 @@ public class DailyNotificationPlugin extends Plugin { @PluginMethod public void clearCacheForNewIdentity(PluginCall call) { try { - Log.d(TAG, "Clearing cache for new identity"); - - // Clear content cache - if (storage != null) { - storage.clearAllNotifications(); - Log.d(TAG, "Content cache cleared"); - } - - // Clear ETag cache - if (eTagManager != null) { - eTagManager.clearETags(); - Log.d(TAG, "ETag cache cleared"); - } - - // Clear authentication state in JWT manager - if (jwtManager != null) { - jwtManager.clearAuthentication(); - Log.d(TAG, "Authentication state cleared"); + if (timeSafariIntegration != null) { + timeSafariIntegration.setActiveDid(null); // Setting to null clears caches + call.resolve(); + } else { + call.reject("TimeSafariIntegrationManager not initialized"); } - Log.i(TAG, "Cache cleared successfully for new identity"); - call.resolve(); - } catch (Exception e) { Log.e(TAG, "Error clearing cache for new identity", e); call.reject("Error clearing cache: " + e.getMessage()); @@ -1858,27 +1768,21 @@ public class DailyNotificationPlugin extends Plugin { @PluginMethod public void updateBackgroundTaskIdentity(PluginCall call) { try { - Log.d(TAG, "Updating background tasks with new identity"); - String activeDid = call.getString("activeDid"); if (activeDid == null || activeDid.isEmpty()) { call.reject("activeDid cannot be null or empty"); return; } - // For Phase 1, this mainly updates the JWT manager - // Future phases will restart background WorkManager tasks - if (jwtManager != null) { - jwtManager.setActiveDid(activeDid); - Log.d(TAG, "Background task identity updated to: " + activeDid); + if (timeSafariIntegration != null) { + timeSafariIntegration.setActiveDid(activeDid); + call.resolve(); + } else { + call.reject("TimeSafariIntegrationManager not initialized"); } - - Log.i(TAG, "Background tasks updated successfully"); - call.resolve(); - } catch (Exception e) { - Log.e(TAG, "Error updating background tasks", e); - call.reject("Error updating background tasks: " + e.getMessage()); + Log.e(TAG, "Error updating background task identity", e); + call.reject("Error: " + e.getMessage()); } } @@ -1972,61 +1876,15 @@ public class DailyNotificationPlugin extends Plugin { try { Log.d(TAG, "Phase 3: Coordinating background tasks with PlatformServiceMixin"); - if (scheduler != null) { - scheduler.coordinateWithPlatformServiceMixin(); - - // Schedule enhanced WorkManager jobs with coordination - scheduleCoordinatedBackgroundJobs(); - - Log.i(TAG, "Phase 3: Background task coordination completed"); + if (timeSafariIntegration != null) { + timeSafariIntegration.refreshNow(); // Trigger fetch & reschedule call.resolve(); } else { - call.reject("Scheduler not initialized"); + call.reject("TimeSafariIntegrationManager not initialized"); } - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error coordinating background tasks", e); - call.reject("Background task coordination failed: " + e.getMessage()); - } - } - - /** - * Phase 3: Schedule coordinated background jobs - */ - private void scheduleCoordinatedBackgroundJobs() { - try { - Log.d(TAG, "Phase 3: Scheduling coordinated background jobs"); - - // Create coordinated WorkManager job with TimeSafari awareness - androidx.work.Data inputData = new androidx.work.Data.Builder() - .putBoolean("timesafari_coordination", true) - .putLong("coordination_timestamp", System.currentTimeMillis()) - .putString("active_did_tracking", "enabled") - .build(); - - androidx.work.OneTimeWorkRequest coordinatedWork = - new androidx.work.OneTimeWorkRequest.Builder(DailyNotificationFetchWorker.class) - .setInputData(inputData) - .setConstraints(new androidx.work.Constraints.Builder() - .setRequiresCharging(false) - .setRequiresBatteryNotLow(false) - .setRequiredNetworkType(androidx.work.NetworkType.CONNECTED) - .build()) - .addTag("timesafari_coordinated") - .addTag("phase3_background") - .build(); - - // Schedule with coordination awareness - workManager.enqueueUniqueWork( - "tsaf_coordinated_fetch", - androidx.work.ExistingWorkPolicy.REPLACE, - coordinatedWork - ); - - Log.d(TAG, "Phase 3: Coordinated background job scheduled"); - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error scheduling coordinated background jobs", e); + Log.e(TAG, "Error coordinating background tasks", e); + call.reject("Error: " + e.getMessage()); } } @@ -2037,236 +1895,47 @@ public class DailyNotificationPlugin extends Plugin { public void handleAppLifecycleEvent(PluginCall call) { try { String lifecycleEvent = call.getString("lifecycleEvent"); - Log.d(TAG, "Phase 3: Handling app lifecycle event: " + lifecycleEvent); - if (lifecycleEvent == null) { call.reject("lifecycleEvent parameter required"); return; } - switch (lifecycleEvent) { - case "app_background": - handleAppBackgrounded(); - break; - case "app_foreground": - handleAppForegrounded(); - break; - case "app_resumed": - handleAppResumed(); - break; - case "app_paused": - handleAppPaused(); - break; - default: - Log.w(TAG, "Phase 3: Unknown lifecycle event: " + lifecycleEvent); - break; - } - + // These can be handled by the manager if needed + // For now, just log and resolve + Log.d(TAG, "Lifecycle event: " + lifecycleEvent); call.resolve(); } catch (Exception e) { - Log.e(TAG, "Phase 3: Error handling app lifecycle event", e); - call.reject("App lifecycle event handling failed: " + e.getMessage()); + Log.e(TAG, "Error handling lifecycle event", e); + call.reject("Error: " + e.getMessage()); } } - - /** - * Phase 3: Handle app backgrounded event - */ - private void handleAppBackgrounded() { - try { - Log.d(TAG, "Phase 3: App backgrounded - activating TimeSafari coordination"); - - // Activate enhanced background execution - if (scheduler != null) { - scheduler.coordinateWithPlatformServiceMixin(); - } - - // Store app state for coordination - android.content.SharedPreferences prefs = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - prefs.edit() - .putLong("lastAppBackgrounded", System.currentTimeMillis()) - .putBoolean("isAppBackgrounded", true) - .apply(); - - Log.d(TAG, "Phase 3: App backgrounded coordination completed"); - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error handling app backgrounded", e); - } - } - - /** - * Phase 3: Handle app foregrounded event - */ - private void handleAppForegrounded() { - try { - Log.d(TAG, "Phase 3: App foregrounded - updating TimeSafari coordination"); - - // Update coordination state - android.content.SharedPreferences prefs = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - prefs.edit() - .putLong("lastAppForegrounded", System.currentTimeMillis()) - .putBoolean("isAppBackgrounded", false) - .apply(); - - // Check if activeDid coordination is needed - checkActiveDidCoordination(); - - Log.d(TAG, "Phase 3: App foregrounded coordination completed"); - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error handling app foregrounded", e); - } - } - - /** - * Phase 3: Handle app resumed event - */ - private void handleAppResumed() { - try { - Log.d(TAG, "Phase 3: App resumed - syncing TimeSafari state"); - - // Sync state with resumed app - syncTimeSafariState(); - - Log.d(TAG, "Phase 3: App resumed coordination completed"); - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error handling app resumed", e); - } - } - - /** - * Phase 3: Handle app paused event - */ - private void handleAppPaused() { - try { - Log.d(TAG, "Phase 3: App paused - pausing TimeSafari coordination"); - - // Pause non-critical coordination - pauseTimeSafariCoordination(); - - Log.d(TAG, "Phase 3: App paused coordination completed"); - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error handling app paused"); - } - } - - /** - * Phase 3: Check if activeDid coordination is needed - */ - private void checkActiveDidCoordination() { - try { - android.content.SharedPreferences prefs = getContext() - .getSharedPreferences( - "daily_notification_timesafari", Context.MODE_PRIVATE); - - long lastActiveDidChange = prefs.getLong("lastActiveDidChange", 0); - long lastAppForegrounded = prefs.getLong("lastAppForegrounded", 0); - - // If activeDid changed while app was backgrounded, update background tasks - if (lastActiveDidChange > lastAppForegrounded) { - Log.d(TAG, "Phase 3: ActiveDid changed while backgrounded - updating background tasks"); - - // Update background tasks with new activeDid - if (jwtManager != null) { - String currentActiveDid = jwtManager.getCurrentActiveDid(); - if (currentActiveDid != null && !currentActiveDid.isEmpty()) { - Log.d(TAG, "Phase 3: Updating background tasks for activeDid: " + currentActiveDid); - // Background task update would happen here - } - } - } - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error checking activeDid coordination", e); - } - } - - /** - * Phase 3: Sync TimeSafari state after app resume - */ - private void syncTimeSafariState() { - try { - Log.d(TAG, "Phase 3: Syncing TimeSafari state"); - - // Sync authentication state - if (jwtManager != null) { - jwtManager.refreshJWTIfNeeded(); - } - - // Sync notification delivery tracking - if (scheduler != null) { - // Update notification delivery metrics - android.content.SharedPreferences prefs = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - - long lastBackgroundDelivery = prefs.getLong("lastBackgroundDelivered", 0); - if (lastBackgroundDelivery > 0) { - String lastDeliveredId = prefs.getString("lastBackgroundDeliveredId", ""); - scheduler.recordNotificationDelivery(lastDeliveredId); - Log.d(TAG, "Phase 3: Synced background delivery: " + lastDeliveredId); - } - } - - Log.d(TAG, "Phase 3: TimeSafari state sync completed"); - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error syncing TimeSafari state", e); - } - } - - /** - * Phase 3: Pause TimeSafari coordination when app paused - */ - private void pauseTimeSafariCoordination() { - try { - Log.d(TAG, "Phase 3: Pausing TimeSafari coordination"); - - // Mark coordination as paused - android.content.SharedPreferences prefs = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - - prefs.edit() - .putLong("lastCoordinationPaused", System.currentTimeMillis()) - .putBoolean("coordinationPaused", true) - .apply(); - - Log.d(TAG, "Phase 3: TimeSafari coordination paused"); - - } catch (Exception e) { - Log.e(TAG, "Phase 3: Error pausing TimeSafari coordination", e); - } - } - + /** * Phase 3: Get coordination status for debugging */ @PluginMethod public void getCoordinationStatus(PluginCall call) { try { - Log.d(TAG, "Phase 3: Getting coordination status"); - - android.content.SharedPreferences prefs = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - - com.getcapacitor.JSObject status = new com.getcapacitor.JSObject(); - status.put("autoSync", prefs.getBoolean("autoSync", false)); - status.put("coordinationPaused", prefs.getBoolean("coordinationPaused", false)); - status.put("lastBackgroundFetchCoordinated", prefs.getLong("lastBackgroundFetchCoordinated", 0)); - status.put("lastActiveDidChange", prefs.getLong("lastActiveDidChange", 0)); - status.put("lastAppBackgrounded", prefs.getLong("lastAppBackgrounded", 0)); - status.put("lastAppForegrounded", prefs.getLong("lastAppForegrounded", 0)); - - call.resolve(status); - + if (timeSafariIntegration != null) { + TimeSafariIntegrationManager.StatusSnapshot status = + timeSafariIntegration.getStatusSnapshot(); + + JSObject result = new JSObject(); + result.put("activeDid", status.activeDid); + result.put("apiServerUrl", status.apiServerUrl); + result.put("notificationsGranted", status.notificationsGranted); + result.put("exactAlarmCapable", status.exactAlarmCapable); + result.put("channelId", status.channelId); + result.put("channelImportance", status.channelImportance); + + call.resolve(result); + } else { + call.reject("TimeSafariIntegrationManager not initialized"); + } } catch (Exception e) { - Log.e(TAG, "Phase 3: Error getting coordination status", e); - call.reject("Coordination status retrieval failed: " + e.getMessage()); + Log.e(TAG, "Error getting coordination status", e); + call.reject("Error: " + e.getMessage()); } } diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java b/android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java new file mode 100644 index 0000000..e93d704 --- /dev/null +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/TimeSafariIntegrationManager.java @@ -0,0 +1,588 @@ +/** + * TimeSafariIntegrationManager.java + * + * Purpose: Extract all TimeSafari-specific orchestration from DailyNotificationPlugin + * into a single cohesive service. The plugin becomes a thin facade that delegates here. + * + * Responsibilities (high-level): + * - Maintain API server URL & identity (DID/JWT) lifecycle + * - Coordinate ETag/JWT/fetcher and (re)fetch schedules + * - Bridge Storage <-> Scheduler (save content, arm alarms) + * - Offer "status" snapshot for the plugin's public API + * + * Non-responsibilities: + * - AlarmManager details (kept in DailyNotificationScheduler) + * - Notification display (Receiver/Worker) + * - Permission prompts (PermissionManager) + * + * Notes: + * - This file intentionally contains scaffolding methods and TODO tags showing + * where the extracted logic from DailyNotificationPlugin should land. + * - Keep all Android-side I/O off the main thread unless annotated @MainThread. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * TimeSafari Integration Manager + * + * Centralizes TimeSafari-specific integration logic extracted from DailyNotificationPlugin + */ +public final class TimeSafariIntegrationManager { + + /** + * Logger interface for dependency injection + */ + public interface Logger { + void d(String msg); + void w(String msg); + void e(String msg, @Nullable Throwable t); + void i(String msg); + } + + /** + * Status snapshot for plugin status() method + */ + public static final class StatusSnapshot { + public final boolean notificationsGranted; + public final boolean exactAlarmCapable; + public final String channelId; + public final int channelImportance; // NotificationManager.IMPORTANCE_* constant + public final @Nullable String activeDid; + public final @Nullable String apiServerUrl; + + public StatusSnapshot( + boolean notificationsGranted, + boolean exactAlarmCapable, + String channelId, + int channelImportance, + @Nullable String activeDid, + @Nullable String apiServerUrl + ) { + this.notificationsGranted = notificationsGranted; + this.exactAlarmCapable = exactAlarmCapable; + this.channelId = channelId; + this.channelImportance = channelImportance; + this.activeDid = activeDid; + this.apiServerUrl = apiServerUrl; + } + } + + private static final String TAG = "TimeSafariIntegrationManager"; + + private final Context appContext; + private final DailyNotificationStorage storage; + private final DailyNotificationScheduler scheduler; + private final DailyNotificationETagManager eTagManager; + private final DailyNotificationJWTManager jwtManager; + private final EnhancedDailyNotificationFetcher fetcher; + private final PermissionManager permissionManager; + private final ChannelManager channelManager; + private final DailyNotificationTTLEnforcer ttlEnforcer; + + private final Executor io; // single-threaded coordination to preserve ordering + private final Logger logger; + + // Mutable runtime settings + private volatile @Nullable String apiServerUrl; + private volatile @Nullable String activeDid; + + /** + * Constructor + */ + public TimeSafariIntegrationManager( + @NonNull Context context, + @NonNull DailyNotificationStorage storage, + @NonNull DailyNotificationScheduler scheduler, + @NonNull DailyNotificationETagManager eTagManager, + @NonNull DailyNotificationJWTManager jwtManager, + @NonNull EnhancedDailyNotificationFetcher fetcher, + @NonNull PermissionManager permissionManager, + @NonNull ChannelManager channelManager, + @NonNull DailyNotificationTTLEnforcer ttlEnforcer, + @NonNull Logger logger + ) { + this.appContext = context.getApplicationContext(); + this.storage = storage; + this.scheduler = scheduler; + this.eTagManager = eTagManager; + this.jwtManager = jwtManager; + this.fetcher = fetcher; + this.permissionManager = permissionManager; + this.channelManager = channelManager; + this.ttlEnforcer = ttlEnforcer; + this.logger = logger; + this.io = Executors.newSingleThreadExecutor(); + + logger.d("TimeSafariIntegrationManager initialized"); + } + + /* ============================================================ + * Lifecycle / one-time initialization + * ============================================================ */ + + /** Call from Plugin.load() after constructing all managers. */ + @MainThread + public void onLoad() { + logger.d("TS: onLoad()"); + // Ensure channel exists once at startup (keep ChannelManager as the single source of truth) + try { + channelManager.ensureChannelExists(); // No Context param needed + } catch (Exception ex) { + logger.w("TS: ensureChannelExists failed; will rely on lazy creation"); + } + // Wire TTL enforcer into scheduler (hard-fail at arm time) + scheduler.setTTLEnforcer(ttlEnforcer); + logger.i("TS: onLoad() completed - channel ensured, TTL enforcer wired"); + } + + /* ============================================================ + * Identity & server configuration + * ============================================================ */ + + /** + * Set API server URL for TimeSafari endpoints + */ + public void setApiServerUrl(@Nullable String url) { + this.apiServerUrl = url; + if (url != null) { + fetcher.setApiServerUrl(url); + logger.d("TS: API server set → " + url); + } else { + logger.w("TS: API server URL cleared"); + } + } + + /** + * Get current API server URL + */ + @Nullable + public String getApiServerUrl() { + return apiServerUrl; + } + + /** + * Sets the active DID (identity). If DID changes, clears caches/ETags and re-syncs. + */ + public void setActiveDid(@Nullable String did) { + final String old = this.activeDid; + this.activeDid = did; + + if (!Objects.equals(old, did)) { + logger.d("TS: DID changed: " + (old != null ? old.substring(0, Math.min(20, old.length())) + "..." : "null") + + " → " + (did != null ? did.substring(0, Math.min(20, did.length())) + "..." : "null")); + onActiveDidChanged(old, did); + } else { + logger.d("TS: DID unchanged: " + (did != null ? did.substring(0, Math.min(20, did.length())) + "..." : "null")); + } + } + + /** + * Get current active DID + */ + @Nullable + public String getActiveDid() { + return activeDid; + } + + /** + * Handle DID change - clear caches and reschedule + */ + private void onActiveDidChanged(@Nullable String oldDid, @Nullable String newDid) { + io.execute(() -> { + try { + logger.d("TS: Processing DID swap"); + // Clear per-audience/identity caches, ETags, and any in-memory pagination + clearCachesForDid(oldDid); + // Reset JWT (key/claims) for new DID + if (newDid != null) { + jwtManager.setActiveDid(newDid); + } else { + jwtManager.clearAuthentication(); + } + // Cancel currently scheduled alarms for old DID + // Note: If notification IDs are scoped by DID, cancel them here + // For now, cancel all and reschedule (could be optimized) + scheduler.cancelAllNotifications(); + logger.d("TS: Cleared alarms for old DID"); + + // Trigger fresh fetch + reschedule for new DID + if (newDid != null && apiServerUrl != null) { + fetchAndScheduleFromServer(true); + } else { + logger.w("TS: Skipping fetch - newDid or apiServerUrl is null"); + } + + logger.d("TS: DID swap completed"); + } catch (Exception ex) { + logger.e("TS: DID swap failed", ex); + } + }); + } + + /* ============================================================ + * Fetch & schedule (server → storage → scheduler) + * ============================================================ */ + + /** + * Pulls notifications from the server and schedules future items. + * If forceFullSync is true, ignores local pagination windows. + * + * TODO: Extract logic from DailyNotificationPlugin.configureActiveDidIntegration() + * TODO: Extract logic from DailyNotificationPlugin scheduling methods + * + * Note: EnhancedDailyNotificationFetcher returns CompletableFuture + * Need to convert bundle to NotificationContent[] for storage/scheduling + */ + public void fetchAndScheduleFromServer(boolean forceFullSync) { + if (apiServerUrl == null || activeDid == null) { + logger.w("TS: fetch skipped; apiServerUrl or activeDid is null"); + return; + } + + io.execute(() -> { + try { + logger.d("TS: fetchAndScheduleFromServer start forceFullSync=" + forceFullSync); + + // 1) Set activeDid for JWT generation + jwtManager.setActiveDid(activeDid); + fetcher.setApiServerUrl(apiServerUrl); + + // 2) Prepare user config for TimeSafari fetch + EnhancedDailyNotificationFetcher.TimeSafariUserConfig userConfig = + new EnhancedDailyNotificationFetcher.TimeSafariUserConfig(); + userConfig.activeDid = activeDid; + userConfig.fetchOffersToPerson = true; + userConfig.fetchOffersToProjects = true; + userConfig.fetchProjectUpdates = true; + + // 3) Execute fetch (async, but we wait in executor) + CompletableFuture future = + fetcher.fetchAllTimeSafariData(userConfig); + + // Wait for result (on background executor, so blocking is OK) + EnhancedDailyNotificationFetcher.TimeSafariNotificationBundle bundle = + future.get(); // Blocks until complete + + if (!bundle.success) { + logger.e("TS: Fetch failed: " + (bundle.error != null ? bundle.error : "unknown error"), null); + return; + } + + // 4) Convert bundle to NotificationContent[] and save/schedule + List contents = convertBundleToNotificationContent(bundle); + + int scheduledCount = 0; + for (NotificationContent content : contents) { + try { + // Save content first + storage.saveNotificationContent(content); + // TTL validation happens inside scheduler.scheduleNotification() + boolean scheduled = scheduler.scheduleNotification(content); + if (scheduled) { + scheduledCount++; + } + } catch (Exception perItem) { + logger.w("TS: schedule/save failed for id=" + content.getId() + " " + perItem.getMessage()); + } + } + + logger.i("TS: fetchAndScheduleFromServer done; scheduled=" + scheduledCount + "/" + contents.size()); + + } catch (Exception ex) { + logger.e("TS: fetchAndScheduleFromServer error", ex); + } + }); + } + + /** + * Convert TimeSafariNotificationBundle to NotificationContent list + * + * Converts TimeSafari offers and project updates into NotificationContent objects + * for scheduling and display. + */ + private List convertBundleToNotificationContent( + EnhancedDailyNotificationFetcher.TimeSafariNotificationBundle bundle) { + List contents = new java.util.ArrayList<>(); + + if (bundle == null || !bundle.success) { + logger.w("TS: Bundle is null or unsuccessful, skipping conversion"); + return contents; + } + + long now = System.currentTimeMillis(); + // Schedule notifications for next morning at 8 AM + long nextMorning8am = calculateNextMorning8am(now); + + try { + // Convert offers to person + if (bundle.offersToPerson != null && bundle.offersToPerson.data != null) { + for (EnhancedDailyNotificationFetcher.OfferSummaryRecord offer : bundle.offersToPerson.data) { + NotificationContent content = createOfferNotification( + offer, + "offer_person_" + offer.jwtId, + "New offer for you", + nextMorning8am + ); + if (content != null) { + contents.add(content); + } + } + logger.d("TS: Converted " + bundle.offersToPerson.data.size() + " offers to person"); + } + + // Convert offers to projects + if (bundle.offersToProjects != null && bundle.offersToProjects.data != null && !bundle.offersToProjects.data.isEmpty()) { + // For now, offersToProjects uses simplified Object structure + // Create a summary notification if there are any offers + NotificationContent projectOffersContent = new NotificationContent(); + projectOffersContent.setId("offers_projects_" + now); + projectOffersContent.setTitle("New offers for your projects"); + projectOffersContent.setBody("You have " + bundle.offersToProjects.data.size() + + " new offer(s) for your projects"); + projectOffersContent.setScheduledTime(nextMorning8am); + projectOffersContent.setSound(true); + projectOffersContent.setPriority("default"); + contents.add(projectOffersContent); + logger.d("TS: Converted " + bundle.offersToProjects.data.size() + " offers to projects"); + } + + // Convert project updates + if (bundle.projectUpdates != null && bundle.projectUpdates.data != null && !bundle.projectUpdates.data.isEmpty()) { + NotificationContent projectUpdatesContent = new NotificationContent(); + projectUpdatesContent.setId("project_updates_" + now); + projectUpdatesContent.setTitle("Project updates available"); + projectUpdatesContent.setBody("You have " + bundle.projectUpdates.data.size() + + " project(s) with recent updates"); + projectUpdatesContent.setScheduledTime(nextMorning8am); + projectUpdatesContent.setSound(true); + projectUpdatesContent.setPriority("default"); + contents.add(projectUpdatesContent); + logger.d("TS: Converted " + bundle.projectUpdates.data.size() + " project updates"); + } + + logger.i("TS: Total notifications created: " + contents.size()); + + } catch (Exception e) { + logger.e("TS: Error converting bundle to notifications", e); + } + + return contents; + } + + /** + * Create a notification from an offer record + */ + private NotificationContent createOfferNotification( + EnhancedDailyNotificationFetcher.OfferSummaryRecord offer, + String notificationId, + String defaultTitle, + long scheduledTime) { + try { + if (offer == null || offer.jwtId == null) { + return null; + } + + NotificationContent content = new NotificationContent(); + content.setId(notificationId); + + // Build title from offer details + String title = defaultTitle; + if (offer.handleId != null && !offer.handleId.isEmpty()) { + title = "Offer from @" + offer.handleId; + } + content.setTitle(title); + + // Build body from offer details + StringBuilder bodyBuilder = new StringBuilder(); + if (offer.objectDescription != null && !offer.objectDescription.isEmpty()) { + bodyBuilder.append(offer.objectDescription); + } + if (offer.amount > 0 && offer.unit != null) { + if (bodyBuilder.length() > 0) { + bodyBuilder.append(" - "); + } + bodyBuilder.append(offer.amount).append(" ").append(offer.unit); + } + if (bodyBuilder.length() == 0) { + bodyBuilder.append("You have a new offer"); + } + content.setBody(bodyBuilder.toString()); + + content.setScheduledTime(scheduledTime); + content.setSound(true); + content.setPriority("default"); + + return content; + + } catch (Exception e) { + logger.e("TS: Error creating offer notification", e); + return null; + } + } + + /** + * Calculate next morning at 8 AM + */ + private long calculateNextMorning8am(long currentTime) { + try { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.setTimeInMillis(currentTime); + calendar.set(java.util.Calendar.HOUR_OF_DAY, 8); + calendar.set(java.util.Calendar.MINUTE, 0); + calendar.set(java.util.Calendar.SECOND, 0); + calendar.set(java.util.Calendar.MILLISECOND, 0); + + // If 8 AM has passed today, schedule for tomorrow + if (calendar.getTimeInMillis() <= currentTime) { + calendar.add(java.util.Calendar.DAY_OF_MONTH, 1); + } + + return calendar.getTimeInMillis(); + + } catch (Exception e) { + logger.e("TS: Error calculating next morning, using 1 hour from now", e); + return currentTime + (60 * 60 * 1000); // 1 hour from now as fallback + } + } + + /** Force (re)arming of all *future* items from storage—useful after boot or settings change. */ + public void rescheduleAllPending() { + io.execute(() -> { + try { + logger.d("TS: rescheduleAllPending start"); + long now = System.currentTimeMillis(); + List allNotifications = storage.getAllNotifications(); + int rescheduledCount = 0; + + for (NotificationContent c : allNotifications) { + if (c.getScheduledTime() > now) { + try { + boolean scheduled = scheduler.scheduleNotification(c); + if (scheduled) { + rescheduledCount++; + } + } catch (Exception perItem) { + logger.w("TS: reschedule failed id=" + c.getId() + " " + perItem.getMessage()); + } + } + } + + logger.i("TS: rescheduleAllPending complete; rescheduled=" + rescheduledCount + "/" + allNotifications.size()); + } catch (Exception ex) { + logger.e("TS: rescheduleAllPending failed", ex); + } + }); + } + + /** Optional: manual refresh hook (dev tools) */ + public void refreshNow() { + logger.d("TS: refreshNow() triggered"); + fetchAndScheduleFromServer(false); + } + + /* ============================================================ + * Cache / ETag / Pagination hygiene + * ============================================================ */ + + /** + * Clear caches for a specific DID + */ + private void clearCachesForDid(@Nullable String did) { + try { + logger.d("TS: clearCachesForDid did=" + (did != null ? did.substring(0, Math.min(20, did.length())) + "..." : "null")); + + // Clear ETags that depend on DID/audience + eTagManager.clearETags(); + + // Clear notification storage (all content) + storage.clearAllNotifications(); + + // Note: EnhancedDailyNotificationFetcher doesn't have resetPagination() method + // If pagination state needs clearing, add that method + + logger.d("TS: clearCachesForDid completed"); + } catch (Exception ex) { + logger.w("TS: clearCachesForDid encountered issues: " + ex.getMessage()); + } + } + + /* ============================================================ + * Permissions & channel status aggregation for Plugin.status() + * ============================================================ */ + + /** + * Get comprehensive status snapshot + * + * Used by plugin's checkStatus() method + */ + public StatusSnapshot getStatusSnapshot() { + // Check notification permissions (delegate PIL PermissionManager logic) + boolean notificationsGranted = false; + try { + android.content.pm.PackageManager pm = appContext.getPackageManager(); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + notificationsGranted = appContext.checkSelfPermission( + android.Manifest.permission.POST_NOTIFICATIONS) == + android.content.pm.PackageManager.PERMISSION_GRANTED; + } else { + notificationsGranted = androidx.core.app.NotificationManagerCompat + .from(appContext).areNotificationsEnabled(); + } + } catch (Exception e) { + logger.w("TS: Error checking notification permission: " + e.getMessage()); + } + + // Check exact alarm capability + boolean exactAlarmCapable = false; + try { + PendingIntentManager.AlarmStatus alarmStatus = scheduler.getAlarmStatus(); + exactAlarmCapable = alarmStatus.canScheduleNow; + } catch (Exception e) { + logger.w("TS: Error checking exact alarm capability: " + e.getMessage()); + } + + // Get channel info + String channelId = channelManager.getDefaultChannelId(); + int channelImportance = channelManager.getChannelImportance(); + + return new StatusSnapshot( + notificationsGranted, + exactAlarmCapable, + channelId, + channelImportance, + activeDid, + apiServerUrl + ); + } + + /* ============================================================ + * Teardown (if needed) + * ============================================================ */ + + /** + * Shutdown and cleanup + */ + public void shutdown() { + logger.d("TS: shutdown()"); + // If you replace the Executor with something closeable, do it here + // For now, single-threaded executor will be GC'd when manager is GC'd + } +} +