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.
This commit is contained in:
@@ -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");
|
||||
if (timeSafariIntegration != null) {
|
||||
timeSafariIntegration.setActiveDid(null); // Setting to null clears caches
|
||||
call.resolve();
|
||||
} else {
|
||||
call.reject("TimeSafariIntegrationManager not initialized");
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TimeSafariNotificationBundle>
|
||||
* 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<EnhancedDailyNotificationFetcher.TimeSafariNotificationBundle> 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<NotificationContent> 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<NotificationContent> convertBundleToNotificationContent(
|
||||
EnhancedDailyNotificationFetcher.TimeSafariNotificationBundle bundle) {
|
||||
List<NotificationContent> 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<NotificationContent> 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user