refactor(android)!: restructure to standard Capacitor plugin layout

Restructure Android project from nested module layout to standard
Capacitor plugin structure following community conventions.

Structure Changes:
- Move plugin code from android/plugin/ to android/src/main/java/
- Move test app from android/app/ to test-apps/android-test-app/app/
- Remove nested android/plugin module structure
- Remove nested android/app test app structure

Build Infrastructure:
- Add Gradle wrapper files (gradlew, gradlew.bat, gradle/wrapper/)
- Transform android/build.gradle from root project to library module
- Update android/settings.gradle for standalone plugin builds
- Add android/gradle.properties with AndroidX configuration
- Add android/consumer-rules.pro for ProGuard rules

Configuration Updates:
- Add prepare script to package.json for automatic builds on npm install
- Update package.json version to 1.0.1
- Update android/build.gradle to properly resolve Capacitor dependencies
- Update test-apps/android-test-app/settings.gradle with correct paths
- Remove android/variables.gradle (hardcode values in build.gradle)

Documentation:
- Update BUILDING.md with new structure and build process
- Update INTEGRATION_GUIDE.md to reflect standard structure
- Update README.md to remove path fix warnings
- Add test-apps/BUILD_PROCESS.md documenting test app build flows

Test App Configuration:
- Fix android-test-app to correctly reference plugin and Capacitor
- Remove capacitor-cordova-android-plugins dependency (not needed)
- Update capacitor.settings.gradle path verification in fix script

BREAKING CHANGE: Plugin now uses standard Capacitor Android structure.
Consuming apps must update their capacitor.settings.gradle to reference
android/ instead of android/plugin/. This is automatically handled by
Capacitor CLI for apps using standard plugin installation.
This commit is contained in:
Matthew Raymer
2025-11-05 08:08:37 +00:00
parent c4b7f6382f
commit d9bdeb6d02
128 changed files with 1654 additions and 1747 deletions

View File

@@ -0,0 +1,206 @@
/**
* BootReceiver.java
*
* Android Boot Receiver for DailyNotification plugin
* Handles system boot events to restore scheduled notifications
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
/**
* Broadcast receiver for system boot events
*
* This receiver is triggered when:
* - Device boots up (BOOT_COMPLETED)
* - App is updated (MY_PACKAGE_REPLACED)
* - Any package is updated (PACKAGE_REPLACED)
*
* It ensures that scheduled notifications are restored after system events
* that might have cleared the alarm manager.
*/
public class BootReceiver extends BroadcastReceiver {
private static final String TAG = "BootReceiver";
// Broadcast actions we handle
private static final String ACTION_LOCKED_BOOT_COMPLETED = "android.intent.action.LOCKED_BOOT_COMPLETED";
private static final String ACTION_BOOT_COMPLETED = "android.intent.action.BOOT_COMPLETED";
private static final String ACTION_MY_PACKAGE_REPLACED = "android.intent.action.MY_PACKAGE_REPLACED";
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null || intent.getAction() == null) {
Log.w(TAG, "Received null intent or action");
return;
}
String action = intent.getAction();
Log.d(TAG, "Received broadcast: " + action);
try {
switch (action) {
case ACTION_LOCKED_BOOT_COMPLETED:
handleLockedBootCompleted(context);
break;
case ACTION_BOOT_COMPLETED:
handleBootCompleted(context);
break;
case ACTION_MY_PACKAGE_REPLACED:
handlePackageReplaced(context, intent);
break;
default:
Log.w(TAG, "Unknown action: " + action);
break;
}
} catch (Exception e) {
Log.e(TAG, "Error handling broadcast: " + action, e);
}
}
/**
* Handle locked boot completion (before user unlock)
*
* @param context Application context
*/
private void handleLockedBootCompleted(Context context) {
Log.i(TAG, "Locked boot completed - preparing for recovery");
try {
// Use device protected storage context for Direct Boot
Context deviceProtectedContext = context;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
deviceProtectedContext = context.createDeviceProtectedStorageContext();
}
// Minimal work here - just log that we're ready
// Full recovery will happen on BOOT_COMPLETED when storage is available
Log.i(TAG, "Locked boot completed - ready for full recovery on unlock");
} catch (Exception e) {
Log.e(TAG, "Error during locked boot completion", e);
}
}
/**
* Handle device boot completion (after user unlock)
*
* @param context Application context
*/
private void handleBootCompleted(Context context) {
Log.i(TAG, "Device boot completed - restoring notifications");
try {
// Initialize components for recovery
DailyNotificationStorage storage = new DailyNotificationStorage(context);
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(android.content.Context.ALARM_SERVICE);
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager);
// Perform boot recovery
boolean recoveryPerformed = performBootRecovery(context, storage, scheduler);
if (recoveryPerformed) {
Log.i(TAG, "Boot recovery completed successfully");
} else {
Log.d(TAG, "Boot recovery skipped (not needed or already performed)");
}
} catch (Exception e) {
Log.e(TAG, "Error during boot recovery", e);
}
}
/**
* Handle package replacement (app update)
*
* @param context Application context
* @param intent Broadcast intent
*/
private void handlePackageReplaced(Context context, Intent intent) {
Log.i(TAG, "Package replaced - restoring notifications");
try {
// Initialize components for recovery
DailyNotificationStorage storage = new DailyNotificationStorage(context);
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(android.content.Context.ALARM_SERVICE);
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager);
// Perform package replacement recovery
boolean recoveryPerformed = performBootRecovery(context, storage, scheduler);
if (recoveryPerformed) {
Log.i(TAG, "Package replacement recovery completed successfully");
} else {
Log.d(TAG, "Package replacement recovery skipped (not needed or already performed)");
}
} catch (Exception e) {
Log.e(TAG, "Error during package replacement recovery", e);
}
}
/**
* Perform boot recovery by rescheduling notifications
*
* @param context Application context
* @param storage Notification storage
* @param scheduler Notification scheduler
* @return true if recovery was performed, false otherwise
*/
private boolean performBootRecovery(Context context, DailyNotificationStorage storage,
DailyNotificationScheduler scheduler) {
try {
Log.d(TAG, "DN|BOOT_RECOVERY_START");
// Get all notifications from storage
java.util.List<NotificationContent> notifications = storage.getAllNotifications();
if (notifications.isEmpty()) {
Log.d(TAG, "DN|BOOT_RECOVERY_SKIP no_notifications");
return false;
}
Log.d(TAG, "DN|BOOT_RECOVERY_FOUND count=" + notifications.size());
int recoveredCount = 0;
long currentTime = System.currentTimeMillis();
for (NotificationContent notification : notifications) {
try {
if (notification.getScheduledTime() > currentTime) {
boolean scheduled = scheduler.scheduleNotification(notification);
if (scheduled) {
recoveredCount++;
Log.d(TAG, "DN|BOOT_RECOVERY_OK id=" + notification.getId());
} else {
Log.w(TAG, "DN|BOOT_RECOVERY_FAIL id=" + notification.getId());
}
} else {
Log.d(TAG, "DN|BOOT_RECOVERY_SKIP_PAST id=" + notification.getId());
}
} catch (Exception e) {
Log.e(TAG, "DN|BOOT_RECOVERY_ERR id=" + notification.getId() + " err=" + e.getMessage(), e);
}
}
Log.i(TAG, "DN|BOOT_RECOVERY_COMPLETE recovered=" + recoveredCount + "/" + notifications.size());
return recoveredCount > 0;
} catch (Exception e) {
Log.e(TAG, "DN|BOOT_RECOVERY_ERR exception=" + e.getMessage(), e);
return false;
}
}
}

View File

@@ -0,0 +1,193 @@
package com.timesafari.dailynotification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.util.Log;
/**
* Manages notification channels and ensures they are properly configured
* for reliable notification delivery.
*
* Handles channel creation, importance checking, and provides deep links
* to channel settings when notifications are blocked.
*
* @author Matthew Raymer
* @version 1.0
*/
public class ChannelManager {
private static final String TAG = "ChannelManager";
private static final String DEFAULT_CHANNEL_ID = "timesafari.daily";
private static final String DEFAULT_CHANNEL_NAME = "Daily Notifications";
private static final String DEFAULT_CHANNEL_DESCRIPTION = "Daily notifications from TimeSafari";
private final Context context;
private final NotificationManager notificationManager;
public ChannelManager(Context context) {
this.context = context;
this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
}
/**
* Ensures the default notification channel exists and is properly configured.
* Creates the channel if it doesn't exist.
*
* @return true if channel is ready for notifications, false if blocked
*/
public boolean ensureChannelExists() {
try {
Log.d(TAG, "Ensuring notification channel exists");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
if (channel == null) {
Log.d(TAG, "Creating notification channel");
createDefaultChannel();
return true;
} else {
Log.d(TAG, "Channel exists with importance: " + channel.getImportance());
return channel.getImportance() != NotificationManager.IMPORTANCE_NONE;
}
} else {
// Pre-Oreo: channels don't exist, always ready
Log.d(TAG, "Pre-Oreo device, channels not applicable");
return true;
}
} catch (Exception e) {
Log.e(TAG, "Error ensuring channel exists", e);
return false;
}
}
/**
* Checks if the notification channel is enabled and can deliver notifications.
*
* @return true if channel is enabled, false if blocked
*/
public boolean isChannelEnabled() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
if (channel == null) {
Log.w(TAG, "Channel does not exist");
return false;
}
int importance = channel.getImportance();
Log.d(TAG, "Channel importance: " + importance);
return importance != NotificationManager.IMPORTANCE_NONE;
} else {
// Pre-Oreo: always enabled
return true;
}
} catch (Exception e) {
Log.e(TAG, "Error checking channel status", e);
return false;
}
}
/**
* Gets the current channel importance level.
*
* @return importance level, or -1 if channel doesn't exist
*/
public int getChannelImportance() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
if (channel != null) {
return channel.getImportance();
}
}
return -1;
} catch (Exception e) {
Log.e(TAG, "Error getting channel importance", e);
return -1;
}
}
/**
* Opens the notification channel settings for the user to enable notifications.
*
* @return true if settings intent was launched, false otherwise
*/
public boolean openChannelSettings() {
try {
Log.d(TAG, "Opening channel settings");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
.putExtra(Settings.EXTRA_CHANNEL_ID, DEFAULT_CHANNEL_ID)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
Log.d(TAG, "Channel settings opened");
return true;
} else {
Log.d(TAG, "Channel settings not available on pre-Oreo");
return false;
}
} catch (Exception e) {
Log.e(TAG, "Error opening channel settings", e);
return false;
}
}
/**
* Creates the default notification channel with high importance.
*/
private void createDefaultChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
DEFAULT_CHANNEL_ID,
DEFAULT_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription(DEFAULT_CHANNEL_DESCRIPTION);
channel.enableLights(true);
channel.enableVibration(true);
channel.setShowBadge(true);
notificationManager.createNotificationChannel(channel);
Log.d(TAG, "Default channel created with HIGH importance");
}
}
/**
* Gets the default channel ID for use in notifications.
*
* @return the default channel ID
*/
public String getDefaultChannelId() {
return DEFAULT_CHANNEL_ID;
}
/**
* Logs the current channel status for debugging.
*/
public void logChannelStatus() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(DEFAULT_CHANNEL_ID);
if (channel != null) {
Log.i(TAG, "Channel Status - ID: " + channel.getId() +
", Importance: " + channel.getImportance() +
", Enabled: " + (channel.getImportance() != NotificationManager.IMPORTANCE_NONE));
} else {
Log.w(TAG, "Channel does not exist");
}
} else {
Log.i(TAG, "Pre-Oreo device, channels not applicable");
}
} catch (Exception e) {
Log.e(TAG, "Error logging channel status", e);
}
}
}

View File

@@ -0,0 +1,482 @@
/**
* DailyNotificationETagManager.java
*
* Android ETag Manager for efficient content fetching
* Implements ETag headers, 304 response handling, and conditional requests
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.util.Log;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/**
* Manages ETag headers and conditional requests for efficient content fetching
*
* This class implements the critical ETag functionality:
* - Stores ETag values for each content URL
* - Sends conditional requests with If-None-Match headers
* - Handles 304 Not Modified responses
* - Tracks network efficiency metrics
* - Provides fallback for ETag failures
*/
public class DailyNotificationETagManager {
// MARK: - Constants
private static final String TAG = "DailyNotificationETagManager";
// HTTP headers
private static final String HEADER_ETAG = "ETag";
private static final String HEADER_IF_NONE_MATCH = "If-None-Match";
private static final String HEADER_LAST_MODIFIED = "Last-Modified";
private static final String HEADER_IF_MODIFIED_SINCE = "If-Modified-Since";
// HTTP status codes
private static final int HTTP_NOT_MODIFIED = 304;
private static final int HTTP_OK = 200;
// Request timeout
private static final int REQUEST_TIMEOUT_MS = 12000; // 12 seconds
// ETag cache TTL
private static final long ETAG_CACHE_TTL_MS = TimeUnit.HOURS.toMillis(24); // 24 hours
// MARK: - Properties
private final DailyNotificationStorage storage;
// ETag cache: URL -> ETagInfo
private final ConcurrentHashMap<String, ETagInfo> etagCache;
// Network metrics
private final NetworkMetrics metrics;
// MARK: - Initialization
/**
* Constructor
*
* @param storage Storage instance for persistence
*/
public DailyNotificationETagManager(DailyNotificationStorage storage) {
this.storage = storage;
this.etagCache = new ConcurrentHashMap<>();
this.metrics = new NetworkMetrics();
// Load ETag cache from storage
loadETagCache();
Log.d(TAG, "ETagManager initialized with " + etagCache.size() + " cached ETags");
}
// MARK: - ETag Cache Management
/**
* Load ETag cache from storage
*/
private void loadETagCache() {
try {
Log.d(TAG, "Loading ETag cache from storage");
// This would typically load from SQLite or SharedPreferences
// For now, we'll start with an empty cache
Log.d(TAG, "ETag cache loaded from storage");
} catch (Exception e) {
Log.e(TAG, "Error loading ETag cache", e);
}
}
/**
* Save ETag cache to storage
*/
private void saveETagCache() {
try {
Log.d(TAG, "Saving ETag cache to storage");
// This would typically save to SQLite or SharedPreferences
// For now, we'll just log the action
Log.d(TAG, "ETag cache saved to storage");
} catch (Exception e) {
Log.e(TAG, "Error saving ETag cache", e);
}
}
/**
* Get ETag for URL
*
* @param url Content URL
* @return ETag value or null if not cached
*/
public String getETag(String url) {
ETagInfo info = etagCache.get(url);
if (info != null && !info.isExpired()) {
return info.etag;
}
return null;
}
/**
* Set ETag for URL
*
* @param url Content URL
* @param etag ETag value
*/
public void setETag(String url, String etag) {
try {
Log.d(TAG, "Setting ETag for " + url + ": " + etag);
ETagInfo info = new ETagInfo(etag, System.currentTimeMillis());
etagCache.put(url, info);
// Save to persistent storage
saveETagCache();
Log.d(TAG, "ETag set successfully");
} catch (Exception e) {
Log.e(TAG, "Error setting ETag", e);
}
}
/**
* Remove ETag for URL
*
* @param url Content URL
*/
public void removeETag(String url) {
try {
Log.d(TAG, "Removing ETag for " + url);
etagCache.remove(url);
saveETagCache();
Log.d(TAG, "ETag removed successfully");
} catch (Exception e) {
Log.e(TAG, "Error removing ETag", e);
}
}
/**
* Clear all ETags
*/
public void clearETags() {
try {
Log.d(TAG, "Clearing all ETags");
etagCache.clear();
saveETagCache();
Log.d(TAG, "All ETags cleared");
} catch (Exception e) {
Log.e(TAG, "Error clearing ETags", e);
}
}
// MARK: - Conditional Requests
/**
* Make conditional request with ETag
*
* @param url Content URL
* @return ConditionalRequestResult with response data
*/
public ConditionalRequestResult makeConditionalRequest(String url) {
try {
Log.d(TAG, "Making conditional request to " + url);
// Get cached ETag
String etag = getETag(url);
// Create HTTP connection
HttpURLConnection connection = createConnection(url, etag);
// Execute request
int responseCode = connection.getResponseCode();
// Handle response
ConditionalRequestResult result = handleResponse(connection, responseCode, url);
// Update metrics
metrics.recordRequest(url, responseCode, result.isFromCache);
Log.i(TAG, "Conditional request completed: " + responseCode + " (cached: " + result.isFromCache + ")");
return result;
} catch (Exception e) {
Log.e(TAG, "Error making conditional request", e);
metrics.recordError(url, e.getMessage());
return ConditionalRequestResult.error(e.getMessage());
}
}
/**
* Create HTTP connection with conditional headers
*
* @param url Content URL
* @param etag ETag value for conditional request
* @return Configured HttpURLConnection
*/
private HttpURLConnection createConnection(String url, String etag) throws IOException {
URL urlObj = new URL(url);
HttpURLConnection connection = (HttpURLConnection) urlObj.openConnection();
// Set request timeout
connection.setConnectTimeout(REQUEST_TIMEOUT_MS);
connection.setReadTimeout(REQUEST_TIMEOUT_MS);
// Set conditional headers
if (etag != null) {
connection.setRequestProperty(HEADER_IF_NONE_MATCH, etag);
Log.d(TAG, "Added If-None-Match header: " + etag);
}
// Set user agent
connection.setRequestProperty("User-Agent", "DailyNotificationPlugin/1.0.0");
return connection;
}
/**
* Handle HTTP response
*
* @param connection HTTP connection
* @param responseCode HTTP response code
* @param url Request URL
* @return ConditionalRequestResult
*/
private ConditionalRequestResult handleResponse(HttpURLConnection connection, int responseCode, String url) {
try {
switch (responseCode) {
case HTTP_NOT_MODIFIED:
Log.d(TAG, "304 Not Modified - using cached content");
return ConditionalRequestResult.notModified();
case HTTP_OK:
Log.d(TAG, "200 OK - new content available");
return handleOKResponse(connection, url);
default:
Log.w(TAG, "Unexpected response code: " + responseCode);
return ConditionalRequestResult.error("Unexpected response code: " + responseCode);
}
} catch (Exception e) {
Log.e(TAG, "Error handling response", e);
return ConditionalRequestResult.error(e.getMessage());
}
}
/**
* Handle 200 OK response
*
* @param connection HTTP connection
* @param url Request URL
* @return ConditionalRequestResult with new content
*/
private ConditionalRequestResult handleOKResponse(HttpURLConnection connection, String url) {
try {
// Get new ETag
String newETag = connection.getHeaderField(HEADER_ETAG);
// Read response body
String content = readResponseBody(connection);
// Update ETag cache
if (newETag != null) {
setETag(url, newETag);
}
return ConditionalRequestResult.success(content, newETag);
} catch (Exception e) {
Log.e(TAG, "Error handling OK response", e);
return ConditionalRequestResult.error(e.getMessage());
}
}
/**
* Read response body from connection
*
* @param connection HTTP connection
* @return Response body as string
*/
private String readResponseBody(HttpURLConnection connection) throws IOException {
// This is a simplified implementation
// In production, you'd want proper stream handling
return "Response body content"; // Placeholder
}
// MARK: - Network Metrics
/**
* Get network efficiency metrics
*
* @return NetworkMetrics with current statistics
*/
public NetworkMetrics getMetrics() {
return metrics;
}
/**
* Reset network metrics
*/
public void resetMetrics() {
metrics.reset();
Log.d(TAG, "Network metrics reset");
}
// MARK: - Cache Management
/**
* Clean expired ETags
*/
public void cleanExpiredETags() {
try {
Log.d(TAG, "Cleaning expired ETags");
int initialSize = etagCache.size();
etagCache.entrySet().removeIf(entry -> entry.getValue().isExpired());
int finalSize = etagCache.size();
if (initialSize != finalSize) {
saveETagCache();
Log.i(TAG, "Cleaned " + (initialSize - finalSize) + " expired ETags");
}
} catch (Exception e) {
Log.e(TAG, "Error cleaning expired ETags", e);
}
}
/**
* Get cache statistics
*
* @return CacheStatistics with cache info
*/
public CacheStatistics getCacheStatistics() {
int totalETags = etagCache.size();
int expiredETags = (int) etagCache.values().stream().filter(ETagInfo::isExpired).count();
return new CacheStatistics(totalETags, expiredETags, totalETags - expiredETags);
}
// MARK: - Data Classes
/**
* ETag information
*/
private static class ETagInfo {
public final String etag;
public final long timestamp;
public ETagInfo(String etag, long timestamp) {
this.etag = etag;
this.timestamp = timestamp;
}
public boolean isExpired() {
return System.currentTimeMillis() - timestamp > ETAG_CACHE_TTL_MS;
}
}
/**
* Conditional request result
*/
public static class ConditionalRequestResult {
public final boolean success;
public final boolean isFromCache;
public final String content;
public final String etag;
public final String error;
private ConditionalRequestResult(boolean success, boolean isFromCache, String content, String etag, String error) {
this.success = success;
this.isFromCache = isFromCache;
this.content = content;
this.etag = etag;
this.error = error;
}
public static ConditionalRequestResult success(String content, String etag) {
return new ConditionalRequestResult(true, false, content, etag, null);
}
public static ConditionalRequestResult notModified() {
return new ConditionalRequestResult(true, true, null, null, null);
}
public static ConditionalRequestResult error(String error) {
return new ConditionalRequestResult(false, false, null, null, error);
}
}
/**
* Network metrics
*/
public static class NetworkMetrics {
public int totalRequests = 0;
public int cachedResponses = 0;
public int networkResponses = 0;
public int errors = 0;
public void recordRequest(String url, int responseCode, boolean fromCache) {
totalRequests++;
if (fromCache) {
cachedResponses++;
} else {
networkResponses++;
}
}
public void recordError(String url, String error) {
errors++;
}
public void reset() {
totalRequests = 0;
cachedResponses = 0;
networkResponses = 0;
errors = 0;
}
public double getCacheHitRatio() {
if (totalRequests == 0) return 0.0;
return (double) cachedResponses / totalRequests;
}
}
/**
* Cache statistics
*/
public static class CacheStatistics {
public final int totalETags;
public final int expiredETags;
public final int validETags;
public CacheStatistics(int totalETags, int expiredETags, int validETags) {
this.totalETags = totalETags;
this.expiredETags = expiredETags;
this.validETags = validETags;
}
@Override
public String toString() {
return String.format("CacheStatistics{total=%d, expired=%d, valid=%d}",
totalETags, expiredETags, validETags);
}
}
}

View File

@@ -0,0 +1,668 @@
/**
* DailyNotificationErrorHandler.java
*
* Android Error Handler for comprehensive error management
* Implements error categorization, retry logic, and telemetry
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.util.Log;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Manages comprehensive error handling with categorization, retry logic, and telemetry
*
* This class implements the critical error handling functionality:
* - Categorizes errors by type, code, and severity
* - Implements exponential backoff retry logic
* - Tracks error metrics and telemetry
* - Provides debugging information
* - Manages retry state and limits
*/
public class DailyNotificationErrorHandler {
// MARK: - Constants
private static final String TAG = "DailyNotificationErrorHandler";
// Retry configuration
private static final int DEFAULT_MAX_RETRIES = 3;
private static final long DEFAULT_BASE_DELAY_MS = 1000; // 1 second
private static final long DEFAULT_MAX_DELAY_MS = 30000; // 30 seconds
private static final double DEFAULT_BACKOFF_MULTIPLIER = 2.0;
// Error severity levels
public enum ErrorSeverity {
LOW, // Minor issues, non-critical
MEDIUM, // Moderate issues, may affect functionality
HIGH, // Serious issues, significant impact
CRITICAL // Critical issues, system failure
}
// Error categories
public enum ErrorCategory {
NETWORK, // Network-related errors
STORAGE, // Storage/database errors
SCHEDULING, // Notification scheduling errors
PERMISSION, // Permission-related errors
CONFIGURATION, // Configuration errors
SYSTEM, // System-level errors
UNKNOWN // Unknown/unclassified errors
}
// MARK: - Properties
private final ConcurrentHashMap<String, RetryState> retryStates;
private final ErrorMetrics metrics;
private final ErrorConfiguration config;
// MARK: - Initialization
/**
* Constructor with default configuration
*/
public DailyNotificationErrorHandler() {
this(new ErrorConfiguration());
}
/**
* Constructor with custom configuration
*
* @param config Error handling configuration
*/
public DailyNotificationErrorHandler(ErrorConfiguration config) {
this.retryStates = new ConcurrentHashMap<>();
this.metrics = new ErrorMetrics();
this.config = config;
Log.d(TAG, "ErrorHandler initialized with max retries: " + config.maxRetries);
}
// MARK: - Error Handling
/**
* Handle error with automatic retry logic
*
* @param operationId Unique identifier for the operation
* @param error Error to handle
* @param retryable Whether this error is retryable
* @return ErrorResult with handling information
*/
public ErrorResult handleError(String operationId, Throwable error, boolean retryable) {
try {
Log.d(TAG, "Handling error for operation: " + operationId);
// Categorize error
ErrorInfo errorInfo = categorizeError(error);
// Update metrics
metrics.recordError(errorInfo);
// Check if retryable and within limits
if (retryable && shouldRetry(operationId, errorInfo)) {
return handleRetryableError(operationId, errorInfo);
} else {
return handleNonRetryableError(operationId, errorInfo);
}
} catch (Exception e) {
Log.e(TAG, "Error in error handler", e);
return ErrorResult.fatal("Error handler failure: " + e.getMessage());
}
}
/**
* Handle error with custom retry configuration
*
* @param operationId Unique identifier for the operation
* @param error Error to handle
* @param retryConfig Custom retry configuration
* @return ErrorResult with handling information
*/
public ErrorResult handleError(String operationId, Throwable error, RetryConfiguration retryConfig) {
try {
Log.d(TAG, "Handling error with custom retry config for operation: " + operationId);
// Categorize error
ErrorInfo errorInfo = categorizeError(error);
// Update metrics
metrics.recordError(errorInfo);
// Check if retryable with custom config
if (shouldRetry(operationId, errorInfo, retryConfig)) {
return handleRetryableError(operationId, errorInfo, retryConfig);
} else {
return handleNonRetryableError(operationId, errorInfo);
}
} catch (Exception e) {
Log.e(TAG, "Error in error handler with custom config", e);
return ErrorResult.fatal("Error handler failure: " + e.getMessage());
}
}
// MARK: - Error Categorization
/**
* Categorize error by type, code, and severity
*
* @param error Error to categorize
* @return ErrorInfo with categorization
*/
private ErrorInfo categorizeError(Throwable error) {
try {
ErrorCategory category = determineCategory(error);
String errorCode = determineErrorCode(error);
ErrorSeverity severity = determineSeverity(error, category);
ErrorInfo errorInfo = new ErrorInfo(
error,
category,
errorCode,
severity,
System.currentTimeMillis()
);
Log.d(TAG, "Error categorized: " + errorInfo);
return errorInfo;
} catch (Exception e) {
Log.e(TAG, "Error during categorization", e);
return new ErrorInfo(error, ErrorCategory.UNKNOWN, "CATEGORIZATION_FAILED", ErrorSeverity.HIGH, System.currentTimeMillis());
}
}
/**
* Determine error category based on error type
*
* @param error Error to analyze
* @return ErrorCategory
*/
private ErrorCategory determineCategory(Throwable error) {
String errorMessage = error.getMessage();
String errorType = error.getClass().getSimpleName();
// Network errors
if (errorType.contains("IOException") || errorType.contains("Socket") ||
errorType.contains("Connect") || errorType.contains("Timeout")) {
return ErrorCategory.NETWORK;
}
// Storage errors
if (errorType.contains("SQLite") || errorType.contains("Database") ||
errorType.contains("Storage") || errorType.contains("File")) {
return ErrorCategory.STORAGE;
}
// Permission errors
if (errorType.contains("Security") || errorType.contains("Permission") ||
errorMessage != null && errorMessage.contains("permission")) {
return ErrorCategory.PERMISSION;
}
// Configuration errors
if (errorType.contains("IllegalArgument") || errorType.contains("Configuration") ||
errorMessage != null && errorMessage.contains("config")) {
return ErrorCategory.CONFIGURATION;
}
// System errors
if (errorType.contains("OutOfMemory") || errorType.contains("StackOverflow") ||
errorType.contains("Runtime")) {
return ErrorCategory.SYSTEM;
}
return ErrorCategory.UNKNOWN;
}
/**
* Determine error code based on error details
*
* @param error Error to analyze
* @return Error code string
*/
private String determineErrorCode(Throwable error) {
String errorType = error.getClass().getSimpleName();
String errorMessage = error.getMessage();
// Generate error code based on type and message
if (errorMessage != null && errorMessage.length() > 0) {
return errorType + "_" + errorMessage.hashCode();
} else {
return errorType + "_" + System.currentTimeMillis();
}
}
/**
* Determine error severity based on error and category
*
* @param error Error to analyze
* @param category Error category
* @return ErrorSeverity
*/
private ErrorSeverity determineSeverity(Throwable error, ErrorCategory category) {
// Critical errors
if (error instanceof OutOfMemoryError || error instanceof StackOverflowError) {
return ErrorSeverity.CRITICAL;
}
// High severity errors
if (category == ErrorCategory.SYSTEM || category == ErrorCategory.STORAGE) {
return ErrorSeverity.HIGH;
}
// Medium severity errors
if (category == ErrorCategory.NETWORK || category == ErrorCategory.PERMISSION) {
return ErrorSeverity.MEDIUM;
}
// Low severity errors
return ErrorSeverity.LOW;
}
// MARK: - Retry Logic
/**
* Check if error should be retried
*
* @param operationId Operation identifier
* @param errorInfo Error information
* @return true if should retry
*/
private boolean shouldRetry(String operationId, ErrorInfo errorInfo) {
return shouldRetry(operationId, errorInfo, null);
}
/**
* Check if error should be retried with custom config
*
* @param operationId Operation identifier
* @param errorInfo Error information
* @param retryConfig Custom retry configuration
* @return true if should retry
*/
private boolean shouldRetry(String operationId, ErrorInfo errorInfo, RetryConfiguration retryConfig) {
try {
// Get retry state
RetryState state = retryStates.get(operationId);
if (state == null) {
state = new RetryState();
retryStates.put(operationId, state);
}
// Check retry limits
int maxRetries = retryConfig != null ? retryConfig.maxRetries : config.maxRetries;
if (state.attemptCount >= maxRetries) {
Log.d(TAG, "Max retries exceeded for operation: " + operationId);
return false;
}
// Check if error is retryable based on category
boolean isRetryable = isErrorRetryable(errorInfo.category);
Log.d(TAG, "Should retry: " + isRetryable + " (attempt: " + state.attemptCount + "/" + maxRetries + ")");
return isRetryable;
} catch (Exception e) {
Log.e(TAG, "Error checking retry eligibility", e);
return false;
}
}
/**
* Check if error category is retryable
*
* @param category Error category
* @return true if retryable
*/
private boolean isErrorRetryable(ErrorCategory category) {
switch (category) {
case NETWORK:
case STORAGE:
return true;
case PERMISSION:
case CONFIGURATION:
case SYSTEM:
case UNKNOWN:
default:
return false;
}
}
/**
* Handle retryable error
*
* @param operationId Operation identifier
* @param errorInfo Error information
* @return ErrorResult with retry information
*/
private ErrorResult handleRetryableError(String operationId, ErrorInfo errorInfo) {
return handleRetryableError(operationId, errorInfo, null);
}
/**
* Handle retryable error with custom config
*
* @param operationId Operation identifier
* @param errorInfo Error information
* @param retryConfig Custom retry configuration
* @return ErrorResult with retry information
*/
private ErrorResult handleRetryableError(String operationId, ErrorInfo errorInfo, RetryConfiguration retryConfig) {
try {
RetryState state = retryStates.get(operationId);
state.attemptCount++;
// Calculate delay with exponential backoff
long delay = calculateRetryDelay(state.attemptCount, retryConfig);
state.nextRetryTime = System.currentTimeMillis() + delay;
Log.i(TAG, "Retryable error handled - retry in " + delay + "ms (attempt " + state.attemptCount + ")");
return ErrorResult.retryable(errorInfo, delay, state.attemptCount);
} catch (Exception e) {
Log.e(TAG, "Error handling retryable error", e);
return ErrorResult.fatal("Retry handling failure: " + e.getMessage());
}
}
/**
* Handle non-retryable error
*
* @param operationId Operation identifier
* @param errorInfo Error information
* @return ErrorResult with failure information
*/
private ErrorResult handleNonRetryableError(String operationId, ErrorInfo errorInfo) {
try {
Log.w(TAG, "Non-retryable error handled for operation: " + operationId);
// Clean up retry state
retryStates.remove(operationId);
return ErrorResult.fatal(errorInfo);
} catch (Exception e) {
Log.e(TAG, "Error handling non-retryable error", e);
return ErrorResult.fatal("Non-retryable error handling failure: " + e.getMessage());
}
}
/**
* Calculate retry delay with exponential backoff
*
* @param attemptCount Current attempt number
* @param retryConfig Custom retry configuration
* @return Delay in milliseconds
*/
private long calculateRetryDelay(int attemptCount, RetryConfiguration retryConfig) {
try {
long baseDelay = retryConfig != null ? retryConfig.baseDelayMs : config.baseDelayMs;
double multiplier = retryConfig != null ? retryConfig.backoffMultiplier : config.backoffMultiplier;
long maxDelay = retryConfig != null ? retryConfig.maxDelayMs : config.maxDelayMs;
// Calculate exponential backoff: baseDelay * (multiplier ^ (attemptCount - 1))
long delay = (long) (baseDelay * Math.pow(multiplier, attemptCount - 1));
// Cap at maximum delay
delay = Math.min(delay, maxDelay);
// Add jitter to prevent thundering herd
long jitter = (long) (delay * 0.1 * Math.random());
delay += jitter;
Log.d(TAG, "Calculated retry delay: " + delay + "ms (attempt " + attemptCount + ")");
return delay;
} catch (Exception e) {
Log.e(TAG, "Error calculating retry delay", e);
return config.baseDelayMs;
}
}
// MARK: - Metrics and Telemetry
/**
* Get error metrics
*
* @return ErrorMetrics with current statistics
*/
public ErrorMetrics getMetrics() {
return metrics;
}
/**
* Reset error metrics
*/
public void resetMetrics() {
metrics.reset();
Log.d(TAG, "Error metrics reset");
}
/**
* Get retry statistics
*
* @return RetryStatistics with retry information
*/
public RetryStatistics getRetryStatistics() {
int totalOperations = retryStates.size();
int activeRetries = 0;
int totalRetries = 0;
for (RetryState state : retryStates.values()) {
if (state.attemptCount > 0) {
activeRetries++;
totalRetries += state.attemptCount;
}
}
return new RetryStatistics(totalOperations, activeRetries, totalRetries);
}
/**
* Clear retry states
*/
public void clearRetryStates() {
retryStates.clear();
Log.d(TAG, "Retry states cleared");
}
// MARK: - Data Classes
/**
* Error information
*/
public static class ErrorInfo {
public final Throwable error;
public final ErrorCategory category;
public final String errorCode;
public final ErrorSeverity severity;
public final long timestamp;
public ErrorInfo(Throwable error, ErrorCategory category, String errorCode, ErrorSeverity severity, long timestamp) {
this.error = error;
this.category = category;
this.errorCode = errorCode;
this.severity = severity;
this.timestamp = timestamp;
}
@Override
public String toString() {
return String.format("ErrorInfo{category=%s, code=%s, severity=%s, error=%s}",
category, errorCode, severity, error.getClass().getSimpleName());
}
}
/**
* Retry state for an operation
*/
private static class RetryState {
public int attemptCount = 0;
public long nextRetryTime = 0;
}
/**
* Error result
*/
public static class ErrorResult {
public final boolean success;
public final boolean retryable;
public final ErrorInfo errorInfo;
public final long retryDelayMs;
public final int attemptCount;
public final String message;
private ErrorResult(boolean success, boolean retryable, ErrorInfo errorInfo, long retryDelayMs, int attemptCount, String message) {
this.success = success;
this.retryable = retryable;
this.errorInfo = errorInfo;
this.retryDelayMs = retryDelayMs;
this.attemptCount = attemptCount;
this.message = message;
}
public static ErrorResult retryable(ErrorInfo errorInfo, long retryDelayMs, int attemptCount) {
return new ErrorResult(false, true, errorInfo, retryDelayMs, attemptCount, "Retryable error");
}
public static ErrorResult fatal(ErrorInfo errorInfo) {
return new ErrorResult(false, false, errorInfo, 0, 0, "Fatal error");
}
public static ErrorResult fatal(String message) {
return new ErrorResult(false, false, null, 0, 0, message);
}
}
/**
* Error configuration
*/
public static class ErrorConfiguration {
public final int maxRetries;
public final long baseDelayMs;
public final long maxDelayMs;
public final double backoffMultiplier;
public ErrorConfiguration() {
this(DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_BACKOFF_MULTIPLIER);
}
public ErrorConfiguration(int maxRetries, long baseDelayMs, long maxDelayMs, double backoffMultiplier) {
this.maxRetries = maxRetries;
this.baseDelayMs = baseDelayMs;
this.maxDelayMs = maxDelayMs;
this.backoffMultiplier = backoffMultiplier;
}
}
/**
* Retry configuration
*/
public static class RetryConfiguration {
public final int maxRetries;
public final long baseDelayMs;
public final long maxDelayMs;
public final double backoffMultiplier;
public RetryConfiguration(int maxRetries, long baseDelayMs, long maxDelayMs, double backoffMultiplier) {
this.maxRetries = maxRetries;
this.baseDelayMs = baseDelayMs;
this.maxDelayMs = maxDelayMs;
this.backoffMultiplier = backoffMultiplier;
}
}
/**
* Error metrics
*/
public static class ErrorMetrics {
private final AtomicInteger totalErrors = new AtomicInteger(0);
private final AtomicInteger networkErrors = new AtomicInteger(0);
private final AtomicInteger storageErrors = new AtomicInteger(0);
private final AtomicInteger schedulingErrors = new AtomicInteger(0);
private final AtomicInteger permissionErrors = new AtomicInteger(0);
private final AtomicInteger configurationErrors = new AtomicInteger(0);
private final AtomicInteger systemErrors = new AtomicInteger(0);
private final AtomicInteger unknownErrors = new AtomicInteger(0);
public void recordError(ErrorInfo errorInfo) {
totalErrors.incrementAndGet();
switch (errorInfo.category) {
case NETWORK:
networkErrors.incrementAndGet();
break;
case STORAGE:
storageErrors.incrementAndGet();
break;
case SCHEDULING:
schedulingErrors.incrementAndGet();
break;
case PERMISSION:
permissionErrors.incrementAndGet();
break;
case CONFIGURATION:
configurationErrors.incrementAndGet();
break;
case SYSTEM:
systemErrors.incrementAndGet();
break;
case UNKNOWN:
default:
unknownErrors.incrementAndGet();
break;
}
}
public void reset() {
totalErrors.set(0);
networkErrors.set(0);
storageErrors.set(0);
schedulingErrors.set(0);
permissionErrors.set(0);
configurationErrors.set(0);
systemErrors.set(0);
unknownErrors.set(0);
}
public int getTotalErrors() { return totalErrors.get(); }
public int getNetworkErrors() { return networkErrors.get(); }
public int getStorageErrors() { return storageErrors.get(); }
public int getSchedulingErrors() { return schedulingErrors.get(); }
public int getPermissionErrors() { return permissionErrors.get(); }
public int getConfigurationErrors() { return configurationErrors.get(); }
public int getSystemErrors() { return systemErrors.get(); }
public int getUnknownErrors() { return unknownErrors.get(); }
}
/**
* Retry statistics
*/
public static class RetryStatistics {
public final int totalOperations;
public final int activeRetries;
public final int totalRetries;
public RetryStatistics(int totalOperations, int activeRetries, int totalRetries) {
this.totalOperations = totalOperations;
this.activeRetries = activeRetries;
this.totalRetries = totalRetries;
}
@Override
public String toString() {
return String.format("RetryStatistics{totalOps=%d, activeRetries=%d, totalRetries=%d}",
totalOperations, activeRetries, totalRetries);
}
}
}

View File

@@ -0,0 +1,384 @@
/**
* DailyNotificationExactAlarmManager.java
*
* Android Exact Alarm Manager with fallback to windowed alarms
* Implements SCHEDULE_EXACT_ALARM permission handling and fallback logic
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.util.Log;
import java.util.concurrent.TimeUnit;
/**
* Manages Android exact alarms with fallback to windowed alarms
*
* This class implements the critical Android alarm management:
* - Requests SCHEDULE_EXACT_ALARM permission
* - Falls back to windowed alarms (±10m) if exact permission denied
* - Provides deep-link to enable exact alarms in settings
* - Handles reboot and time-change recovery
*/
public class DailyNotificationExactAlarmManager {
// MARK: - Constants
private static final String TAG = "DailyNotificationExactAlarmManager";
// Permission constants
private static final String PERMISSION_SCHEDULE_EXACT_ALARM = "android.permission.SCHEDULE_EXACT_ALARM";
// Fallback window settings
private static final long FALLBACK_WINDOW_START_MS = TimeUnit.MINUTES.toMillis(-10); // 10 minutes before
private static final long FALLBACK_WINDOW_LENGTH_MS = TimeUnit.MINUTES.toMillis(20); // 20 minutes total
// Deep-link constants
private static final String EXACT_ALARM_SETTINGS_ACTION = "android.settings.REQUEST_SCHEDULE_EXACT_ALARM";
private static final String EXACT_ALARM_SETTINGS_PACKAGE = "com.android.settings";
// MARK: - Properties
private final Context context;
private final AlarmManager alarmManager;
private final DailyNotificationScheduler scheduler;
// Alarm state
private boolean exactAlarmsEnabled = false;
private boolean exactAlarmsSupported = false;
// MARK: - Initialization
/**
* Constructor
*
* @param context Application context
* @param alarmManager System AlarmManager service
* @param scheduler Notification scheduler
*/
public DailyNotificationExactAlarmManager(Context context, AlarmManager alarmManager, DailyNotificationScheduler scheduler) {
this.context = context;
this.alarmManager = alarmManager;
this.scheduler = scheduler;
// Check exact alarm support and status
checkExactAlarmSupport();
checkExactAlarmStatus();
Log.d(TAG, "ExactAlarmManager initialized: supported=" + exactAlarmsSupported + ", enabled=" + exactAlarmsEnabled);
}
// MARK: - Exact Alarm Support
/**
* Check if exact alarms are supported on this device
*/
private void checkExactAlarmSupport() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
exactAlarmsSupported = true;
Log.d(TAG, "Exact alarms supported on Android S+");
} else {
exactAlarmsSupported = false;
Log.d(TAG, "Exact alarms not supported on Android " + Build.VERSION.SDK_INT);
}
}
/**
* Check current exact alarm status
*/
private void checkExactAlarmStatus() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
exactAlarmsEnabled = alarmManager.canScheduleExactAlarms();
Log.d(TAG, "Exact alarm status: " + (exactAlarmsEnabled ? "enabled" : "disabled"));
} else {
exactAlarmsEnabled = true; // Always available on older Android versions
Log.d(TAG, "Exact alarms always available on Android " + Build.VERSION.SDK_INT);
}
}
/**
* Get exact alarm status
*
* @return Status information
*/
public ExactAlarmStatus getExactAlarmStatus() {
return new ExactAlarmStatus(
exactAlarmsSupported,
exactAlarmsEnabled,
canScheduleExactAlarms(),
getFallbackWindowInfo()
);
}
/**
* Check if exact alarms can be scheduled
*
* @return true if exact alarms can be scheduled
*/
public boolean canScheduleExactAlarms() {
if (!exactAlarmsSupported) {
return false;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return alarmManager.canScheduleExactAlarms();
}
return true;
}
/**
* Get fallback window information
*
* @return Fallback window info
*/
public FallbackWindowInfo getFallbackWindowInfo() {
return new FallbackWindowInfo(
FALLBACK_WINDOW_START_MS,
FALLBACK_WINDOW_LENGTH_MS,
"±10 minutes"
);
}
// MARK: - Alarm Scheduling
/**
* Schedule alarm with exact or fallback logic
*
* @param pendingIntent PendingIntent to trigger
* @param triggerTime Exact trigger time
* @return true if scheduling was successful
*/
public boolean scheduleAlarm(PendingIntent pendingIntent, long triggerTime) {
try {
Log.d(TAG, "Scheduling alarm for " + triggerTime);
if (canScheduleExactAlarms()) {
return scheduleExactAlarm(pendingIntent, triggerTime);
} else {
return scheduleWindowedAlarm(pendingIntent, triggerTime);
}
} catch (Exception e) {
Log.e(TAG, "Error scheduling alarm", e);
return false;
}
}
/**
* Schedule exact alarm
*
* @param pendingIntent PendingIntent to trigger
* @param triggerTime Exact trigger time
* @return true if scheduling was successful
*/
private boolean scheduleExactAlarm(PendingIntent pendingIntent, long triggerTime) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent);
Log.i(TAG, "Exact alarm scheduled for " + triggerTime);
return true;
} else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent);
Log.i(TAG, "Exact alarm scheduled for " + triggerTime + " (pre-M)");
return true;
}
} catch (Exception e) {
Log.e(TAG, "Error scheduling exact alarm", e);
return false;
}
}
/**
* Schedule windowed alarm as fallback
*
* @param pendingIntent PendingIntent to trigger
* @param triggerTime Target trigger time
* @return true if scheduling was successful
*/
private boolean scheduleWindowedAlarm(PendingIntent pendingIntent, long triggerTime) {
try {
// Calculate window start time (10 minutes before target)
long windowStartTime = triggerTime + FALLBACK_WINDOW_START_MS;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
alarmManager.setWindow(AlarmManager.RTC_WAKEUP, windowStartTime, FALLBACK_WINDOW_LENGTH_MS, pendingIntent);
Log.i(TAG, "Windowed alarm scheduled: target=" + triggerTime + ", window=" + windowStartTime + " to " + (windowStartTime + FALLBACK_WINDOW_LENGTH_MS));
return true;
} else {
// Fallback to inexact alarm on older versions
alarmManager.set(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent);
Log.i(TAG, "Inexact alarm scheduled for " + triggerTime + " (pre-KitKat)");
return true;
}
} catch (Exception e) {
Log.e(TAG, "Error scheduling windowed alarm", e);
return false;
}
}
// MARK: - Permission Management
/**
* Request exact alarm permission
*
* @return true if permission request was initiated
*/
public boolean requestExactAlarmPermission() {
if (!exactAlarmsSupported) {
Log.w(TAG, "Exact alarms not supported on this device");
return false;
}
if (exactAlarmsEnabled) {
Log.d(TAG, "Exact alarms already enabled");
return true;
}
try {
// Open exact alarm settings
Intent intent = new Intent(EXACT_ALARM_SETTINGS_ACTION);
intent.setPackage(EXACT_ALARM_SETTINGS_PACKAGE);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
Log.i(TAG, "Exact alarm permission request initiated");
return true;
} catch (Exception e) {
Log.e(TAG, "Error requesting exact alarm permission", e);
return false;
}
}
/**
* Open exact alarm settings
*
* @return true if settings were opened
*/
public boolean openExactAlarmSettings() {
try {
Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
Log.i(TAG, "Exact alarm settings opened");
return true;
} catch (Exception e) {
Log.e(TAG, "Error opening exact alarm settings", e);
return false;
}
}
/**
* Check if exact alarm permission is granted
*
* @return true if permission is granted
*/
public boolean hasExactAlarmPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return context.checkSelfPermission(PERMISSION_SCHEDULE_EXACT_ALARM) == PackageManager.PERMISSION_GRANTED;
}
return true; // Always available on older versions
}
// MARK: - Reboot and Time Change Recovery
/**
* Handle system reboot
*
* This method should be called when the system boots to restore
* scheduled alarms that were lost during reboot.
*/
public void handleSystemReboot() {
try {
Log.i(TAG, "Handling system reboot - restoring scheduled alarms");
// Re-schedule all pending notifications
scheduler.restoreScheduledNotifications();
Log.i(TAG, "System reboot handling completed");
} catch (Exception e) {
Log.e(TAG, "Error handling system reboot", e);
}
}
/**
* Handle time change
*
* This method should be called when the system time changes
* to adjust scheduled alarms accordingly.
*/
public void handleTimeChange() {
try {
Log.i(TAG, "Handling time change - adjusting scheduled alarms");
// Re-schedule all pending notifications with adjusted times
scheduler.adjustScheduledNotifications();
Log.i(TAG, "Time change handling completed");
} catch (Exception e) {
Log.e(TAG, "Error handling time change", e);
}
}
// MARK: - Status Classes
/**
* Exact alarm status information
*/
public static class ExactAlarmStatus {
public final boolean supported;
public final boolean enabled;
public final boolean canSchedule;
public final FallbackWindowInfo fallbackWindow;
public ExactAlarmStatus(boolean supported, boolean enabled, boolean canSchedule, FallbackWindowInfo fallbackWindow) {
this.supported = supported;
this.enabled = enabled;
this.canSchedule = canSchedule;
this.fallbackWindow = fallbackWindow;
}
@Override
public String toString() {
return String.format("ExactAlarmStatus{supported=%s, enabled=%s, canSchedule=%s, fallbackWindow=%s}",
supported, enabled, canSchedule, fallbackWindow);
}
}
/**
* Fallback window information
*/
public static class FallbackWindowInfo {
public final long startMs;
public final long lengthMs;
public final String description;
public FallbackWindowInfo(long startMs, long lengthMs, String description) {
this.startMs = startMs;
this.lengthMs = lengthMs;
this.description = description;
}
@Override
public String toString() {
return String.format("FallbackWindowInfo{start=%dms, length=%dms, description='%s'}",
startMs, lengthMs, description);
}
}
}

View File

@@ -0,0 +1,549 @@
/**
* DailyNotificationFetchWorker.java
*
* WorkManager worker for background content fetching
* Implements the prefetch step with timeout handling and retry logic
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.CompletableFuture;
import java.util.Random;
/**
* Background worker for fetching daily notification content
*
* This worker implements the prefetch step of the offline-first pipeline.
* It runs in the background to fetch content before it's needed,
* with proper timeout handling and retry mechanisms.
*/
public class DailyNotificationFetchWorker extends Worker {
private static final String TAG = "DailyNotificationFetchWorker";
private static final String KEY_SCHEDULED_TIME = "scheduled_time";
private static final String KEY_FETCH_TIME = "fetch_time";
private static final String KEY_RETRY_COUNT = "retry_count";
private static final String KEY_IMMEDIATE = "immediate";
private static final int MAX_RETRY_ATTEMPTS = 3;
private static final long WORK_TIMEOUT_MS = 8 * 60 * 1000; // 8 minutes total
// Legacy timeout (will be replaced by SchedulingPolicy)
private static final long FETCH_TIMEOUT_MS_DEFAULT = 30 * 1000; // 30 seconds
private final Context context;
private final DailyNotificationStorage storage;
private final DailyNotificationFetcher fetcher; // Legacy fetcher (fallback only)
/**
* Constructor
*
* @param context Application context
* @param params Worker parameters
*/
public DailyNotificationFetchWorker(@NonNull Context context,
@NonNull WorkerParameters params) {
super(context, params);
this.context = context;
this.storage = new DailyNotificationStorage(context);
this.fetcher = new DailyNotificationFetcher(context, storage);
}
/**
* Main work method - fetch content with timeout and retry logic
*
* @return Result indicating success, failure, or retry
*/
@NonNull
@Override
public Result doWork() {
try {
Log.d(TAG, "Starting background content fetch");
// Get input data
Data inputData = getInputData();
long scheduledTime = inputData.getLong(KEY_SCHEDULED_TIME, 0);
long fetchTime = inputData.getLong(KEY_FETCH_TIME, 0);
int retryCount = inputData.getInt(KEY_RETRY_COUNT, 0);
boolean immediate = inputData.getBoolean(KEY_IMMEDIATE, false);
Log.d(TAG, String.format("PR2: Fetch parameters - Scheduled: %d, Fetch: %d, Retry: %d, Immediate: %s",
scheduledTime, fetchTime, retryCount, immediate));
// Check if we should proceed with fetch
if (!shouldProceedWithFetch(scheduledTime, fetchTime)) {
Log.d(TAG, "Skipping fetch - conditions not met");
return Result.success();
}
// PR2: Attempt to fetch content using native fetcher SPI
List<NotificationContent> contents = fetchContentWithTimeout(scheduledTime, fetchTime, immediate);
if (contents != null && !contents.isEmpty()) {
// Success - save contents and schedule notifications
handleSuccessfulFetch(contents);
return Result.success();
} else {
// Fetch failed - handle retry logic
return handleFailedFetch(retryCount, scheduledTime);
}
} catch (Exception e) {
Log.e(TAG, "Unexpected error during background fetch", e);
return handleFailedFetch(0, 0);
}
}
/**
* Check if we should proceed with the fetch
*
* @param scheduledTime When notification is scheduled for
* @param fetchTime When fetch was originally scheduled for
* @return true if fetch should proceed
*/
private boolean shouldProceedWithFetch(long scheduledTime, long fetchTime) {
long currentTime = System.currentTimeMillis();
// If this is an immediate fetch, always proceed
if (fetchTime == 0) {
return true;
}
// Check if fetch time has passed
if (currentTime < fetchTime) {
Log.d(TAG, "Fetch time not yet reached");
return false;
}
// Check if notification time has passed
if (currentTime >= scheduledTime) {
Log.d(TAG, "Notification time has passed, fetch not needed");
return false;
}
// Check if we already have recent content
if (!storage.shouldFetchNewContent()) {
Log.d(TAG, "Recent content available, fetch not needed");
return false;
}
return true;
}
/**
* Fetch content with timeout handling using native fetcher SPI (PR2)
*
* @param scheduledTime When notification is scheduled for
* @param fetchTime When fetch was triggered
* @param immediate Whether this is an immediate fetch
* @return List of fetched notification contents or null if failed
*/
private List<NotificationContent> fetchContentWithTimeout(long scheduledTime, long fetchTime, boolean immediate) {
try {
// Get SchedulingPolicy for timeout configuration
SchedulingPolicy policy = getSchedulingPolicy();
long fetchTimeoutMs = policy.fetchTimeoutMs != null ?
policy.fetchTimeoutMs : FETCH_TIMEOUT_MS_DEFAULT;
Log.d(TAG, "PR2: Fetching content with native fetcher SPI, timeout: " + fetchTimeoutMs + "ms");
// Get native fetcher from static registry
NativeNotificationContentFetcher nativeFetcher = DailyNotificationPlugin.getNativeFetcherStatic();
if (nativeFetcher == null) {
Log.w(TAG, "PR2: Native fetcher not registered, falling back to legacy fetcher");
// Fallback to legacy fetcher
NotificationContent content = fetcher.fetchContentImmediately();
if (content != null) {
return java.util.Collections.singletonList(content);
}
return null;
}
long startTime = System.currentTimeMillis();
// Create FetchContext
String trigger = immediate ? "manual" :
(fetchTime > 0 ? "prefetch" : "background_work");
Long scheduledTimeOpt = scheduledTime > 0 ? scheduledTime : null;
Map<String, Object> metadata = new HashMap<>();
metadata.put("retryCount", 0);
metadata.put("immediate", immediate);
FetchContext context = new FetchContext(
trigger,
scheduledTimeOpt,
fetchTime > 0 ? fetchTime : System.currentTimeMillis(),
metadata
);
// Call native fetcher with timeout
CompletableFuture<List<NotificationContent>> future = nativeFetcher.fetchContent(context);
List<NotificationContent> contents;
try {
contents = future.get(fetchTimeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
Log.e(TAG, "PR2: Native fetcher timeout after " + fetchTimeoutMs + "ms", e);
return null;
}
long fetchDuration = System.currentTimeMillis() - startTime;
if (contents != null && !contents.isEmpty()) {
Log.i(TAG, "PR2: Content fetched successfully - " + contents.size() +
" items in " + fetchDuration + "ms");
// TODO PR2: Record metrics (items_fetched, fetch_duration_ms, fetch_success)
return contents;
} else {
Log.w(TAG, "PR2: Native fetcher returned empty list after " + fetchDuration + "ms");
// TODO PR2: Record metrics (fetch_success=false)
return null;
}
} catch (Exception e) {
Log.e(TAG, "PR2: Error during native fetcher call", e);
// TODO PR2: Record metrics (fetch_fail_class=retryable)
return null;
}
}
/**
* Get SchedulingPolicy from SharedPreferences or return default
*
* @return SchedulingPolicy instance
*/
private SchedulingPolicy getSchedulingPolicy() {
try {
// Try to load from SharedPreferences (set via plugin's setPolicy method)
android.content.SharedPreferences prefs = context.getSharedPreferences(
"daily_notification_spi", Context.MODE_PRIVATE);
// For now, return default policy
// TODO: Deserialize from SharedPreferences in future enhancement
return SchedulingPolicy.createDefault();
} catch (Exception e) {
Log.w(TAG, "Error loading SchedulingPolicy, using default", e);
return SchedulingPolicy.createDefault();
}
}
/**
* Handle successful content fetch (PR2: now handles List<NotificationContent>)
*
* @param contents List of successfully fetched notification contents
*/
private void handleSuccessfulFetch(List<NotificationContent> contents) {
try {
Log.d(TAG, "PR2: Handling successful content fetch - " + contents.size() + " items");
// Update last fetch time
storage.setLastFetchTime(System.currentTimeMillis());
// Get existing notifications for duplicate checking (prevent prefetch from creating duplicate)
java.util.List<NotificationContent> existingNotifications = storage.getAllNotifications();
long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts
// Track scheduled times in current batch to prevent within-batch duplicates
java.util.Set<Long> batchScheduledTimes = new java.util.HashSet<>();
// Save all contents and schedule notifications (with duplicate checking)
int scheduledCount = 0;
int skippedCount = 0;
for (NotificationContent content : contents) {
try {
// Check for duplicate notification at the same scheduled time
// First check within current batch (prevents duplicates in same fetch)
long scheduledTime = content.getScheduledTime();
boolean duplicateInBatch = false;
for (Long batchTime : batchScheduledTimes) {
if (Math.abs(batchTime - scheduledTime) <= toleranceMs) {
Log.w(TAG, "PR2: DUPLICATE_SKIP_BATCH id=" + content.getId() +
" scheduled_time=" + scheduledTime +
" time_diff_ms=" + Math.abs(batchTime - scheduledTime));
duplicateInBatch = true;
skippedCount++;
break;
}
}
if (duplicateInBatch) {
continue;
}
// Then check against existing notifications in storage
boolean duplicateInStorage = false;
for (NotificationContent existing : existingNotifications) {
if (Math.abs(existing.getScheduledTime() - scheduledTime) <= toleranceMs) {
Log.w(TAG, "PR2: DUPLICATE_SKIP_STORAGE id=" + content.getId() +
" existing_id=" + existing.getId() +
" scheduled_time=" + scheduledTime +
" time_diff_ms=" + Math.abs(existing.getScheduledTime() - scheduledTime));
duplicateInStorage = true;
skippedCount++;
break;
}
}
if (duplicateInStorage) {
// Skip this notification - one already exists for this time
continue;
}
// Mark this scheduledTime as processed in current batch
batchScheduledTimes.add(scheduledTime);
// Save content to storage
storage.saveNotificationContent(content);
// Schedule notification if not already scheduled
scheduleNotificationIfNeeded(content);
scheduledCount++;
} catch (Exception e) {
Log.e(TAG, "PR2: Error processing notification content: " + content.getId(), e);
}
}
Log.i(TAG, "PR2: Successful fetch handling completed - " + scheduledCount + "/" +
contents.size() + " notifications scheduled" +
(skippedCount > 0 ? ", " + skippedCount + " duplicates skipped" : ""));
// TODO PR2: Record metrics (items_enqueued=scheduledCount)
} catch (Exception e) {
Log.e(TAG, "PR2: Error handling successful fetch", e);
}
}
/**
* Handle failed content fetch with retry logic
*
* @param retryCount Current retry attempt
* @param scheduledTime When notification is scheduled for
* @return Result indicating retry or failure
*/
private Result handleFailedFetch(int retryCount, long scheduledTime) {
try {
Log.d(TAG, "PR2: Handling failed fetch - Retry: " + retryCount);
if (retryCount < MAX_RETRY_ATTEMPTS) {
// PR2: Schedule retry with SchedulingPolicy backoff
scheduleRetry(retryCount + 1, scheduledTime);
Log.i(TAG, "PR2: Scheduled retry attempt " + (retryCount + 1));
return Result.retry();
} else {
// Max retries reached - use fallback content
Log.w(TAG, "PR2: Max retries reached, using fallback content");
useFallbackContent(scheduledTime);
return Result.success();
}
} catch (Exception e) {
Log.e(TAG, "PR2 metabolites Error handling failed fetch", e);
return Result.failure();
}
}
/**
* Schedule a retry attempt
*
* @param retryCount New retry attempt number
* @param scheduledTime When notification is scheduled for
*/
private void scheduleRetry(int retryCount, long scheduledTime) {
try {
Log.d(TAG, "Scheduling retry attempt " + retryCount);
// Calculate retry delay with exponential backoff
long retryDelay = calculateRetryDelay(retryCount);
// Create retry work request
Data retryData = new Data.Builder()
.putLong(KEY_SCHEDULED_TIME, scheduledTime)
.putLong(KEY_FETCH_TIME, System.currentTimeMillis())
.putInt(KEY_RETRY_COUNT, retryCount)
.build();
androidx.work.OneTimeWorkRequest retryWork =
new androidx.work.OneTimeWorkRequest.Builder(DailyNotificationFetchWorker.class)
.setInputData(retryData)
.setInitialDelay(retryDelay, TimeUnit.MILLISECONDS)
.build();
androidx.work.WorkManager.getInstance(context).enqueue(retryWork);
Log.d(TAG, "Retry scheduled for " + retryDelay + "ms from now");
} catch (Exception e) {
Log.e(TAG, "Error scheduling retry", e);
}
}
/**
* Calculate retry delay with exponential backoff using SchedulingPolicy (PR2)
*
* @param retryCount Current retry attempt
* @return Delay in milliseconds
*/
private long calculateRetryDelay(int retryCount) {
SchedulingPolicy policy = getSchedulingPolicy();
SchedulingPolicy.RetryBackoff backoff = policy.retryBackoff;
// Calculate exponential delay: minMs * (factor ^ (retryCount - 1))
long baseDelay = backoff.minMs;
double exponentialMultiplier = Math.pow(backoff.factor, retryCount - 1);
long exponentialDelay = (long) (baseDelay * exponentialMultiplier);
// Cap at maxMs
long cappedDelay = Math.min(exponentialDelay, backoff.maxMs);
// Add jitter: delay * (1 + jitterPct/100 * random(0-1))
Random random = new Random();
double jitter = backoff.jitterPct / 100.0 * random.nextDouble();
long finalDelay = (long) (cappedDelay * (1.0 + jitter));
Log.d(TAG, "PR2: Calculated retry delay - attempt=" + retryCount +
", base=" + baseDelay + "ms, exponential=" + exponentialDelay + "ms, " +
"capped=" + cappedDelay + "ms, jitter=" + String.format("%.1f%%", jitter * 100) +
", final=" + finalDelay + "ms");
return finalDelay;
}
/**
* Use fallback content when all retries fail
*
* @param scheduledTime When notification is scheduled for
*/
private void useFallbackContent(long scheduledTime) {
try {
Log.d(TAG, "Using fallback content for scheduled time: " + scheduledTime);
// Get fallback content from storage or create emergency content
NotificationContent fallbackContent = getFallbackContent(scheduledTime);
if (fallbackContent != null) {
// Save fallback content
storage.saveNotificationContent(fallbackContent);
// Schedule notification
scheduleNotificationIfNeeded(fallbackContent);
Log.i(TAG, "Fallback content applied successfully");
} else {
Log.e(TAG, "Failed to get fallback content");
}
} catch (Exception e) {
Log.e(TAG, "Error using fallback content", e);
}
}
/**
* Get fallback content for the scheduled time
*
* @param scheduledTime When notification is scheduled for
* @return Fallback notification content
*/
private NotificationContent getFallbackContent(long scheduledTime) {
try {
// Try to get last known good content
NotificationContent lastContent = storage.getLastNotification();
if (lastContent != null && !lastContent.isStale()) {
Log.d(TAG, "Using last known good content as fallback");
// Create new content based on last good content
NotificationContent fallbackContent = new NotificationContent();
fallbackContent.setTitle(lastContent.getTitle());
fallbackContent.setBody(lastContent.getBody() + " (from " +
lastContent.getAgeString() + ")");
fallbackContent.setScheduledTime(scheduledTime);
fallbackContent.setSound(lastContent.isSound());
fallbackContent.setPriority(lastContent.getPriority());
fallbackContent.setUrl(lastContent.getUrl());
// fetchedAt is set in constructor, no need to set it again
return fallbackContent;
}
// Create emergency fallback content
Log.w(TAG, "Creating emergency fallback content");
return createEmergencyFallbackContent(scheduledTime);
} catch (Exception e) {
Log.e(TAG, "Error getting fallback content", e);
return createEmergencyFallbackContent(scheduledTime);
}
}
/**
* Create emergency fallback content
*
* @param scheduledTime When notification is scheduled for
* @return Emergency notification content
*/
private NotificationContent createEmergencyFallbackContent(long scheduledTime) {
NotificationContent content = new NotificationContent();
content.setTitle("Daily Update");
content.setBody("🌅 Good morning! Ready to make today amazing?");
content.setScheduledTime(scheduledTime);
// fetchedAt is set in constructor, no need to set it again
content.setPriority("default");
content.setSound(true);
return content;
}
/**
* Schedule notification if not already scheduled
*
* @param content Notification content to schedule
*/
private void scheduleNotificationIfNeeded(NotificationContent content) {
try {
Log.d(TAG, "Checking if notification needs scheduling: " + content.getId());
// Check if notification is already scheduled
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
context,
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
);
if (!scheduler.isNotificationScheduled(content.getId())) {
Log.d(TAG, "Scheduling notification: " + content.getId());
boolean scheduled = scheduler.scheduleNotification(content);
if (scheduled) {
Log.i(TAG, "Notification scheduled successfully");
} else {
Log.e(TAG, "Failed to schedule notification");
}
} else {
Log.d(TAG, "Notification already scheduled: " + content.getId());
}
} catch (Exception e) {
Log.e(TAG, "Error checking/scheduling notification", e);
}
}
}

View File

@@ -0,0 +1,512 @@
/**
* DailyNotificationFetcher.java
*
* Handles background content fetching for daily notifications
* Implements the prefetch step of the prefetch → cache → schedule → display pipeline
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.concurrent.TimeUnit;
/**
* Manages background content fetching for daily notifications
*
* This class implements the prefetch step of the offline-first pipeline.
* It schedules background work to fetch content before it's needed,
* with proper timeout handling and fallback mechanisms.
*/
public class DailyNotificationFetcher {
private static final String TAG = "DailyNotificationFetcher";
private static final String WORK_TAG_FETCH = "daily_notification_fetch";
private static final String WORK_TAG_MAINTENANCE = "daily_notification_maintenance";
private static final int NETWORK_TIMEOUT_MS = 30000; // 30 seconds
private static final int MAX_RETRY_ATTEMPTS = 3;
private static final long RETRY_DELAY_MS = 60000; // 1 minute
private final Context context;
private final DailyNotificationStorage storage; // Deprecated path (kept for transitional read paths)
private final com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
private final WorkManager workManager;
// ETag manager for efficient fetching
private final DailyNotificationETagManager etagManager;
/**
* Constructor
*
* @param context Application context
* @param storage Storage instance for saving fetched content
*/
public DailyNotificationFetcher(Context context, DailyNotificationStorage storage) {
this(context, storage, null);
}
public DailyNotificationFetcher(Context context,
DailyNotificationStorage storage,
com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
this.context = context;
this.storage = storage;
this.roomStorage = roomStorage;
this.workManager = WorkManager.getInstance(context);
this.etagManager = new DailyNotificationETagManager(storage);
Log.d(TAG, "DailyNotificationFetcher initialized with ETag support");
}
/**
* Schedule a background fetch for content
*
* @param fetchTime When to fetch the content (already calculated, typically 5 minutes before notification)
*/
public void scheduleFetch(long fetchTime) {
try {
Log.d(TAG, "Scheduling background fetch for time: " + fetchTime);
long currentTime = System.currentTimeMillis();
long delayMs = fetchTime - currentTime;
Log.d(TAG, "DN|FETCH_SCHEDULING fetch_time=" + fetchTime +
" current=" + currentTime +
" delay_ms=" + delayMs);
if (fetchTime > currentTime) {
// Create work data - we need to calculate the notification time (fetchTime + 5 minutes)
long scheduledTime = fetchTime + TimeUnit.MINUTES.toMillis(5);
Data inputData = new Data.Builder()
.putLong("scheduled_time", scheduledTime)
.putLong("fetch_time", fetchTime)
.putInt("retry_count", 0)
.build();
// Create unique work name based on scheduled time to prevent duplicate fetches
// Use scheduled time rounded to nearest minute to handle multiple notifications
// scheduled close together
long scheduledTimeMinutes = scheduledTime / (60 * 1000);
String workName = "fetch_" + scheduledTimeMinutes;
// Create one-time work request
OneTimeWorkRequest fetchWork = new OneTimeWorkRequest.Builder(
DailyNotificationFetchWorker.class)
.setInputData(inputData)
.addTag(WORK_TAG_FETCH)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.build();
// Use unique work name with REPLACE policy (newer fetch replaces older)
// This prevents duplicate fetch workers for the same scheduled time
workManager.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
fetchWork
);
Log.i(TAG, "DN|WORK_ENQUEUED work_id=" + fetchWork.getId().toString() +
" fetch_at=" + fetchTime +
" work_name=" + workName +
" delay_ms=" + delayMs +
" delay_minutes=" + (delayMs / 60000.0));
Log.i(TAG, "Background fetch scheduled successfully");
} else {
Log.w(TAG, "DN|FETCH_PAST_TIME fetch_time=" + fetchTime +
" current=" + currentTime +
" past_by_ms=" + (currentTime - fetchTime));
scheduleImmediateFetch();
}
} catch (Exception e) {
Log.e(TAG, "Error scheduling background fetch", e);
// Fallback to immediate fetch
scheduleImmediateFetch();
}
}
/**
* Schedule an immediate fetch (fallback)
*/
public void scheduleImmediateFetch() {
try {
Log.d(TAG, "Scheduling immediate fetch");
Data inputData = new Data.Builder()
.putLong("scheduled_time", System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
.putLong("fetch_time", System.currentTimeMillis())
.putInt("retry_count", 0)
.putBoolean("immediate", true)
.build();
OneTimeWorkRequest fetchWork = new OneTimeWorkRequest.Builder(
DailyNotificationFetchWorker.class)
.setInputData(inputData)
.addTag(WORK_TAG_FETCH)
.build();
workManager.enqueue(fetchWork);
Log.i(TAG, "Immediate fetch scheduled successfully");
} catch (Exception e) {
Log.e(TAG, "Error scheduling immediate fetch", e);
}
}
/**
* Fetch content immediately (synchronous)
*
* @return Fetched notification content or null if failed
*/
public NotificationContent fetchContentImmediately() {
try {
Log.d(TAG, "Fetching content immediately");
// Check if we should fetch new content
if (!storage.shouldFetchNewContent()) {
Log.d(TAG, "Content fetch not needed yet");
return storage.getLastNotification();
}
// Attempt to fetch from network
NotificationContent content = fetchFromNetwork();
if (content != null) {
// Save to Room storage (authoritative)
saveToRoomIfAvailable(content);
// Save to legacy storage for transitional compatibility
try {
storage.saveNotificationContent(content);
storage.setLastFetchTime(System.currentTimeMillis());
} catch (Exception legacyErr) {
Log.w(TAG, "Legacy storage save failed (continuing): " + legacyErr.getMessage());
}
Log.i(TAG, "Content fetched and saved successfully");
return content;
} else {
// Fallback to cached content
Log.w(TAG, "Network fetch failed, using cached content");
return getFallbackContent();
}
} catch (Exception e) {
Log.e(TAG, "Error during immediate content fetch", e);
return getFallbackContent();
}
}
/**
* Persist fetched content to Room storage when available
*/
private void saveToRoomIfAvailable(NotificationContent content) {
if (roomStorage == null) {
return;
}
try {
com.timesafari.dailynotification.entities.NotificationContentEntity entity =
new com.timesafari.dailynotification.entities.NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0",
null,
"daily",
content.getTitle(),
content.getBody(),
content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId()
);
entity.priority = mapPriority(content.getPriority());
try {
java.lang.reflect.Method isVibration = NotificationContent.class.getDeclaredMethod("isVibration");
Object vib = isVibration.invoke(content);
if (vib instanceof Boolean) {
entity.vibrationEnabled = (Boolean) vib;
}
} catch (Exception ignored) { }
entity.soundEnabled = content.isSound();
entity.mediaUrl = content.getMediaUrl();
entity.deliveryStatus = "pending";
roomStorage.saveNotificationContent(entity);
} catch (Throwable t) {
Log.w(TAG, "Room storage save failed: " + t.getMessage(), t);
}
}
private int mapPriority(String priority) {
if (priority == null) return 0;
switch (priority) {
case "max":
case "high":
return 2;
case "low":
case "min":
return -1;
default:
return 0;
}
}
/**
* Fetch content from network with ETag support
*
* @return Fetched content or null if failed
*/
private NotificationContent fetchFromNetwork() {
try {
Log.d(TAG, "Fetching content from network with ETag support");
// Get content endpoint URL
String contentUrl = getContentEndpoint();
// Make conditional request with ETag
DailyNotificationETagManager.ConditionalRequestResult result =
etagManager.makeConditionalRequest(contentUrl);
if (result.success) {
if (result.isFromCache) {
Log.d(TAG, "Content not modified (304) - using cached content");
return storage.getLastNotification();
} else {
Log.d(TAG, "New content available (200) - parsing response");
return parseNetworkResponse(result.content);
}
} else {
Log.w(TAG, "Conditional request failed: " + result.error);
return null;
}
} catch (Exception e) {
Log.e(TAG, "Error during network fetch with ETag", e);
return null;
}
}
/**
* Parse network response into notification content
*
* @param connection HTTP connection with response
* @return Parsed notification content or null if parsing failed
*/
private NotificationContent parseNetworkResponse(HttpURLConnection connection) {
try {
// This is a simplified parser - in production you'd use a proper JSON parser
// For now, we'll create a placeholder content
NotificationContent content = new NotificationContent();
content.setTitle("Daily Update");
content.setBody("Your daily notification is ready");
content.setScheduledTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
// fetchedAt is set in constructor, no need to set it again
return content;
} catch (Exception e) {
Log.e(TAG, "Error parsing network response", e);
return null;
}
}
/**
* Parse network response string into notification content
*
* @param responseString Response content as string
* @return Parsed notification content or null if parsing failed
*/
private NotificationContent parseNetworkResponse(String responseString) {
try {
Log.d(TAG, "Parsing network response string");
// This is a simplified parser - in production you'd use a proper JSON parser
// For now, we'll create a placeholder content
NotificationContent content = new NotificationContent();
content.setTitle("Daily Update");
content.setBody("Your daily notification is ready");
content.setScheduledTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
// fetchedAt is set in constructor, no need to set it again
Log.d(TAG, "Network response parsed successfully");
return content;
} catch (Exception e) {
Log.e(TAG, "Error parsing network response string", e);
return null;
}
}
/**
* Get fallback content when network fetch fails
*
* @return Fallback notification content
*/
private NotificationContent getFallbackContent() {
try {
// Try to get last known good content
NotificationContent lastContent = storage.getLastNotification();
if (lastContent != null && !lastContent.isStale()) {
Log.d(TAG, "Using last known good content as fallback");
return lastContent;
}
// Create emergency fallback content
Log.w(TAG, "Creating emergency fallback content");
return createEmergencyFallbackContent();
} catch (Exception e) {
Log.e(TAG, "Error getting fallback content", e);
return createEmergencyFallbackContent();
}
}
/**
* Create emergency fallback content
*
* @return Emergency notification content
*/
private NotificationContent createEmergencyFallbackContent() {
NotificationContent content = new NotificationContent();
content.setTitle("Daily Update");
content.setBody("🌅 Good morning! Ready to make today amazing?");
content.setScheduledTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
// fetchedAt is set in constructor, no need to set it again
content.setPriority("default");
content.setSound(true);
return content;
}
/**
* Get the content endpoint URL
*
* @return Content endpoint URL
*/
private String getContentEndpoint() {
// This would typically come from configuration
// For now, return a placeholder
return "https://api.timesafari.com/daily-content";
}
/**
* Schedule maintenance work
*/
public void scheduleMaintenance() {
try {
Log.d(TAG, "Scheduling maintenance work");
Data inputData = new Data.Builder()
.putLong("maintenance_time", System.currentTimeMillis())
.build();
OneTimeWorkRequest maintenanceWork = new OneTimeWorkRequest.Builder(
DailyNotificationMaintenanceWorker.class)
.setInputData(inputData)
.addTag(WORK_TAG_MAINTENANCE)
.setInitialDelay(TimeUnit.HOURS.toMillis(2), TimeUnit.MILLISECONDS)
.build();
workManager.enqueue(maintenanceWork);
Log.i(TAG, "Maintenance work scheduled successfully");
} catch (Exception e) {
Log.e(TAG, "Error scheduling maintenance work", e);
}
}
/**
* Cancel all scheduled fetch work
*/
public void cancelAllFetchWork() {
try {
Log.d(TAG, "Cancelling all fetch work");
workManager.cancelAllWorkByTag(WORK_TAG_FETCH);
workManager.cancelAllWorkByTag(WORK_TAG_MAINTENANCE);
Log.i(TAG, "All fetch work cancelled");
} catch (Exception e) {
Log.e(TAG, "Error cancelling fetch work", e);
}
}
/**
* Check if fetch work is scheduled
*
* @return true if fetch work is scheduled
*/
public boolean isFetchWorkScheduled() {
// This would check WorkManager for pending work
// For now, return a placeholder
return false;
}
/**
* Get fetch statistics
*
* @return Fetch statistics as a string
*/
public String getFetchStats() {
return String.format("Last fetch: %d, Fetch work scheduled: %s",
storage.getLastFetchTime(),
isFetchWorkScheduled() ? "yes" : "no");
}
/**
* Get ETag manager for external access
*
* @return ETag manager instance
*/
public DailyNotificationETagManager getETagManager() {
return etagManager;
}
/**
* Get network efficiency metrics
*
* @return Network metrics
*/
public DailyNotificationETagManager.NetworkMetrics getNetworkMetrics() {
return etagManager.getMetrics();
}
/**
* Get ETag cache statistics
*
* @return Cache statistics
*/
public DailyNotificationETagManager.CacheStatistics getCacheStatistics() {
return etagManager.getCacheStatistics();
}
/**
* Clean expired ETags
*/
public void cleanExpiredETags() {
etagManager.cleanExpiredETags();
}
/**
* Reset network metrics
*/
public void resetNetworkMetrics() {
etagManager.resetMetrics();
}
}

View File

@@ -0,0 +1,407 @@
/**
* DailyNotificationJWTManager.java
*
* Android JWT Manager for TimeSafari authentication enhancement
* Extends existing ETagManager infrastructure with DID-based JWT authentication
*
* @author Matthew Raymer
* @version 1.0.0
* @created 2025-10-03 06:53:30 UTC
*/
package com.timesafari.dailynotification;
import android.util.Log;
import android.content.Context;
import java.net.HttpURLConnection;
import java.util.HashMap;
import java.util.Map;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
/**
* Manages JWT authentication for TimeSafari integration
*
* This class extends the existing ETagManager infrastructure by adding:
* - DID-based JWT token generation
* - Automatic JWT header injection into HTTP requests
* - JWT token expiration management
* - Integration with existing DailyNotificationETagManager
*
* Phase 1 Implementation: Extends existing DailyNotificationETagManager.java
*/
public class DailyNotificationJWTManager {
// MARK: - Constants
private static final String TAG = "DailyNotificationJWTManager";
// JWT Headers
private static final String HEADER_AUTHORIZATION = "Authorization";
private static final String HEADER_CONTENT_TYPE = "Content-Type";
// JWT Configuration
private static final int DEFAULT_JWT_EXPIRATION_SECONDS = 60;
// JWT Algorithm (simplified for Phase 1)
private static final String ALGORITHM = "HS256";
// MARK: - Properties
private final DailyNotificationStorage storage;
private final DailyNotificationETagManager eTagManager;
// Current authentication state
private String currentActiveDid;
private String currentJWTToken;
private long jwtExpirationTime;
// Configuration
private int jwtExpirationSeconds;
// MARK: - Initialization
/**
* Constructor
*
* @param storage Storage instance for persistence
* @param eTagManager ETagManager instance for HTTP enhancements
*/
public DailyNotificationJWTManager(DailyNotificationStorage storage, DailyNotificationETagManager eTagManager) {
this.storage = storage;
this.eTagManager = eTagManager;
this.jwtExpirationSeconds = DEFAULT_JWT_EXPIRATION_SECONDS;
Log.d(TAG, "JWTManager initialized with ETagManager integration");
}
// MARK: - ActiveDid Management
/**
* Set the active DID for authentication
*
* @param activeDid The DID to use for JWT generation
*/
public void setActiveDid(String activeDid) {
setActiveDid(activeDid, DEFAULT_JWT_EXPIRATION_SECONDS);
}
/**
* Set the active DID for authentication with custom expiration
*
* @param activeDid The DID to use for JWT generation
* @param expirationSeconds JWT expiration time in seconds
*/
public void setActiveDid(String activeDid, int expirationSeconds) {
try {
Log.d(TAG, "Setting activeDid: " + activeDid + " with " + expirationSeconds + "s expiration");
this.currentActiveDid = activeDid;
this.jwtExpirationSeconds = expirationSeconds;
// Generate new JWT token immediately
generateAndCacheJWT();
Log.i(TAG, "ActiveDid set successfully");
} catch (Exception e) {
Log.e(TAG, "Error setting activeDid", e);
throw new RuntimeException("Failed to set activeDid", e);
}
}
/**
* Get the current active DID
*
* @return Current active DID or null if not set
*/
public String getCurrentActiveDid() {
return currentActiveDid;
}
/**
* Check if we have a valid active DID and JWT token
*
* @return true if authentication is ready
*/
public boolean isAuthenticated() {
return currentActiveDid != null &&
currentJWTToken != null &&
!isTokenExpired();
}
// MARK: - JWT Token Management
/**
* Generate JWT token for current activeDid
*
* @param expiresInSeconds Expiration time in seconds
* @return Generated JWT token
*/
public String generateJWTForActiveDid(String activeDid, int expiresInSeconds) {
try {
Log.d(TAG, "Generating JWT for activeDid: " + activeDid);
long currentTime = System.currentTimeMillis() / 1000;
// Create JWT payload
Map<String, Object> payload = new HashMap<>();
payload.put("exp", currentTime + expiresInSeconds);
payload.put("iat", currentTime);
payload.put("iss", activeDid);
payload.put("aud", "timesafari.notifications");
payload.put("sub", activeDid);
// Generate JWT token (simplified implementation for Phase 1)
String jwt = signWithDID(payload, activeDid);
Log.d(TAG, "JWT generated successfully");
return jwt;
} catch (Exception e) {
Log.e(TAG, "Error generating JWT", e);
throw new RuntimeException("Failed to generate JWT", e);
}
}
/**
* Generate and cache JWT token for current activeDid
*/
private void generateAndCacheJWT() {
if (currentActiveDid == null) {
Log.w(TAG, "Cannot generate JWT: no activeDid set");
return;
}
try {
currentJWTToken = generateJWTForActiveDid(currentActiveDid, jwtExpirationSeconds);
jwtExpirationTime = System.currentTimeMillis() + (jwtExpirationSeconds * 1000L);
Log.d(TAG, "JWT cached successfully, expires at: " + jwtExpirationTime);
} catch (Exception e) {
Log.e(TAG, "Error caching JWT", e);
throw new RuntimeException("Failed to cache JWT", e);
}
}
/**
* Check if current JWT token is expired
*
* @return true if token is expired
*/
private boolean isTokenExpired() {
return currentJWTToken == null || System.currentTimeMillis() >= jwtExpirationTime;
}
/**
* Refresh JWT token if needed
*/
public void refreshJWTIfNeeded() {
if (isTokenExpired()) {
Log.d(TAG, "JWT token expired, refreshing");
generateAndCacheJWT();
}
}
/**
* Get current valid JWT token (refreshes if needed)
*
* @return Current JWT token
*/
public String getCurrentJWTToken() {
refreshJWTIfNeeded();
return currentJWTToken;
}
// MARK: - HTTP Client Enhancement
/**
* Enhance HTTP client with JWT authentication headers
*
* Extends existing DailyNotificationETagManager connection creation
*
* @param connection HTTP connection to enhance
* @param activeDid DID for authentication (optional, uses current if null)
*/
public void enhanceHttpClientWithJWT(HttpURLConnection connection, String activeDid) {
try {
// Set activeDid if provided
if (activeDid != null && !activeDid.equals(currentActiveDid)) {
setActiveDid(activeDid);
}
// Ensure we have a valid token
if (!isAuthenticated()) {
throw new IllegalStateException("No valid authentication available");
}
// Add JWT Authorization header
String jwt = getCurrentJWTToken();
connection.setRequestProperty(HEADER_AUTHORIZATION, "Bearer " + jwt);
// Set JSON content type for API requests
connection.setRequestProperty(HEADER_CONTENT_TYPE, "application/json");
Log.d(TAG, "HTTP client enhanced with JWT authentication");
} catch (Exception e) {
Log.e(TAG, "Error enhancing HTTP client with JWT", e);
throw new RuntimeException("Failed to enhance HTTP client", e);
}
}
/**
* Enhance HTTP client with JWT authentication for current activeDid
*
* @param connection HTTP connection to enhance
*/
public void enhanceHttpClientWithJWT(HttpURLConnection connection) {
enhanceHttpClientWithJWT(connection, null);
}
// MARK: - JWT Signing (Simplified for Phase 1)
/**
* Sign JWT payload with DID (simplified implementation)
*
* Phase 1: Basic implementation using DID-based signing
* Later phases: Integrate with proper DID cryptography
*
* @param payload JWT payload
* @param did DID for signing
* @return Signed JWT token
*/
private String signWithDID(Map<String, Object> payload, String did) {
try {
// Phase 1: Simplified JWT implementation
// In production, this would use proper DID + cryptography libraries
// Create JWT header
Map<String, Object> header = new HashMap<>();
header.put("alg", ALGORITHM);
header.put("typ", "JWT");
// Encode header and payload
StringBuilder jwtBuilder = new StringBuilder();
// Header
jwtBuilder.append(base64UrlEncode(mapToJson(header)));
jwtBuilder.append(".");
// Payload
jwtBuilder.append(base64UrlEncode(mapToJson(payload)));
jwtBuilder.append(".");
// Signature (simplified - would use proper DID signing)
String signature = createSignature(jwtBuilder.toString(), did);
jwtBuilder.append(signature);
String jwt = jwtBuilder.toString();
Log.d(TAG, "JWT signed successfully (length: " + jwt.length() + ")");
return jwt;
} catch (Exception e) {
Log.e(TAG, "Error signing JWT", e);
throw new RuntimeException("Failed to sign JWT", e);
}
}
/**
* Create JWT signature (simplified for Phase 1)
*
* @param data Data to sign
* @param did DID for signature
* @return Base64-encoded signature
*/
private String createSignature(String data, String did) throws Exception {
// Phase 1: Simplified signature using DID hash
// Production would use proper DID cryptographic signing
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest((data + ":" + did).getBytes(StandardCharsets.UTF_8));
return base64UrlEncode(hash);
}
/**
* Convert map to JSON string (simplified)
*/
private String mapToJson(Map<String, Object> map) {
StringBuilder json = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> entry : map.entrySet()) {
if (!first) json.append(",");
json.append("\"").append(entry.getKey()).append("\":");
Object value = entry.getValue();
if (value instanceof String) {
json.append("\"").append(value).append("\"");
} else {
json.append(value);
}
first = false;
}
json.append("}");
return json.toString();
}
/**
* Base64 URL-safe encoding
*/
private String base64UrlEncode(byte[] data) {
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(data);
}
/**
* Base64 URL-safe encoding for strings
*/
private String base64UrlEncode(String data) {
return base64UrlEncode(data.getBytes(StandardCharsets.UTF_8));
}
// MARK: - Testing and Debugging
/**
* Get current JWT token info for debugging
*
* @return Token information
*/
public String getTokenDebugInfo() {
return String.format(
"JWT Token Info - ActiveDID: %s, HasToken: %s, Expired: %s, ExpiresAt: %d",
currentActiveDid,
currentJWTToken != null,
isTokenExpired(),
jwtExpirationTime
);
}
/**
* Clear authentication state
*/
public void clearAuthentication() {
try {
Log.d(TAG, "Clearing authentication state");
currentActiveDid = null;
currentJWTToken = null;
jwtExpirationTime = 0;
Log.i(TAG, "Authentication state cleared");
} catch (Exception e) {
Log.e(TAG, "Error clearing authentication", e);
}
}
}

View File

@@ -0,0 +1,403 @@
/**
* DailyNotificationMaintenanceWorker.java
*
* WorkManager worker for maintenance tasks
* Handles cleanup, optimization, and system health checks
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.util.List;
/**
* Background worker for maintenance tasks
*
* This worker handles periodic maintenance of the notification system,
* including cleanup of old data, optimization of storage, and health checks.
*/
public class DailyNotificationMaintenanceWorker extends Worker {
private static final String TAG = "DailyNotificationMaintenanceWorker";
private static final String KEY_MAINTENANCE_TIME = "maintenance_time";
private static final long WORK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes total
private static final int MAX_NOTIFICATIONS_TO_KEEP = 50; // Keep only recent notifications
private final Context context;
private final DailyNotificationStorage storage;
/**
* Constructor
*
* @param context Application context
* @param params Worker parameters
*/
public DailyNotificationMaintenanceWorker(@NonNull Context context,
@NonNull WorkerParameters params) {
super(context, params);
this.context = context;
this.storage = new DailyNotificationStorage(context);
}
/**
* Main work method - perform maintenance tasks
*
* @return Result indicating success or failure
*/
@NonNull
@Override
public Result doWork() {
try {
Log.d(TAG, "Starting maintenance work");
// Get input data
Data inputData = getInputData();
long maintenanceTime = inputData.getLong(KEY_MAINTENANCE_TIME, 0);
Log.d(TAG, "Maintenance time: " + maintenanceTime);
// Perform maintenance tasks
boolean success = performMaintenance();
if (success) {
Log.i(TAG, "Maintenance completed successfully");
return Result.success();
} else {
Log.w(TAG, "Maintenance completed with warnings");
return Result.success(); // Still consider it successful
}
} catch (Exception e) {
Log.e(TAG, "Error during maintenance work", e);
return Result.failure();
}
}
/**
* Perform all maintenance tasks
*
* @return true if all tasks completed successfully
*/
private boolean performMaintenance() {
try {
Log.d(TAG, "Performing maintenance tasks");
boolean allSuccessful = true;
// Task 1: Clean up old notifications
boolean cleanupSuccess = cleanupOldNotifications();
if (!cleanupSuccess) {
allSuccessful = false;
}
// Task 2: Optimize storage
boolean optimizationSuccess = optimizeStorage();
if (!optimizationSuccess) {
allSuccessful = false;
}
// Task 3: Health check
boolean healthCheckSuccess = performHealthCheck();
if (!healthCheckSuccess) {
allSuccessful = false;
}
// Task 4: Schedule next maintenance
scheduleNextMaintenance();
Log.d(TAG, "Maintenance tasks completed. All successful: " + allSuccessful);
return allSuccessful;
} catch (Exception e) {
Log.e(TAG, "Error during maintenance tasks", e);
return false;
}
}
/**
* Clean up old notifications
*
* @return true if cleanup was successful
*/
private boolean cleanupOldNotifications() {
try {
Log.d(TAG, "Cleaning up old notifications");
// Get all notifications
List<NotificationContent> allNotifications = storage.getAllNotifications();
int initialCount = allNotifications.size();
if (initialCount <= MAX_NOTIFICATIONS_TO_KEEP) {
Log.d(TAG, "No cleanup needed, notification count: " + initialCount);
return true;
}
// Remove old notifications, keeping the most recent ones
int notificationsToRemove = initialCount - MAX_NOTIFICATIONS_TO_KEEP;
int removedCount = 0;
for (int i = 0; i < notificationsToRemove && i < allNotifications.size(); i++) {
NotificationContent notification = allNotifications.get(i);
storage.removeNotification(notification.getId());
removedCount++;
}
Log.i(TAG, "Cleanup completed. Removed " + removedCount + " old notifications");
return true;
} catch (Exception e) {
Log.e(TAG, "Error during notification cleanup", e);
return false;
}
}
/**
* Optimize storage usage
*
* @return true if optimization was successful
*/
private boolean optimizeStorage() {
try {
Log.d(TAG, "Optimizing storage");
// Get storage statistics
String stats = storage.getStorageStats();
Log.d(TAG, "Storage stats before optimization: " + stats);
// Perform storage optimization
// This could include:
// - Compacting data structures
// - Removing duplicate entries
// - Optimizing cache usage
// For now, just log the current state
Log.d(TAG, "Storage optimization completed");
return true;
} catch (Exception e) {
Log.e(TAG, "Error during storage optimization", e);
return false;
}
}
/**
* Perform system health check
*
* @return true if health check passed
*/
private boolean performHealthCheck() {
try {
Log.d(TAG, "Performing health check");
boolean healthOk = true;
// Check 1: Storage health
boolean storageHealth = checkStorageHealth();
if (!storageHealth) {
healthOk = false;
}
// Check 2: Notification count health
boolean countHealth = checkNotificationCountHealth();
if (!countHealth) {
healthOk = false;
}
// Check 3: Data integrity
boolean dataIntegrity = checkDataIntegrity();
if (!dataIntegrity) {
healthOk = false;
}
if (healthOk) {
Log.i(TAG, "Health check passed");
} else {
Log.w(TAG, "Health check failed - some issues detected");
}
return healthOk;
} catch (Exception e) {
Log.e(TAG, "Error during health check", e);
return false;
}
}
/**
* Check storage health
*
* @return true if storage is healthy
*/
private boolean checkStorageHealth() {
try {
Log.d(TAG, "Checking storage health");
// Check if storage is accessible
int notificationCount = storage.getNotificationCount();
if (notificationCount < 0) {
Log.w(TAG, "Storage health issue: Invalid notification count");
return false;
}
// Check if storage is empty (this might be normal)
if (storage.isEmpty()) {
Log.d(TAG, "Storage is empty (this might be normal)");
}
Log.d(TAG, "Storage health check passed");
return true;
} catch (Exception e) {
Log.e(TAG, "Error checking storage health", e);
return false;
}
}
/**
* Check notification count health
*
* @return true if notification count is healthy
*/
private boolean checkNotificationCountHealth() {
try {
Log.d(TAG, "Checking notification count health");
int notificationCount = storage.getNotificationCount();
// Check for reasonable limits
if (notificationCount > 1000) {
Log.w(TAG, "Notification count health issue: Too many notifications (" +
notificationCount + ")");
return false;
}
Log.d(TAG, "Notification count health check passed: " + notificationCount);
return true;
} catch (Exception e) {
Log.e(TAG, "Error checking notification count health", e);
return false;
}
}
/**
* Check data integrity
*
* @return true if data integrity is good
*/
private boolean checkDataIntegrity() {
try {
Log.d(TAG, "Checking data integrity");
// Get all notifications and check basic integrity
List<NotificationContent> allNotifications = storage.getAllNotifications();
for (NotificationContent notification : allNotifications) {
// Check required fields
if (notification.getId() == null || notification.getId().isEmpty()) {
Log.w(TAG, "Data integrity issue: Notification with null/empty ID");
return false;
}
if (notification.getTitle() == null || notification.getTitle().isEmpty()) {
Log.w(TAG, "Data integrity issue: Notification with null/empty title");
return false;
}
if (notification.getBody() == null || notification.getBody().isEmpty()) {
Log.w(TAG, "Data integrity issue: Notification with null/empty body");
return false;
}
// Check timestamp validity
if (notification.getScheduledTime() <= 0) {
Log.w(TAG, "Data integrity issue: Invalid scheduled time");
return false;
}
if (notification.getFetchedAt() <= 0) {
Log.w(TAG, "Data integrity issue: Invalid fetch time");
return false;
}
}
Log.d(TAG, "Data integrity check passed");
return true;
} catch (Exception e) {
Log.e(TAG, "Error checking data integrity", e);
return false;
}
}
/**
* Schedule next maintenance run
*/
private void scheduleNextMaintenance() {
try {
Log.d(TAG, "Scheduling next maintenance");
// Schedule maintenance for tomorrow at 2 AM
long nextMaintenanceTime = calculateNextMaintenanceTime();
Data maintenanceData = new Data.Builder()
.putLong(KEY_MAINTENANCE_TIME, nextMaintenanceTime)
.build();
androidx.work.OneTimeWorkRequest maintenanceWork =
new androidx.work.OneTimeWorkRequest.Builder(DailyNotificationMaintenanceWorker.class)
.setInputData(maintenanceData)
.setInitialDelay(nextMaintenanceTime - System.currentTimeMillis(),
java.util.concurrent.TimeUnit.MILLISECONDS)
.build();
androidx.work.WorkManager.getInstance(context).enqueue(maintenanceWork);
Log.i(TAG, "Next maintenance scheduled for " + nextMaintenanceTime);
} catch (Exception e) {
Log.e(TAG, "Error scheduling next maintenance", e);
}
}
/**
* Calculate next maintenance time (2 AM tomorrow)
*
* @return Timestamp for next maintenance
*/
private long calculateNextMaintenanceTime() {
try {
java.util.Calendar calendar = java.util.Calendar.getInstance();
// Set to 2 AM
calendar.set(java.util.Calendar.HOUR_OF_DAY, 2);
calendar.set(java.util.Calendar.MINUTE, 0);
calendar.set(java.util.Calendar.SECOND, 0);
calendar.set(java.util.Calendar.MILLISECOND, 0);
// If 2 AM has passed today, schedule for tomorrow
if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1);
}
return calendar.getTimeInMillis();
} catch (Exception e) {
Log.e(TAG, "Error calculating next maintenance time", e);
// Fallback: 24 hours from now
return System.currentTimeMillis() + (24 * 60 * 60 * 1000);
}
}
}

View File

@@ -0,0 +1,114 @@
/**
* DailyNotificationMigration.java
*
* Migration utilities for transitioning from SharedPreferences to SQLite
* Handles data migration while preserving existing notification data
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
/**
* Handles migration from SharedPreferences to SQLite database
*
* This class provides utilities to:
* - Migrate existing notification data from SharedPreferences
* - Preserve all existing notification content during transition
* - Provide backward compatibility during migration period
* - Validate migration success
*/
public class DailyNotificationMigration {
private static final String TAG = "DailyNotificationMigration";
private static final String PREFS_NAME = "DailyNotificationPrefs";
private static final String KEY_NOTIFICATIONS = "notifications";
private static final String KEY_SETTINGS = "settings";
private static final String KEY_LAST_FETCH = "last_fetch";
private static final String KEY_ADAPTIVE_SCHEDULING = "adaptive_scheduling";
private final Context context;
// Legacy SQLite helper reference (now removed). Keep as Object for compatibility; not used.
private final Object database;
private final Gson gson;
/**
* Constructor
*
* @param context Application context
* @param database SQLite database instance
*/
public DailyNotificationMigration(Context context, Object database) {
this.context = context;
this.database = database;
this.gson = new Gson();
}
/**
* Perform complete migration from SharedPreferences to SQLite
*
* @return true if migration was successful
*/
public boolean migrateToSQLite() {
Log.d(TAG, "Migration skipped (legacy SQLite removed)");
return true;
}
/**
* Check if migration is needed
*
* @return true if migration is required
*/
private boolean isMigrationNeeded() { return false; }
/**
* Migrate notification content from SharedPreferences to SQLite
*
* @param db SQLite database instance
* @return Number of notifications migrated
*/
private int migrateNotificationContent(SQLiteDatabase db) { return 0; }
/**
* Migrate settings from SharedPreferences to SQLite
*
* @param db SQLite database instance
* @return Number of settings migrated
*/
private int migrateSettings(SQLiteDatabase db) { return 0; }
/**
* Mark migration as complete in the database
*
* @param db SQLite database instance
*/
private void markMigrationComplete(SQLiteDatabase db) { }
/**
* Validate migration success
*
* @return true if migration was successful
*/
public boolean validateMigration() { return true; }
/**
* Get migration statistics
*
* @return Migration statistics string
*/
public String getMigrationStats() { return "Migration stats: 0 notifications, 0 settings"; }
}

View File

@@ -0,0 +1,803 @@
/**
* DailyNotificationPerformanceOptimizer.java
*
* Android Performance Optimizer for database, memory, and battery optimization
* Implements query optimization, memory management, and battery tracking
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.os.Debug;
import android.util.Log;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
/**
* Optimizes performance through database, memory, and battery management
*
* This class implements the critical performance optimization functionality:
* - Database query optimization with indexes
* - Memory usage monitoring and optimization
* - Object pooling for frequently used objects
* - Battery usage tracking and optimization
* - Background CPU usage minimization
* - Network request optimization
*/
public class DailyNotificationPerformanceOptimizer {
// MARK: - Constants
private static final String TAG = "DailyNotificationPerformanceOptimizer";
// Performance monitoring intervals
private static final long MEMORY_CHECK_INTERVAL_MS = TimeUnit.MINUTES.toMillis(5);
private static final long BATTERY_CHECK_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10);
private static final long PERFORMANCE_REPORT_INTERVAL_MS = TimeUnit.HOURS.toMillis(1);
// Memory thresholds
private static final long MEMORY_WARNING_THRESHOLD_MB = 50;
private static final long MEMORY_CRITICAL_THRESHOLD_MB = 100;
// Object pool sizes
private static final int DEFAULT_POOL_SIZE = 10;
private static final int MAX_POOL_SIZE = 50;
// MARK: - Properties
private final Context context;
// Legacy SQLite helper reference (now removed). Keep as Object for compatibility; not used.
private final Object database;
private final ScheduledExecutorService scheduler;
// Performance metrics
private final PerformanceMetrics metrics;
// Object pools
private final ConcurrentHashMap<Class<?>, ObjectPool<?>> objectPools;
// Memory monitoring
private final AtomicLong lastMemoryCheck;
private final AtomicLong lastBatteryCheck;
// MARK: - Initialization
/**
* Constructor
*
* @param context Application context
* @param database Database instance for optimization
*/
public DailyNotificationPerformanceOptimizer(Context context, Object database) {
this.context = context;
this.database = database;
this.scheduler = Executors.newScheduledThreadPool(2);
this.metrics = new PerformanceMetrics();
this.objectPools = new ConcurrentHashMap<>();
this.lastMemoryCheck = new AtomicLong(0);
this.lastBatteryCheck = new AtomicLong(0);
// Initialize object pools
initializeObjectPools();
// Start performance monitoring
startPerformanceMonitoring();
Log.d(TAG, "PerformanceOptimizer initialized");
}
// MARK: - Database Optimization
/**
* Optimize database performance
*/
public void optimizeDatabase() {
try {
Log.d(TAG, "Optimizing database performance");
// Add database indexes
addDatabaseIndexes();
// Optimize query performance
optimizeQueryPerformance();
// Implement connection pooling
optimizeConnectionPooling();
// Analyze database performance
analyzeDatabasePerformance();
Log.i(TAG, "Database optimization completed");
} catch (Exception e) {
Log.e(TAG, "Error optimizing database", e);
}
}
/**
* Add database indexes for query optimization
*/
private void addDatabaseIndexes() {
try {
Log.d(TAG, "Adding database indexes for query optimization");
// Add indexes for common queries
// database.execSQL("CREATE INDEX IF NOT EXISTS idx_notif_contents_slot_time ON notif_contents(slot_id, fetched_at DESC)");
// database.execSQL("CREATE INDEX IF NOT EXISTS idx_notif_deliveries_status ON notif_deliveries(status)");
// database.execSQL("CREATE INDEX IF NOT EXISTS idx_notif_deliveries_fire_time ON notif_deliveries(fire_at)");
// database.execSQL("CREATE INDEX IF NOT EXISTS idx_notif_config_key ON notif_config(k)");
// Add composite indexes for complex queries
// database.execSQL("CREATE INDEX IF NOT EXISTS idx_notif_contents_slot_fetch ON notif_contents(slot_id, fetched_at)");
// database.execSQL("CREATE INDEX IF NOT EXISTS idx_notif_deliveries_slot_status ON notif_deliveries(slot_id, status)");
Log.i(TAG, "Database indexes added successfully");
} catch (Exception e) {
Log.e(TAG, "Error adding database indexes", e);
}
}
/**
* Optimize query performance
*/
private void optimizeQueryPerformance() {
try {
Log.d(TAG, "Optimizing query performance");
// Set database optimization pragmas
// database.execSQL("PRAGMA optimize");
// database.execSQL("PRAGMA analysis_limit=1000");
// database.execSQL("PRAGMA optimize");
// Enable query plan analysis
// database.execSQL("PRAGMA query_only=0");
Log.i(TAG, "Query performance optimization completed");
} catch (Exception e) {
Log.e(TAG, "Error optimizing query performance", e);
}
}
/**
* Optimize connection pooling
*/
private void optimizeConnectionPooling() {
try {
Log.d(TAG, "Optimizing connection pooling");
// Set connection pool settings
// database.execSQL("PRAGMA cache_size=10000");
// database.execSQL("PRAGMA temp_store=MEMORY");
// database.execSQL("PRAGMA mmap_size=268435456"); // 256MB
Log.i(TAG, "Connection pooling optimization completed");
} catch (Exception e) {
Log.e(TAG, "Error optimizing connection pooling", e);
}
}
/**
* Analyze database performance
*/
private void analyzeDatabasePerformance() {
try {
Log.d(TAG, "Analyzing database performance");
// Get database statistics
// long pageCount = database.getPageCount();
// long pageSize = database.getPageSize();
// long cacheSize = database.getCacheSize();
// Log.i(TAG, String.format("Database stats: pages=%d, pageSize=%d, cacheSize=%d",
// pageCount, pageSize, cacheSize));
// Update metrics
// metrics.recordDatabaseStats(pageCount, pageSize, cacheSize);
} catch (Exception e) {
Log.e(TAG, "Error analyzing database performance", e);
}
}
// MARK: - Memory Optimization
/**
* Optimize memory usage
*/
public void optimizeMemory() {
try {
Log.d(TAG, "Optimizing memory usage");
// Check current memory usage
long memoryUsage = getCurrentMemoryUsage();
if (memoryUsage > MEMORY_CRITICAL_THRESHOLD_MB) {
Log.w(TAG, "Critical memory usage detected: " + memoryUsage + "MB");
performCriticalMemoryCleanup();
} else if (memoryUsage > MEMORY_WARNING_THRESHOLD_MB) {
Log.w(TAG, "High memory usage detected: " + memoryUsage + "MB");
performMemoryCleanup();
}
// Optimize object pools
optimizeObjectPools();
// Update metrics
metrics.recordMemoryUsage(memoryUsage);
Log.i(TAG, "Memory optimization completed");
} catch (Exception e) {
Log.e(TAG, "Error optimizing memory", e);
}
}
/**
* Get current memory usage in MB
*
* @return Memory usage in MB
*/
private long getCurrentMemoryUsage() {
try {
Debug.MemoryInfo memoryInfo = new Debug.MemoryInfo();
Debug.getMemoryInfo(memoryInfo);
long totalPss = memoryInfo.getTotalPss();
return totalPss / 1024; // Convert to MB
} catch (Exception e) {
Log.e(TAG, "Error getting memory usage", e);
return 0;
}
}
/**
* Perform critical memory cleanup
*/
private void performCriticalMemoryCleanup() {
try {
Log.w(TAG, "Performing critical memory cleanup");
// Clear object pools
clearObjectPools();
// Force garbage collection
System.gc();
// Clear caches
clearCaches();
Log.i(TAG, "Critical memory cleanup completed");
} catch (Exception e) {
Log.e(TAG, "Error performing critical memory cleanup", e);
}
}
/**
* Perform regular memory cleanup
*/
private void performMemoryCleanup() {
try {
Log.d(TAG, "Performing regular memory cleanup");
// Clean up expired objects in pools
cleanupObjectPools();
// Clear old caches
clearOldCaches();
Log.i(TAG, "Regular memory cleanup completed");
} catch (Exception e) {
Log.e(TAG, "Error performing memory cleanup", e);
}
}
// MARK: - Object Pooling
/**
* Initialize object pools
*/
private void initializeObjectPools() {
try {
Log.d(TAG, "Initializing object pools");
// Create pools for frequently used objects
createObjectPool(StringBuilder.class, DEFAULT_POOL_SIZE);
createObjectPool(String.class, DEFAULT_POOL_SIZE);
Log.i(TAG, "Object pools initialized");
} catch (Exception e) {
Log.e(TAG, "Error initializing object pools", e);
}
}
/**
* Create object pool for a class
*
* @param clazz Class to create pool for
* @param initialSize Initial pool size
*/
private <T> void createObjectPool(Class<T> clazz, int initialSize) {
try {
ObjectPool<T> pool = new ObjectPool<>(clazz, initialSize);
objectPools.put(clazz, pool);
Log.d(TAG, "Object pool created for " + clazz.getSimpleName() + " with size " + initialSize);
} catch (Exception e) {
Log.e(TAG, "Error creating object pool for " + clazz.getSimpleName(), e);
}
}
/**
* Get object from pool
*
* @param clazz Class of object to get
* @return Object from pool or new instance
*/
@SuppressWarnings("unchecked")
public <T> T getObject(Class<T> clazz) {
try {
ObjectPool<T> pool = (ObjectPool<T>) objectPools.get(clazz);
if (pool != null) {
return pool.getObject();
}
// Create new instance if no pool exists
return clazz.newInstance();
} catch (Exception e) {
Log.e(TAG, "Error getting object from pool", e);
return null;
}
}
/**
* Return object to pool
*
* @param clazz Class of object
* @param object Object to return
*/
@SuppressWarnings("unchecked")
public <T> void returnObject(Class<T> clazz, T object) {
try {
ObjectPool<T> pool = (ObjectPool<T>) objectPools.get(clazz);
if (pool != null) {
pool.returnObject(object);
}
} catch (Exception e) {
Log.e(TAG, "Error returning object to pool", e);
}
}
/**
* Optimize object pools
*/
private void optimizeObjectPools() {
try {
Log.d(TAG, "Optimizing object pools");
for (ObjectPool<?> pool : objectPools.values()) {
pool.optimize();
}
Log.i(TAG, "Object pools optimized");
} catch (Exception e) {
Log.e(TAG, "Error optimizing object pools", e);
}
}
/**
* Clean up object pools
*/
private void cleanupObjectPools() {
try {
Log.d(TAG, "Cleaning up object pools");
for (ObjectPool<?> pool : objectPools.values()) {
pool.cleanup();
}
Log.i(TAG, "Object pools cleaned up");
} catch (Exception e) {
Log.e(TAG, "Error cleaning up object pools", e);
}
}
/**
* Clear object pools
*/
private void clearObjectPools() {
try {
Log.d(TAG, "Clearing object pools");
for (ObjectPool<?> pool : objectPools.values()) {
pool.clear();
}
Log.i(TAG, "Object pools cleared");
} catch (Exception e) {
Log.e(TAG, "Error clearing object pools", e);
}
}
// MARK: - Battery Optimization
/**
* Optimize battery usage
*/
public void optimizeBattery() {
try {
Log.d(TAG, "Optimizing battery usage");
// Minimize background CPU usage
minimizeBackgroundCPUUsage();
// Optimize network requests
optimizeNetworkRequests();
// Track battery usage
trackBatteryUsage();
Log.i(TAG, "Battery optimization completed");
} catch (Exception e) {
Log.e(TAG, "Error optimizing battery", e);
}
}
/**
* Minimize background CPU usage
*/
private void minimizeBackgroundCPUUsage() {
try {
Log.d(TAG, "Minimizing background CPU usage");
// Reduce scheduler thread pool size
// This would be implemented based on system load
// Optimize background task frequency
// This would adjust task intervals based on battery level
Log.i(TAG, "Background CPU usage minimized");
} catch (Exception e) {
Log.e(TAG, "Error minimizing background CPU usage", e);
}
}
/**
* Optimize network requests
*/
private void optimizeNetworkRequests() {
try {
Log.d(TAG, "Optimizing network requests");
// Batch network requests when possible
// Reduce request frequency during low battery
// Use efficient data formats
Log.i(TAG, "Network requests optimized");
} catch (Exception e) {
Log.e(TAG, "Error optimizing network requests", e);
}
}
/**
* Track battery usage
*/
private void trackBatteryUsage() {
try {
Log.d(TAG, "Tracking battery usage");
// This would integrate with battery monitoring APIs
// Track battery consumption patterns
// Adjust behavior based on battery level
Log.i(TAG, "Battery usage tracking completed");
} catch (Exception e) {
Log.e(TAG, "Error tracking battery usage", e);
}
}
// MARK: - Performance Monitoring
/**
* Start performance monitoring
*/
private void startPerformanceMonitoring() {
try {
Log.d(TAG, "Starting performance monitoring");
// Schedule memory monitoring
scheduler.scheduleAtFixedRate(this::checkMemoryUsage, 0, MEMORY_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS);
// Schedule battery monitoring
scheduler.scheduleAtFixedRate(this::checkBatteryUsage, 0, BATTERY_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS);
// Schedule performance reporting
scheduler.scheduleAtFixedRate(this::reportPerformance, 0, PERFORMANCE_REPORT_INTERVAL_MS, TimeUnit.MILLISECONDS);
Log.i(TAG, "Performance monitoring started");
} catch (Exception e) {
Log.e(TAG, "Error starting performance monitoring", e);
}
}
/**
* Check memory usage
*/
private void checkMemoryUsage() {
try {
long currentTime = System.currentTimeMillis();
if (currentTime - lastMemoryCheck.get() < MEMORY_CHECK_INTERVAL_MS) {
return;
}
lastMemoryCheck.set(currentTime);
long memoryUsage = getCurrentMemoryUsage();
metrics.recordMemoryUsage(memoryUsage);
if (memoryUsage > MEMORY_WARNING_THRESHOLD_MB) {
Log.w(TAG, "High memory usage detected: " + memoryUsage + "MB");
optimizeMemory();
}
} catch (Exception e) {
Log.e(TAG, "Error checking memory usage", e);
}
}
/**
* Check battery usage
*/
private void checkBatteryUsage() {
try {
long currentTime = System.currentTimeMillis();
if (currentTime - lastBatteryCheck.get() < BATTERY_CHECK_INTERVAL_MS) {
return;
}
lastBatteryCheck.set(currentTime);
// This would check actual battery usage
// For now, we'll just log the check
Log.d(TAG, "Battery usage check performed");
} catch (Exception e) {
Log.e(TAG, "Error checking battery usage", e);
}
}
/**
* Report performance metrics
*/
private void reportPerformance() {
try {
Log.i(TAG, "Performance Report:");
Log.i(TAG, " Memory Usage: " + metrics.getAverageMemoryUsage() + "MB");
Log.i(TAG, " Database Queries: " + metrics.getTotalDatabaseQueries());
Log.i(TAG, " Object Pool Hits: " + metrics.getObjectPoolHits());
Log.i(TAG, " Performance Score: " + metrics.getPerformanceScore());
} catch (Exception e) {
Log.e(TAG, "Error reporting performance", e);
}
}
// MARK: - Utility Methods
/**
* Clear caches
*/
private void clearCaches() {
try {
Log.d(TAG, "Clearing caches");
// Clear database caches
// database.execSQL("PRAGMA cache_size=0");
// database.execSQL("PRAGMA cache_size=1000");
Log.i(TAG, "Caches cleared");
} catch (Exception e) {
Log.e(TAG, "Error clearing caches", e);
}
}
/**
* Clear old caches
*/
private void clearOldCaches() {
try {
Log.d(TAG, "Clearing old caches");
// This would clear old cache entries
// For now, we'll just log the action
Log.i(TAG, "Old caches cleared");
} catch (Exception e) {
Log.e(TAG, "Error clearing old caches", e);
}
}
// MARK: - Public API
/**
* Get performance metrics
*
* @return PerformanceMetrics with current statistics
*/
public PerformanceMetrics getMetrics() {
return metrics;
}
/**
* Reset performance metrics
*/
public void resetMetrics() {
metrics.reset();
Log.d(TAG, "Performance metrics reset");
}
/**
* Shutdown optimizer
*/
public void shutdown() {
try {
Log.d(TAG, "Shutting down performance optimizer");
scheduler.shutdown();
clearObjectPools();
Log.i(TAG, "Performance optimizer shutdown completed");
} catch (Exception e) {
Log.e(TAG, "Error shutting down performance optimizer", e);
}
}
// MARK: - Data Classes
/**
* Object pool for managing object reuse
*/
private static class ObjectPool<T> {
private final Class<T> clazz;
private final java.util.Queue<T> pool;
private final int maxSize;
private int currentSize;
public ObjectPool(Class<T> clazz, int maxSize) {
this.clazz = clazz;
this.pool = new java.util.concurrent.ConcurrentLinkedQueue<>();
this.maxSize = maxSize;
this.currentSize = 0;
}
public T getObject() {
T object = pool.poll();
if (object == null) {
try {
object = clazz.newInstance();
} catch (Exception e) {
Log.e(TAG, "Error creating new object", e);
return null;
}
} else {
currentSize--;
}
return object;
}
public void returnObject(T object) {
if (currentSize < maxSize) {
pool.offer(object);
currentSize++;
}
}
public void optimize() {
// Remove excess objects
while (currentSize > maxSize / 2) {
T object = pool.poll();
if (object != null) {
currentSize--;
} else {
break;
}
}
}
public void cleanup() {
pool.clear();
currentSize = 0;
}
public void clear() {
pool.clear();
currentSize = 0;
}
}
/**
* Performance metrics
*/
public static class PerformanceMetrics {
private final AtomicLong totalMemoryUsage = new AtomicLong(0);
private final AtomicLong memoryCheckCount = new AtomicLong(0);
private final AtomicLong totalDatabaseQueries = new AtomicLong(0);
private final AtomicLong objectPoolHits = new AtomicLong(0);
private final AtomicLong performanceScore = new AtomicLong(100);
public void recordMemoryUsage(long usage) {
totalMemoryUsage.addAndGet(usage);
memoryCheckCount.incrementAndGet();
}
public void recordDatabaseQuery() {
totalDatabaseQueries.incrementAndGet();
}
public void recordObjectPoolHit() {
objectPoolHits.incrementAndGet();
}
public void updatePerformanceScore(long score) {
performanceScore.set(score);
}
public void recordDatabaseStats(long pageCount, long pageSize, long cacheSize) {
// Update performance score based on database stats
long score = Math.min(100, Math.max(0, 100 - (pageCount / 1000)));
updatePerformanceScore(score);
}
public void reset() {
totalMemoryUsage.set(0);
memoryCheckCount.set(0);
totalDatabaseQueries.set(0);
objectPoolHits.set(0);
performanceScore.set(100);
}
public long getAverageMemoryUsage() {
long count = memoryCheckCount.get();
return count > 0 ? totalMemoryUsage.get() / count : 0;
}
public long getTotalDatabaseQueries() {
return totalDatabaseQueries.get();
}
public long getObjectPoolHits() {
return objectPoolHits.get();
}
public long getPerformanceScore() {
return performanceScore.get();
}
}
}

View File

@@ -0,0 +1,381 @@
/**
* DailyNotificationRebootRecoveryManager.java
*
* Android Reboot Recovery Manager for notification restoration
* Handles system reboots and time changes to restore scheduled notifications
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.util.Log;
import java.util.concurrent.TimeUnit;
/**
* Manages recovery from system reboots and time changes
*
* This class implements the critical recovery functionality:
* - Listens for system reboot broadcasts
* - Handles time change events
* - Restores scheduled notifications after reboot
* - Adjusts notification times after time changes
*/
public class DailyNotificationRebootRecoveryManager {
// MARK: - Constants
private static final String TAG = "DailyNotificationRebootRecoveryManager";
// Broadcast actions
private static final String ACTION_BOOT_COMPLETED = "android.intent.action.BOOT_COMPLETED";
private static final String ACTION_MY_PACKAGE_REPLACED = "android.intent.action.MY_PACKAGE_REPLACED";
private static final String ACTION_PACKAGE_REPLACED = "android.intent.action.PACKAGE_REPLACED";
private static final String ACTION_TIME_CHANGED = "android.intent.action.TIME_SET";
private static final String ACTION_TIMEZONE_CHANGED = "android.intent.action.TIMEZONE_CHANGED";
// Recovery delay
private static final long RECOVERY_DELAY_MS = TimeUnit.SECONDS.toMillis(5);
// MARK: - Properties
private final Context context;
private final DailyNotificationScheduler scheduler;
private final DailyNotificationExactAlarmManager exactAlarmManager;
private final DailyNotificationRollingWindow rollingWindow;
// Broadcast receivers
private BootCompletedReceiver bootCompletedReceiver;
private TimeChangeReceiver timeChangeReceiver;
// Recovery state
private boolean recoveryInProgress = false;
private long lastRecoveryTime = 0;
// MARK: - Initialization
/**
* Constructor
*
* @param context Application context
* @param scheduler Notification scheduler
* @param exactAlarmManager Exact alarm manager
* @param rollingWindow Rolling window manager
*/
public DailyNotificationRebootRecoveryManager(Context context,
DailyNotificationScheduler scheduler,
DailyNotificationExactAlarmManager exactAlarmManager,
DailyNotificationRollingWindow rollingWindow) {
this.context = context;
this.scheduler = scheduler;
this.exactAlarmManager = exactAlarmManager;
this.rollingWindow = rollingWindow;
Log.d(TAG, "RebootRecoveryManager initialized");
}
/**
* Register broadcast receivers
*/
public void registerReceivers() {
try {
Log.d(TAG, "Registering broadcast receivers");
// Register boot completed receiver
bootCompletedReceiver = new BootCompletedReceiver();
IntentFilter bootFilter = new IntentFilter();
bootFilter.addAction(ACTION_BOOT_COMPLETED);
bootFilter.addAction(ACTION_MY_PACKAGE_REPLACED);
bootFilter.addAction(ACTION_PACKAGE_REPLACED);
context.registerReceiver(bootCompletedReceiver, bootFilter);
// Register time change receiver
timeChangeReceiver = new TimeChangeReceiver();
IntentFilter timeFilter = new IntentFilter();
timeFilter.addAction(ACTION_TIME_CHANGED);
timeFilter.addAction(ACTION_TIMEZONE_CHANGED);
context.registerReceiver(timeChangeReceiver, timeFilter);
Log.i(TAG, "Broadcast receivers registered successfully");
} catch (Exception e) {
Log.e(TAG, "Error registering broadcast receivers", e);
}
}
/**
* Unregister broadcast receivers
*/
public void unregisterReceivers() {
try {
Log.d(TAG, "Unregistering broadcast receivers");
if (bootCompletedReceiver != null) {
context.unregisterReceiver(bootCompletedReceiver);
bootCompletedReceiver = null;
}
if (timeChangeReceiver != null) {
context.unregisterReceiver(timeChangeReceiver);
timeChangeReceiver = null;
}
Log.i(TAG, "Broadcast receivers unregistered successfully");
} catch (Exception e) {
Log.e(TAG, "Error unregistering broadcast receivers", e);
}
}
// MARK: - Recovery Methods
/**
* Handle system reboot recovery
*
* This method restores all scheduled notifications that were lost
* during the system reboot.
*/
public void handleSystemReboot() {
try {
Log.i(TAG, "Handling system reboot recovery");
// Check if recovery is already in progress
if (recoveryInProgress) {
Log.w(TAG, "Recovery already in progress, skipping");
return;
}
// Check if recovery was recently performed
long currentTime = System.currentTimeMillis();
if (currentTime - lastRecoveryTime < RECOVERY_DELAY_MS) {
Log.w(TAG, "Recovery performed recently, skipping");
return;
}
recoveryInProgress = true;
lastRecoveryTime = currentTime;
// Perform recovery operations
performRebootRecovery();
recoveryInProgress = false;
Log.i(TAG, "System reboot recovery completed");
} catch (Exception e) {
Log.e(TAG, "Error handling system reboot", e);
recoveryInProgress = false;
}
}
/**
* Handle time change recovery
*
* This method adjusts all scheduled notifications to account
* for system time changes.
*/
public void handleTimeChange() {
try {
Log.i(TAG, "Handling time change recovery");
// Check if recovery is already in progress
if (recoveryInProgress) {
Log.w(TAG, "Recovery already in progress, skipping");
return;
}
recoveryInProgress = true;
// Perform time change recovery
performTimeChangeRecovery();
recoveryInProgress = false;
Log.i(TAG, "Time change recovery completed");
} catch (Exception e) {
Log.e(TAG, "Error handling time change", e);
recoveryInProgress = false;
}
}
/**
* Perform reboot recovery operations
*/
private void performRebootRecovery() {
try {
Log.d(TAG, "Performing reboot recovery operations");
// Wait a bit for system to stabilize
Thread.sleep(2000);
// Restore scheduled notifications
scheduler.restoreScheduledNotifications();
// Restore rolling window
rollingWindow.forceMaintenance();
// Log recovery statistics
logRecoveryStatistics("reboot");
} catch (Exception e) {
Log.e(TAG, "Error performing reboot recovery", e);
}
}
/**
* Perform time change recovery operations
*/
private void performTimeChangeRecovery() {
try {
Log.d(TAG, "Performing time change recovery operations");
// Adjust scheduled notifications
scheduler.adjustScheduledNotifications();
// Update rolling window
rollingWindow.forceMaintenance();
// Log recovery statistics
logRecoveryStatistics("time_change");
} catch (Exception e) {
Log.e(TAG, "Error performing time change recovery", e);
}
}
/**
* Log recovery statistics
*
* @param recoveryType Type of recovery performed
*/
private void logRecoveryStatistics(String recoveryType) {
try {
// Get recovery statistics
int restoredCount = scheduler.getRestoredNotificationCount();
int adjustedCount = scheduler.getAdjustedNotificationCount();
Log.i(TAG, String.format("Recovery statistics (%s): restored=%d, adjusted=%d",
recoveryType, restoredCount, adjustedCount));
} catch (Exception e) {
Log.e(TAG, "Error logging recovery statistics", e);
}
}
// MARK: - Broadcast Receivers
/**
* Broadcast receiver for boot completed events
*/
private class BootCompletedReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
try {
String action = intent.getAction();
Log.d(TAG, "BootCompletedReceiver received action: " + action);
if (ACTION_BOOT_COMPLETED.equals(action) ||
ACTION_MY_PACKAGE_REPLACED.equals(action) ||
ACTION_PACKAGE_REPLACED.equals(action)) {
// Handle system reboot
handleSystemReboot();
}
} catch (Exception e) {
Log.e(TAG, "Error in BootCompletedReceiver", e);
}
}
}
/**
* Broadcast receiver for time change events
*/
private class TimeChangeReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
try {
String action = intent.getAction();
Log.d(TAG, "TimeChangeReceiver received action: " + action);
if (ACTION_TIME_CHANGED.equals(action) ||
ACTION_TIMEZONE_CHANGED.equals(action)) {
// Handle time change
handleTimeChange();
}
} catch (Exception e) {
Log.e(TAG, "Error in TimeChangeReceiver", e);
}
}
}
// MARK: - Public Methods
/**
* Get recovery status
*
* @return Recovery status information
*/
public RecoveryStatus getRecoveryStatus() {
return new RecoveryStatus(
recoveryInProgress,
lastRecoveryTime,
System.currentTimeMillis() - lastRecoveryTime
);
}
/**
* Force recovery (for testing)
*/
public void forceRecovery() {
Log.i(TAG, "Forcing recovery");
handleSystemReboot();
}
/**
* Check if recovery is needed
*
* @return true if recovery is needed
*/
public boolean isRecoveryNeeded() {
// Check if system was recently rebooted
long currentTime = System.currentTimeMillis();
long timeSinceLastRecovery = currentTime - lastRecoveryTime;
// Recovery needed if more than 1 hour since last recovery
return timeSinceLastRecovery > TimeUnit.HOURS.toMillis(1);
}
// MARK: - Status Classes
/**
* Recovery status information
*/
public static class RecoveryStatus {
public final boolean inProgress;
public final long lastRecoveryTime;
public final long timeSinceLastRecovery;
public RecoveryStatus(boolean inProgress, long lastRecoveryTime, long timeSinceLastRecovery) {
this.inProgress = inProgress;
this.lastRecoveryTime = lastRecoveryTime;
this.timeSinceLastRecovery = timeSinceLastRecovery;
}
@Override
public String toString() {
return String.format("RecoveryStatus{inProgress=%s, lastRecovery=%d, timeSince=%d}",
inProgress, lastRecoveryTime, timeSinceLastRecovery);
}
}
}

View File

@@ -0,0 +1,458 @@
/**
* DailyNotificationReceiver.java
*
* Broadcast receiver for handling scheduled notification alarms
* Displays notifications when scheduled time is reached
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Trace;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
/**
* Broadcast receiver for daily notification alarms
*
* This receiver is triggered by AlarmManager when it's time to display
* a notification. It retrieves the notification content from storage
* and displays it to the user.
*/
public class DailyNotificationReceiver extends BroadcastReceiver {
private static final String TAG = "DailyNotificationReceiver";
private static final String CHANNEL_ID = "timesafari.daily";
private static final String EXTRA_NOTIFICATION_ID = "notification_id";
/**
* Handle broadcast intent when alarm triggers
*
* Ultra-lightweight receiver that only parses intent and enqueues work.
* All heavy operations (storage, JSON, scheduling) are moved to WorkManager.
*
* @param context Application context
* @param intent Broadcast intent
*/
@Override
public void onReceive(Context context, Intent intent) {
Trace.beginSection("DN:onReceive");
try {
Log.d(TAG, "DN|RECEIVE_START action=" + intent.getAction());
String action = intent.getAction();
if (action == null) {
Log.w(TAG, "DN|RECEIVE_ERR null_action");
return;
}
if ("com.timesafari.daily.NOTIFICATION".equals(action)) {
// Parse intent and enqueue work - keep receiver ultra-light
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId == null) {
Log.w(TAG, "DN|RECEIVE_ERR missing_id");
return;
}
// Enqueue work immediately - don't block receiver
enqueueNotificationWork(context, notificationId);
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
} else if ("com.timesafari.daily.DISMISS".equals(action)) {
// Handle dismissal - also lightweight
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId != null) {
enqueueDismissalWork(context, notificationId);
Log.d(TAG, "DN|DISMISS_OK enqueued=" + notificationId);
}
} else {
Log.w(TAG, "DN|RECEIVE_ERR unknown_action=" + action);
}
} catch (Exception e) {
Log.e(TAG, "DN|RECEIVE_ERR exception=" + e.getMessage(), e);
} finally {
Trace.endSection();
}
}
/**
* Enqueue notification processing work to WorkManager with deduplication
*
* Uses unique work name based on notification ID to prevent duplicate
* work items from being enqueued for the same notification. WorkManager's
* enqueueUniqueWork automatically prevents duplicates when using the same
* work name.
*
* @param context Application context
* @param notificationId ID of notification to process
*/
private void enqueueNotificationWork(Context context, String notificationId) {
try {
// Create unique work name based on notification ID to prevent duplicates
// WorkManager will automatically skip if work with this name already exists
String workName = "display_" + notificationId;
Data inputData = new Data.Builder()
.putString("notification_id", notificationId)
.putString("action", "display")
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
.setInputData(inputData)
.addTag("daily_notification_display")
.build();
// Use unique work name with KEEP policy (don't replace if exists)
// This prevents duplicate work items from being enqueued even if
// the receiver is triggered multiple times for the same notification
WorkManager.getInstance(context).enqueueUniqueWork(
workName,
ExistingWorkPolicy.KEEP,
workRequest
);
Log.d(TAG, "DN|WORK_ENQUEUE display=" + notificationId + " work_name=" + workName);
} catch (Exception e) {
Log.e(TAG, "DN|WORK_ENQUEUE_ERR display=" + notificationId + " err=" + e.getMessage(), e);
}
}
/**
* Enqueue notification dismissal work to WorkManager with deduplication
*
* Uses unique work name based on notification ID to prevent duplicate
* dismissal work items.
*
* @param context Application context
* @param notificationId ID of notification to dismiss
*/
private void enqueueDismissalWork(Context context, String notificationId) {
try {
// Create unique work name based on notification ID to prevent duplicates
String workName = "dismiss_" + notificationId;
Data inputData = new Data.Builder()
.putString("notification_id", notificationId)
.putString("action", "dismiss")
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
.setInputData(inputData)
.addTag("daily_notification_dismiss")
.build();
// Use unique work name with REPLACE policy (allow new dismissal to replace pending)
WorkManager.getInstance(context).enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
workRequest
);
Log.d(TAG, "DN|WORK_ENQUEUE dismiss=" + notificationId + " work_name=" + workName);
} catch (Exception e) {
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
}
}
/**
* Handle notification intent
*
* @param context Application context
* @param intent Intent containing notification data
*/
private void handleNotificationIntent(Context context, Intent intent) {
try {
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId == null) {
Log.w(TAG, "Notification ID not found in intent");
return;
}
Log.d(TAG, "Processing notification: " + notificationId);
// Get notification content from storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
NotificationContent content = storage.getNotificationContent(notificationId);
if (content == null) {
Log.w(TAG, "Notification content not found: " + notificationId);
return;
}
// Check if notification is ready to display
if (!content.isReadyToDisplay()) {
Log.d(TAG, "Notification not ready to display yet: " + notificationId);
return;
}
// JIT Freshness Re-check (Soft TTL)
content = performJITFreshnessCheck(context, content);
// Display the notification
displayNotification(context, content);
// Schedule next notification if this is a recurring daily notification
scheduleNextNotification(context, content);
Log.i(TAG, "Notification processed successfully: " + notificationId);
} catch (Exception e) {
Log.e(TAG, "Error handling notification intent", e);
}
}
/**
* Perform JIT (Just-In-Time) freshness re-check for notification content
*
* This implements a soft TTL mechanism that attempts to refresh stale content
* just before displaying the notification. If the refresh fails or content
* is not stale, the original content is returned.
*
* @param context Application context
* @param content Original notification content
* @return Updated content if refresh succeeded, original content otherwise
*/
private NotificationContent performJITFreshnessCheck(Context context, NotificationContent content) {
try {
// Check if content is stale (older than 6 hours for JIT check)
long currentTime = System.currentTimeMillis();
long age = currentTime - content.getFetchedAt();
long staleThreshold = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
if (age < staleThreshold) {
Log.d(TAG, "Content is fresh (age: " + (age / 1000 / 60) + " minutes), skipping JIT refresh");
return content;
}
Log.i(TAG, "Content is stale (age: " + (age / 1000 / 60) + " minutes), attempting JIT refresh");
// Attempt to fetch fresh content
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(context, new DailyNotificationStorage(context));
// Attempt immediate fetch for fresh content
NotificationContent freshContent = fetcher.fetchContentImmediately();
if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) {
Log.i(TAG, "JIT refresh succeeded, using fresh content");
// Update the original content with fresh data while preserving the original ID and scheduled time
String originalId = content.getId();
long originalScheduledTime = content.getScheduledTime();
content.setTitle(freshContent.getTitle());
content.setBody(freshContent.getBody());
content.setSound(freshContent.isSound());
content.setPriority(freshContent.getPriority());
content.setUrl(freshContent.getUrl());
content.setMediaUrl(freshContent.getMediaUrl());
content.setScheduledTime(originalScheduledTime); // Preserve original scheduled time
// Note: fetchedAt remains unchanged to preserve original fetch time
// Save updated content to storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
storage.saveNotificationContent(content);
return content;
} else {
Log.w(TAG, "JIT refresh failed or returned empty content, using original content");
return content;
}
} catch (Exception e) {
Log.e(TAG, "Error during JIT freshness check", e);
return content; // Return original content on error
}
}
/**
* Display the notification to the user
*
* @param context Application context
* @param content Notification content to display
*/
private void displayNotification(Context context, NotificationContent content) {
try {
Log.d(TAG, "Displaying notification: " + content.getId());
NotificationManager notificationManager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager == null) {
Log.e(TAG, "NotificationManager not available");
return;
}
// Create notification builder
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(content.getTitle())
.setContentText(content.getBody())
.setPriority(getNotificationPriority(content.getPriority()))
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER);
// Add sound if enabled
if (content.isSound()) {
builder.setDefaults(NotificationCompat.DEFAULT_SOUND);
}
// Add click action if URL is available
if (content.getUrl() != null && !content.getUrl().isEmpty()) {
Intent clickIntent = new Intent(Intent.ACTION_VIEW);
clickIntent.setData(android.net.Uri.parse(content.getUrl()));
PendingIntent clickPendingIntent = PendingIntent.getActivity(
context,
content.getId().hashCode(),
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
builder.setContentIntent(clickPendingIntent);
}
// Add dismiss action
Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class);
dismissIntent.setAction("com.timesafari.daily.DISMISS");
dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
context,
content.getId().hashCode() + 1000, // Different request code
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
builder.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"Dismiss",
dismissPendingIntent
);
// Build and display notification
int notificationId = content.getId().hashCode();
notificationManager.notify(notificationId, builder.build());
Log.i(TAG, "Notification displayed successfully: " + content.getId());
} catch (Exception e) {
Log.e(TAG, "Error displaying notification", e);
}
}
/**
* Schedule the next occurrence of this daily notification
*
* @param context Application context
* @param content Current notification content
*/
private void scheduleNextNotification(Context context, NotificationContent content) {
try {
Log.d(TAG, "Scheduling next notification for: " + content.getId());
// Calculate next occurrence (24 hours from now)
long nextScheduledTime = content.getScheduledTime() + (24 * 60 * 60 * 1000);
// Create new content for next occurrence
NotificationContent nextContent = new NotificationContent();
nextContent.setTitle(content.getTitle());
nextContent.setBody(content.getBody());
nextContent.setScheduledTime(nextScheduledTime);
nextContent.setSound(content.isSound());
nextContent.setPriority(content.getPriority());
nextContent.setUrl(content.getUrl());
// fetchedAt is set in constructor, no need to set it again
// Save to storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
storage.saveNotificationContent(nextContent);
// Schedule the notification
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
context,
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
);
boolean scheduled = scheduler.scheduleNotification(nextContent);
if (scheduled) {
Log.i(TAG, "Next notification scheduled successfully");
} else {
Log.e(TAG, "Failed to schedule next notification");
}
} catch (Exception e) {
Log.e(TAG, "Error scheduling next notification", e);
}
}
/**
* Get notification priority constant
*
* @param priority Priority string from content
* @return NotificationCompat priority constant
*/
private int getNotificationPriority(String priority) {
if (priority == null) {
return NotificationCompat.PRIORITY_DEFAULT;
}
switch (priority.toLowerCase()) {
case "high":
return NotificationCompat.PRIORITY_HIGH;
case "low":
return NotificationCompat.PRIORITY_LOW;
case "min":
return NotificationCompat.PRIORITY_MIN;
case "max":
return NotificationCompat.PRIORITY_MAX;
default:
return NotificationCompat.PRIORITY_DEFAULT;
}
}
/**
* Handle notification dismissal
*
* @param context Application context
* @param notificationId ID of dismissed notification
*/
private void handleNotificationDismissal(Context context, String notificationId) {
try {
Log.d(TAG, "Handling notification dismissal: " + notificationId);
// Remove from storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
storage.removeNotification(notificationId);
// Cancel any pending alarms
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
context,
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
);
scheduler.cancelNotification(notificationId);
Log.i(TAG, "Notification dismissed successfully: " + notificationId);
} catch (Exception e) {
Log.e(TAG, "Error handling notification dismissal", e);
}
}
}

View File

@@ -0,0 +1,383 @@
/**
* DailyNotificationRollingWindow.java
*
* Rolling window safety for notification scheduling
* Ensures today's notifications are always armed and tomorrow's are armed within iOS caps
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* Manages rolling window safety for notification scheduling
*
* This class implements the critical rolling window logic:
* - Today's remaining notifications are always armed
* - Tomorrow's notifications are armed only if within iOS capacity limits
* - Automatic window maintenance as time progresses
* - Platform-specific capacity management
*/
public class DailyNotificationRollingWindow {
private static final String TAG = "DailyNotificationRollingWindow";
// iOS notification limits
private static final int IOS_MAX_PENDING_NOTIFICATIONS = 64;
private static final int IOS_MAX_DAILY_NOTIFICATIONS = 20;
// Android has no hard limits, but we use reasonable defaults
private static final int ANDROID_MAX_PENDING_NOTIFICATIONS = 100;
private static final int ANDROID_MAX_DAILY_NOTIFICATIONS = 50;
// Window maintenance intervals
private static final long WINDOW_MAINTENANCE_INTERVAL_MS = TimeUnit.MINUTES.toMillis(15);
private final Context context;
private final DailyNotificationScheduler scheduler;
private final DailyNotificationTTLEnforcer ttlEnforcer;
private final DailyNotificationStorage storage;
private final boolean isIOSPlatform;
// Window state
private long lastMaintenanceTime = 0;
private int currentPendingCount = 0;
private int currentDailyCount = 0;
/**
* Constructor
*
* @param context Application context
* @param scheduler Notification scheduler
* @param ttlEnforcer TTL enforcement instance
* @param storage Storage instance
* @param isIOSPlatform Whether running on iOS platform
*/
public DailyNotificationRollingWindow(Context context,
DailyNotificationScheduler scheduler,
DailyNotificationTTLEnforcer ttlEnforcer,
DailyNotificationStorage storage,
boolean isIOSPlatform) {
this.context = context;
this.scheduler = scheduler;
this.ttlEnforcer = ttlEnforcer;
this.storage = storage;
this.isIOSPlatform = isIOSPlatform;
Log.d(TAG, "Rolling window initialized for " + (isIOSPlatform ? "iOS" : "Android"));
}
/**
* Maintain the rolling window by ensuring proper notification coverage
*
* This method should be called periodically to maintain the rolling window:
* - Arms today's remaining notifications
* - Arms tomorrow's notifications if within capacity limits
* - Updates window state and statistics
*/
public void maintainRollingWindow() {
try {
long currentTime = System.currentTimeMillis();
// Check if maintenance is needed
if (currentTime - lastMaintenanceTime < WINDOW_MAINTENANCE_INTERVAL_MS) {
Log.d(TAG, "Window maintenance not needed yet");
return;
}
Log.d(TAG, "Starting rolling window maintenance");
// Update current state
updateWindowState();
// Arm today's remaining notifications
armTodaysRemainingNotifications();
// Arm tomorrow's notifications if within capacity
armTomorrowsNotificationsIfWithinCapacity();
// Update maintenance time
lastMaintenanceTime = currentTime;
Log.i(TAG, String.format("Rolling window maintenance completed: pending=%d, daily=%d",
currentPendingCount, currentDailyCount));
} catch (Exception e) {
Log.e(TAG, "Error during rolling window maintenance", e);
}
}
/**
* Arm today's remaining notifications
*
* Ensures all notifications for today that haven't fired yet are armed
*/
private void armTodaysRemainingNotifications() {
try {
Log.d(TAG, "Arming today's remaining notifications");
// Get today's date
Calendar today = Calendar.getInstance();
String todayDate = formatDate(today);
// Get all notifications for today
List<NotificationContent> todaysNotifications = getNotificationsForDate(todayDate);
int armedCount = 0;
int skippedCount = 0;
for (NotificationContent notification : todaysNotifications) {
// Check if notification is in the future
if (notification.getScheduledTime() > System.currentTimeMillis()) {
// Check TTL before arming
if (ttlEnforcer != null && !ttlEnforcer.validateBeforeArming(notification)) {
Log.w(TAG, "Skipping today's notification due to TTL: " + notification.getId());
skippedCount++;
continue;
}
// Arm the notification
boolean armed = scheduler.scheduleNotification(notification);
if (armed) {
armedCount++;
currentPendingCount++;
} else {
Log.w(TAG, "Failed to arm today's notification: " + notification.getId());
}
}
}
Log.i(TAG, String.format("Today's notifications: armed=%d, skipped=%d", armedCount, skippedCount));
} catch (Exception e) {
Log.e(TAG, "Error arming today's remaining notifications", e);
}
}
/**
* Arm tomorrow's notifications if within capacity limits
*
* Only arms tomorrow's notifications if we're within platform-specific limits
*/
private void armTomorrowsNotificationsIfWithinCapacity() {
try {
Log.d(TAG, "Checking capacity for tomorrow's notifications");
// Check if we're within capacity limits
if (!isWithinCapacityLimits()) {
Log.w(TAG, "At capacity limit, skipping tomorrow's notifications");
return;
}
// Get tomorrow's date
Calendar tomorrow = Calendar.getInstance();
tomorrow.add(Calendar.DAY_OF_MONTH, 1);
String tomorrowDate = formatDate(tomorrow);
// Get all notifications for tomorrow
List<NotificationContent> tomorrowsNotifications = getNotificationsForDate(tomorrowDate);
int armedCount = 0;
int skippedCount = 0;
for (NotificationContent notification : tomorrowsNotifications) {
// Check TTL before arming
if (ttlEnforcer != null && !ttlEnforcer.validateBeforeArming(notification)) {
Log.w(TAG, "Skipping tomorrow's notification due to TTL: " + notification.getId());
skippedCount++;
continue;
}
// Arm the notification
boolean armed = scheduler.scheduleNotification(notification);
if (armed) {
armedCount++;
currentPendingCount++;
currentDailyCount++;
} else {
Log.w(TAG, "Failed to arm tomorrow's notification: " + notification.getId());
}
// Check capacity after each arm
if (!isWithinCapacityLimits()) {
Log.w(TAG, "Reached capacity limit while arming tomorrow's notifications");
break;
}
}
Log.i(TAG, String.format("Tomorrow's notifications: armed=%d, skipped=%d", armedCount, skippedCount));
} catch (Exception e) {
Log.e(TAG, "Error arming tomorrow's notifications", e);
}
}
/**
* Check if we're within platform-specific capacity limits
*
* @return true if within limits
*/
private boolean isWithinCapacityLimits() {
int maxPending = isIOSPlatform ? IOS_MAX_PENDING_NOTIFICATIONS : ANDROID_MAX_PENDING_NOTIFICATIONS;
int maxDaily = isIOSPlatform ? IOS_MAX_DAILY_NOTIFICATIONS : ANDROID_MAX_DAILY_NOTIFICATIONS;
boolean withinPendingLimit = currentPendingCount < maxPending;
boolean withinDailyLimit = currentDailyCount < maxDaily;
Log.d(TAG, String.format("Capacity check: pending=%d/%d, daily=%d/%d, within=%s",
currentPendingCount, maxPending, currentDailyCount, maxDaily,
withinPendingLimit && withinDailyLimit));
return withinPendingLimit && withinDailyLimit;
}
/**
* Update window state by counting current notifications
*/
private void updateWindowState() {
try {
Log.d(TAG, "Updating window state");
// Count pending notifications
currentPendingCount = countPendingNotifications();
// Count today's notifications
Calendar today = Calendar.getInstance();
String todayDate = formatDate(today);
currentDailyCount = countNotificationsForDate(todayDate);
Log.d(TAG, String.format("Window state updated: pending=%d, daily=%d",
currentPendingCount, currentDailyCount));
} catch (Exception e) {
Log.e(TAG, "Error updating window state", e);
}
}
/**
* Count pending notifications
*
* @return Number of pending notifications
*/
private int countPendingNotifications() {
try {
// This would typically query the storage for pending notifications
// For now, we'll use a placeholder implementation
return 0; // TODO: Implement actual counting logic
} catch (Exception e) {
Log.e(TAG, "Error counting pending notifications", e);
return 0;
}
}
/**
* Count notifications for a specific date
*
* @param date Date in YYYY-MM-DD format
* @return Number of notifications for the date
*/
private int countNotificationsForDate(String date) {
try {
// This would typically query the storage for notifications on a specific date
// For now, we'll use a placeholder implementation
return 0; // TODO: Implement actual counting logic
} catch (Exception e) {
Log.e(TAG, "Error counting notifications for date: " + date, e);
return 0;
}
}
/**
* Get notifications for a specific date
*
* @param date Date in YYYY-MM-DD format
* @return List of notifications for the date
*/
private List<NotificationContent> getNotificationsForDate(String date) {
try {
// This would typically query the storage for notifications on a specific date
// For now, we'll return an empty list
return new ArrayList<>(); // TODO: Implement actual retrieval logic
} catch (Exception e) {
Log.e(TAG, "Error getting notifications for date: " + date, e);
return new ArrayList<>();
}
}
/**
* Format date as YYYY-MM-DD
*
* @param calendar Calendar instance
* @return Formatted date string
*/
private String formatDate(Calendar calendar) {
int year = calendar.get(Calendar.YEAR);
int month = calendar.get(Calendar.MONTH) + 1; // Calendar months are 0-based
int day = calendar.get(Calendar.DAY_OF_MONTH);
return String.format("%04d-%02d-%02d", year, month, day);
}
/**
* Get rolling window statistics
*
* @return Statistics string
*/
public String getRollingWindowStats() {
try {
int maxPending = isIOSPlatform ? IOS_MAX_PENDING_NOTIFICATIONS : ANDROID_MAX_PENDING_NOTIFICATIONS;
int maxDaily = isIOSPlatform ? IOS_MAX_DAILY_NOTIFICATIONS : ANDROID_MAX_DAILY_NOTIFICATIONS;
return String.format("Rolling window stats: pending=%d/%d, daily=%d/%d, platform=%s",
currentPendingCount, maxPending, currentDailyCount, maxDaily,
isIOSPlatform ? "iOS" : "Android");
} catch (Exception e) {
Log.e(TAG, "Error getting rolling window stats", e);
return "Error retrieving rolling window statistics";
}
}
/**
* Force window maintenance (for testing or manual triggers)
*/
public void forceMaintenance() {
Log.i(TAG, "Forcing rolling window maintenance");
lastMaintenanceTime = 0; // Reset maintenance time
maintainRollingWindow();
}
/**
* Check if window maintenance is needed
*
* @return true if maintenance is needed
*/
public boolean isMaintenanceNeeded() {
long currentTime = System.currentTimeMillis();
return currentTime - lastMaintenanceTime >= WINDOW_MAINTENANCE_INTERVAL_MS;
}
/**
* Get time until next maintenance
*
* @return Milliseconds until next maintenance
*/
public long getTimeUntilNextMaintenance() {
long currentTime = System.currentTimeMillis();
long nextMaintenanceTime = lastMaintenanceTime + WINDOW_MAINTENANCE_INTERVAL_MS;
return Math.max(0, nextMaintenanceTime - currentTime);
}
}

View File

@@ -0,0 +1,668 @@
/**
* DailyNotificationStorage.java
*
* Storage management for notification content and settings
* Implements tiered storage: Key-Value (quick) + DB (structured) + Files (large assets)
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.File;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* Manages storage for notification content and settings
*
* This class implements the tiered storage approach:
* - Tier 1: SharedPreferences for quick access to settings and recent data
* - Tier 2: In-memory cache for structured notification content
* - Tier 3: File system for large assets (future use)
*/
public class DailyNotificationStorage {
private static final String TAG = "DailyNotificationStorage";
private static final String PREFS_NAME = "DailyNotificationPrefs";
private static final String KEY_NOTIFICATIONS = "notifications";
private static final String KEY_SETTINGS = "settings";
private static final String KEY_LAST_FETCH = "last_fetch";
private static final String KEY_ADAPTIVE_SCHEDULING = "adaptive_scheduling";
private static final int MAX_CACHE_SIZE = 100; // Maximum notifications to keep in memory
private static final long CACHE_CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
private static final int MAX_STORAGE_ENTRIES = 100; // Maximum total storage entries
private static final long RETENTION_PERIOD_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
private static final int BATCH_CLEANUP_SIZE = 50; // Clean up in batches
private final Context context;
private final SharedPreferences prefs;
private final Gson gson;
private final ConcurrentHashMap<String, NotificationContent> notificationCache;
private final List<NotificationContent> notificationList;
/**
* Constructor
*
* @param context Application context
*/
public DailyNotificationStorage(Context context) {
this.context = context;
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
// Create Gson with custom deserializer for NotificationContent
com.google.gson.GsonBuilder gsonBuilder = new com.google.gson.GsonBuilder();
gsonBuilder.registerTypeAdapter(NotificationContent.class, new NotificationContent.NotificationContentDeserializer());
this.gson = gsonBuilder.create();
this.notificationCache = new ConcurrentHashMap<>();
this.notificationList = Collections.synchronizedList(new ArrayList<>());
loadNotificationsFromStorage();
cleanupOldNotifications();
// Remove duplicates on startup and cancel their alarms/workers
java.util.List<String> removedIds = deduplicateNotifications();
cancelRemovedNotifications(removedIds);
}
/**
* Save notification content to storage
*
* @param content Notification content to save
*/
public void saveNotificationContent(NotificationContent content) {
try {
Log.d(TAG, "DN|STORAGE_SAVE_START id=" + content.getId());
// Add to cache
notificationCache.put(content.getId(), content);
// Add to list and sort by scheduled time
synchronized (notificationList) {
notificationList.removeIf(n -> n.getId().equals(content.getId()));
notificationList.add(content);
Collections.sort(notificationList,
Comparator.comparingLong(NotificationContent::getScheduledTime));
// Apply storage cap and retention policy
enforceStorageLimits();
}
// Persist to SharedPreferences
saveNotificationsToStorage();
Log.d(TAG, "DN|STORAGE_SAVE_OK id=" + content.getId() + " total=" + notificationList.size());
} catch (Exception e) {
Log.e(TAG, "Error saving notification content", e);
}
}
/**
* Get notification content by ID
*
* @param id Notification ID
* @return Notification content or null if not found
*/
public NotificationContent getNotificationContent(String id) {
return notificationCache.get(id);
}
/**
* Get the last notification that was delivered
*
* @return Last notification or null if none exists
*/
public NotificationContent getLastNotification() {
synchronized (notificationList) {
if (notificationList.isEmpty()) {
return null;
}
// Find the most recent delivered notification
long currentTime = System.currentTimeMillis();
for (int i = notificationList.size() - 1; i >= 0; i--) {
NotificationContent notification = notificationList.get(i);
if (notification.getScheduledTime() <= currentTime) {
return notification;
}
}
return null;
}
}
/**
* Get all notifications
*
* @return List of all notifications
*/
public List<NotificationContent> getAllNotifications() {
synchronized (notificationList) {
return new ArrayList<>(notificationList);
}
}
/**
* Get notifications that are ready to be displayed
*
* @return List of ready notifications
*/
public List<NotificationContent> getReadyNotifications() {
List<NotificationContent> readyNotifications = new ArrayList<>();
long currentTime = System.currentTimeMillis();
synchronized (notificationList) {
for (NotificationContent notification : notificationList) {
if (notification.isReadyToDisplay()) {
readyNotifications.add(notification);
}
}
}
return readyNotifications;
}
/**
* Get the next scheduled notification
*
* @return Next notification or null if none scheduled
*/
public NotificationContent getNextNotification() {
synchronized (notificationList) {
long currentTime = System.currentTimeMillis();
for (NotificationContent notification : notificationList) {
if (notification.getScheduledTime() > currentTime) {
return notification;
}
}
return null;
}
}
/**
* Remove notification by ID
*
* @param id Notification ID to remove
*/
public void removeNotification(String id) {
try {
Log.d(TAG, "Removing notification: " + id);
notificationCache.remove(id);
synchronized (notificationList) {
notificationList.removeIf(n -> n.getId().equals(id));
}
saveNotificationsToStorage();
Log.d(TAG, "Notification removed successfully");
} catch (Exception e) {
Log.e(TAG, "Error removing notification", e);
}
}
/**
* Clear all notifications
*/
public void clearAllNotifications() {
try {
Log.d(TAG, "Clearing all notifications");
notificationCache.clear();
synchronized (notificationList) {
notificationList.clear();
}
saveNotificationsToStorage();
Log.d(TAG, "All notifications cleared successfully");
} catch (Exception e) {
Log.e(TAG, "Error clearing notifications", e);
}
}
/**
* Get notification count
*
* @return Number of notifications
*/
public int getNotificationCount() {
return notificationCache.size();
}
/**
* Check if storage is empty
*
* @return true if no notifications exist
*/
public boolean isEmpty() {
return notificationCache.isEmpty();
}
/**
* Set sound enabled setting
*
* @param enabled true to enable sound
*/
public void setSoundEnabled(boolean enabled) {
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean("sound_enabled", enabled);
editor.apply();
Log.d(TAG, "Sound setting updated: " + enabled);
}
/**
* Get sound enabled setting
*
* @return true if sound is enabled
*/
public boolean isSoundEnabled() {
return prefs.getBoolean("sound_enabled", true);
}
/**
* Set notification priority
*
* @param priority Priority string (high, default, low)
*/
public void setPriority(String priority) {
SharedPreferences.Editor editor = prefs.edit();
editor.putString("priority", priority);
editor.apply();
Log.d(TAG, "Priority setting updated: " + priority);
}
/**
* Get notification priority
*
* @return Priority string
*/
public String getPriority() {
return prefs.getString("priority", "default");
}
/**
* Set timezone setting
*
* @param timezone Timezone identifier
*/
public void setTimezone(String timezone) {
SharedPreferences.Editor editor = prefs.edit();
editor.putString("timezone", timezone);
editor.apply();
Log.d(TAG, "Timezone setting updated: " + timezone);
}
/**
* Get timezone setting
*
* @return Timezone identifier
*/
public String getTimezone() {
return prefs.getString("timezone", "UTC");
}
/**
* Set adaptive scheduling enabled
*
* @param enabled true to enable adaptive scheduling
*/
public void setAdaptiveSchedulingEnabled(boolean enabled) {
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean(KEY_ADAPTIVE_SCHEDULING, enabled);
editor.apply();
Log.d(TAG, "Adaptive scheduling setting updated: " + enabled);
}
/**
* Check if adaptive scheduling is enabled
*
* @return true if adaptive scheduling is enabled
*/
public boolean isAdaptiveSchedulingEnabled() {
return prefs.getBoolean(KEY_ADAPTIVE_SCHEDULING, true);
}
/**
* Set last fetch timestamp
*
* @param timestamp Last fetch time in milliseconds
*/
public void setLastFetchTime(long timestamp) {
SharedPreferences.Editor editor = prefs.edit();
editor.putLong(KEY_LAST_FETCH, timestamp);
editor.apply();
Log.d(TAG, "Last fetch time updated: " + timestamp);
}
/**
* Get last fetch timestamp
*
* @return Last fetch time in milliseconds
*/
public long getLastFetchTime() {
return prefs.getLong(KEY_LAST_FETCH, 0);
}
/**
* Check if it's time to fetch new content
*
* @return true if fetch is needed
*/
public boolean shouldFetchNewContent() {
long lastFetch = getLastFetchTime();
long currentTime = System.currentTimeMillis();
long timeSinceLastFetch = currentTime - lastFetch;
// Fetch if more than 12 hours have passed
return timeSinceLastFetch > 12 * 60 * 60 * 1000;
}
/**
* Load notifications from persistent storage
*/
private void loadNotificationsFromStorage() {
try {
String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]");
Log.d(TAG, "Loading notifications from storage: " + notificationsJson);
Type type = new TypeToken<ArrayList<NotificationContent>>(){}.getType();
List<NotificationContent> notifications = gson.fromJson(notificationsJson, type);
if (notifications != null) {
for (NotificationContent notification : notifications) {
notificationCache.put(notification.getId(), notification);
notificationList.add(notification);
}
// Sort by scheduled time
Collections.sort(notificationList,
Comparator.comparingLong(NotificationContent::getScheduledTime));
Log.d(TAG, "Loaded " + notifications.size() + " notifications from storage");
}
} catch (Exception e) {
Log.e(TAG, "Error loading notifications from storage", e);
}
}
/**
* Save notifications to persistent storage
*/
private void saveNotificationsToStorage() {
try {
List<NotificationContent> notifications;
synchronized (notificationList) {
notifications = new ArrayList<>(notificationList);
}
String notificationsJson = gson.toJson(notifications);
SharedPreferences.Editor editor = prefs.edit();
editor.putString(KEY_NOTIFICATIONS, notificationsJson);
editor.apply();
Log.d(TAG, "Saved " + notifications.size() + " notifications to storage");
} catch (Exception e) {
Log.e(TAG, "Error saving notifications to storage", e);
}
}
/**
* Clean up old notifications to prevent memory bloat
*/
private void cleanupOldNotifications() {
try {
long currentTime = System.currentTimeMillis();
long cutoffTime = currentTime - (7 * 24 * 60 * 60 * 1000); // 7 days ago
synchronized (notificationList) {
notificationList.removeIf(notification ->
notification.getScheduledTime() < cutoffTime);
}
// Update cache to match
notificationCache.clear();
for (NotificationContent notification : notificationList) {
notificationCache.put(notification.getId(), notification);
}
// Limit cache size
if (notificationCache.size() > MAX_CACHE_SIZE) {
List<NotificationContent> sortedNotifications = new ArrayList<>(notificationList);
Collections.sort(sortedNotifications,
Comparator.comparingLong(NotificationContent::getScheduledTime));
int toRemove = sortedNotifications.size() - MAX_CACHE_SIZE;
for (int i = 0; i < toRemove; i++) {
NotificationContent notification = sortedNotifications.get(i);
notificationCache.remove(notification.getId());
}
notificationList.clear();
notificationList.addAll(sortedNotifications.subList(toRemove, sortedNotifications.size()));
}
saveNotificationsToStorage();
Log.d(TAG, "Cleanup completed. Cache size: " + notificationCache.size());
} catch (Exception e) {
Log.e(TAG, "Error during cleanup", e);
}
}
/**
* Get storage statistics
*
* @return Storage statistics as a string
*/
public String getStorageStats() {
return String.format("Notifications: %d, Cache size: %d, Last fetch: %d",
notificationList.size(),
notificationCache.size(),
getLastFetchTime());
}
/**
* Remove duplicate notifications (same scheduledTime within tolerance)
*
* Keeps the most recently created notification for each scheduledTime,
* removes older duplicates to prevent accumulation.
*
* @return List of notification IDs that were removed (for cancellation of alarms/workers)
*/
public java.util.List<String> deduplicateNotifications() {
try {
long toleranceMs = 60 * 1000; // 1 minute tolerance
java.util.Map<Long, NotificationContent> scheduledTimeMap = new java.util.HashMap<>();
java.util.List<String> idsToRemove = new java.util.ArrayList<>();
synchronized (notificationList) {
// First pass: find all duplicates, keep the one with latest fetchedAt
for (NotificationContent notification : notificationList) {
long scheduledTime = notification.getScheduledTime();
boolean foundMatch = false;
for (java.util.Map.Entry<Long, NotificationContent> entry : scheduledTimeMap.entrySet()) {
if (Math.abs(entry.getKey() - scheduledTime) <= toleranceMs) {
// Found a duplicate - keep the one with latest fetchedAt
if (notification.getFetchedAt() > entry.getValue().getFetchedAt()) {
idsToRemove.add(entry.getValue().getId());
entry.setValue(notification);
} else {
idsToRemove.add(notification.getId());
}
foundMatch = true;
break;
}
}
if (!foundMatch) {
scheduledTimeMap.put(scheduledTime, notification);
}
}
// Remove duplicates
if (!idsToRemove.isEmpty()) {
notificationList.removeIf(n -> idsToRemove.contains(n.getId()));
for (String id : idsToRemove) {
notificationCache.remove(id);
}
saveNotificationsToStorage();
Log.i(TAG, "DN|DEDUPE_CLEANUP removed=" + idsToRemove.size() + " duplicates");
}
}
return idsToRemove;
} catch (Exception e) {
Log.e(TAG, "Error during deduplication", e);
return new java.util.ArrayList<>();
}
}
/**
* Cancel alarms and workers for removed notification IDs
*
* This ensures that when notifications are removed (e.g., during deduplication),
* their associated alarms and WorkManager workers are also cancelled to prevent
* zombie workers trying to display non-existent notifications.
*
* @param removedIds List of notification IDs that were removed
*/
private void cancelRemovedNotifications(java.util.List<String> removedIds) {
if (removedIds == null || removedIds.isEmpty()) {
return;
}
try {
// Cancel alarms for removed notifications
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
context,
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
);
for (String id : removedIds) {
scheduler.cancelNotification(id);
}
// Note: WorkManager workers can't be cancelled by notification ID directly
// Workers will handle missing content gracefully by returning Result.success()
// (see DailyNotificationWorker.handleDisplayNotification - it returns success for missing content)
// This prevents retry loops for notifications removed during deduplication
Log.i(TAG, "DN|DEDUPE_CLEANUP cancelled alarms for " + removedIds.size() + " removed notifications");
} catch (Exception e) {
Log.e(TAG, "DN|DEDUPE_CLEANUP_ERR failed to cancel alarms/workers", e);
}
}
/**
* Enforce storage limits and retention policy
*
* This method implements both storage capping (max entries) and retention policy
* (remove old entries) to prevent unbounded growth.
*/
private void enforceStorageLimits() {
try {
long currentTime = System.currentTimeMillis();
int initialSize = notificationList.size();
int removedCount = 0;
// First, remove expired entries (older than retention period)
notificationList.removeIf(notification -> {
long age = currentTime - notification.getScheduledTime();
return age > RETENTION_PERIOD_MS;
});
removedCount = initialSize - notificationList.size();
if (removedCount > 0) {
Log.d(TAG, "DN|RETENTION_CLEANUP removed=" + removedCount + " expired_entries");
}
// Then, enforce storage cap by removing oldest entries if over limit
while (notificationList.size() > MAX_STORAGE_ENTRIES) {
NotificationContent oldest = notificationList.remove(0);
notificationCache.remove(oldest.getId());
removedCount++;
}
if (removedCount > 0) {
Log.i(TAG, "DN|STORAGE_LIMITS_ENFORCED removed=" + removedCount +
" total=" + notificationList.size() +
" max=" + MAX_STORAGE_ENTRIES);
}
} catch (Exception e) {
Log.e(TAG, "DN|STORAGE_LIMITS_ERR err=" + e.getMessage(), e);
}
}
/**
* Perform batch cleanup of old notifications
*
* This method can be called periodically to clean up old notifications
* in batches to avoid blocking the main thread.
*
* @return Number of notifications removed
*/
public int performBatchCleanup() {
try {
long currentTime = System.currentTimeMillis();
int removedCount = 0;
int batchSize = 0;
synchronized (notificationList) {
java.util.Iterator<NotificationContent> iterator = notificationList.iterator();
while (iterator.hasNext() && batchSize < BATCH_CLEANUP_SIZE) {
NotificationContent notification = iterator.next();
long age = currentTime - notification.getScheduledTime();
if (age > RETENTION_PERIOD_MS) {
iterator.remove();
notificationCache.remove(notification.getId());
removedCount++;
batchSize++;
}
}
}
if (removedCount > 0) {
saveNotificationsToStorage();
Log.i(TAG, "DN|BATCH_CLEANUP_OK removed=" + removedCount +
" batch_size=" + batchSize +
" remaining=" + notificationList.size());
}
return removedCount;
} catch (Exception e) {
Log.e(TAG, "DN|BATCH_CLEANUP_ERR err=" + e.getMessage(), e);
return 0;
}
}
}

View File

@@ -0,0 +1,333 @@
/**
* DailyNotificationTTLEnforcer.java
*
* TTL-at-fire enforcement for notification freshness
* Implements the skip rule: if (T - fetchedAt) > ttlSeconds → skip arming
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;
import java.util.concurrent.TimeUnit;
/**
* Enforces TTL-at-fire rules for notification freshness
*
* This class implements the critical freshness enforcement:
* - Before arming for T, if (T fetchedAt) > ttlSeconds → skip
* - Logs TTL violations for debugging
* - Supports both SQLite and SharedPreferences storage
* - Provides freshness validation before scheduling
*/
public class DailyNotificationTTLEnforcer {
private static final String TAG = "DailyNotificationTTLEnforcer";
private static final String LOG_CODE_TTL_VIOLATION = "TTL_VIOLATION";
// Default TTL values
private static final long DEFAULT_TTL_SECONDS = 90000; // 25 hours (for daily notifications)
private static final long MIN_TTL_SECONDS = 60; // 1 minute
private static final long MAX_TTL_SECONDS = 172800; // 48 hours
private final Context context;
// Legacy SQLite helper reference (now removed). Keep as Object for compatibility; not used.
private final Object database;
private final boolean useSharedStorage;
/**
* Constructor
*
* @param context Application context
* @param database SQLite database (null if using SharedPreferences)
* @param useSharedStorage Whether to use SQLite or SharedPreferences
*/
public DailyNotificationTTLEnforcer(Context context, Object database, boolean useSharedStorage) {
this.context = context;
this.database = database;
this.useSharedStorage = useSharedStorage;
}
/**
* Check if notification content is fresh enough to arm
*
* @param slotId Notification slot ID
* @param scheduledTime T (slot time) - when notification should fire
* @param fetchedAt When content was fetched
* @return true if content is fresh enough to arm
*/
public boolean isContentFresh(String slotId, long scheduledTime, long fetchedAt) {
try {
long ttlSeconds = getTTLSeconds();
// Calculate age at fire time
long ageAtFireTime = scheduledTime - fetchedAt;
long ageAtFireSeconds = TimeUnit.MILLISECONDS.toSeconds(ageAtFireTime);
boolean isFresh = ageAtFireSeconds <= ttlSeconds;
if (!isFresh) {
logTTLViolation(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds);
}
Log.d(TAG, String.format("TTL check for %s: age=%ds, ttl=%ds, fresh=%s",
slotId, ageAtFireSeconds, ttlSeconds, isFresh));
return isFresh;
} catch (Exception e) {
Log.e(TAG, "Error checking content freshness", e);
// Default to allowing arming if check fails
return true;
}
}
/**
* Check if notification content is fresh enough to arm (using stored fetchedAt)
*
* @param slotId Notification slot ID
* @param scheduledTime T (slot time) - when notification should fire
* @return true if content is fresh enough to arm
*/
public boolean isContentFresh(String slotId, long scheduledTime) {
try {
long fetchedAt = getFetchedAt(slotId);
if (fetchedAt == 0) {
Log.w(TAG, "No fetchedAt found for slot: " + slotId);
return false;
}
return isContentFresh(slotId, scheduledTime, fetchedAt);
} catch (Exception e) {
Log.e(TAG, "Error checking content freshness for slot: " + slotId, e);
return false;
}
}
/**
* Validate freshness before arming notification
*
* @param notificationContent Notification content to validate
* @return true if notification should be armed
*/
public boolean validateBeforeArming(NotificationContent notificationContent) {
try {
String slotId = notificationContent.getId();
long scheduledTime = notificationContent.getScheduledTime();
long fetchedAt = notificationContent.getFetchedAt();
Log.d(TAG, String.format("Validating freshness before arming: slot=%s, scheduled=%d, fetched=%d",
slotId, scheduledTime, fetchedAt));
boolean isFresh = isContentFresh(slotId, scheduledTime, fetchedAt);
if (!isFresh) {
Log.w(TAG, "Skipping arming due to TTL violation: " + slotId);
return false;
}
Log.d(TAG, "Content is fresh, proceeding with arming: " + slotId);
return true;
} catch (Exception e) {
Log.e(TAG, "Error validating freshness before arming", e);
return false;
}
}
/**
* Get TTL seconds from configuration
*
* @return TTL in seconds
*/
private long getTTLSeconds() {
try {
return getTTLFromSharedPreferences();
} catch (Exception e) {
Log.e(TAG, "Error getting TTL seconds", e);
return DEFAULT_TTL_SECONDS;
}
}
/**
* Get TTL from SQLite database
*
* @return TTL in seconds
*/
private long getTTLFromSQLite() { return DEFAULT_TTL_SECONDS; }
/**
* Get TTL from SharedPreferences
*
* @return TTL in seconds
*/
private long getTTLFromSharedPreferences() {
try {
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
long ttlSeconds = prefs.getLong("ttlSeconds", DEFAULT_TTL_SECONDS);
// Validate TTL range
ttlSeconds = Math.max(MIN_TTL_SECONDS, Math.min(MAX_TTL_SECONDS, ttlSeconds));
return ttlSeconds;
} catch (Exception e) {
Log.e(TAG, "Error getting TTL from SharedPreferences", e);
return DEFAULT_TTL_SECONDS;
}
}
/**
* Get fetchedAt timestamp for a slot
*
* @param slotId Notification slot ID
* @return FetchedAt timestamp in milliseconds
*/
private long getFetchedAt(String slotId) {
try {
return getFetchedAtFromSharedPreferences(slotId);
} catch (Exception e) {
Log.e(TAG, "Error getting fetchedAt for slot: " + slotId, e);
return 0;
}
}
/**
* Get fetchedAt from SQLite database
*
* @param slotId Notification slot ID
* @return FetchedAt timestamp in milliseconds
*/
private long getFetchedAtFromSQLite(String slotId) { return 0; }
/**
* Get fetchedAt from SharedPreferences
*
* @param slotId Notification slot ID
* @return FetchedAt timestamp in milliseconds
*/
private long getFetchedAtFromSharedPreferences(String slotId) {
try {
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
return prefs.getLong("last_fetch_" + slotId, 0);
} catch (Exception e) {
Log.e(TAG, "Error getting fetchedAt from SharedPreferences", e);
return 0;
}
}
/**
* Log TTL violation with detailed information
*
* @param slotId Notification slot ID
* @param scheduledTime When notification was scheduled to fire
* @param fetchedAt When content was fetched
* @param ageAtFireSeconds Age of content at fire time
* @param ttlSeconds TTL limit in seconds
*/
private void logTTLViolation(String slotId, long scheduledTime, long fetchedAt,
long ageAtFireSeconds, long ttlSeconds) {
try {
String violationMessage = String.format(
"TTL violation: slot=%s, scheduled=%d, fetched=%d, age=%ds, ttl=%ds",
slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds
);
Log.w(TAG, LOG_CODE_TTL_VIOLATION + ": " + violationMessage);
// Store violation in database or SharedPreferences for analytics
storeTTLViolation(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds);
} catch (Exception e) {
Log.e(TAG, "Error logging TTL violation", e);
}
}
/**
* Store TTL violation for analytics
*/
private void storeTTLViolation(String slotId, long scheduledTime, long fetchedAt,
long ageAtFireSeconds, long ttlSeconds) {
try {
storeTTLViolationInSharedPreferences(slotId, scheduledTime, fetchedAt, ageAtFireSeconds, ttlSeconds);
} catch (Exception e) {
Log.e(TAG, "Error storing TTL violation", e);
}
}
/**
* Store TTL violation in SQLite database
*/
private void storeTTLViolationInSQLite(String slotId, long scheduledTime, long fetchedAt,
long ageAtFireSeconds, long ttlSeconds) { }
/**
* Store TTL violation in SharedPreferences
*/
private void storeTTLViolationInSharedPreferences(String slotId, long scheduledTime, long fetchedAt,
long ageAtFireSeconds, long ttlSeconds) {
try {
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
String violationKey = "ttl_violation_" + slotId + "_" + scheduledTime;
String violationValue = String.format("%d,%d,%d,%d", fetchedAt, ageAtFireSeconds, ttlSeconds, System.currentTimeMillis());
editor.putString(violationKey, violationValue);
editor.apply();
} catch (Exception e) {
Log.e(TAG, "Error storing TTL violation in SharedPreferences", e);
}
}
/**
* Get TTL violation statistics
*
* @return Statistics string
*/
public String getTTLViolationStats() {
try {
return getTTLViolationStatsFromSharedPreferences();
} catch (Exception e) {
Log.e(TAG, "Error getting TTL violation stats", e);
return "Error retrieving TTL violation statistics";
}
}
/**
* Get TTL violation statistics from SQLite
*/
private String getTTLViolationStatsFromSQLite() { return "TTL violations: 0"; }
/**
* Get TTL violation statistics from SharedPreferences
*/
private String getTTLViolationStatsFromSharedPreferences() {
try {
SharedPreferences prefs = context.getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE);
java.util.Map<String, ?> allPrefs = prefs.getAll();
int violationCount = 0;
for (String key : allPrefs.keySet()) {
if (key.startsWith("ttl_violation_")) {
violationCount++;
}
}
return String.format("TTL violations: %d", violationCount);
} catch (Exception e) {
Log.e(TAG, "Error getting TTL violation stats from SharedPreferences", e);
return "Error retrieving TTL violation statistics";
}
}
}

View File

@@ -0,0 +1,862 @@
/**
* DailyNotificationWorker.java
*
* WorkManager worker for handling notification processing
* Moves heavy operations (storage, JSON, scheduling) out of BroadcastReceiver
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Trace;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ConcurrentHashMap;
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.DailyNotificationFetcher;
/**
* WorkManager worker for processing daily notifications
*
* This worker handles the heavy operations that were previously done in
* the BroadcastReceiver, ensuring the receiver stays ultra-lightweight.
*/
public class DailyNotificationWorker extends Worker {
private static final String TAG = "DailyNotificationWorker";
private static final String CHANNEL_ID = "timesafari.daily";
// Work deduplication tracking
private static final ConcurrentHashMap<String, AtomicBoolean> activeWork = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, Long> workTimestamps = new ConcurrentHashMap<>();
private static final long WORK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
public DailyNotificationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
Trace.beginSection("DN:Worker");
try {
Data inputData = getInputData();
String notificationId = inputData.getString("notification_id");
String action = inputData.getString("action");
if (notificationId == null || action == null) {
Log.e(TAG, "DN|WORK_ERR missing_params id=" + notificationId + " action=" + action);
return Result.failure();
}
// Create unique work key for deduplication
String workKey = createWorkKey(notificationId, action);
// Check for work deduplication
if (!acquireWorkLock(workKey)) {
Log.d(TAG, "DN|WORK_SKIP duplicate_work key=" + workKey);
return Result.success(); // Return success for duplicate work
}
try {
Log.d(TAG, "DN|WORK_START id=" + notificationId + " action=" + action + " key=" + workKey);
// Check if work is idempotent (already completed)
if (isWorkAlreadyCompleted(workKey)) {
Log.d(TAG, "DN|WORK_SKIP already_completed key=" + workKey);
return Result.success();
}
Result result;
if ("display".equals(action)) {
result = handleDisplayNotification(notificationId);
} else if ("dismiss".equals(action)) {
result = handleDismissNotification(notificationId);
} else {
Log.e(TAG, "DN|WORK_ERR unknown_action=" + action);
result = Result.failure();
}
// Mark work as completed if successful
if (result == Result.success()) {
markWorkAsCompleted(workKey);
}
return result;
} finally {
// Always release the work lock
releaseWorkLock(workKey);
}
} catch (Exception e) {
Log.e(TAG, "DN|WORK_ERR exception=" + e.getMessage(), e);
return Result.retry();
} finally {
Trace.endSection();
}
}
/**
* Handle notification display
*
* @param notificationId ID of notification to display
* @return Work result
*/
private Result handleDisplayNotification(String notificationId) {
Trace.beginSection("DN:display");
try {
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
// Prefer Room storage; fallback to legacy SharedPreferences storage
NotificationContent content = getContentFromRoomOrLegacy(notificationId);
if (content == null) {
// Content not found - likely removed during deduplication or cleanup
// Return success instead of failure to prevent retries for intentionally removed notifications
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
return Result.success(); // Success prevents retry loops for removed notifications
}
// Check if notification is ready to display
if (!content.isReadyToDisplay()) {
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
return Result.success();
}
// JIT Freshness Re-check (Soft TTL)
content = performJITFreshnessCheck(content);
// Display the notification
boolean displayed = displayNotification(content);
if (displayed) {
// Schedule next notification if this is a recurring daily notification
scheduleNextNotification(content);
Log.i(TAG, "DN|DISPLAY_OK id=" + notificationId);
return Result.success();
} else {
Log.e(TAG, "DN|DISPLAY_ERR display_failed id=" + notificationId);
return Result.retry();
}
} catch (Exception e) {
Log.e(TAG, "DN|DISPLAY_ERR exception id=" + notificationId + " err=" + e.getMessage(), e);
return Result.retry();
} finally {
Trace.endSection();
}
}
/**
* Handle notification dismissal
*
* @param notificationId ID of notification to dismiss
* @return Work result
*/
private Result handleDismissNotification(String notificationId) {
Trace.beginSection("DN:dismiss");
try {
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
// Remove from Room if present; also remove from legacy storage for compatibility
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// No direct delete DAO exposed via service; legacy removal still applied
} catch (Exception ignored) { }
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
storage.removeNotification(notificationId);
// Cancel any pending alarms
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
getApplicationContext(),
(android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
);
scheduler.cancelNotification(notificationId);
Log.i(TAG, "DN|DISMISS_OK id=" + notificationId);
return Result.success();
} catch (Exception e) {
Log.e(TAG, "DN|DISMISS_ERR exception id=" + notificationId + " err=" + e.getMessage(), e);
return Result.retry();
} finally {
Trace.endSection();
}
}
/**
* Perform JIT (Just-In-Time) freshness re-check for notification content
* with soft re-fetch for borderline age content
*
* @param content Original notification content
* @return Updated content if refresh succeeded, original content otherwise
*/
private NotificationContent performJITFreshnessCheck(NotificationContent content) {
Trace.beginSection("DN:jitCheck");
try {
// Check if content is stale (older than 6 hours for JIT check)
long currentTime = System.currentTimeMillis();
long age = currentTime - content.getFetchedAt();
long staleThreshold = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
long borderlineThreshold = 4 * 60 * 60 * 1000; // 4 hours in milliseconds (80% of TTL)
int ageMinutes = (int) (age / 1000 / 60);
if (age < staleThreshold) {
// Check if content is borderline stale (80% of TTL) for soft re-fetch
if (age >= borderlineThreshold) {
Log.i(TAG, "DN|JIT_BORDERLINE ageMin=" + ageMinutes + " id=" + content.getId() + " triggering_soft_refetch");
// Trigger soft re-fetch for tomorrow's content asynchronously
scheduleSoftRefetchForTomorrow(content);
} else {
Log.d(TAG, "DN|JIT_FRESH skip=true ageMin=" + ageMinutes + " id=" + content.getId());
}
return content;
}
Log.i(TAG, "DN|JIT_STALE skip=false ageMin=" + ageMinutes + " id=" + content.getId());
// Attempt to fetch fresh content
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
getApplicationContext(),
new DailyNotificationStorage(getApplicationContext())
);
// Attempt immediate fetch for fresh content
NotificationContent freshContent = fetcher.fetchContentImmediately();
if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) {
Log.i(TAG, "DN|JIT_REFRESH_OK id=" + content.getId());
// Update the original content with fresh data while preserving the original ID and scheduled time
String originalId = content.getId();
long originalScheduledTime = content.getScheduledTime();
content.setTitle(freshContent.getTitle());
content.setBody(freshContent.getBody());
content.setSound(freshContent.isSound());
content.setPriority(freshContent.getPriority());
content.setUrl(freshContent.getUrl());
content.setMediaUrl(freshContent.getMediaUrl());
content.setScheduledTime(originalScheduledTime); // Preserve original scheduled time
// Note: fetchedAt remains unchanged to preserve original fetch time
// Save updated content to storage
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
storage.saveNotificationContent(content);
return content;
} else {
Log.w(TAG, "DN|JIT_REFRESH_FAIL id=" + content.getId());
return content;
}
} catch (Exception e) {
Log.e(TAG, "DN|JIT_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
return content; // Return original content on error
} finally {
Trace.endSection();
}
}
/**
* Schedule soft re-fetch for tomorrow's content asynchronously
*
* This prefetches fresh content for tomorrow while still showing today's notification.
* The soft re-fetch runs in the background and updates tomorrow's notification content.
*
* @param content Current notification content
*/
private void scheduleSoftRefetchForTomorrow(NotificationContent content) {
try {
// Calculate tomorrow's scheduled time (24 hours from current scheduled time)
long tomorrowScheduledTime = content.getScheduledTime() + TimeUnit.HOURS.toMillis(24);
// Schedule soft re-fetch 2 hours before tomorrow's notification
long softRefetchTime = tomorrowScheduledTime - TimeUnit.HOURS.toMillis(2);
if (softRefetchTime > System.currentTimeMillis()) {
androidx.work.WorkManager workManager = androidx.work.WorkManager.getInstance(getApplicationContext());
// Create constraints for the soft re-fetch work
androidx.work.Constraints constraints = new androidx.work.Constraints.Builder()
.setRequiredNetworkType(androidx.work.NetworkType.CONNECTED)
.setRequiresBatteryNotLow(false)
.setRequiresCharging(false)
.setRequiresDeviceIdle(false)
.build();
// Create input data
androidx.work.Data inputData = new androidx.work.Data.Builder()
.putLong("tomorrow_scheduled_time", tomorrowScheduledTime)
.putString("action", "soft_refetch")
.putString("original_id", content.getId())
.build();
// Create one-time work request
androidx.work.OneTimeWorkRequest softRefetchWork = new androidx.work.OneTimeWorkRequest.Builder(
com.timesafari.dailynotification.SoftRefetchWorker.class)
.setConstraints(constraints)
.setInputData(inputData)
.setInitialDelay(softRefetchTime - System.currentTimeMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
.addTag("soft_refetch")
.build();
// Enqueue the work
workManager.enqueue(softRefetchWork);
Log.d(TAG, "DN|SOFT_REFETCH_SCHEDULED original_id=" + content.getId() +
" tomorrow_time=" + tomorrowScheduledTime +
" refetch_time=" + softRefetchTime);
}
} catch (Exception e) {
Log.e(TAG, "DN|SOFT_REFETCH_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
}
}
/**
* Display the notification to the user
*
* @param content Notification content to display
* @return true if displayed successfully, false otherwise
*/
private boolean displayNotification(NotificationContent content) {
Trace.beginSection("DN:displayNotif");
try {
Log.d(TAG, "DN|DISPLAY_NOTIF_START id=" + content.getId());
NotificationManager notificationManager =
(NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager == null) {
Log.e(TAG, "DN|DISPLAY_NOTIF_ERR no_manager id=" + content.getId());
return false;
}
// Create notification builder
NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(), CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(content.getTitle())
.setContentText(content.getBody())
.setPriority(getNotificationPriority(content.getPriority()))
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER);
// Add sound if enabled
if (content.isSound()) {
builder.setDefaults(NotificationCompat.DEFAULT_SOUND);
}
// Add click action - always open the app, optionally with URL
Intent clickIntent;
if (content.getUrl() != null && !content.getUrl().isEmpty()) {
// If URL is provided, open the app and pass the URL as data
clickIntent = new Intent(Intent.ACTION_VIEW);
clickIntent.setData(android.net.Uri.parse(content.getUrl()));
clickIntent.setPackage(getApplicationContext().getPackageName());
clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Log.d(TAG, "DN|CLICK_INTENT with_url=" + content.getUrl());
} else {
// If no URL, just open the main app
clickIntent = getApplicationContext().getPackageManager().getLaunchIntentForPackage(getApplicationContext().getPackageName());
clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
Log.d(TAG, "DN|CLICK_INTENT app_only");
}
PendingIntent clickPendingIntent = PendingIntent.getActivity(
getApplicationContext(),
content.getId().hashCode(),
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
builder.setContentIntent(clickPendingIntent);
// Add action buttons
// 1. Dismiss action
Intent dismissIntent = new Intent(getApplicationContext(), DailyNotificationReceiver.class);
dismissIntent.setAction("com.timesafari.daily.DISMISS");
dismissIntent.putExtra("notification_id", content.getId());
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
getApplicationContext(),
content.getId().hashCode() + 1000, // Different request code
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
builder.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"Dismiss",
dismissPendingIntent
);
// 2. View Details action (if URL is available)
if (content.getUrl() != null && !content.getUrl().isEmpty()) {
Intent viewDetailsIntent = new Intent(Intent.ACTION_VIEW);
viewDetailsIntent.setData(android.net.Uri.parse(content.getUrl()));
viewDetailsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent viewDetailsPendingIntent = PendingIntent.getActivity(
getApplicationContext(),
content.getId().hashCode() + 2000, // Different request code
viewDetailsIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
builder.addAction(
android.R.drawable.ic_menu_info_details,
"View Details",
viewDetailsPendingIntent
);
Log.d(TAG, "DN|ACTION_BUTTONS added_view_details url=" + content.getUrl());
} else {
Log.d(TAG, "DN|ACTION_BUTTONS dismiss_only");
}
// Build and display notification
int notificationId = content.getId().hashCode();
notificationManager.notify(notificationId, builder.build());
Log.i(TAG, "DN|DISPLAY_NOTIF_OK id=" + content.getId());
return true;
} catch (Exception e) {
Log.e(TAG, "DN|DISPLAY_NOTIF_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
return false;
} finally {
Trace.endSection();
}
}
/**
* Schedule the next occurrence of this daily notification with DST-safe calculation
* and deduplication to prevent double-firing
*
* @param content Current notification content
*/
private void scheduleNextNotification(NotificationContent content) {
Trace.beginSection("DN:scheduleNext");
try {
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
// Calculate next occurrence using DST-safe ZonedDateTime
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
// Check for existing notification at the same time to prevent duplicates
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
java.util.List<NotificationContent> existingNotifications = legacyStorage.getAllNotifications();
// Look for existing notification scheduled at the same time (within 1 minute tolerance)
boolean duplicateFound = false;
long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts
for (NotificationContent existing : existingNotifications) {
if (Math.abs(existing.getScheduledTime() - nextScheduledTime) <= toleranceMs) {
Log.w(TAG, "DN|RESCHEDULE_DUPLICATE id=" + content.getId() +
" existing_id=" + existing.getId() +
" time_diff_ms=" + Math.abs(existing.getScheduledTime() - nextScheduledTime));
duplicateFound = true;
break;
}
}
if (duplicateFound) {
Log.i(TAG, "DN|RESCHEDULE_SKIP id=" + content.getId() + " duplicate_prevented");
return;
}
// Create new content for next occurrence
NotificationContent nextContent = new NotificationContent();
nextContent.setTitle(content.getTitle());
nextContent.setBody(content.getBody());
nextContent.setScheduledTime(nextScheduledTime);
nextContent.setSound(content.isSound());
nextContent.setPriority(content.getPriority());
nextContent.setUrl(content.getUrl());
// fetchedAt is set in constructor, no need to set it again
// Save to Room (authoritative) and legacy storage (compat)
saveNextToRoom(nextContent);
DailyNotificationStorage legacyStorage2 = new DailyNotificationStorage(getApplicationContext());
legacyStorage2.saveNotificationContent(nextContent);
// Schedule the notification
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
getApplicationContext(),
(android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
);
boolean scheduled = scheduler.scheduleNotification(nextContent);
if (scheduled) {
// Log next scheduled time in readable format
String nextTimeStr = formatScheduledTime(nextScheduledTime);
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr);
// Schedule background fetch for next notification (5 minutes before scheduled time)
try {
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
getApplicationContext(),
storageForFetcher,
roomStorageForFetcher
);
// Calculate fetch time (5 minutes before notification)
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
long currentTime = System.currentTimeMillis();
if (fetchTime > currentTime) {
fetcher.scheduleFetch(fetchTime);
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() +
" next_fetch=" + fetchTime +
" next_notification=" + nextScheduledTime);
} else {
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
" fetch_time=" + fetchTime +
" current=" + currentTime);
fetcher.scheduleImmediateFetch();
}
} catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
" error scheduling prefetch", e);
}
} else {
Log.e(TAG, "DN|RESCHEDULE_ERR id=" + content.getId());
}
} catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
} finally {
Trace.endSection();
}
}
/**
* Try to load content from Room; fallback to legacy storage
*/
private NotificationContent getContentFromRoomOrLegacy(String notificationId) {
// Attempt Room
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// For now, Room service provides ID-based get via DAO through a helper in future; we re-query by ID via DAO
com.timesafari.dailynotification.database.DailyNotificationDatabase db =
com.timesafari.dailynotification.database.DailyNotificationDatabase.getInstance(getApplicationContext());
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
if (entity != null) {
return mapEntityToContent(entity);
}
} catch (Throwable t) {
Log.w(TAG, "DN|ROOM_READ_FAIL id=" + notificationId + " err=" + t.getMessage());
}
// Fallback legacy
DailyNotificationStorage legacy = new DailyNotificationStorage(getApplicationContext());
return legacy.getNotificationContent(notificationId);
}
private NotificationContent mapEntityToContent(NotificationContentEntity entity) {
NotificationContent c = new NotificationContent();
// Preserve ID by embedding in URL hashcode; actual NotificationContent lacks explicit setter for ID in snippet
// Assuming NotificationContent has setId; if not, ID used only for hashing here remains consistent via title/body/time
try {
java.lang.reflect.Method setId = NotificationContent.class.getDeclaredMethod("setId", String.class);
setId.setAccessible(true);
setId.invoke(c, entity.id);
} catch (Exception ignored) { }
c.setTitle(entity.title);
c.setBody(entity.body);
c.setScheduledTime(entity.scheduledTime);
c.setPriority(mapPriorityFromInt(entity.priority));
c.setSound(entity.soundEnabled);
try {
java.lang.reflect.Method setVibration = NotificationContent.class.getDeclaredMethod("setVibration", boolean.class);
setVibration.setAccessible(true);
setVibration.invoke(c, entity.vibrationEnabled);
} catch (Exception ignored) { }
c.setMediaUrl(entity.mediaUrl);
return c;
}
private String mapPriorityFromInt(int p) {
if (p >= 2) return "high";
if (p <= -1) return "low";
return "default";
}
private void saveNextToRoom(NotificationContent content) {
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
NotificationContentEntity entity = new NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0",
null,
"daily",
content.getTitle(),
content.getBody(),
content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId()
);
entity.priority = mapPriorityToInt(content.getPriority());
try {
java.lang.reflect.Method isVibration = NotificationContent.class.getDeclaredMethod("isVibration");
Object vib = isVibration.invoke(content);
if (vib instanceof Boolean) {
entity.vibrationEnabled = (Boolean) vib;
}
} catch (Exception ignored) { }
entity.soundEnabled = content.isSound();
room.saveNotificationContent(entity);
} catch (Throwable t) {
Log.w(TAG, "DN|ROOM_SAVE_FAIL err=" + t.getMessage());
}
}
private int mapPriorityToInt(String priority) {
if (priority == null) return 0;
switch (priority) {
case "max":
case "high":
return 2;
case "low":
case "min":
return -1;
default:
return 0;
}
}
/**
* Calculate next scheduled time with DST-safe handling
*
* @param currentScheduledTime Current scheduled time
* @return Next scheduled time (24 hours later, DST-safe)
*/
private long calculateNextScheduledTime(long currentScheduledTime) {
try {
// Get user's timezone
ZoneId userZone = ZoneId.systemDefault();
// Convert to ZonedDateTime
ZonedDateTime currentZoned = ZonedDateTime.ofInstant(
java.time.Instant.ofEpochMilli(currentScheduledTime),
userZone
);
// Add 24 hours (handles DST transitions automatically)
ZonedDateTime nextZoned = currentZoned.plusHours(24);
// Convert back to epoch millis
return nextZoned.toInstant().toEpochMilli();
} catch (Exception e) {
Log.e(TAG, "DN|DST_CALC_ERR fallback_to_simple err=" + e.getMessage(), e);
// Fallback to simple 24-hour addition if DST calculation fails
return currentScheduledTime + (24 * 60 * 60 * 1000);
}
}
/**
* Format scheduled time for logging
*
* @param scheduledTime Epoch millis
* @return Formatted time string
*/
private String formatScheduledTime(long scheduledTime) {
try {
ZonedDateTime zoned = ZonedDateTime.ofInstant(
java.time.Instant.ofEpochMilli(scheduledTime),
ZoneId.systemDefault()
);
return zoned.format(DateTimeFormatter.ofPattern("HH:mm:ss on MM/dd/yyyy"));
} catch (Exception e) {
return "epoch:" + scheduledTime;
}
}
/**
* Get notification priority constant
*
* @param priority Priority string from content
* @return NotificationCompat priority constant
*/
private int getNotificationPriority(String priority) {
if (priority == null) {
return NotificationCompat.PRIORITY_DEFAULT;
}
switch (priority.toLowerCase()) {
case "high":
return NotificationCompat.PRIORITY_HIGH;
case "low":
return NotificationCompat.PRIORITY_LOW;
case "min":
return NotificationCompat.PRIORITY_MIN;
case "max":
return NotificationCompat.PRIORITY_MAX;
default:
return NotificationCompat.PRIORITY_DEFAULT;
}
}
// MARK: - Work Deduplication and Idempotence Methods
/**
* Create unique work key for deduplication
*
* @param notificationId Notification ID
* @param action Action type
* @return Unique work key
*/
private String createWorkKey(String notificationId, String action) {
return String.format("%s_%s_%d", notificationId, action, System.currentTimeMillis() / (60 * 1000)); // Group by minute
}
/**
* Acquire work lock to prevent duplicate execution
*
* @param workKey Unique work key
* @return true if lock acquired, false if work is already running
*/
private boolean acquireWorkLock(String workKey) {
try {
// Clean up expired locks
cleanupExpiredLocks();
// Try to acquire lock
AtomicBoolean lock = activeWork.computeIfAbsent(workKey, k -> new AtomicBoolean(false));
if (lock.compareAndSet(false, true)) {
workTimestamps.put(workKey, System.currentTimeMillis());
Log.d(TAG, "DN|LOCK_ACQUIRED key=" + workKey);
return true;
} else {
Log.d(TAG, "DN|LOCK_BUSY key=" + workKey);
return false;
}
} catch (Exception e) {
Log.e(TAG, "DN|LOCK_ERR key=" + workKey + " err=" + e.getMessage(), e);
return false;
}
}
/**
* Release work lock
*
* @param workKey Unique work key
*/
private void releaseWorkLock(String workKey) {
try {
AtomicBoolean lock = activeWork.get(workKey);
if (lock != null) {
lock.set(false);
workTimestamps.remove(workKey);
Log.d(TAG, "DN|LOCK_RELEASED key=" + workKey);
}
} catch (Exception e) {
Log.e(TAG, "DN|LOCK_RELEASE_ERR key=" + workKey + " err=" + e.getMessage(), e);
}
}
/**
* Check if work is already completed (idempotence)
*
* @param workKey Unique work key
* @return true if work is already completed
*/
private boolean isWorkAlreadyCompleted(String workKey) {
try {
// Check if we have a completion record for this work
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
String completionKey = "work_completed_" + workKey;
// For now, we'll use a simple approach - check if the work was completed recently
// In a production system, this would be stored in a database
return false; // Always allow work to proceed for now
} catch (Exception e) {
Log.e(TAG, "DN|IDEMPOTENCE_CHECK_ERR key=" + workKey + " err=" + e.getMessage(), e);
return false;
}
}
/**
* Mark work as completed for idempotence
*
* @param workKey Unique work key
*/
private void markWorkAsCompleted(String workKey) {
try {
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
String completionKey = "work_completed_" + workKey;
long completionTime = System.currentTimeMillis();
// Store completion timestamp
// Legacy storeLong may not exist; skip persistence for idempotence marker
Log.d(TAG, "DN|WORK_COMPLETED key=" + workKey + " time=" + completionTime);
} catch (Exception e) {
Log.e(TAG, "DN|WORK_COMPLETION_ERR key=" + workKey + " err=" + e.getMessage(), e);
}
}
/**
* Clean up expired work locks
*/
private void cleanupExpiredLocks() {
try {
long currentTime = System.currentTimeMillis();
activeWork.entrySet().removeIf(entry -> {
String workKey = entry.getKey();
Long timestamp = workTimestamps.get(workKey);
if (timestamp != null && (currentTime - timestamp) > WORK_TIMEOUT_MS) {
Log.d(TAG, "DN|LOCK_CLEANUP expired key=" + workKey);
workTimestamps.remove(workKey);
return true;
}
return false;
});
} catch (Exception e) {
Log.e(TAG, "DN|LOCK_CLEANUP_ERR err=" + e.getMessage(), e);
}
}
/**
* Get work deduplication statistics
*
* @return Statistics string
*/
public static String getWorkDeduplicationStats() {
return String.format("Active work: %d, Timestamps: %d",
activeWork.size(), workTimestamps.size());
}
}

View File

@@ -0,0 +1,31 @@
/**
* DailyReminderInfo.java
*
* Data class representing a daily reminder configuration
* and its current state.
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
/**
* Information about a scheduled daily reminder
*/
public class DailyReminderInfo {
public String id;
public String title;
public String body;
public String time;
public boolean sound;
public boolean vibration;
public String priority;
public boolean repeatDaily;
public String timezone;
public boolean isScheduled;
public long nextTriggerTime;
public long createdAt;
public long lastTriggered;
}

View File

@@ -0,0 +1,403 @@
/**
* DailyReminderManager.java
*
* Manages daily reminder functionality including creation, updates,
* cancellation, and retrieval. Handles persistent storage and
* notification scheduling.
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import com.getcapacitor.JSObject;
import com.getcapacitor.PluginCall;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Manager class for daily reminder operations
*
* Responsibilities:
* - Schedule daily reminders
* - Cancel scheduled reminders
* - Update existing reminders
* - Retrieve reminder list
* - Manage persistent storage
*/
public class DailyReminderManager {
private static final String TAG = "DailyReminderManager";
private static final String PREF_NAME = "daily_reminders";
private static final String REMINDER_ID_PREFIX = "reminder_";
private final Context context;
private final DailyNotificationScheduler scheduler;
/**
* Initialize the DailyReminderManager
*
* @param context Android context
* @param scheduler Notification scheduler instance
*/
public DailyReminderManager(Context context,
DailyNotificationScheduler scheduler) {
this.context = context;
this.scheduler = scheduler;
Log.d(TAG, "DailyReminderManager initialized");
}
/**
* Schedule a new daily reminder
*
* @param id Unique identifier for the reminder
* @param title Reminder title
* @param body Reminder body text
* @param time Time in HH:mm format
* @param sound Whether to play sound
* @param vibration Whether to vibrate
* @param priority Notification priority
* @param repeatDaily Whether to repeat daily
* @param timezone Optional timezone string
* @return true if scheduled successfully
*/
public boolean scheduleReminder(String id, String title, String body,
String time, boolean sound,
boolean vibration, String priority,
boolean repeatDaily, String timezone) {
try {
Log.d(TAG, "Scheduling daily reminder: " + id);
// Validate time format
String[] timeParts = time.split(":");
if (timeParts.length != 2) {
Log.e(TAG, "Invalid time format: " + time);
return false;
}
int hour = Integer.parseInt(timeParts[0]);
int minute = Integer.parseInt(timeParts[1]);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
Log.e(TAG, "Invalid time values");
return false;
}
// Create reminder content
NotificationContent reminderContent = new NotificationContent();
reminderContent.setId(REMINDER_ID_PREFIX + id);
reminderContent.setTitle(title);
reminderContent.setBody(body);
reminderContent.setSound(sound);
reminderContent.setPriority(priority);
// Calculate next trigger time
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
// If time has passed today, schedule for tomorrow
if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
calendar.add(Calendar.DAY_OF_MONTH, 1);
}
reminderContent.setScheduledTime(calendar.getTimeInMillis());
// Store reminder in database
storeReminderInDatabase(id, title, body, time, sound,
vibration, priority, repeatDaily, timezone);
// Schedule the notification
boolean scheduled = scheduler.scheduleNotification(reminderContent);
if (scheduled) {
Log.i(TAG, "Daily reminder scheduled successfully: " + id);
} else {
Log.e(TAG, "Failed to schedule daily reminder: " + id);
}
return scheduled;
} catch (Exception e) {
Log.e(TAG, "Error scheduling daily reminder", e);
return false;
}
}
/**
* Cancel a scheduled reminder
*
* @param reminderId Reminder ID to cancel
* @return true if cancelled successfully
*/
public boolean cancelReminder(String reminderId) {
try {
Log.d(TAG, "Cancelling daily reminder: " + reminderId);
// Cancel the scheduled notification
scheduler.cancelNotification(REMINDER_ID_PREFIX + reminderId);
// Remove from database
removeReminderFromDatabase(reminderId);
Log.i(TAG, "Daily reminder cancelled: " + reminderId);
return true;
} catch (Exception e) {
Log.e(TAG, "Error cancelling daily reminder", e);
return false;
}
}
/**
* Update an existing reminder
*
* @param reminderId Reminder ID to update
* @param title Optional new title
* @param body Optional new body
* @param time Optional new time in HH:mm format
* @param sound Optional new sound setting
* @param vibration Optional new vibration setting
* @param priority Optional new priority
* @param repeatDaily Optional new repeat setting
* @param timezone Optional new timezone
* @return true if updated successfully
*/
public boolean updateReminder(String reminderId, String title,
String body, String time, Boolean sound,
Boolean vibration, String priority,
Boolean repeatDaily, String timezone) {
try {
Log.d(TAG, "Updating daily reminder: " + reminderId);
// Cancel existing reminder
scheduler.cancelNotification(REMINDER_ID_PREFIX + reminderId);
// Update in database
updateReminderInDatabase(reminderId, title, body, time,
sound, vibration, priority,
repeatDaily, timezone);
// Reschedule with new settings
if (title != null && body != null && time != null) {
// Parse time
String[] timeParts = time.split(":");
int hour = Integer.parseInt(timeParts[0]);
int minute = Integer.parseInt(timeParts[1]);
// Create new reminder content
NotificationContent reminderContent = new NotificationContent();
reminderContent.setId(REMINDER_ID_PREFIX + reminderId);
reminderContent.setTitle(title);
reminderContent.setBody(body);
reminderContent.setSound(sound != null ? sound : true);
reminderContent.setPriority(priority != null ? priority : "normal");
// Calculate next trigger time
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
calendar.add(Calendar.DAY_OF_MONTH, 1);
}
reminderContent.setScheduledTime(calendar.getTimeInMillis());
// Schedule the updated notification
boolean scheduled = scheduler.scheduleNotification(reminderContent);
if (!scheduled) {
Log.e(TAG, "Failed to reschedule updated reminder");
return false;
}
}
Log.i(TAG, "Daily reminder updated: " + reminderId);
return true;
} catch (Exception e) {
Log.e(TAG, "Error updating daily reminder", e);
return false;
}
}
/**
* Get all scheduled reminders
*
* @return List of DailyReminderInfo objects
*/
public List<DailyReminderInfo> getReminders() {
try {
Log.d(TAG, "Getting scheduled reminders");
return getRemindersFromDatabase();
} catch (Exception e) {
Log.e(TAG, "Error getting reminders from database", e);
return new ArrayList<>();
}
}
/**
* Store reminder in SharedPreferences database
*/
private void storeReminderInDatabase(String id, String title, String body,
String time, boolean sound,
boolean vibration, String priority,
boolean repeatDaily, String timezone) {
try {
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME,
Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putString(id + "_title", title);
editor.putString(id + "_body", body);
editor.putString(id + "_time", time);
editor.putBoolean(id + "_sound", sound);
editor.putBoolean(id + "_vibration", vibration);
editor.putString(id + "_priority", priority);
editor.putBoolean(id + "_repeatDaily", repeatDaily);
editor.putString(id + "_timezone", timezone);
editor.putLong(id + "_createdAt", System.currentTimeMillis());
editor.putBoolean(id + "_isScheduled", true);
editor.apply();
Log.d(TAG, "Reminder stored in database: " + id);
} catch (Exception e) {
Log.e(TAG, "Error storing reminder in database", e);
}
}
/**
* Remove reminder from SharedPreferences database
*/
private void removeReminderFromDatabase(String id) {
try {
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME,
Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.remove(id + "_title");
editor.remove(id + "_body");
editor.remove(id + "_time");
editor.remove(id + "_sound");
editor.remove(id + "_vibration");
editor.remove(id + "_priority");
editor.remove(id + "_repeatDaily");
editor.remove(id + "_timezone");
editor.remove(id + "_createdAt");
editor.remove(id + "_isScheduled");
editor.remove(id + "_lastTriggered");
editor.apply();
Log.d(TAG, "Reminder removed from database: " + id);
} catch (Exception e) {
Log.e(TAG, "Error removing reminder from database", e);
}
}
/**
* Get reminders from SharedPreferences database
*/
private List<DailyReminderInfo> getRemindersFromDatabase() {
List<DailyReminderInfo> reminders = new ArrayList<>();
try {
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME,
Context.MODE_PRIVATE);
Map<String, ?> allEntries = prefs.getAll();
Set<String> reminderIds = new HashSet<>();
for (String key : allEntries.keySet()) {
if (key.endsWith("_title")) {
String id = key.substring(0, key.length() - 6); // Remove "_title"
reminderIds.add(id);
}
}
for (String id : reminderIds) {
DailyReminderInfo reminder = new DailyReminderInfo();
reminder.id = id;
reminder.title = prefs.getString(id + "_title", "");
reminder.body = prefs.getString(id + "_body", "");
reminder.time = prefs.getString(id + "_time", "");
reminder.sound = prefs.getBoolean(id + "_sound", true);
reminder.vibration = prefs.getBoolean(id + "_vibration", true);
reminder.priority = prefs.getString(id + "_priority", "normal");
reminder.repeatDaily = prefs.getBoolean(id + "_repeatDaily", true);
reminder.timezone = prefs.getString(id + "_timezone", null);
reminder.isScheduled = prefs.getBoolean(id + "_isScheduled", false);
reminder.createdAt = prefs.getLong(id + "_createdAt", 0);
reminder.lastTriggered = prefs.getLong(id + "_lastTriggered", 0);
// Calculate next trigger time
String[] timeParts = reminder.time.split(":");
int hour = Integer.parseInt(timeParts[0]);
int minute = Integer.parseInt(timeParts[1]);
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
calendar.add(Calendar.DAY_OF_MONTH, 1);
}
reminder.nextTriggerTime = calendar.getTimeInMillis();
reminders.add(reminder);
}
} catch (Exception e) {
Log.e(TAG, "Error getting reminders from database", e);
}
return reminders;
}
/**
* Update reminder in SharedPreferences database
*/
private void updateReminderInDatabase(String id, String title, String body,
String time, Boolean sound,
Boolean vibration, String priority,
Boolean repeatDaily, String timezone) {
try {
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME,
Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
if (title != null) editor.putString(id + "_title", title);
if (body != null) editor.putString(id + "_body", body);
if (time != null) editor.putString(id + "_time", time);
if (sound != null) editor.putBoolean(id + "_sound", sound);
if (vibration != null) editor.putBoolean(id + "_vibration", vibration);
if (priority != null) editor.putString(id + "_priority", priority);
if (repeatDaily != null) editor.putBoolean(id + "_repeatDaily", repeatDaily);
if (timezone != null) editor.putString(id + "_timezone", timezone);
editor.apply();
Log.d(TAG, "Reminder updated in database: " + id);
} catch (Exception e) {
Log.e(TAG, "Error updating reminder in database", e);
}
}
}

View File

@@ -0,0 +1,173 @@
/**
* DozeFallbackWorker.java
*
* WorkManager worker for handling deep doze fallback scenarios
* Re-arms exact alarms if they get pruned during deep doze mode
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.content.Context;
import android.os.Trace;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* WorkManager worker for doze fallback scenarios
*
* This worker runs 30 minutes before scheduled notifications to check
* if exact alarms are still active and re-arm them if needed.
*/
public class DozeFallbackWorker extends Worker {
private static final String TAG = "DozeFallbackWorker";
public DozeFallbackWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
Trace.beginSection("DN:DozeFallback");
try {
long scheduledTime = getInputData().getLong("scheduled_time", -1);
String action = getInputData().getString("action");
if (scheduledTime == -1 || !"doze_fallback".equals(action)) {
Log.e(TAG, "DN|DOZE_FALLBACK_ERR invalid_input_data");
return Result.failure();
}
Log.d(TAG, "DN|DOZE_FALLBACK_START scheduled_time=" + scheduledTime);
// Check if we're within 30 minutes of the scheduled time
long currentTime = System.currentTimeMillis();
long timeUntilNotification = scheduledTime - currentTime;
if (timeUntilNotification < 0) {
Log.w(TAG, "DN|DOZE_FALLBACK_SKIP notification_already_past");
return Result.success();
}
if (timeUntilNotification > TimeUnit.MINUTES.toMillis(30)) {
Log.w(TAG, "DN|DOZE_FALLBACK_SKIP too_early time_until=" + (timeUntilNotification / 1000 / 60) + "min");
return Result.success();
}
// Check if exact alarm is still scheduled
boolean alarmStillActive = checkExactAlarmStatus(scheduledTime);
if (!alarmStillActive) {
Log.w(TAG, "DN|DOZE_FALLBACK_REARM exact_alarm_missing scheduled_time=" + scheduledTime);
// Re-arm the exact alarm
boolean rearmed = rearmExactAlarm(scheduledTime);
if (rearmed) {
Log.i(TAG, "DN|DOZE_FALLBACK_OK exact_alarm_rearmed");
return Result.success();
} else {
Log.e(TAG, "DN|DOZE_FALLBACK_ERR rearm_failed");
return Result.retry();
}
} else {
Log.d(TAG, "DN|DOZE_FALLBACK_OK exact_alarm_active");
return Result.success();
}
} catch (Exception e) {
Log.e(TAG, "DN|DOZE_FALLBACK_ERR exception=" + e.getMessage(), e);
return Result.retry();
} finally {
Trace.endSection();
}
}
/**
* Check if exact alarm is still active for the scheduled time
*
* @param scheduledTime The scheduled notification time
* @return true if alarm is still active, false otherwise
*/
private boolean checkExactAlarmStatus(long scheduledTime) {
try {
// Get all notifications from storage
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
List<NotificationContent> notifications = storage.getAllNotifications();
// Look for notification scheduled at the target time (within 1 minute tolerance)
long toleranceMs = 60 * 1000; // 1 minute tolerance
for (NotificationContent notification : notifications) {
if (Math.abs(notification.getScheduledTime() - scheduledTime) <= toleranceMs) {
Log.d(TAG, "DN|DOZE_FALLBACK_CHECK found_notification id=" + notification.getId());
return true;
}
}
Log.w(TAG, "DN|DOZE_FALLBACK_CHECK no_notification_found scheduled_time=" + scheduledTime);
return false;
} catch (Exception e) {
Log.e(TAG, "DN|DOZE_FALLBACK_CHECK_ERR err=" + e.getMessage(), e);
return false;
}
}
/**
* Re-arm the exact alarm for the scheduled time
*
* @param scheduledTime The scheduled notification time
* @return true if re-arming succeeded, false otherwise
*/
private boolean rearmExactAlarm(long scheduledTime) {
try {
// Get all notifications from storage
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
List<NotificationContent> notifications = storage.getAllNotifications();
// Find the notification scheduled at the target time
long toleranceMs = 60 * 1000; // 1 minute tolerance
for (NotificationContent notification : notifications) {
if (Math.abs(notification.getScheduledTime() - scheduledTime) <= toleranceMs) {
Log.d(TAG, "DN|DOZE_FALLBACK_REARM found_target id=" + notification.getId());
// Re-schedule the notification
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
getApplicationContext(),
(AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
);
boolean scheduled = scheduler.scheduleNotification(notification);
if (scheduled) {
Log.i(TAG, "DN|DOZE_FALLBACK_REARM_OK id=" + notification.getId());
return true;
} else {
Log.e(TAG, "DN|DOZE_FALLBACK_REARM_FAIL id=" + notification.getId());
return false;
}
}
}
Log.w(TAG, "DN|DOZE_FALLBACK_REARM_ERR no_target_found scheduled_time=" + scheduledTime);
return false;
} catch (Exception e) {
Log.e(TAG, "DN|DOZE_FALLBACK_REARM_ERR exception=" + e.getMessage(), e);
return false;
}
}
}

View File

@@ -0,0 +1,624 @@
/**
* EnhancedDailyNotificationFetcher.java
*
* Enhanced Android content fetcher with TimeSafari Endorser.ch API support
* Extends existing DailyNotificationFetcher with JWT authentication and Endorser.ch endpoints
*
* @author Matthew Raymer
* @version 1.0.0
* @created 2025-10-03 06:53:30 UTC
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
/**
* Enhanced content fetcher with TimeSafari integration
*
* This class extends the existing DailyNotificationFetcher with:
* - JWT authentication via DailyNotificationJWTManager
* - Endorser.ch API endpoint support
* - ActiveDid-aware content fetching
* - Parallel API request handling for offers, projects, people, items
* - Integration with existing ETagManager infrastructure
*/
public class EnhancedDailyNotificationFetcher extends DailyNotificationFetcher {
// MARK: - Constants
private static final String TAG = "EnhancedDailyNotificationFetcher";
// Endorser.ch API Endpoints
private static final String ENDPOINT_OFFERS = "/api/v2/report/offers";
private static final String ENDPOINT_OFFERS_TO_PLANS = "/api/v2/report/offersToPlansOwnedByMe";
private static final String ENDPOINT_PLANS_UPDATED = "/api/v2/report/plansLastUpdatedBetween";
// API Configuration
private static final int API_TIMEOUT_MS = 30000; // 30 seconds
// MARK: - Properties
private final DailyNotificationJWTManager jwtManager;
private String apiServerUrl;
// MARK: - Initialization
/**
* Constructor with JWT Manager integration
*
* @param context Android context
* @param etagManager ETagManager instance (from parent)
* @param jwtManager JWT authentication manager
*/
public EnhancedDailyNotificationFetcher(
Context context,
DailyNotificationStorage storage,
DailyNotificationETagManager etagManager,
DailyNotificationJWTManager jwtManager
) {
super(context, storage);
this.jwtManager = jwtManager;
Log.d(TAG, "EnhancedDailyNotificationFetcher initialized with JWT support");
}
/**
* Set API server URL for Endorser.ch endpoints
*
* @param apiServerUrl Base URL for TimeSafari API server
*/
public void setApiServerUrl(String apiServerUrl) {
this.apiServerUrl = apiServerUrl;
Log.d(TAG, "API Server URL set: " + apiServerUrl);
}
// MARK: - Endorser.ch API Methods
/**
* Fetch offers to complete user with pagination
*
* This implements the GET /api/v2/report/offers endpoint
*
* @param recipientDid DID of user receiving offers
* @param afterId JWT ID of last known offer (for pagination)
* @param beforeId JWT ID of earliest known offer (optional)
* @return Future with OffersResponse result
*/
public CompletableFuture<OffersResponse> fetchEndorserOffers(String recipientDid, String afterId, String beforeId) {
try {
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PERSON_START recipient=" + recipientDid);
// Validate parameters
if (recipientDid == null || recipientDid.isEmpty()) {
throw new IllegalArgumentException("recipientDid cannot be null or empty");
}
if (apiServerUrl == null || apiServerUrl.isEmpty()) {
throw new IllegalStateException("API server URL not set");
}
// Build URL with query parameters
String url = buildOffersUrl(recipientDid, afterId, beforeId);
Log.d(TAG, "ENH|URL_BUILD url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Make authenticated request
CompletableFuture<OffersResponse> future = makeAuthenticatedRequest(url, OffersResponse.class);
future.thenAccept(response -> {
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PERSON_OK count=" + (response != null && response.data != null ? response.data.size() : 0));
}).exceptionally(e -> {
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PERSON_ERR err=" + e.getMessage());
return null;
});
return future;
} catch (Exception e) {
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PERSON_ERR err=" + e.getMessage(), e);
CompletableFuture<OffersResponse> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
}
}
/**
* Fetch offers to projects owned by user
*
* This implements the GET /api/v2/report/offersToPlansOwnedByMe endpoint
*
* @param afterId JWT ID of last known offer (for pagination)
* @return Future with OffersToPlansResponse result
*/
public CompletableFuture<OffersToPlansResponse> fetchOffersToMyPlans(String afterId) {
try {
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PLANS_START afterId=" + (afterId != null ? afterId.substring(0, Math.min(20, afterId.length())) : "null"));
String url = buildOffersToPlansUrl(afterId);
Log.d(TAG, "ENH|URL_BUILD url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Make authenticated request
CompletableFuture<OffersToPlansResponse> future = makeAuthenticatedRequest(url, OffersToPlansResponse.class);
future.thenAccept(response -> {
Log.i(TAG, "ENH|FETCH_OFFERS_TO_PLANS_OK count=" + (response != null && response.data != null ? response.data.size() : 0));
}).exceptionally(e -> {
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PLANS_ERR err=" + e.getMessage());
return null;
});
return future;
} catch (Exception e) {
Log.e(TAG, "ENH|FETCH_OFFERS_TO_PLANS_ERR err=" + e.getMessage(), e);
CompletableFuture<OffersToPlansResponse> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
}
}
/**
* Fetch project updates for starred/interesting projects
*
* This implements the POST /api/v2/report/plansLastUpdatedBetween endpoint
*
* @param planIds Array of plan IDs to check for updates
* @param afterId JWT ID of last known project update
* @return Future with PlansLastUpdatedResponse result
*/
public CompletableFuture<PlansLastUpdatedResponse> fetchProjectsLastUpdated(List<String> planIds, String afterId) {
try {
Log.i(TAG, "ENH|FETCH_PROJECT_UPDATES_START planCount=" + (planIds != null ? planIds.size() : 0) + " afterId=" + (afterId != null ? afterId.substring(0, Math.min(20, afterId.length())) : "null"));
String url = apiServerUrl + ENDPOINT_PLANS_UPDATED;
Log.d(TAG, "ENH|URL_BUILD url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Create POST request body
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("planIds", planIds);
if (afterId != null) {
requestBody.put("afterId", afterId);
}
// Make authenticated POST request
CompletableFuture<PlansLastUpdatedResponse> future = makeAuthenticatedPostRequest(url, requestBody, PlansLastUpdatedResponse.class);
future.thenAccept(response -> {
Log.i(TAG, "ENH|FETCH_PROJECT_UPDATES_OK count=" + (response != null && response.data != null ? response.data.size() : 0));
}).exceptionally(e -> {
Log.e(TAG, "ENH|FETCH_PROJECT_UPDATES_ERR err=" + e.getMessage());
return null;
});
return future;
} catch (Exception e) {
Log.e(TAG, "ENH|FETCH_PROJECT_UPDATES_ERR err=" + e.getMessage(), e);
CompletableFuture<PlansLastUpdatedResponse> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
}
}
/**
* Fetch all TimeSafari notification data in parallel (main method)
*
* This combines offers and project updates into a comprehensive fetch operation
*
* @param userConfig TimeSafari user configuration
* @return Future with comprehensive notification data
*/
public CompletableFuture<TimeSafariNotificationBundle> fetchAllTimeSafariData(TimeSafariUserConfig userConfig) {
try {
Log.i(TAG, "ENH|FETCH_ALL_START activeDid=" + (userConfig.activeDid != null ? userConfig.activeDid.substring(0, Math.min(30, userConfig.activeDid.length())) : "null"));
// Validate configuration
if (userConfig.activeDid == null) {
Log.e(TAG, "ENH|FETCH_ALL_ERR activeDid required");
throw new IllegalArgumentException("activeDid is required");
}
// Set activeDid for authentication
jwtManager.setActiveDid(userConfig.activeDid);
Log.d(TAG, "ENH|JWT_ENHANCE_START activeDid set for authentication");
// Create list of parallel requests
List<CompletableFuture<?>> futures = new ArrayList<>();
// Request 1: Offers to person
final CompletableFuture<OffersResponse> offersToPerson = userConfig.fetchOffersToPerson ?
fetchEndorserOffers(userConfig.activeDid, userConfig.lastKnownOfferId, null) : null;
if (offersToPerson != null) {
futures.add(offersToPerson);
}
// Request 2: Offers to user's projects
final CompletableFuture<OffersToPlansResponse> offersToProjects = userConfig.fetchOffersToProjects ?
fetchOffersToMyPlans(userConfig.lastKnownOfferId) : null;
if (offersToProjects != null) {
futures.add(offersToProjects);
}
// Request 3: Project updates
final CompletableFuture<PlansLastUpdatedResponse> projectUpdates =
(userConfig.fetchProjectUpdates && userConfig.starredPlanIds != null && !userConfig.starredPlanIds.isEmpty()) ?
fetchProjectsLastUpdated(userConfig.starredPlanIds, userConfig.lastKnownPlanId) : null;
if (projectUpdates != null) {
futures.add(projectUpdates);
}
Log.d(TAG, "ENH|PARALLEL_REQUESTS count=" + futures.size());
// Wait for all requests to complete
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
// Combine results into bundle
return allFutures.thenApply(v -> {
try {
TimeSafariNotificationBundle bundle = new TimeSafariNotificationBundle();
if (offersToPerson != null) {
bundle.offersToPerson = offersToPerson.get();
}
if (offersToProjects != null) {
bundle.offersToProjects = offersToProjects.get();
}
if (projectUpdates != null) {
bundle.projectUpdates = projectUpdates.get();
}
bundle.fetchTimestamp = System.currentTimeMillis();
bundle.success = true;
Log.i(TAG, "ENH|FETCH_ALL_OK timestamp=" + bundle.fetchTimestamp);
return bundle;
} catch (Exception e) {
Log.e(TAG, "ENH|FETCH_ALL_ERR processing err=" + e.getMessage(), e);
TimeSafariNotificationBundle errorBundle = new TimeSafariNotificationBundle();
errorBundle.success = false;
errorBundle.error = e.getMessage();
return errorBundle;
}
});
} catch (Exception e) {
Log.e(TAG, "ENH|FETCH_ALL_ERR start err=" + e.getMessage(), e);
CompletableFuture<TimeSafariNotificationBundle> errorFuture = new CompletableFuture<>();
errorFuture.completeExceptionally(e);
return errorFuture;
}
}
// MARK: - URL Building
/**
* Build offers URL with query parameters
*/
private String buildOffersUrl(String recipientDid, String afterId, String beforeId) {
StringBuilder url = new StringBuilder();
url.append(apiServerUrl).append(ENDPOINT_OFFERS);
url.append("?recipientDid=").append(recipientDid);
if (afterId != null) {
url.append("&afterId=").append(afterId);
}
if (beforeId != null) {
url.append("&beforeId=").append(beforeId);
}
return url.toString();
}
/**
* Build offers to plans URL with query parameters
*/
private String buildOffersToPlansUrl(String afterId) {
StringBuilder url = new StringBuilder();
url.append(apiServerUrl).append(ENDPOINT_OFFERS_TO_PLANS);
if (afterId != null) {
url.append("?afterId=").append(afterId);
}
return url.toString();
}
// MARK: - Authenticated HTTP Requests
/**
* Make authenticated GET request
*
* @param url Request URL
* @param responseClass Expected response type
* @return Future with response
*/
private <T> CompletableFuture<T> makeAuthenticatedRequest(String url, Class<T> responseClass) {
return CompletableFuture.supplyAsync(() -> {
try {
Log.d(TAG, "ENH|HTTP_GET_START url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Create HTTP connection
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(API_TIMEOUT_MS);
connection.setReadTimeout(API_TIMEOUT_MS);
connection.setRequestMethod("GET");
// Enhance with JWT authentication
jwtManager.enhanceHttpClientWithJWT(connection);
Log.d(TAG, "ENH|JWT_ENHANCE_GET JWT authentication applied");
// Execute request
int responseCode = connection.getResponseCode();
Log.d(TAG, "ENH|HTTP_GET_STATUS code=" + responseCode);
if (responseCode == 200) {
String responseBody = readResponseBody(connection);
Log.d(TAG, "ENH|HTTP_GET_OK bodySize=" + (responseBody != null ? responseBody.length() : 0));
return parseResponse(responseBody, responseClass);
} else {
Log.e(TAG, "ENH|HTTP_GET_ERR code=" + responseCode);
throw new IOException("HTTP error: " + responseCode);
}
} catch (Exception e) {
Log.e(TAG, "ENH|HTTP_GET_ERR exception err=" + e.getMessage(), e);
throw new RuntimeException(e);
}
});
}
/**
* Make authenticated POST request
*
* @param url Request URL
* @param requestBody POST body data
* @param responseChallass Expected response type
* @return Future with response
*/
private <T> CompletableFuture<T> makeAuthenticatedPostRequest(String url, Map<String, Object> requestBody, Class<T> responseChallass) {
return CompletableFuture.supplyAsync(() -> {
try {
Log.d(TAG, "ENH|HTTP_POST_START url=" + url.substring(0, Math.min(100, url.length())) + "...");
// Create HTTP connection
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(API_TIMEOUT_MS);
connection.setReadTimeout(API_TIMEOUT_MS);
connection.setRequestMethod("POST");
connection.setDoOutput(true);
// Enhance with JWT authentication
connection.setRequestProperty("Content-Type", "application/json");
jwtManager.enhanceHttpClientWithJWT(connection);
Log.d(TAG, "ENH|JWT_ENHANCE_POST JWT authentication applied");
// Write POST body
String jsonBody = mapToJson(requestBody);
Log.d(TAG, "ENH|HTTP_POST_BODY bodySize=" + jsonBody.length());
connection.getOutputStream().write(jsonBody.getBytes(StandardCharsets.UTF_8));
// Execute request
int responseCode = connection.getResponseCode();
Log.d(TAG, "ENH|HTTP_POST_STATUS code=" + responseCode);
if (responseCode == 200) {
String responseBody = readResponseBody(connection);
Log.d(TAG, "ENH|HTTP_POST_OK bodySize=" + (responseBody != null ? responseBody.length() : 0));
return parseResponse(responseBody, responseChallass);
} else {
Log.e(TAG, "ENH|HTTP_POST_ERR code=" + responseCode);
throw new IOException("HTTP error: " + responseCode);
}
} catch (Exception e) {
Log.e(TAG, "ENH|HTTP_POST_ERR exception err=" + e.getMessage(), e);
throw new RuntimeException(e);
}
});
}
// MARK: - Response Processing
/**
* Read response body from connection
*/
private String readResponseBody(HttpURLConnection connection) throws IOException {
// This is a simplified implementation
// In production, you'd want proper stream handling
return "Mock response body"; // Placeholder
}
/**
* Parse JSON response into object
*/
private <T> T parseResponse(String jsonResponse, Class<T> responseChallass) {
// Phase 1: Simplified parsing
// Production would use proper JSON parsing (Gson, Jackson, etc.)
try {
if (responseChallass == OffersResponse.class) {
return (T) createMockOffersResponse();
} else if (responseChallass == OffersToPlansResponse.class) {
return (T) createMockOffersToPlansResponse();
} else if (responseChallass == PlansLastUpdatedResponse.class) {
return (T) createMockPlansResponse();
} else {
throw new IllegalArgumentException("Unsupported response type: " + responseChallass.getName());
}
} catch (Exception e) {
Log.e(TAG, "Error parsing response", e);
throw new RuntimeException("Failed to parse response", e);
}
}
/**
* Convert map to JSON (simplified)
*/
private String mapToJson(Map<String, Object> map) {
StringBuilder json = new StringBuilder("{");
boolean first = true;
for (Map.Entry<String, Object> entry : map.entrySet()) {
if (!first) json.append(",");
json.append("\"").append(entry.getKey()).append("\":");
Object value = entry.getValue();
if (value instanceof String) {
json.append("\"").append(value).append("\"");
} else if (value instanceof List) {
json.append(listToJson((List<?>) value));
} else {
json.append(value);
}
first = false;
}
json.append("}");
return json.toString();
}
/**
* Convert list to JSON (simplified)
*/
private String listToJson(List<?> list) {
StringBuilder json = new StringBuilder("[");
boolean first = true;
for (Object item : list) {
if (!first) json.append(",");
if (item instanceof String) {
json.append("\"").append(item).append("\"");
} else {
json.append(item);
}
first = false;
}
json.append("]");
return json.toString();
}
// MARK: - Mock Responses (Phase 1 Testing)
private OffersResponse createMockOffersResponse() {
OffersResponse response = new OffersResponse();
response.data = new ArrayList<>();
response.hitLimit = false;
// Add mock offer
OfferSummaryRecord offer = new OfferSummaryRecord();
offer.jwtId = "mock-offer-1";
offer.handleId = "offer-123";
offer.offeredByDid = "did:example:offerer";
offer.recipientDid = "did:example:recipient";
offer.amount = 1000;
offer.unit = "USD";
offer.objectDescription = "Mock offer for testing";
response.data.add(offer);
return response;
}
private OffersToPlansResponse createMockOffersToPlansResponse() {
OffersToPlansResponse response = new OffersToPlansResponse();
response.data = new ArrayList<>();
response.hitLimit = false;
return response;
}
private PlansLastUpdatedResponse createMockPlansResponse() {
PlansLastUpdatedResponse response = new PlansLastUpdatedResponse();
response.data = new ArrayList<>();
response.hitLimit = false;
return response;
}
// MARK: - Data Classes
/**
* TimeSafari user configuration for API requests
*/
public static class TimeSafariUserConfig {
public String activeDid;
public String lastKnownOfferId;
public String lastKnownPlanId;
public List<String> starredPlanIds;
public boolean fetchOffersToPerson = true;
public boolean fetchOffersToProjects = true;
public boolean fetchProjectUpdates = true;
}
/**
* Comprehensive notification data bundle
*/
public static class TimeSafariNotificationBundle {
public OffersResponse offersToPerson;
public OffersToPlansResponse offersToProjects;
public PlansLastUpdatedResponse projectUpdates;
public long fetchTimestamp;
public boolean success;
public String error;
}
/**
* Offer summary record
*/
public static class OfferSummaryRecord {
public String jwtId;
public String handleId;
public String offeredByDid;
public String recipientDid;
public int amount;
public String unit;
public String objectDescription;
// Additional fields as needed
}
/**
* Offers response
*/
public static class OffersResponse {
public List<OfferSummaryRecord> data;
public boolean hitLimit;
}
/**
* Offers to plans response
*/
public static class OffersToPlansResponse {
public List<Object> data; // Simplified for Phase 1
public boolean hitLimit;
}
/**
* Plans last updated response
*/
public static class PlansLastUpdatedResponse {
public List<Object> data; // Simplified for Phase 1
public boolean hitLimit;
}
}

View File

@@ -0,0 +1,130 @@
/**
* FetchContext.java
*
* Context information provided to content fetchers about why a fetch was triggered.
*
* This class is part of the Integration Point Refactor (PR1) SPI implementation.
* It provides fetchers with metadata about the fetch request, including trigger
* type, scheduling information, and optional metadata.
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* Context provided to content fetchers about why fetch was triggered
*
* This follows the TypeScript interface from src/types/content-fetcher.ts and
* ensures type safety between JS and native fetcher implementations.
*/
public class FetchContext {
/**
* Reason why the fetch was triggered
*
* Valid values: "background_work", "prefetch", "manual", "scheduled"
*/
@NonNull
public final String trigger;
/**
* When notification is scheduled for (optional, epoch milliseconds)
*
* Only present when trigger is "prefetch" or "scheduled"
*/
@Nullable
public final Long scheduledTime;
/**
* When the fetch was triggered (required, epoch milliseconds)
*/
public final long fetchTime;
/**
* Additional context metadata (optional)
*
* Plugin may populate with app state, network info, etc.
* Fetcher can use for logging, debugging, or conditional logic.
*/
@NonNull
public final Map<String, Object> metadata;
/**
* Constructor with all fields
*
* @param trigger Trigger type (required)
* @param scheduledTime Scheduled time (optional)
* @param fetchTime When fetch triggered (required)
* @param metadata Additional metadata (optional, can be null)
*/
public FetchContext(
@NonNull String trigger,
@Nullable Long scheduledTime,
long fetchTime,
@Nullable Map<String, Object> metadata) {
if (trigger == null || trigger.isEmpty()) {
throw new IllegalArgumentException("trigger is required");
}
this.trigger = trigger;
this.scheduledTime = scheduledTime;
this.fetchTime = fetchTime;
this.metadata = metadata != null ?
Collections.unmodifiableMap(new HashMap<>(metadata)) :
Collections.emptyMap();
}
/**
* Constructor with minimal fields (no metadata)
*
* @param trigger Trigger type
* @param scheduledTime Scheduled time (can be null)
* @param fetchTime When fetch triggered
*/
public FetchContext(
@NonNull String trigger,
@Nullable Long scheduledTime,
long fetchTime) {
this(trigger, scheduledTime, fetchTime, null);
}
/**
* Get metadata value by key
*
* @param key Metadata key
* @return Value or null if not present
*/
@Nullable
public Object getMetadata(@NonNull String key) {
return metadata.get(key);
}
/**
* Check if metadata contains key
*
* @param key Metadata key
* @return True if key exists
*/
public boolean hasMetadata(@NonNull String key) {
return metadata.containsKey(key);
}
@Override
public String toString() {
return "FetchContext{" +
"trigger='" + trigger + '\'' +
", scheduledTime=" + scheduledTime +
", fetchTime=" + fetchTime +
", metadataSize=" + metadata.size() +
'}';
}
}

View File

@@ -0,0 +1,146 @@
/**
* NativeNotificationContentFetcher.java
*
* Service Provider Interface (SPI) for native content fetchers.
*
* This interface is part of the Integration Point Refactor (PR1) that allows
* host apps to provide their own content fetching logic without hardcoding
* TimeSafari-specific code in the plugin.
*
* Host apps implement this interface in native code (Kotlin/Java) and register
* it with the plugin. The plugin calls this interface from background workers
* (WorkManager) to fetch notification content.
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import androidx.annotation.NonNull;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* Native content fetcher interface for host app implementations
*
* This interface enables the plugin to call host app's native code for
* fetching notification content. This is the ONLY path used by background
* workers, as JavaScript bridges are unreliable in background contexts.
*
* Implementation Requirements:
* - Must be thread-safe (may be called from WorkManager background threads)
* - Must complete within reasonable time (plugin enforces timeout)
* - Should return empty list on failure rather than throwing exceptions
* - Should handle errors gracefully and log for debugging
*
* Example Implementation:
* <pre>
* class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
* private final TimeSafariApi api;
* private final TokenProvider tokenProvider;
*
* @Override
* public CompletableFuture<List<NotificationContent>> fetchContent(
* FetchContext context) {
* return CompletableFuture.supplyAsync(() -> {
* try {
* String jwt = tokenProvider.freshToken();
* // Fetch from TimeSafari API
* // Convert to NotificationContent[]
* return notificationContents;
* } catch (Exception e) {
* Log.e("Fetcher", "Fetch failed", e);
* return Collections.emptyList();
* }
* });
* }
* }
* </pre>
*/
public interface NativeNotificationContentFetcher {
/**
* Fetch notification content from external source
*
* This method is called by the plugin when:
* - Background fetch work is triggered (WorkManager)
* - Prefetch is scheduled before notification time
* - Manual refresh is requested (if native fetcher enabled)
*
* The plugin will:
* - Enforce a timeout (default 30 seconds, configurable via SchedulingPolicy)
* - Handle empty lists gracefully (no notifications scheduled)
* - Log errors for debugging
* - Retry on failure based on SchedulingPolicy
*
* @param context Context about why fetch was triggered, including
* trigger type, scheduled time, and optional metadata
* @return CompletableFuture that resolves to list of NotificationContent.
* Empty list indicates no content available (not an error).
* The future should complete exceptionally only on unrecoverable errors.
*/
@NonNull
CompletableFuture<List<NotificationContent>> fetchContent(@NonNull FetchContext context);
/**
* Optional: Configure the native fetcher with API credentials and settings
*
* <p>This method is called by the plugin when {@code configureNativeFetcher} is invoked
* from TypeScript. It provides a cross-platform mechanism for passing configuration
* from the JavaScript layer to native code without using platform-specific storage
* mechanisms.</p>
*
* <p><b>When to implement:</b></p>
* <ul>
* <li>Your fetcher needs API credentials (URL, authentication tokens, etc.)</li>
* <li>Configuration should come from TypeScript/JavaScript code (e.g., from app config)</li>
* <li>You want to avoid hardcoding credentials in native code</li>
* </ul>
*
* <p><b>When to skip (use default no-op):</b></p>
* <ul>
* <li>Your fetcher gets credentials from platform-specific storage (SharedPreferences, Keychain, etc.)</li>
* <li>Your fetcher has hardcoded test credentials</li>
* <li>Configuration is handled internally and doesn't need external input</li>
* </ul>
*
* <p><b>Thread Safety:</b> This method may be called from any thread. Implementations
* must be thread-safe if storing configuration in instance variables.</p>
*
* <p><b>Implementation Pattern:</b></p>
* <pre>{@code
* private volatile String apiBaseUrl;
* private volatile String activeDid;
* private volatile String jwtSecret;
*
* @Override
* public void configure(String apiBaseUrl, String activeDid, String jwtSecret) {
* this.apiBaseUrl = apiBaseUrl;
* this.activeDid = activeDid;
* this.jwtSecret = jwtSecret;
* Log.i(TAG, "Fetcher configured with API: " + apiBaseUrl);
* }
* }</pre>
*
* @param apiBaseUrl Base URL for API server. Examples:
* - Android emulator: "http://10.0.2.2:3000" (maps to host localhost:3000)
* - iOS simulator: "http://localhost:3000"
* - Production: "https://api.timesafari.com"
* @param activeDid Active DID (Decentralized Identifier) for authentication.
* Used as the JWT issuer/subject. Format: "did:ethr:0x..."
* @param jwtToken Pre-generated JWT token (ES256K signed) from TypeScript.
* This token is generated in the host app using TimeSafari's
* {@code createEndorserJwtForKey()} function. The native fetcher
* should use this token directly in the Authorization header as
* "Bearer {jwtToken}". No JWT generation or signing is needed in Java.
*
* @see DailyNotificationPlugin#configureNativeFetcher(PluginCall)
*/
default void configure(String apiBaseUrl, String activeDid, String jwtToken) {
// Default no-op implementation - fetchers that need config can override
// This allows fetchers that don't need TypeScript-provided configuration
// to ignore this method without implementing an empty body.
}
}

View File

@@ -0,0 +1,388 @@
/**
* NotificationContent.java
*
* Data model for notification content following the project directive schema
* Implements the canonical NotificationContent v1 structure
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.util.Log;
import java.util.UUID;
/**
* Represents notification content with all required fields
*
* This class follows the canonical schema defined in the project directive:
* - id: string (uuid)
* - title: string
* - body: string (plain text; may include simple emoji)
* - scheduledTime: epoch millis (client-local target)
* - mediaUrl: string? (for future; must be mirrored to local path before use)
* - fetchTime: epoch millis
*/
public class NotificationContent {
private String id;
private String title;
private String body;
private long scheduledTime;
private String mediaUrl;
private final long fetchedAt; // When content was fetched (immutable)
private long scheduledAt; // When this instance was scheduled
// Gson will try to deserialize this field, but we ignore it to keep fetchedAt immutable
@SuppressWarnings("unused")
private transient long fetchTime; // Legacy field for Gson compatibility (ignored)
// Custom deserializer to handle fetchedAt field
public static class NotificationContentDeserializer implements com.google.gson.JsonDeserializer<NotificationContent> {
@Override
public NotificationContent deserialize(com.google.gson.JsonElement json, java.lang.reflect.Type typeOfT, com.google.gson.JsonDeserializationContext context) throws com.google.gson.JsonParseException {
com.google.gson.JsonObject jsonObject = json.getAsJsonObject();
// Preserve original ID and fetchedAt from JSON
String id = jsonObject.has("id") ? jsonObject.get("id").getAsString() : null;
long fetchedAt = jsonObject.has("fetchedAt") ? jsonObject.get("fetchedAt").getAsLong() : System.currentTimeMillis();
// Create instance with preserved fetchedAt
NotificationContent content = new NotificationContent(id, fetchedAt);
// Deserialize other fields
if (jsonObject.has("title")) content.title = jsonObject.get("title").getAsString();
if (jsonObject.has("body")) content.body = jsonObject.get("body").getAsString();
if (jsonObject.has("scheduledTime")) content.scheduledTime = jsonObject.get("scheduledTime").getAsLong();
if (jsonObject.has("mediaUrl")) content.mediaUrl = jsonObject.get("mediaUrl").getAsString();
if (jsonObject.has("scheduledAt")) content.scheduledAt = jsonObject.get("scheduledAt").getAsLong();
if (jsonObject.has("sound")) content.sound = jsonObject.get("sound").getAsBoolean();
if (jsonObject.has("priority")) content.priority = jsonObject.get("priority").getAsString();
if (jsonObject.has("url")) content.url = jsonObject.get("url").getAsString();
// Reduced logging - only in debug builds
// Log.d("NotificationContent", "Deserialized content with fetchedAt=" + content.fetchedAt + " (from constructor)");
return content;
}
}
private boolean sound;
private String priority;
private String url;
/**
* Default constructor with auto-generated UUID
*/
public NotificationContent() {
this.id = UUID.randomUUID().toString();
this.fetchedAt = System.currentTimeMillis();
this.scheduledAt = System.currentTimeMillis();
this.sound = true;
this.priority = "default";
// Reduced logging to prevent log spam - only log first few instances
// (Logging removed - too verbose when loading many notifications from storage)
}
/**
* Package-private constructor for deserialization
* Preserves original fetchedAt from storage
*
* @param id Original notification ID
* @param fetchedAt Original fetch timestamp
*/
NotificationContent(String id, long fetchedAt) {
this.id = id != null ? id : UUID.randomUUID().toString();
this.fetchedAt = fetchedAt;
this.scheduledAt = System.currentTimeMillis(); // Reset scheduledAt on load
this.sound = true;
this.priority = "default";
}
/**
* Constructor with all required fields
*
* @param title Notification title
* @param body Notification body text
* @param scheduledTime When to display the notification
*/
public NotificationContent(String title, String body, long scheduledTime) {
this();
this.title = title;
this.body = body;
this.scheduledTime = scheduledTime;
}
// Getters and Setters
/**
* Get the unique identifier for this notification
*
* @return UUID string
*/
public String getId() {
return id;
}
/**
* Set the unique identifier for this notification
*
* @param id UUID string
*/
public void setId(String id) {
this.id = id;
}
/**
* Get the notification title
*
* @return Title string
*/
public String getTitle() {
return title;
}
/**
* Set the notification title
*
* @param title Title string
*/
public void setTitle(String title) {
this.title = title;
}
/**
* Get the notification body text
*
* @return Body text string
*/
public String getBody() {
return body;
}
/**
* Set the notification body text
*
* @param body Body text string
*/
public void setBody(String body) {
this.body = body;
}
/**
* Get the scheduled time for this notification
*
* @return Timestamp in milliseconds
*/
public long getScheduledTime() {
return scheduledTime;
}
/**
* Set the scheduled time for this notification
*
* @param scheduledTime Timestamp in milliseconds
*/
public void setScheduledTime(long scheduledTime) {
this.scheduledTime = scheduledTime;
}
/**
* Get the media URL (optional, for future use)
*
* @return Media URL string or null
*/
public String getMediaUrl() {
return mediaUrl;
}
/**
* Set the media URL (optional, for future use)
*
* @param mediaUrl Media URL string or null
*/
public void setMediaUrl(String mediaUrl) {
this.mediaUrl = mediaUrl;
}
/**
* Get the fetch time when content was retrieved (immutable)
*
* @return Timestamp in milliseconds
*/
public long getFetchedAt() {
return fetchedAt;
}
/**
* Get when this notification instance was scheduled
*
* @return Timestamp in milliseconds
*/
public long getScheduledAt() {
return scheduledAt;
}
/**
* Set when this notification instance was scheduled
*
* @param scheduledAt Timestamp in milliseconds
*/
public void setScheduledAt(long scheduledAt) {
this.scheduledAt = scheduledAt;
}
/**
* Check if sound should be played
*
* @return true if sound is enabled
*/
public boolean isSound() {
return sound;
}
/**
* Set whether sound should be played
*
* @param sound true to enable sound
*/
public void setSound(boolean sound) {
this.sound = sound;
}
/**
* Get the notification priority
*
* @return Priority string (high, default, low)
*/
public String getPriority() {
return priority;
}
/**
* Set the notification priority
*
* @param priority Priority string (high, default, low)
*/
public void setPriority(String priority) {
this.priority = priority;
}
/**
* Get the associated URL
*
* @return URL string or null
*/
public String getUrl() {
return url;
}
/**
* Set the associated URL
*
* @param url URL string or null
*/
public void setUrl(String url) {
this.url = url;
}
/**
* Check if this notification content is stale (older than 24 hours)
*
* @return true if notification content is stale
*/
public boolean isStale() {
long currentTime = System.currentTimeMillis();
long age = currentTime - fetchedAt;
return age > 24 * 60 * 60 * 1000; // 24 hours in milliseconds
}
/**
* Get the age of this notification content in milliseconds
*
* @return Age in milliseconds
*/
public long getAge() {
return System.currentTimeMillis() - fetchedAt;
}
/**
* Get the age since this notification was scheduled
*
* @return Age in milliseconds
*/
public long getScheduledAge() {
return System.currentTimeMillis() - scheduledAt;
}
/**
* Get the age of this notification in a human-readable format
*
* @return Human-readable age string
*/
public String getAgeString() {
long age = getAge();
long seconds = age / 1000;
long minutes = seconds / 60;
long hours = minutes / 60;
long days = hours / 24;
if (days > 0) {
return days + " day" + (days == 1 ? "" : "s") + " ago";
} else if (hours > 0) {
return hours + " hour" + (hours == 1 ? "" : "s") + " ago";
} else if (minutes > 0) {
return minutes + " minute" + (minutes == 1 ? "" : "s") + " ago";
} else {
return "just now";
}
}
/**
* Check if this notification is ready to be displayed
*
* @return true if notification should be displayed now
*/
public boolean isReadyToDisplay() {
return System.currentTimeMillis() >= scheduledTime;
}
/**
* Get time until this notification should be displayed
*
* @return Time in milliseconds until display
*/
public long getTimeUntilDisplay() {
return Math.max(0, scheduledTime - System.currentTimeMillis());
}
@Override
public String toString() {
return "NotificationContent{" +
"id='" + id + '\'' +
", title='" + title + '\'' +
", body='" + body + '\'' +
", scheduledTime=" + scheduledTime +
", mediaUrl='" + mediaUrl + '\'' +
", fetchedAt=" + fetchedAt +
", scheduledAt=" + scheduledAt +
", sound=" + sound +
", priority='" + priority + '\'' +
", url='" + url + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
NotificationContent that = (NotificationContent) o;
return id.equals(that.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}

View File

@@ -0,0 +1,349 @@
/**
* NotificationStatusChecker.java
*
* Comprehensive status checking for notification system
* Provides unified API for UI guidance and troubleshooting
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.app.NotificationManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Log;
import com.getcapacitor.JSObject;
/**
* Comprehensive status checker for notification system
*
* This class provides a unified API to check all aspects of the notification
* system status, enabling the UI to guide users when notifications don't appear.
*/
public class NotificationStatusChecker {
private static final String TAG = "NotificationStatusChecker";
private final Context context;
private final NotificationManager notificationManager;
private final ChannelManager channelManager;
private final PendingIntentManager pendingIntentManager;
public NotificationStatusChecker(Context context) {
this.context = context.getApplicationContext();
this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
this.channelManager = new ChannelManager(context);
this.pendingIntentManager = new PendingIntentManager(context);
}
/**
* Get comprehensive notification system status
*
* @return JSObject containing all status information
*/
public JSObject getComprehensiveStatus() {
try {
Log.d(TAG, "DN|STATUS_CHECK_START");
JSObject status = new JSObject();
// Core permissions
boolean postNotificationsGranted = checkPostNotificationsPermission();
boolean exactAlarmsGranted = checkExactAlarmsPermission();
// Channel status
boolean channelEnabled = channelManager.isChannelEnabled();
int channelImportance = channelManager.getChannelImportance();
String channelId = channelManager.getDefaultChannelId();
// Alarm manager status
PendingIntentManager.AlarmStatus alarmStatus = pendingIntentManager.getAlarmStatus();
// Overall readiness
boolean canScheduleNow = postNotificationsGranted &&
channelEnabled &&
exactAlarmsGranted;
// Build status object
status.put("postNotificationsGranted", postNotificationsGranted);
status.put("exactAlarmsGranted", exactAlarmsGranted);
status.put("channelEnabled", channelEnabled);
status.put("channelImportance", channelImportance);
status.put("channelId", channelId);
status.put("canScheduleNow", canScheduleNow);
status.put("exactAlarmsSupported", alarmStatus.exactAlarmsSupported);
status.put("androidVersion", alarmStatus.androidVersion);
// Add issue descriptions for UI guidance
JSObject issues = new JSObject();
if (!postNotificationsGranted) {
issues.put("postNotifications", "POST_NOTIFICATIONS permission not granted");
}
if (!channelEnabled) {
issues.put("channelDisabled", "Notification channel is disabled or blocked");
}
if (!exactAlarmsGranted) {
issues.put("exactAlarms", "Exact alarm permission not granted");
}
status.put("issues", issues);
// Add actionable guidance
JSObject guidance = new JSObject();
if (!postNotificationsGranted) {
guidance.put("postNotifications", "Request notification permission in app settings");
}
if (!channelEnabled) {
guidance.put("channelDisabled", "Enable notifications in system settings");
}
if (!exactAlarmsGranted) {
guidance.put("exactAlarms", "Grant exact alarm permission in system settings");
}
status.put("guidance", guidance);
Log.d(TAG, "DN|STATUS_CHECK_OK canSchedule=" + canScheduleNow +
" postGranted=" + postNotificationsGranted +
" channelEnabled=" + channelEnabled +
" exactGranted=" + exactAlarmsGranted);
return status;
} catch (Exception e) {
Log.e(TAG, "DN|STATUS_CHECK_ERR err=" + e.getMessage(), e);
// Return minimal status on error
JSObject errorStatus = new JSObject();
errorStatus.put("canScheduleNow", false);
errorStatus.put("error", e.getMessage());
return errorStatus;
}
}
/**
* Check POST_NOTIFICATIONS permission status
*
* @return true if permission is granted, false otherwise
*/
private boolean checkPostNotificationsPermission() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
} else {
// Pre-Android 13, notifications are allowed by default
return true;
}
} catch (Exception e) {
Log.e(TAG, "DN|PERM_CHECK_ERR postNotifications err=" + e.getMessage(), e);
return false;
}
}
/**
* Check SCHEDULE_EXACT_ALARM permission status
*
* @return true if permission is granted, false otherwise
*/
private boolean checkExactAlarmsPermission() {
try {
return pendingIntentManager.canScheduleExactAlarms();
} catch (Exception e) {
Log.e(TAG, "DN|PERM_CHECK_ERR exactAlarms err=" + e.getMessage(), e);
return false;
}
}
/**
* Get detailed channel status information
*
* @return JSObject containing channel details
*/
public JSObject getChannelStatus() {
try {
Log.d(TAG, "DN|CHANNEL_STATUS_START");
JSObject channelStatus = new JSObject();
boolean channelExists = channelManager.ensureChannelExists();
boolean channelEnabled = channelManager.isChannelEnabled();
int channelImportance = channelManager.getChannelImportance();
String channelId = channelManager.getDefaultChannelId();
channelStatus.put("channelExists", channelExists);
channelStatus.put("channelEnabled", channelEnabled);
channelStatus.put("channelImportance", channelImportance);
channelStatus.put("channelId", channelId);
channelStatus.put("channelBlocked", channelImportance == NotificationManager.IMPORTANCE_NONE);
// Add importance description
String importanceDescription = getImportanceDescription(channelImportance);
channelStatus.put("importanceDescription", importanceDescription);
Log.d(TAG, "DN|CHANNEL_STATUS_OK enabled=" + channelEnabled +
" importance=" + channelImportance +
" blocked=" + (channelImportance == NotificationManager.IMPORTANCE_NONE));
return channelStatus;
} catch (Exception e) {
Log.e(TAG, "DN|CHANNEL_STATUS_ERR err=" + e.getMessage(), e);
JSObject errorStatus = new JSObject();
errorStatus.put("error", e.getMessage());
return errorStatus;
}
}
/**
* Get alarm manager status information
*
* @return JSObject containing alarm manager details
*/
public JSObject getAlarmStatus() {
try {
Log.d(TAG, "DN|ALARM_STATUS_START");
PendingIntentManager.AlarmStatus alarmStatus = pendingIntentManager.getAlarmStatus();
JSObject status = new JSObject();
status.put("exactAlarmsSupported", alarmStatus.exactAlarmsSupported);
status.put("exactAlarmsGranted", alarmStatus.exactAlarmsGranted);
status.put("androidVersion", alarmStatus.androidVersion);
status.put("canScheduleExactAlarms", alarmStatus.exactAlarmsGranted);
Log.d(TAG, "DN|ALARM_STATUS_OK supported=" + alarmStatus.exactAlarmsSupported +
" granted=" + alarmStatus.exactAlarmsGranted +
" android=" + alarmStatus.androidVersion);
return status;
} catch (Exception e) {
Log.e(TAG, "DN|ALARM_STATUS_ERR err=" + e.getMessage(), e);
JSObject errorStatus = new JSObject();
errorStatus.put("error", e.getMessage());
return errorStatus;
}
}
/**
* Get permission status information
*
* @return JSObject containing permission details
*/
public JSObject getPermissionStatus() {
try {
Log.d(TAG, "DN|PERMISSION_STATUS_START");
JSObject permissionStatus = new JSObject();
boolean postNotificationsGranted = checkPostNotificationsPermission();
boolean exactAlarmsGranted = checkExactAlarmsPermission();
permissionStatus.put("postNotificationsGranted", postNotificationsGranted);
permissionStatus.put("exactAlarmsGranted", exactAlarmsGranted);
permissionStatus.put("allPermissionsGranted", postNotificationsGranted && exactAlarmsGranted);
// Add permission descriptions
JSObject descriptions = new JSObject();
descriptions.put("postNotifications", "Allows app to display notifications");
descriptions.put("exactAlarms", "Allows app to schedule precise alarm times");
permissionStatus.put("descriptions", descriptions);
Log.d(TAG, "DN|PERMISSION_STATUS_OK postGranted=" + postNotificationsGranted +
" exactGranted=" + exactAlarmsGranted);
return permissionStatus;
} catch (Exception e) {
Log.e(TAG, "DN|PERMISSION_STATUS_ERR err=" + e.getMessage(), e);
JSObject errorStatus = new JSObject();
errorStatus.put("error", e.getMessage());
return errorStatus;
}
}
/**
* Get human-readable importance description
*
* @param importance Notification importance level
* @return Human-readable description
*/
private String getImportanceDescription(int importance) {
switch (importance) {
case NotificationManager.IMPORTANCE_NONE:
return "Blocked - No notifications will be shown";
case NotificationManager.IMPORTANCE_MIN:
return "Minimal - Only shown in notification shade";
case NotificationManager.IMPORTANCE_LOW:
return "Low - Shown in notification shade, no sound";
case NotificationManager.IMPORTANCE_DEFAULT:
return "Default - Shown with sound and on lock screen";
case NotificationManager.IMPORTANCE_HIGH:
return "High - Shown with sound, on lock screen, and heads-up";
case NotificationManager.IMPORTANCE_MAX:
return "Maximum - Shown with sound, on lock screen, heads-up, and can bypass Do Not Disturb";
default:
return "Unknown importance level: " + importance;
}
}
/**
* Check if the notification system is ready to schedule notifications
*
* @return true if ready, false otherwise
*/
public boolean isReadyToSchedule() {
try {
boolean postNotificationsGranted = checkPostNotificationsPermission();
boolean channelEnabled = channelManager.isChannelEnabled();
boolean exactAlarmsGranted = checkExactAlarmsPermission();
boolean ready = postNotificationsGranted && channelEnabled && exactAlarmsGranted;
Log.d(TAG, "DN|READY_CHECK ready=" + ready +
" postGranted=" + postNotificationsGranted +
" channelEnabled=" + channelEnabled +
" exactGranted=" + exactAlarmsGranted);
return ready;
} catch (Exception e) {
Log.e(TAG, "DN|READY_CHECK_ERR err=" + e.getMessage(), e);
return false;
}
}
/**
* Get a summary of issues preventing notification scheduling
*
* @return Array of issue descriptions
*/
public String[] getIssues() {
try {
java.util.List<String> issues = new java.util.ArrayList<>();
if (!checkPostNotificationsPermission()) {
issues.add("POST_NOTIFICATIONS permission not granted");
}
if (!channelManager.isChannelEnabled()) {
issues.add("Notification channel is disabled or blocked");
}
if (!checkExactAlarmsPermission()) {
issues.add("Exact alarm permission not granted");
}
return issues.toArray(new String[0]);
} catch (Exception e) {
Log.e(TAG, "DN|ISSUES_ERR err=" + e.getMessage(), e);
return new String[]{"Error checking status: " + e.getMessage()};
}
}
}

View File

@@ -0,0 +1,255 @@
package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
/**
* Manages PendingIntent creation with proper flags and exact alarm handling
*
* Ensures all PendingIntents use correct flags for modern Android versions
* and provides comprehensive exact alarm permission handling.
*
* @author Matthew Raymer
* @version 1.0
*/
public class PendingIntentManager {
private static final String TAG = "PendingIntentManager";
// Modern PendingIntent flags for Android 12+
private static final int MODERN_PENDING_INTENT_FLAGS =
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE;
// Legacy flags for older Android versions (if needed)
private static final int LEGACY_PENDING_INTENT_FLAGS =
PendingIntent.FLAG_UPDATE_CURRENT;
private final Context context;
private final AlarmManager alarmManager;
public PendingIntentManager(Context context) {
this.context = context;
this.alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
}
/**
* Create a PendingIntent for broadcast with proper flags
*
* @param intent The intent to wrap
* @param requestCode Unique request code
* @return PendingIntent with correct flags
*/
public PendingIntent createBroadcastPendingIntent(Intent intent, int requestCode) {
try {
int flags = getPendingIntentFlags();
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
} catch (Exception e) {
Log.e(TAG, "Error creating broadcast PendingIntent", e);
throw e;
}
}
/**
* Create a PendingIntent for activity with proper flags
*
* @param intent The intent to wrap
* @param requestCode Unique request code
* @return PendingIntent with correct flags
*/
public PendingIntent createActivityPendingIntent(Intent intent, int requestCode) {
try {
int flags = getPendingIntentFlags();
return PendingIntent.getActivity(context, requestCode, intent, flags);
} catch (Exception e) {
Log.e(TAG, "Error creating activity PendingIntent", e);
throw e;
}
}
/**
* Create a PendingIntent for service with proper flags
*
* @param intent The intent to wrap
* @param requestCode Unique request code
* @return PendingIntent with correct flags
*/
public PendingIntent createServicePendingIntent(Intent intent, int requestCode) {
try {
int flags = getPendingIntentFlags();
return PendingIntent.getService(context, requestCode, intent, flags);
} catch (Exception e) {
Log.e(TAG, "Error creating service PendingIntent", e);
throw e;
}
}
/**
* Get the appropriate PendingIntent flags for the current Android version
*
* @return Flags to use for PendingIntent creation
*/
private int getPendingIntentFlags() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return MODERN_PENDING_INTENT_FLAGS;
} else {
return LEGACY_PENDING_INTENT_FLAGS;
}
}
/**
* Check if exact alarms can be scheduled
*
* @return true if exact alarms can be scheduled
*/
public boolean canScheduleExactAlarms() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return alarmManager.canScheduleExactAlarms();
} else {
return true; // Pre-Android 12, always allowed
}
} catch (Exception e) {
Log.e(TAG, "Error checking exact alarm permission", e);
return false;
}
}
/**
* Schedule an exact alarm with proper error handling
*
* @param pendingIntent PendingIntent to trigger
* @param triggerTime When to trigger the alarm
* @return true if scheduling was successful
*/
public boolean scheduleExactAlarm(PendingIntent pendingIntent, long triggerTime) {
try {
if (!canScheduleExactAlarms()) {
Log.w(TAG, "Cannot schedule exact alarm - permission not granted");
return false;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
);
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
);
}
Log.d(TAG, "Exact alarm scheduled successfully for " + triggerTime);
return true;
} catch (SecurityException e) {
Log.e(TAG, "SecurityException scheduling exact alarm - permission denied", e);
return false;
} catch (Exception e) {
Log.e(TAG, "Error scheduling exact alarm", e);
return false;
}
}
/**
* Schedule a windowed alarm as fallback
*
* @param pendingIntent PendingIntent to trigger
* @param triggerTime Target trigger time
* @param windowLengthMs Window length in milliseconds
* @return true if scheduling was successful
*/
public boolean scheduleWindowedAlarm(PendingIntent pendingIntent, long triggerTime, long windowLengthMs) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
);
} else {
alarmManager.set(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
);
}
Log.d(TAG, "Windowed alarm scheduled successfully for " + triggerTime + " (window: " + windowLengthMs + "ms)");
return true;
} catch (Exception e) {
Log.e(TAG, "Error scheduling windowed alarm", e);
return false;
}
}
/**
* Cancel a scheduled alarm
*
* @param pendingIntent PendingIntent to cancel
* @return true if cancellation was successful
*/
public boolean cancelAlarm(PendingIntent pendingIntent) {
try {
alarmManager.cancel(pendingIntent);
Log.d(TAG, "Alarm cancelled successfully");
return true;
} catch (Exception e) {
Log.e(TAG, "Error cancelling alarm", e);
return false;
}
}
/**
* Get detailed alarm scheduling status
*
* @return AlarmStatus object with detailed information
*/
public AlarmStatus getAlarmStatus() {
boolean exactAlarmsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S;
boolean exactAlarmsGranted = canScheduleExactAlarms();
boolean canScheduleNow = exactAlarmsGranted || !exactAlarmsSupported;
return new AlarmStatus(
exactAlarmsSupported,
exactAlarmsGranted,
canScheduleNow,
Build.VERSION.SDK_INT
);
}
/**
* Data class for alarm status information
*/
public static class AlarmStatus {
public final boolean exactAlarmsSupported;
public final boolean exactAlarmsGranted;
public final boolean canScheduleNow;
public final int androidVersion;
public AlarmStatus(boolean exactAlarmsSupported, boolean exactAlarmsGranted,
boolean canScheduleNow, int androidVersion) {
this.exactAlarmsSupported = exactAlarmsSupported;
this.exactAlarmsGranted = exactAlarmsGranted;
this.canScheduleNow = canScheduleNow;
this.androidVersion = androidVersion;
}
@Override
public String toString() {
return "AlarmStatus{" +
"exactAlarmsSupported=" + exactAlarmsSupported +
", exactAlarmsGranted=" + exactAlarmsGranted +
", canScheduleNow=" + canScheduleNow +
", androidVersion=" + androidVersion +
'}';
}
}
}

View File

@@ -0,0 +1,291 @@
/**
* PermissionManager.java
*
* Specialized manager for permission handling and notification settings
* Handles notification permissions, channel management, and exact alarm settings
*
* @author Matthew Raymer
* @version 2.0.0 - Modular Architecture
*/
package com.timesafari.dailynotification;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.provider.Settings;
import android.util.Log;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.PluginCall;
/**
* Manager class for permission and settings management
*
* Responsibilities:
* - Request notification permissions
* - Check permission status
* - Manage notification channels
* - Handle exact alarm settings
* - Provide comprehensive status checking
*/
public class PermissionManager {
private static final String TAG = "PermissionManager";
private final Context context;
private final ChannelManager channelManager;
/**
* Initialize the PermissionManager
*
* @param context Android context
* @param channelManager Channel manager for notification channels
*/
public PermissionManager(Context context, ChannelManager channelManager) {
this.context = context;
this.channelManager = channelManager;
Log.d(TAG, "PermissionManager initialized");
}
/**
* Request notification permissions from the user
*
* @param call Plugin call
*/
public void requestNotificationPermissions(PluginCall call) {
try {
Log.d(TAG, "Requesting notification permissions");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// For Android 13+, request POST_NOTIFICATIONS permission
requestPermission(Manifest.permission.POST_NOTIFICATIONS, call);
} else {
// For older versions, permissions are granted at install time
JSObject result = new JSObject();
result.put("success", true);
result.put("granted", true);
result.put("message", "Notifications enabled (pre-Android 13)");
call.resolve(result);
}
} catch (Exception e) {
Log.e(TAG, "Error requesting notification permissions", e);
call.reject("Failed to request permissions: " + e.getMessage());
}
}
/**
* Check the current status of notification permissions
*
* @param call Plugin call
*/
public void checkPermissionStatus(PluginCall call) {
try {
Log.d(TAG, "Checking permission status");
boolean postNotificationsGranted = false;
boolean exactAlarmsGranted = false;
// Check POST_NOTIFICATIONS permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
} else {
postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled();
}
// Check exact alarm permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(Context.ALARM_SERVICE);
exactAlarmsGranted = alarmManager.canScheduleExactAlarms();
} else {
exactAlarmsGranted = true; // Pre-Android 12, exact alarms are always allowed
}
JSObject result = new JSObject();
result.put("success", true);
result.put("postNotificationsGranted", postNotificationsGranted);
result.put("exactAlarmsGranted", exactAlarmsGranted);
result.put("channelEnabled", channelManager.isChannelEnabled());
result.put("channelImportance", channelManager.getChannelImportance());
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Error checking permission status", e);
call.reject("Failed to check permissions: " + e.getMessage());
}
}
/**
* Open exact alarm settings for the user
*
* @param call Plugin call
*/
public void openExactAlarmSettings(PluginCall call) {
try {
Log.d(TAG, "Opening exact alarm settings");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Intent intent = new Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM);
intent.setData(android.net.Uri.parse("package:" + context.getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
context.startActivity(intent);
JSObject result = new JSObject();
result.put("success", true);
result.put("message", "Exact alarm settings opened");
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Failed to open exact alarm settings", e);
call.reject("Failed to open exact alarm settings: " + e.getMessage());
}
} else {
JSObject result = new JSObject();
result.put("success", true);
result.put("message", "Exact alarms not supported on this Android version");
call.resolve(result);
}
} catch (Exception e) {
Log.e(TAG, "Error opening exact alarm settings", e);
call.reject("Failed to open exact alarm settings: " + e.getMessage());
}
}
/**
* Check if the notification channel is enabled
*
* @param call Plugin call
*/
public void isChannelEnabled(PluginCall call) {
try {
Log.d(TAG, "Checking channel status");
boolean enabled = channelManager.isChannelEnabled();
int importance = channelManager.getChannelImportance();
JSObject result = new JSObject();
result.put("success", true);
result.put("enabled", enabled);
result.put("importance", importance);
result.put("channelId", channelManager.getDefaultChannelId());
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Error checking channel status", e);
call.reject("Failed to check channel status: " + e.getMessage());
}
}
/**
* Open notification channel settings for the user
*
* @param call Plugin call
*/
public void openChannelSettings(PluginCall call) {
try {
Log.d(TAG, "Opening channel settings");
boolean opened = channelManager.openChannelSettings();
JSObject result = new JSObject();
result.put("success", true);
result.put("opened", opened);
result.put("message", opened ? "Channel settings opened" : "Failed to open channel settings");
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Error opening channel settings", e);
call.reject("Failed to open channel settings: " + e.getMessage());
}
}
/**
* Get comprehensive status of the notification system
*
* @param call Plugin call
*/
public void checkStatus(PluginCall call) {
try {
Log.d(TAG, "Checking comprehensive status");
// Check permissions
boolean postNotificationsGranted = false;
boolean exactAlarmsGranted = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED;
} else {
postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(Context.ALARM_SERVICE);
exactAlarmsGranted = alarmManager.canScheduleExactAlarms();
} else {
exactAlarmsGranted = true;
}
// Check channel status
boolean channelEnabled = channelManager.isChannelEnabled();
int channelImportance = channelManager.getChannelImportance();
// Determine overall status
boolean canScheduleNow = postNotificationsGranted && channelEnabled && exactAlarmsGranted;
JSObject result = new JSObject();
result.put("success", true);
result.put("canScheduleNow", canScheduleNow);
result.put("postNotificationsGranted", postNotificationsGranted);
result.put("exactAlarmsGranted", exactAlarmsGranted);
result.put("channelEnabled", channelEnabled);
result.put("channelImportance", channelImportance);
result.put("channelId", channelManager.getDefaultChannelId());
result.put("androidVersion", Build.VERSION.SDK_INT);
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Error checking comprehensive status", e);
call.reject("Failed to check status: " + e.getMessage());
}
}
/**
* Request a specific permission
*
* @param permission Permission to request
* @param call Plugin call
*/
private void requestPermission(String permission, PluginCall call) {
try {
// This would typically be handled by the Capacitor framework
// For now, we'll check if the permission is already granted
boolean granted = context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
JSObject result = new JSObject();
result.put("success", true);
result.put("granted", granted);
result.put("permission", permission);
result.put("message", granted ? "Permission already granted" : "Permission not granted");
call.resolve(result);
} catch (Exception e) {
Log.e(TAG, "Error requesting permission: " + permission, e);
call.reject("Failed to request permission: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,198 @@
/**
* SchedulingPolicy.java
*
* Policy configuration for notification scheduling, fetching, and retry behavior.
*
* This class is part of the Integration Point Refactor (PR1) SPI implementation.
* It allows host apps to configure scheduling behavior including retry backoff,
* prefetch timing, deduplication windows, and cache TTL.
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Scheduling policy configuration
*
* Controls how the plugin schedules fetches, handles retries, manages
* deduplication, and enforces TTL policies.
*
* This follows the TypeScript interface from src/types/content-fetcher.ts
* and ensures consistency between JS and native configuration.
*/
public class SchedulingPolicy {
/**
* How early to prefetch before scheduled notification time (milliseconds)
*
* Example: If set to 300000 (5 minutes), and notification is scheduled for
* 8:00 AM, the fetch will be triggered at 7:55 AM.
*
* Default: 5 minutes (300000ms)
*/
@Nullable
public Long prefetchWindowMs;
/**
* Retry backoff configuration (required)
*
* Controls exponential backoff behavior for failed fetches
*/
@NonNull
public RetryBackoff retryBackoff;
/**
* Maximum items to fetch per batch
*
* Limits the number of NotificationContent items that can be fetched
* in a single operation. Helps prevent oversized responses.
*
* Default: 50
*/
@Nullable
public Integer maxBatchSize;
/**
* Deduplication window (milliseconds)
*
* Prevents duplicate notifications within this time window. Plugin
* uses dedupeKey (or id) to detect duplicates.
*
* Default: 24 hours (86400000ms)
*/
@Nullable
public Long dedupeHorizonMs;
/**
* Default cache TTL if item doesn't specify ttlSeconds (seconds)
*
* Used when NotificationContent doesn't have ttlSeconds set.
* Determines how long cached content remains valid.
*
* Default: 6 hours (21600 seconds)
*/
@Nullable
public Integer cacheTtlSeconds;
/**
* Whether exact alarms are allowed (Android 12+)
*
* Controls whether plugin should attempt to use exact alarms.
* Requires SCHEDULE_EXACT_ALARM permission on Android 12+.
*
* Default: false (use inexact alarms)
*/
@Nullable
public Boolean exactAlarmsAllowed;
/**
* Fetch timeout in milliseconds
*
* Maximum time to wait for native fetcher to complete.
* Plugin enforces this timeout when calling fetchContent().
*
* Default: 30 seconds (30000ms)
*/
@Nullable
public Long fetchTimeoutMs;
/**
* Default constructor with required field
*
* @param retryBackoff Retry backoff configuration (required)
*/
public SchedulingPolicy(@NonNull RetryBackoff retryBackoff) {
this.retryBackoff = retryBackoff;
}
/**
* Retry backoff configuration
*
* Controls exponential backoff behavior for retryable failures.
* Delay = min(max(minMs, lastDelay * factor), maxMs) * (1 + jitterPct/100 * random)
*/
public static class RetryBackoff {
/**
* Minimum delay between retries (milliseconds)
*
* First retry will wait at least this long.
* Default: 2000ms (2 seconds)
*/
public long minMs;
/**
* Maximum delay between retries (milliseconds)
*
* Retry delay will never exceed this value.
* Default: 600000ms (10 minutes)
*/
public long maxMs;
/**
* Exponential backoff multiplier
*
* Each retry multiplies previous delay by this factor.
* Example: factor=2 means delays: 2s, 4s, 8s, 16s, ...
* Default: 2.0
*/
public double factor;
/**
* Jitter percentage (0-100)
*
* Adds randomness to prevent thundering herd.
* Final delay = calculatedDelay * (1 + jitterPct/100 * random(0-1))
* Default: 20 (20% jitter)
*/
public int jitterPct;
/**
* Default constructor with sensible defaults
*/
public RetryBackoff() {
this.minMs = 2000;
this.maxMs = 600000;
this.factor = 2.0;
this.jitterPct = 20;
}
/**
* Constructor with all parameters
*
* @param minMs Minimum delay (ms)
* @param maxMs Maximum delay (ms)
* @param factor Exponential multiplier
* @param jitterPct Jitter percentage (0-100)
*/
public RetryBackoff(long minMs, long maxMs, double factor, int jitterPct) {
this.minMs = minMs;
this.maxMs = maxMs;
this.factor = factor;
this.jitterPct = jitterPct;
}
}
/**
* Create default policy with sensible defaults
*
* @return Default SchedulingPolicy instance
*/
@NonNull
public static SchedulingPolicy createDefault() {
SchedulingPolicy policy = new SchedulingPolicy(new RetryBackoff());
policy.prefetchWindowMs = 300000L; // 5 minutes
policy.maxBatchSize = 50;
policy.dedupeHorizonMs = 86400000L; // 24 hours
policy.cacheTtlSeconds = 21600; // 6 hours
policy.exactAlarmsAllowed = false;
policy.fetchTimeoutMs = 30000L; // 30 seconds
return policy;
}
}

View File

@@ -0,0 +1,153 @@
/**
* SoftRefetchWorker.java
*
* WorkManager worker for soft re-fetching notification content
* Prefetches fresh content for tomorrow's notifications asynchronously
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.os.Trace;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* WorkManager worker for soft re-fetching notification content
*
* This worker runs 2 hours before tomorrow's notifications to prefetch
* fresh content, ensuring tomorrow's notifications are always fresh.
*/
public class SoftRefetchWorker extends Worker {
private static final String TAG = "SoftRefetchWorker";
public SoftRefetchWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
Trace.beginSection("DN:SoftRefetch");
try {
long tomorrowScheduledTime = getInputData().getLong("tomorrow_scheduled_time", -1);
String action = getInputData().getString("action");
String originalId = getInputData().getString("original_id");
if (tomorrowScheduledTime == -1 || !"soft_refetch".equals(action)) {
Log.e(TAG, "DN|SOFT_REFETCH_ERR invalid_input_data");
return Result.failure();
}
Log.d(TAG, "DN|SOFT_REFETCH_START original_id=" + originalId +
" tomorrow_time=" + tomorrowScheduledTime);
// Check if we're within 2 hours of tomorrow's notification
long currentTime = System.currentTimeMillis();
long timeUntilNotification = tomorrowScheduledTime - currentTime;
if (timeUntilNotification < 0) {
Log.w(TAG, "DN|SOFT_REFETCH_SKIP notification_already_past");
return Result.success();
}
if (timeUntilNotification > TimeUnit.HOURS.toMillis(2)) {
Log.w(TAG, "DN|SOFT_REFETCH_SKIP too_early time_until=" + (timeUntilNotification / 1000 / 60) + "min");
return Result.success();
}
// Fetch fresh content for tomorrow
boolean refetchSuccess = performSoftRefetch(tomorrowScheduledTime, originalId);
if (refetchSuccess) {
Log.i(TAG, "DN|SOFT_REFETCH_OK original_id=" + originalId);
return Result.success();
} else {
Log.e(TAG, "DN|SOFT_REFETCH_ERR original_id=" + originalId);
return Result.retry();
}
} catch (Exception e) {
Log.e(TAG, "DN|SOFT_REFETCH_ERR exception=" + e.getMessage(), e);
return Result.retry();
} finally {
Trace.endSection();
}
}
/**
* Perform soft re-fetch for tomorrow's notification content
*
* @param tomorrowScheduledTime The scheduled time for tomorrow's notification
* @param originalId The original notification ID
* @return true if refetch succeeded, false otherwise
*/
private boolean performSoftRefetch(long tomorrowScheduledTime, String originalId) {
try {
// Get all notifications from storage
DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
List<NotificationContent> notifications = storage.getAllNotifications();
// Find tomorrow's notification (within 1 minute tolerance)
long toleranceMs = 60 * 1000; // 1 minute tolerance
NotificationContent tomorrowNotification = null;
for (NotificationContent notification : notifications) {
if (Math.abs(notification.getScheduledTime() - tomorrowScheduledTime) <= toleranceMs) {
tomorrowNotification = notification;
Log.d(TAG, "DN|SOFT_REFETCH_FOUND tomorrow_id=" + notification.getId());
break;
}
}
if (tomorrowNotification == null) {
Log.w(TAG, "DN|SOFT_REFETCH_ERR no_tomorrow_notification_found");
return false;
}
// Fetch fresh content
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
getApplicationContext(),
storage
);
NotificationContent freshContent = fetcher.fetchContentImmediately();
if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) {
Log.i(TAG, "DN|SOFT_REFETCH_FRESH_CONTENT tomorrow_id=" + tomorrowNotification.getId());
// Update tomorrow's notification with fresh content
tomorrowNotification.setTitle(freshContent.getTitle());
tomorrowNotification.setBody(freshContent.getBody());
tomorrowNotification.setSound(freshContent.isSound());
tomorrowNotification.setPriority(freshContent.getPriority());
tomorrowNotification.setUrl(freshContent.getUrl());
tomorrowNotification.setMediaUrl(freshContent.getMediaUrl());
// Keep original scheduled time and ID
// Save updated content to storage
storage.saveNotificationContent(tomorrowNotification);
Log.i(TAG, "DN|SOFT_REFETCH_UPDATED tomorrow_id=" + tomorrowNotification.getId());
return true;
} else {
Log.w(TAG, "DN|SOFT_REFETCH_FAIL no_fresh_content tomorrow_id=" + tomorrowNotification.getId());
return false;
}
} catch (Exception e) {
Log.e(TAG, "DN|SOFT_REFETCH_ERR exception=" + e.getMessage(), e);
return false;
}
}
}

View File

@@ -0,0 +1,684 @@
/**
* 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.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import org.json.JSONArray;
import org.json.JSONException;
/**
* 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;
// Load starred plan IDs from SharedPreferences
userConfig.starredPlanIds = loadStarredPlanIdsFromSharedPreferences();
logger.d("TS: Loaded starredPlanIds count=" +
(userConfig.starredPlanIds != null ? userConfig.starredPlanIds.size() : 0));
// 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);
// Get existing notifications for duplicate checking
java.util.List<NotificationContent> existingNotifications = storage.getAllNotifications();
long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts
java.util.Set<Long> batchScheduledTimes = new java.util.HashSet<>();
int scheduledCount = 0;
int skippedCount = 0;
for (NotificationContent content : contents) {
try {
// Check for duplicates within current batch
long scheduledTime = content.getScheduledTime();
boolean duplicateInBatch = false;
for (Long batchTime : batchScheduledTimes) {
if (Math.abs(batchTime - scheduledTime) <= toleranceMs) {
logger.w("TS: DUPLICATE_SKIP_BATCH id=" + content.getId() +
" scheduled_time=" + scheduledTime);
duplicateInBatch = true;
skippedCount++;
break;
}
}
if (duplicateInBatch) {
continue;
}
// Check for duplicates in existing storage
boolean duplicateInStorage = false;
for (NotificationContent existing : existingNotifications) {
if (Math.abs(existing.getScheduledTime() - scheduledTime) <= toleranceMs) {
logger.w("TS: DUPLICATE_SKIP_STORAGE id=" + content.getId() +
" existing_id=" + existing.getId() +
" scheduled_time=" + scheduledTime);
duplicateInStorage = true;
skippedCount++;
break;
}
}
if (duplicateInStorage) {
continue;
}
// Mark this scheduledTime as processed
batchScheduledTimes.add(scheduledTime);
// 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() +
(skippedCount > 0 ? ", " + skippedCount + " duplicates skipped" : ""));
} 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
}
/* ============================================================
* Helper Methods
* ============================================================ */
/**
* Load starred plan IDs from SharedPreferences
*
* Reads the persisted starred plan IDs that were stored via
* DailyNotificationPlugin.updateStarredPlans()
*
* @return List of starred plan IDs, or empty list if none stored
*/
@NonNull
private List<String> loadStarredPlanIdsFromSharedPreferences() {
try {
SharedPreferences preferences = appContext
.getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE);
String starredPlansJson = preferences.getString("starredPlanIds", "[]");
if (starredPlansJson == null || starredPlansJson.isEmpty()) {
return new ArrayList<>();
}
JSONArray jsonArray = new JSONArray(starredPlansJson);
List<String> planIds = new ArrayList<>();
for (int i = 0; i < jsonArray.length(); i++) {
planIds.add(jsonArray.getString(i));
}
return planIds;
} catch (JSONException e) {
logger.e("TS: Error parsing starredPlanIds from SharedPreferences", e);
return new ArrayList<>();
} catch (Exception e) {
logger.e("TS: Unexpected error loading starredPlanIds", e);
return new ArrayList<>();
}
}
}

View File

@@ -0,0 +1,306 @@
/**
* NotificationConfigDao.java
*
* Data Access Object for NotificationConfigEntity operations
* Provides efficient queries for configuration management and user preferences
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.dao;
import androidx.room.*;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.List;
/**
* Data Access Object for notification configuration operations
*
* Provides efficient database operations for:
* - Configuration management and user preferences
* - Plugin settings and state persistence
* - TimeSafari integration configuration
* - Performance tuning and behavior settings
*/
@Dao
public interface NotificationConfigDao {
// ===== BASIC CRUD OPERATIONS =====
/**
* Insert a new configuration entity
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertConfig(NotificationConfigEntity config);
/**
* Insert multiple configuration entities
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertConfigs(List<NotificationConfigEntity> configs);
/**
* Update an existing configuration entity
*/
@Update
void updateConfig(NotificationConfigEntity config);
/**
* Delete a configuration entity by ID
*/
@Query("DELETE FROM notification_config WHERE id = :id")
void deleteConfig(String id);
/**
* Delete configurations by key
*/
@Query("DELETE FROM notification_config WHERE config_key = :configKey")
void deleteConfigsByKey(String configKey);
// ===== QUERY OPERATIONS =====
/**
* Get configuration by ID
*/
@Query("SELECT * FROM notification_config WHERE id = :id")
NotificationConfigEntity getConfigById(String id);
/**
* Get configuration by key
*/
@Query("SELECT * FROM notification_config WHERE config_key = :configKey")
NotificationConfigEntity getConfigByKey(String configKey);
/**
* Get configuration by key and TimeSafari DID
*/
@Query("SELECT * FROM notification_config WHERE config_key = :configKey AND timesafari_did = :timesafariDid")
NotificationConfigEntity getConfigByKeyAndDid(String configKey, String timesafariDid);
/**
* Get all configuration entities
*/
@Query("SELECT * FROM notification_config ORDER BY updated_at DESC")
List<NotificationConfigEntity> getAllConfigs();
/**
* Get configurations by TimeSafari DID
*/
@Query("SELECT * FROM notification_config WHERE timesafari_did = :timesafariDid ORDER BY updated_at DESC")
List<NotificationConfigEntity> getConfigsByTimeSafariDid(String timesafariDid);
/**
* Get configurations by type
*/
@Query("SELECT * FROM notification_config WHERE config_type = :configType ORDER BY updated_at DESC")
List<NotificationConfigEntity> getConfigsByType(String configType);
/**
* Get active configurations
*/
@Query("SELECT * FROM notification_config WHERE is_active = 1 ORDER BY updated_at DESC")
List<NotificationConfigEntity> getActiveConfigs();
/**
* Get encrypted configurations
*/
@Query("SELECT * FROM notification_config WHERE is_encrypted = 1 ORDER BY updated_at DESC")
List<NotificationConfigEntity> getEncryptedConfigs();
// ===== CONFIGURATION-SPECIFIC QUERIES =====
/**
* Get user preferences
*/
@Query("SELECT * FROM notification_config WHERE config_type = 'user_preference' AND timesafari_did = :timesafariDid ORDER BY updated_at DESC")
List<NotificationConfigEntity> getUserPreferences(String timesafariDid);
/**
* Get plugin settings
*/
@Query("SELECT * FROM notification_config WHERE config_type = 'plugin_setting' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getPluginSettings();
/**
* Get TimeSafari integration settings
*/
@Query("SELECT * FROM notification_config WHERE config_type = 'timesafari_integration' AND timesafari_did = :timesafariDid ORDER BY updated_at DESC")
List<NotificationConfigEntity> getTimeSafariIntegrationSettings(String timesafariDid);
/**
* Get performance settings
*/
@Query("SELECT * FROM notification_config WHERE config_type = 'performance_setting' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getPerformanceSettings();
/**
* Get notification preferences
*/
@Query("SELECT * FROM notification_config WHERE config_type = 'notification_preference' AND timesafari_did = :timesafariDid ORDER BY updated_at DESC")
List<NotificationConfigEntity> getNotificationPreferences(String timesafariDid);
// ===== VALUE-BASED QUERIES =====
/**
* Get configurations by data type
*/
@Query("SELECT * FROM notification_config WHERE config_data_type = :dataType ORDER BY updated_at DESC")
List<NotificationConfigEntity> getConfigsByDataType(String dataType);
/**
* Get boolean configurations
*/
@Query("SELECT * FROM notification_config WHERE config_data_type = 'boolean' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getBooleanConfigs();
/**
* Get integer configurations
*/
@Query("SELECT * FROM notification_config WHERE config_data_type = 'integer' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getIntegerConfigs();
/**
* Get string configurations
*/
@Query("SELECT * FROM notification_config WHERE config_data_type = 'string' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getStringConfigs();
/**
* Get JSON configurations
*/
@Query("SELECT * FROM notification_config WHERE config_data_type = 'json' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getJsonConfigs();
// ===== ANALYTICS QUERIES =====
/**
* Get configuration count by type
*/
@Query("SELECT COUNT(*) FROM notification_config WHERE config_type = :configType")
int getConfigCountByType(String configType);
/**
* Get configuration count by TimeSafari DID
*/
@Query("SELECT COUNT(*) FROM notification_config WHERE timesafari_did = :timesafariDid")
int getConfigCountByTimeSafariDid(String timesafariDid);
/**
* Get total configuration count
*/
@Query("SELECT COUNT(*) FROM notification_config")
int getTotalConfigCount();
/**
* Get active configuration count
*/
@Query("SELECT COUNT(*) FROM notification_config WHERE is_active = 1")
int getActiveConfigCount();
/**
* Get encrypted configuration count
*/
@Query("SELECT COUNT(*) FROM notification_config WHERE is_encrypted = 1")
int getEncryptedConfigCount();
// ===== CLEANUP OPERATIONS =====
/**
* Delete expired configurations
*/
@Query("DELETE FROM notification_config WHERE (created_at + (ttl_seconds * 1000)) < :currentTime")
int deleteExpiredConfigs(long currentTime);
/**
* Delete old configurations
*/
@Query("DELETE FROM notification_config WHERE created_at < :cutoffTime")
int deleteOldConfigs(long cutoffTime);
/**
* Delete configurations by TimeSafari DID
*/
@Query("DELETE FROM notification_config WHERE timesafari_did = :timesafariDid")
int deleteConfigsByTimeSafariDid(String timesafariDid);
/**
* Delete inactive configurations
*/
@Query("DELETE FROM notification_config WHERE is_active = 0")
int deleteInactiveConfigs();
/**
* Delete configurations by type
*/
@Query("DELETE FROM notification_config WHERE config_type = :configType")
int deleteConfigsByType(String configType);
// ===== BULK OPERATIONS =====
/**
* Update configuration values for multiple configs
*/
@Query("UPDATE notification_config SET config_value = :newValue, updated_at = :updatedAt WHERE id IN (:ids)")
void updateConfigValuesForConfigs(List<String> ids, String newValue, long updatedAt);
/**
* Activate/deactivate multiple configurations
*/
@Query("UPDATE notification_config SET is_active = :isActive, updated_at = :updatedAt WHERE id IN (:ids)")
void updateActiveStatusForConfigs(List<String> ids, boolean isActive, long updatedAt);
/**
* Mark configurations as encrypted
*/
@Query("UPDATE notification_config SET is_encrypted = 1, encryption_key_id = :keyId, updated_at = :updatedAt WHERE id IN (:ids)")
void markConfigsAsEncrypted(List<String> ids, String keyId, long updatedAt);
// ===== UTILITY QUERIES =====
/**
* Check if configuration exists by key
*/
@Query("SELECT COUNT(*) > 0 FROM notification_config WHERE config_key = :configKey")
boolean configExistsByKey(String configKey);
/**
* Check if configuration exists by key and TimeSafari DID
*/
@Query("SELECT COUNT(*) > 0 FROM notification_config WHERE config_key = :configKey AND timesafari_did = :timesafariDid")
boolean configExistsByKeyAndDid(String configKey, String timesafariDid);
/**
* Get configuration keys by type
*/
@Query("SELECT config_key FROM notification_config WHERE config_type = :configType ORDER BY updated_at DESC")
List<String> getConfigKeysByType(String configType);
/**
* Get configuration keys by TimeSafari DID
*/
@Query("SELECT config_key FROM notification_config WHERE timesafari_did = :timesafariDid ORDER BY updated_at DESC")
List<String> getConfigKeysByTimeSafariDid(String timesafariDid);
// ===== MIGRATION QUERIES =====
/**
* Get configurations by plugin version
*/
@Query("SELECT * FROM notification_config WHERE config_key LIKE 'plugin_version_%' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getConfigsByPluginVersion();
/**
* Get configurations that need migration
*/
@Query("SELECT * FROM notification_config WHERE config_key LIKE 'migration_%' ORDER BY updated_at DESC")
List<NotificationConfigEntity> getConfigsNeedingMigration();
/**
* Delete migration-related configurations
*/
@Query("DELETE FROM notification_config WHERE config_key LIKE 'migration_%'")
int deleteMigrationConfigs();
}

View File

@@ -0,0 +1,237 @@
/**
* NotificationContentDao.java
*
* Data Access Object for NotificationContentEntity operations
* Provides efficient queries and operations for notification content management
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.dao;
import androidx.room.*;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import java.util.List;
/**
* Data Access Object for notification content operations
*
* Provides efficient database operations for:
* - CRUD operations on notification content
* - Plugin-specific queries and filtering
* - Performance-optimized bulk operations
* - Analytics and reporting queries
*/
@Dao
public interface NotificationContentDao {
// ===== BASIC CRUD OPERATIONS =====
/**
* Insert a new notification content entity
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertNotification(NotificationContentEntity notification);
/**
* Insert multiple notification content entities
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertNotifications(List<NotificationContentEntity> notifications);
/**
* Update an existing notification content entity
*/
@Update
void updateNotification(NotificationContentEntity notification);
/**
* Delete a notification content entity by ID
*/
@Query("DELETE FROM notification_content WHERE id = :id")
void deleteNotification(String id);
/**
* Delete multiple notification content entities by IDs
*/
@Query("DELETE FROM notification_content WHERE id IN (:ids)")
void deleteNotifications(List<String> ids);
// ===== QUERY OPERATIONS =====
/**
* Get notification content by ID
*/
@Query("SELECT * FROM notification_content WHERE id = :id")
NotificationContentEntity getNotificationById(String id);
/**
* Get all notification content entities
*/
@Query("SELECT * FROM notification_content ORDER BY scheduled_time ASC")
List<NotificationContentEntity> getAllNotifications();
/**
* Get notifications by TimeSafari DID
*/
@Query("SELECT * FROM notification_content WHERE timesafari_did = :timesafariDid ORDER BY scheduled_time ASC")
List<NotificationContentEntity> getNotificationsByTimeSafariDid(String timesafariDid);
/**
* Get notifications by plugin version
*/
@Query("SELECT * FROM notification_content WHERE plugin_version = :pluginVersion ORDER BY created_at DESC")
List<NotificationContentEntity> getNotificationsByPluginVersion(String pluginVersion);
/**
* Get notifications by type
*/
@Query("SELECT * FROM notification_content WHERE notification_type = :notificationType ORDER BY scheduled_time ASC")
List<NotificationContentEntity> getNotificationsByType(String notificationType);
/**
* Get notifications ready for delivery
*/
@Query("SELECT * FROM notification_content WHERE scheduled_time <= :currentTime AND delivery_status != 'delivered' ORDER BY scheduled_time ASC")
List<NotificationContentEntity> getNotificationsReadyForDelivery(long currentTime);
/**
* Get expired notifications
*/
@Query("SELECT * FROM notification_content WHERE (created_at + (ttl_seconds * 1000)) < :currentTime")
List<NotificationContentEntity> getExpiredNotifications(long currentTime);
// ===== PLUGIN-SPECIFIC QUERIES =====
/**
* Get notifications scheduled for a specific time range
*/
@Query("SELECT * FROM notification_content WHERE scheduled_time BETWEEN :startTime AND :endTime ORDER BY scheduled_time ASC")
List<NotificationContentEntity> getNotificationsInTimeRange(long startTime, long endTime);
/**
* Get notifications by delivery status
*/
@Query("SELECT * FROM notification_content WHERE delivery_status = :deliveryStatus ORDER BY scheduled_time ASC")
List<NotificationContentEntity> getNotificationsByDeliveryStatus(String deliveryStatus);
/**
* Get notifications with user interactions
*/
@Query("SELECT * FROM notification_content WHERE user_interaction_count > 0 ORDER BY last_user_interaction DESC")
List<NotificationContentEntity> getNotificationsWithUserInteractions();
/**
* Get notifications by priority
*/
@Query("SELECT * FROM notification_content WHERE priority = :priority ORDER BY scheduled_time ASC")
List<NotificationContentEntity> getNotificationsByPriority(int priority);
// ===== ANALYTICS QUERIES =====
/**
* Get notification count by type
*/
@Query("SELECT COUNT(*) FROM notification_content WHERE notification_type = :notificationType")
int getNotificationCountByType(String notificationType);
/**
* Get notification count by TimeSafari DID
*/
@Query("SELECT COUNT(*) FROM notification_content WHERE timesafari_did = :timesafariDid")
int getNotificationCountByTimeSafariDid(String timesafariDid);
/**
* Get total notification count
*/
@Query("SELECT COUNT(*) FROM notification_content")
int getTotalNotificationCount();
/**
* Get average user interaction count
*/
@Query("SELECT AVG(user_interaction_count) FROM notification_content WHERE user_interaction_count > 0")
double getAverageUserInteractionCount();
/**
* Get notifications with high interaction rates
*/
@Query("SELECT * FROM notification_content WHERE user_interaction_count > :minInteractions ORDER BY user_interaction_count DESC")
List<NotificationContentEntity> getHighInteractionNotifications(int minInteractions);
// ===== CLEANUP OPERATIONS =====
/**
* Delete expired notifications
*/
@Query("DELETE FROM notification_content WHERE (created_at + (ttl_seconds * 1000)) < :currentTime")
int deleteExpiredNotifications(long currentTime);
/**
* Delete notifications older than specified time
*/
@Query("DELETE FROM notification_content WHERE created_at < :cutoffTime")
int deleteOldNotifications(long cutoffTime);
/**
* Delete notifications by plugin version
*/
@Query("DELETE FROM notification_content WHERE plugin_version < :minVersion")
int deleteNotificationsByPluginVersion(String minVersion);
/**
* Delete notifications by TimeSafari DID
*/
@Query("DELETE FROM notification_content WHERE timesafari_did = :timesafariDid")
int deleteNotificationsByTimeSafariDid(String timesafariDid);
// ===== BULK OPERATIONS =====
/**
* Update delivery status for multiple notifications
*/
@Query("UPDATE notification_content SET delivery_status = :deliveryStatus, updated_at = :updatedAt WHERE id IN (:ids)")
void updateDeliveryStatusForNotifications(List<String> ids, String deliveryStatus, long updatedAt);
/**
* Increment delivery attempts for multiple notifications
*/
@Query("UPDATE notification_content SET delivery_attempts = delivery_attempts + 1, last_delivery_attempt = :currentTime, updated_at = :currentTime WHERE id IN (:ids)")
void incrementDeliveryAttemptsForNotifications(List<String> ids, long currentTime);
/**
* Update user interaction count for multiple notifications
*/
@Query("UPDATE notification_content SET user_interaction_count = user_interaction_count + 1, last_user_interaction = :currentTime, updated_at = :currentTime WHERE id IN (:ids)")
void incrementUserInteractionsForNotifications(List<String> ids, long currentTime);
// ===== PERFORMANCE QUERIES =====
/**
* Get notification IDs only (for lightweight operations)
*/
@Query("SELECT id FROM notification_content WHERE scheduled_time <= :currentTime AND delivery_status != 'delivered'")
List<String> getNotificationIdsReadyForDelivery(long currentTime);
/**
* Get notification count by delivery status
*/
@Query("SELECT delivery_status AS deliveryStatus, COUNT(*) AS count FROM notification_content GROUP BY delivery_status")
List<NotificationCountByStatus> getNotificationCountByDeliveryStatus();
/**
* Data class for delivery status counts
*/
class NotificationCountByStatus {
public String deliveryStatus;
public int count;
public NotificationCountByStatus(String deliveryStatus, int count) {
this.deliveryStatus = deliveryStatus;
this.count = count;
}
}
}

View File

@@ -0,0 +1,309 @@
/**
* NotificationDeliveryDao.java
*
* Data Access Object for NotificationDeliveryEntity operations
* Provides efficient queries for delivery tracking and analytics
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.dao;
import androidx.room.*;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import java.util.List;
/**
* Data Access Object for notification delivery tracking operations
*
* Provides efficient database operations for:
* - Delivery event tracking and analytics
* - Performance monitoring and debugging
* - User interaction analysis
* - Error tracking and reporting
*/
@Dao
public interface NotificationDeliveryDao {
// ===== BASIC CRUD OPERATIONS =====
/**
* Insert a new delivery tracking entity
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertDelivery(NotificationDeliveryEntity delivery);
/**
* Insert multiple delivery tracking entities
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertDeliveries(List<NotificationDeliveryEntity> deliveries);
/**
* Update an existing delivery tracking entity
*/
@Update
void updateDelivery(NotificationDeliveryEntity delivery);
/**
* Delete a delivery tracking entity by ID
*/
@Query("DELETE FROM notification_delivery WHERE id = :id")
void deleteDelivery(String id);
/**
* Delete delivery tracking entities by notification ID
*/
@Query("DELETE FROM notification_delivery WHERE notification_id = :notificationId")
void deleteDeliveriesByNotificationId(String notificationId);
// ===== QUERY OPERATIONS =====
/**
* Get delivery tracking by ID
*/
@Query("SELECT * FROM notification_delivery WHERE id = :id")
NotificationDeliveryEntity getDeliveryById(String id);
/**
* Get all delivery tracking entities
*/
@Query("SELECT * FROM notification_delivery ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getAllDeliveries();
/**
* Get delivery tracking by notification ID
*/
@Query("SELECT * FROM notification_delivery WHERE notification_id = :notificationId ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesByNotificationId(String notificationId);
/**
* Get delivery tracking by TimeSafari DID
*/
@Query("SELECT * FROM notification_delivery WHERE timesafari_did = :timesafariDid ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesByTimeSafariDid(String timesafariDid);
/**
* Get delivery tracking by status
*/
@Query("SELECT * FROM notification_delivery WHERE delivery_status = :deliveryStatus ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesByStatus(String deliveryStatus);
/**
* Get successful deliveries
*/
@Query("SELECT * FROM notification_delivery WHERE delivery_status = 'delivered' ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getSuccessfulDeliveries();
/**
* Get failed deliveries
*/
@Query("SELECT * FROM notification_delivery WHERE delivery_status = 'failed' ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getFailedDeliveries();
/**
* Get deliveries with user interactions
*/
@Query("SELECT * FROM notification_delivery WHERE user_interaction_type IS NOT NULL ORDER BY user_interaction_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesWithUserInteractions();
// ===== TIME-BASED QUERIES =====
/**
* Get deliveries in time range
*/
@Query("SELECT * FROM notification_delivery WHERE delivery_timestamp BETWEEN :startTime AND :endTime ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesInTimeRange(long startTime, long endTime);
/**
* Get recent deliveries
*/
@Query("SELECT * FROM notification_delivery WHERE delivery_timestamp > :sinceTime ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getRecentDeliveries(long sinceTime);
/**
* Get deliveries by delivery method
*/
@Query("SELECT * FROM notification_delivery WHERE delivery_method = :deliveryMethod ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesByMethod(String deliveryMethod);
// ===== ANALYTICS QUERIES =====
/**
* Get delivery success rate
*/
@Query("SELECT COUNT(*) FROM notification_delivery WHERE delivery_status = 'delivered'")
int getSuccessfulDeliveryCount();
/**
* Get delivery failure count
*/
@Query("SELECT COUNT(*) FROM notification_delivery WHERE delivery_status = 'failed'")
int getFailedDeliveryCount();
/**
* Get total delivery count
*/
@Query("SELECT COUNT(*) FROM notification_delivery")
int getTotalDeliveryCount();
/**
* Get average delivery duration
*/
@Query("SELECT AVG(delivery_duration_ms) FROM notification_delivery WHERE delivery_duration_ms > 0")
double getAverageDeliveryDuration();
/**
* Get user interaction count
*/
@Query("SELECT COUNT(*) FROM notification_delivery WHERE user_interaction_type IS NOT NULL")
int getUserInteractionCount();
/**
* Get average user interaction duration
*/
@Query("SELECT AVG(user_interaction_duration_ms) FROM notification_delivery WHERE user_interaction_duration_ms > 0")
double getAverageUserInteractionDuration();
// ===== ERROR ANALYSIS QUERIES =====
/**
* Get deliveries by error code
*/
@Query("SELECT * FROM notification_delivery WHERE error_code = :errorCode ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesByErrorCode(String errorCode);
/**
* Get most common error codes
*/
@Query("SELECT error_code AS errorCode, COUNT(*) AS count FROM notification_delivery WHERE error_code IS NOT NULL GROUP BY error_code ORDER BY count DESC")
List<ErrorCodeCount> getErrorCodeCounts();
/**
* Get deliveries with specific error messages
*/
@Query("SELECT * FROM notification_delivery WHERE error_message LIKE :errorPattern ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesByErrorPattern(String errorPattern);
// ===== PERFORMANCE ANALYSIS QUERIES =====
/**
* Get deliveries by battery level
*/
@Query("SELECT * FROM notification_delivery WHERE battery_level BETWEEN :minBattery AND :maxBattery ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesByBatteryLevel(int minBattery, int maxBattery);
/**
* Get deliveries in doze mode
*/
@Query("SELECT * FROM notification_delivery WHERE doze_mode_active = 1 ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesInDozeMode();
/**
* Get deliveries without exact alarm permission
*/
@Query("SELECT * FROM notification_delivery WHERE exact_alarm_permission = 0 ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesWithoutExactAlarmPermission();
/**
* Get deliveries without notification permission
*/
@Query("SELECT * FROM notification_delivery WHERE notification_permission = 0 ORDER BY delivery_timestamp DESC")
List<NotificationDeliveryEntity> getDeliveriesWithoutNotificationPermission();
// ===== CLEANUP OPERATIONS =====
/**
* Delete old delivery tracking data
*/
@Query("DELETE FROM notification_delivery WHERE delivery_timestamp < :cutoffTime")
int deleteOldDeliveries(long cutoffTime);
/**
* Delete delivery tracking by TimeSafari DID
*/
@Query("DELETE FROM notification_delivery WHERE timesafari_did = :timesafariDid")
int deleteDeliveriesByTimeSafariDid(String timesafariDid);
/**
* Delete failed deliveries older than specified time
*/
@Query("DELETE FROM notification_delivery WHERE delivery_status = 'failed' AND delivery_timestamp < :cutoffTime")
int deleteOldFailedDeliveries(long cutoffTime);
// ===== BULK OPERATIONS =====
/**
* Update delivery status for multiple deliveries
*/
@Query("UPDATE notification_delivery SET delivery_status = :deliveryStatus WHERE id IN (:ids)")
void updateDeliveryStatusForDeliveries(List<String> ids, String deliveryStatus);
/**
* Record user interactions for multiple deliveries
*/
@Query("UPDATE notification_delivery SET user_interaction_type = :interactionType, user_interaction_timestamp = :timestamp, user_interaction_duration_ms = :duration WHERE id IN (:ids)")
void recordUserInteractionsForDeliveries(List<String> ids, String interactionType, long timestamp, long duration);
// ===== REPORTING QUERIES =====
/**
* Get delivery statistics by day
*/
@Query("SELECT DATE(delivery_timestamp/1000, 'unixepoch') as day, COUNT(*) as count, SUM(CASE WHEN delivery_status = 'delivered' THEN 1 ELSE 0 END) as successful FROM notification_delivery GROUP BY DATE(delivery_timestamp/1000, 'unixepoch') ORDER BY day DESC")
List<DailyDeliveryStats> getDailyDeliveryStats();
/**
* Get delivery statistics by hour
*/
@Query("SELECT strftime('%H', delivery_timestamp/1000, 'unixepoch') as hour, COUNT(*) as count, SUM(CASE WHEN delivery_status = 'delivered' THEN 1 ELSE 0 END) as successful FROM notification_delivery GROUP BY strftime('%H', delivery_timestamp/1000, 'unixepoch') ORDER BY hour")
List<HourlyDeliveryStats> getHourlyDeliveryStats();
// ===== DATA CLASSES FOR COMPLEX QUERIES =====
/**
* Data class for error code counts
*/
class ErrorCodeCount {
public String errorCode;
public int count;
public ErrorCodeCount(String errorCode, int count) {
this.errorCode = errorCode;
this.count = count;
}
}
/**
* Data class for daily delivery statistics
*/
class DailyDeliveryStats {
public String day;
public int count;
public int successful;
public DailyDeliveryStats(String day, int count, int successful) {
this.day = day;
this.count = count;
this.successful = successful;
}
}
/**
* Data class for hourly delivery statistics
*/
class HourlyDeliveryStats {
public String hour;
public int count;
public int successful;
public HourlyDeliveryStats(String hour, int count, int successful) {
this.hour = hour;
this.count = count;
this.successful = successful;
}
}
}

View File

@@ -0,0 +1,300 @@
/**
* DailyNotificationDatabase.java
*
* Room database for the DailyNotification plugin
* Provides centralized data management with encryption, retention policies, and migration support
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.database;
import android.content.Context;
import androidx.room.*;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import com.timesafari.dailynotification.dao.NotificationContentDao;
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
import com.timesafari.dailynotification.dao.NotificationConfigDao;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Room database for the DailyNotification plugin
*
* This database provides:
* - Centralized data management for all plugin data
* - Encryption support for sensitive information
* - Automatic retention policy enforcement
* - Migration support for schema changes
* - Performance optimization with proper indexing
* - Background thread execution for database operations
*/
@Database(
entities = {
NotificationContentEntity.class,
NotificationDeliveryEntity.class,
NotificationConfigEntity.class
},
version = 1,
exportSchema = false
)
public abstract class DailyNotificationDatabase extends RoomDatabase {
private static final String TAG = "DailyNotificationDatabase";
private static final String DATABASE_NAME = "daily_notification_plugin.db";
// Singleton instance
private static volatile DailyNotificationDatabase INSTANCE;
// Thread pool for database operations
private static final int NUMBER_OF_THREADS = 4;
public static final ExecutorService databaseWriteExecutor = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
// DAO accessors
public abstract NotificationContentDao notificationContentDao();
public abstract NotificationDeliveryDao notificationDeliveryDao();
public abstract NotificationConfigDao notificationConfigDao();
/**
* Get singleton instance of the database
*
* @param context Application context
* @return Database instance
*/
public static DailyNotificationDatabase getInstance(Context context) {
if (INSTANCE == null) {
synchronized (DailyNotificationDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(),
DailyNotificationDatabase.class,
DATABASE_NAME
)
.addCallback(roomCallback)
.addMigrations(MIGRATION_1_2) // Add future migrations here
.build();
}
}
}
return INSTANCE;
}
/**
* Room database callback for initialization and cleanup
*/
private static RoomDatabase.Callback roomCallback = new RoomDatabase.Callback() {
@Override
public void onCreate(SupportSQLiteDatabase db) {
super.onCreate(db);
// Initialize database with default data if needed
databaseWriteExecutor.execute(() -> {
// Populate with default configurations
populateDefaultConfigurations();
});
}
@Override
public void onOpen(SupportSQLiteDatabase db) {
super.onOpen(db);
// Perform any necessary setup when database is opened
databaseWriteExecutor.execute(() -> {
// Clean up expired data
cleanupExpiredData();
});
}
};
/**
* Populate database with default configurations
*/
private static void populateDefaultConfigurations() {
if (INSTANCE == null) return;
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
// Default plugin settings
NotificationConfigEntity defaultSettings = new NotificationConfigEntity(
"default_plugin_settings",
null, // Global settings
"plugin_setting",
"default_settings",
"{}",
"json"
);
defaultSettings.setTypedValue("{\"version\":\"1.0.0\",\"retention_days\":7,\"max_notifications\":100}");
configDao.insertConfig(defaultSettings);
// Default performance settings
NotificationConfigEntity performanceSettings = new NotificationConfigEntity(
"default_performance_settings",
null, // Global settings
"performance_setting",
"performance_config",
"{}",
"json"
);
performanceSettings.setTypedValue("{\"max_concurrent_deliveries\":5,\"delivery_timeout_ms\":30000,\"retry_attempts\":3}");
configDao.insertConfig(performanceSettings);
}
/**
* Clean up expired data from all tables
*/
private static void cleanupExpiredData() {
if (INSTANCE == null) return;
long currentTime = System.currentTimeMillis();
// Clean up expired notifications
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
int deletedNotifications = contentDao.deleteExpiredNotifications(currentTime);
// Clean up old delivery tracking data (keep for 30 days)
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
long deliveryCutoff = currentTime - (30L * 24 * 60 * 60 * 1000); // 30 days ago
int deletedDeliveries = deliveryDao.deleteOldDeliveries(deliveryCutoff);
// Clean up expired configurations
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
int deletedConfigs = configDao.deleteExpiredConfigs(currentTime);
android.util.Log.d(TAG, "Cleanup completed: " + deletedNotifications + " notifications, " +
deletedDeliveries + " deliveries, " + deletedConfigs + " configs");
}
/**
* Migration from version 1 to 2
* Add new columns for enhanced functionality
*/
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
// Add new columns to notification_content table
database.execSQL("ALTER TABLE notification_content ADD COLUMN analytics_data TEXT");
database.execSQL("ALTER TABLE notification_content ADD COLUMN priority_level INTEGER DEFAULT 0");
// Add new columns to notification_delivery table
database.execSQL("ALTER TABLE notification_delivery ADD COLUMN delivery_metadata TEXT");
database.execSQL("ALTER TABLE notification_delivery ADD COLUMN performance_metrics TEXT");
// Add new columns to notification_config table
database.execSQL("ALTER TABLE notification_config ADD COLUMN config_category TEXT DEFAULT 'general'");
database.execSQL("ALTER TABLE notification_config ADD COLUMN config_priority INTEGER DEFAULT 0");
}
};
/**
* Close the database connection
* Should be called when the plugin is being destroyed
*/
public static void closeDatabase() {
if (INSTANCE != null) {
INSTANCE.close();
INSTANCE = null;
}
}
/**
* Clear all data from the database
* Use with caution - this will delete all plugin data
*/
public static void clearAllData() {
if (INSTANCE == null) return;
databaseWriteExecutor.execute(() -> {
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
// Clear all tables
contentDao.deleteNotificationsByPluginVersion("0"); // Delete all
deliveryDao.deleteDeliveriesByTimeSafariDid("all"); // Delete all
configDao.deleteConfigsByType("all"); // Delete all
android.util.Log.d(TAG, "All plugin data cleared");
});
}
/**
* Get database statistics
*
* @return Database statistics as a formatted string
*/
public static String getDatabaseStats() {
if (INSTANCE == null) return "Database not initialized";
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
int notificationCount = contentDao.getTotalNotificationCount();
int deliveryCount = deliveryDao.getTotalDeliveryCount();
int configCount = configDao.getTotalConfigCount();
return String.format("Database Stats:\n" +
" Notifications: %d\n" +
" Deliveries: %d\n" +
" Configurations: %d\n" +
" Total Records: %d",
notificationCount, deliveryCount, configCount,
notificationCount + deliveryCount + configCount);
}
/**
* Perform database maintenance
* Includes cleanup, optimization, and integrity checks
*/
public static void performMaintenance() {
if (INSTANCE == null) return;
databaseWriteExecutor.execute(() -> {
long startTime = System.currentTimeMillis();
// Clean up expired data
cleanupExpiredData();
// Additional maintenance tasks can be added here
// - Vacuum database
// - Analyze tables for query optimization
// - Check database integrity
long duration = System.currentTimeMillis() - startTime;
android.util.Log.d(TAG, "Database maintenance completed in " + duration + "ms");
});
}
/**
* Export database data for backup or migration
*
* @return Database export as JSON string
*/
public static String exportDatabaseData() {
if (INSTANCE == null) return "{}";
// This would typically serialize all data to JSON
// Implementation depends on specific export requirements
return "{\"export\":\"not_implemented_yet\"}";
}
/**
* Import database data from backup
*
* @param jsonData JSON data to import
* @return Success status
*/
public static boolean importDatabaseData(String jsonData) {
if (INSTANCE == null || jsonData == null) return false;
// This would typically deserialize JSON data and insert into database
// Implementation depends on specific import requirements
return false;
}
}

View File

@@ -0,0 +1,250 @@
/**
* NotificationConfigEntity.java
*
* Room entity for storing plugin configuration and user preferences
* Manages settings, preferences, and plugin state across sessions
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
/**
* Room entity for storing plugin configuration and user preferences
*
* This entity manages:
* - User notification preferences
* - Plugin settings and state
* - TimeSafari integration configuration
* - Performance and behavior tuning
*/
@Entity(
tableName = "notification_config",
indices = {
@Index(value = {"timesafari_did"}),
@Index(value = {"config_type"}),
@Index(value = {"updated_at"})
}
)
public class NotificationConfigEntity {
@PrimaryKey
@NonNull
@ColumnInfo(name = "id")
public String id;
@ColumnInfo(name = "timesafari_did")
public String timesafariDid;
@ColumnInfo(name = "config_type")
public String configType;
@ColumnInfo(name = "config_key")
public String configKey;
@ColumnInfo(name = "config_value")
public String configValue;
@ColumnInfo(name = "config_data_type")
public String configDataType;
@ColumnInfo(name = "is_encrypted")
public boolean isEncrypted;
@ColumnInfo(name = "encryption_key_id")
public String encryptionKeyId;
@ColumnInfo(name = "created_at")
public long createdAt;
@ColumnInfo(name = "updated_at")
public long updatedAt;
@ColumnInfo(name = "ttl_seconds")
public long ttlSeconds;
@ColumnInfo(name = "is_active")
public boolean isActive;
@ColumnInfo(name = "metadata")
public String metadata;
/**
* Default constructor for Room
*/
public NotificationConfigEntity() {
this.createdAt = System.currentTimeMillis();
this.updatedAt = System.currentTimeMillis();
this.isEncrypted = false;
this.isActive = true;
this.ttlSeconds = 30 * 24 * 60 * 60; // Default 30 days
}
/**
* Constructor for configuration entries
*/
@Ignore
public NotificationConfigEntity(@NonNull String id, String timesafariDid,
String configType, String configKey,
String configValue, String configDataType) {
this();
this.id = id;
this.timesafariDid = timesafariDid;
this.configType = configType;
this.configKey = configKey;
this.configValue = configValue;
this.configDataType = configDataType;
}
/**
* Update the configuration value and timestamp
*/
public void updateValue(String newValue) {
this.configValue = newValue;
this.updatedAt = System.currentTimeMillis();
}
/**
* Mark configuration as encrypted
*/
public void setEncrypted(String keyId) {
this.isEncrypted = true;
this.encryptionKeyId = keyId;
touch();
}
/**
* Update the last updated timestamp
*/
public void touch() {
this.updatedAt = System.currentTimeMillis();
}
/**
* Check if this configuration has expired
*/
public boolean isExpired() {
long expirationTime = createdAt + (ttlSeconds * 1000);
return System.currentTimeMillis() > expirationTime;
}
/**
* Get time until expiration in milliseconds
*/
public long getTimeUntilExpiration() {
long expirationTime = createdAt + (ttlSeconds * 1000);
return Math.max(0, expirationTime - System.currentTimeMillis());
}
/**
* Get configuration age in milliseconds
*/
public long getConfigAge() {
return System.currentTimeMillis() - createdAt;
}
/**
* Get time since last update in milliseconds
*/
public long getTimeSinceUpdate() {
return System.currentTimeMillis() - updatedAt;
}
/**
* Parse configuration value based on data type
*/
public Object getParsedValue() {
if (configValue == null) {
return null;
}
switch (configDataType) {
case "boolean":
return Boolean.parseBoolean(configValue);
case "integer":
try {
return Integer.parseInt(configValue);
} catch (NumberFormatException e) {
return 0;
}
case "long":
try {
return Long.parseLong(configValue);
} catch (NumberFormatException e) {
return 0L;
}
case "float":
try {
return Float.parseFloat(configValue);
} catch (NumberFormatException e) {
return 0.0f;
}
case "double":
try {
return Double.parseDouble(configValue);
} catch (NumberFormatException e) {
return 0.0;
}
case "json":
case "string":
default:
return configValue;
}
}
/**
* Set configuration value with proper data type
*/
public void setTypedValue(Object value) {
if (value == null) {
this.configValue = null;
this.configDataType = "string";
} else if (value instanceof Boolean) {
this.configValue = value.toString();
this.configDataType = "boolean";
} else if (value instanceof Integer) {
this.configValue = value.toString();
this.configDataType = "integer";
} else if (value instanceof Long) {
this.configValue = value.toString();
this.configDataType = "long";
} else if (value instanceof Float) {
this.configValue = value.toString();
this.configDataType = "float";
} else if (value instanceof Double) {
this.configValue = value.toString();
this.configDataType = "double";
} else if (value instanceof String) {
this.configValue = (String) value;
this.configDataType = "string";
} else {
// For complex objects, serialize as JSON
this.configValue = value.toString();
this.configDataType = "json";
}
touch();
}
@Override
public String toString() {
return "NotificationConfigEntity{" +
"id='" + id + '\'' +
", timesafariDid='" + timesafariDid + '\'' +
", configType='" + configType + '\'' +
", configKey='" + configKey + '\'' +
", configDataType='" + configDataType + '\'' +
", isEncrypted=" + isEncrypted +
", isActive=" + isActive +
", isExpired=" + isExpired() +
'}';
}
}

View File

@@ -0,0 +1,214 @@
/**
* NotificationContentEntity.java
*
* Room entity for storing notification content with plugin-specific fields
* Includes encryption support, TTL management, and TimeSafari integration
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
/**
* Room entity representing notification content stored in the plugin database
*
* This entity stores notification data with plugin-specific fields including:
* - Plugin version tracking for migration support
* - TimeSafari DID integration for user identification
* - Encryption support for sensitive content
* - TTL management for automatic cleanup
* - Analytics fields for usage tracking
*/
@Entity(
tableName = "notification_content",
indices = {
@Index(value = {"timesafari_did"}),
@Index(value = {"notification_type"}),
@Index(value = {"scheduled_time"}),
@Index(value = {"created_at"}),
@Index(value = {"plugin_version"})
}
)
public class NotificationContentEntity {
@PrimaryKey
@NonNull
@ColumnInfo(name = "id")
public String id;
@ColumnInfo(name = "plugin_version")
public String pluginVersion;
@ColumnInfo(name = "timesafari_did")
public String timesafariDid;
@ColumnInfo(name = "notification_type")
public String notificationType;
@ColumnInfo(name = "title")
public String title;
@ColumnInfo(name = "body")
public String body;
@ColumnInfo(name = "scheduled_time")
public long scheduledTime;
@ColumnInfo(name = "timezone")
public String timezone;
@ColumnInfo(name = "priority")
public int priority;
@ColumnInfo(name = "vibration_enabled")
public boolean vibrationEnabled;
@ColumnInfo(name = "sound_enabled")
public boolean soundEnabled;
@ColumnInfo(name = "media_url")
public String mediaUrl;
@ColumnInfo(name = "encrypted_content")
public String encryptedContent;
@ColumnInfo(name = "encryption_key_id")
public String encryptionKeyId;
@ColumnInfo(name = "created_at")
public long createdAt;
@ColumnInfo(name = "updated_at")
public long updatedAt;
@ColumnInfo(name = "ttl_seconds")
public long ttlSeconds;
@ColumnInfo(name = "delivery_status")
public String deliveryStatus;
@ColumnInfo(name = "delivery_attempts")
public int deliveryAttempts;
@ColumnInfo(name = "last_delivery_attempt")
public long lastDeliveryAttempt;
@ColumnInfo(name = "user_interaction_count")
public int userInteractionCount;
@ColumnInfo(name = "last_user_interaction")
public long lastUserInteraction;
@ColumnInfo(name = "metadata")
public String metadata;
/**
* Default constructor for Room
*/
public NotificationContentEntity() {
this.createdAt = System.currentTimeMillis();
this.updatedAt = System.currentTimeMillis();
this.deliveryAttempts = 0;
this.userInteractionCount = 0;
this.ttlSeconds = 7 * 24 * 60 * 60; // Default 7 days
}
/**
* Constructor with required fields
*/
@Ignore
public NotificationContentEntity(@NonNull String id, String pluginVersion, String timesafariDid,
String notificationType, String title, String body,
long scheduledTime, String timezone) {
this();
this.id = id;
this.pluginVersion = pluginVersion;
this.timesafariDid = timesafariDid;
this.notificationType = notificationType;
this.title = title;
this.body = body;
this.scheduledTime = scheduledTime;
this.timezone = timezone;
}
/**
* Check if this notification has expired based on TTL
*/
public boolean isExpired() {
long expirationTime = createdAt + (ttlSeconds * 1000);
return System.currentTimeMillis() > expirationTime;
}
/**
* Check if this notification is ready for delivery
*/
public boolean isReadyForDelivery() {
return System.currentTimeMillis() >= scheduledTime && !isExpired();
}
/**
* Update the last updated timestamp
*/
public void touch() {
this.updatedAt = System.currentTimeMillis();
}
/**
* Increment delivery attempts and update timestamp
*/
public void recordDeliveryAttempt() {
this.deliveryAttempts++;
this.lastDeliveryAttempt = System.currentTimeMillis();
touch();
}
/**
* Record user interaction
*/
public void recordUserInteraction() {
this.userInteractionCount++;
this.lastUserInteraction = System.currentTimeMillis();
touch();
}
/**
* Get time until expiration in milliseconds
*/
public long getTimeUntilExpiration() {
long expirationTime = createdAt + (ttlSeconds * 1000);
return Math.max(0, expirationTime - System.currentTimeMillis());
}
/**
* Get time until scheduled delivery in milliseconds
*/
public long getTimeUntilDelivery() {
return Math.max(0, scheduledTime - System.currentTimeMillis());
}
@Override
public String toString() {
return "NotificationContentEntity{" +
"id='" + id + '\'' +
", pluginVersion='" + pluginVersion + '\'' +
", timesafariDid='" + timesafariDid + '\'' +
", notificationType='" + notificationType + '\'' +
", title='" + title + '\'' +
", scheduledTime=" + scheduledTime +
", deliveryStatus='" + deliveryStatus + '\'' +
", deliveryAttempts=" + deliveryAttempts +
", userInteractionCount=" + userInteractionCount +
", isExpired=" + isExpired() +
", isReadyForDelivery=" + isReadyForDelivery() +
'}';
}
}

View File

@@ -0,0 +1,225 @@
/**
* NotificationDeliveryEntity.java
*
* Room entity for tracking notification delivery events and analytics
* Provides detailed tracking of delivery attempts, failures, and user interactions
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
/**
* Room entity for tracking notification delivery events
*
* This entity provides detailed analytics and tracking for:
* - Delivery attempts and their outcomes
* - User interaction patterns
* - Performance metrics
* - Error tracking and debugging
*/
@Entity(
tableName = "notification_delivery",
foreignKeys = @ForeignKey(
entity = NotificationContentEntity.class,
parentColumns = "id",
childColumns = "notification_id",
onDelete = ForeignKey.CASCADE
),
indices = {
@Index(value = {"notification_id"}),
@Index(value = {"delivery_timestamp"}),
@Index(value = {"delivery_status"}),
@Index(value = {"user_interaction_type"}),
@Index(value = {"timesafari_did"})
}
)
public class NotificationDeliveryEntity {
@PrimaryKey
@NonNull
@ColumnInfo(name = "id")
public String id;
@ColumnInfo(name = "notification_id")
public String notificationId;
@ColumnInfo(name = "timesafari_did")
public String timesafariDid;
@ColumnInfo(name = "delivery_timestamp")
public long deliveryTimestamp;
@ColumnInfo(name = "delivery_status")
public String deliveryStatus;
@ColumnInfo(name = "delivery_method")
public String deliveryMethod;
@ColumnInfo(name = "delivery_attempt_number")
public int deliveryAttemptNumber;
@ColumnInfo(name = "delivery_duration_ms")
public long deliveryDurationMs;
@ColumnInfo(name = "user_interaction_type")
public String userInteractionType;
@ColumnInfo(name = "user_interaction_timestamp")
public long userInteractionTimestamp;
@ColumnInfo(name = "user_interaction_duration_ms")
public long userInteractionDurationMs;
@ColumnInfo(name = "error_code")
public String errorCode;
@ColumnInfo(name = "error_message")
public String errorMessage;
@ColumnInfo(name = "device_info")
public String deviceInfo;
@ColumnInfo(name = "network_info")
public String networkInfo;
@ColumnInfo(name = "battery_level")
public int batteryLevel;
@ColumnInfo(name = "doze_mode_active")
public boolean dozeModeActive;
@ColumnInfo(name = "exact_alarm_permission")
public boolean exactAlarmPermission;
@ColumnInfo(name = "notification_permission")
public boolean notificationPermission;
@ColumnInfo(name = "metadata")
public String metadata;
/**
* Default constructor for Room
*/
public NotificationDeliveryEntity() {
this.deliveryTimestamp = System.currentTimeMillis();
this.deliveryAttemptNumber = 1;
this.deliveryDurationMs = 0;
this.userInteractionDurationMs = 0;
this.batteryLevel = -1;
this.dozeModeActive = false;
this.exactAlarmPermission = false;
this.notificationPermission = false;
}
/**
* Constructor for delivery tracking
*/
@Ignore
public NotificationDeliveryEntity(@NonNull String id, String notificationId,
String timesafariDid, String deliveryStatus,
String deliveryMethod) {
this();
this.id = id;
this.notificationId = notificationId;
this.timesafariDid = timesafariDid;
this.deliveryStatus = deliveryStatus;
this.deliveryMethod = deliveryMethod;
}
/**
* Record successful delivery
*/
public void recordSuccessfulDelivery(long durationMs) {
this.deliveryStatus = "delivered";
this.deliveryDurationMs = durationMs;
this.deliveryTimestamp = System.currentTimeMillis();
}
/**
* Record failed delivery
*/
public void recordFailedDelivery(String errorCode, String errorMessage, long durationMs) {
this.deliveryStatus = "failed";
this.errorCode = errorCode;
this.errorMessage = errorMessage;
this.deliveryDurationMs = durationMs;
this.deliveryTimestamp = System.currentTimeMillis();
}
/**
* Record user interaction
*/
public void recordUserInteraction(String interactionType, long durationMs) {
this.userInteractionType = interactionType;
this.userInteractionTimestamp = System.currentTimeMillis();
this.userInteractionDurationMs = durationMs;
}
/**
* Set device context information
*/
public void setDeviceContext(int batteryLevel, boolean dozeModeActive,
boolean exactAlarmPermission, boolean notificationPermission) {
this.batteryLevel = batteryLevel;
this.dozeModeActive = dozeModeActive;
this.exactAlarmPermission = exactAlarmPermission;
this.notificationPermission = notificationPermission;
}
/**
* Check if this delivery was successful
*/
public boolean isSuccessful() {
return "delivered".equals(deliveryStatus);
}
/**
* Check if this delivery had user interaction
*/
public boolean hasUserInteraction() {
return userInteractionType != null && !userInteractionType.isEmpty();
}
/**
* Get delivery age in milliseconds
*/
public long getDeliveryAge() {
return System.currentTimeMillis() - deliveryTimestamp;
}
/**
* Get time since user interaction in milliseconds
*/
public long getTimeSinceUserInteraction() {
if (userInteractionTimestamp == 0) {
return -1; // No interaction recorded
}
return System.currentTimeMillis() - userInteractionTimestamp;
}
@Override
public String toString() {
return "NotificationDeliveryEntity{" +
"id='" + id + '\'' +
", notificationId='" + notificationId + '\'' +
", deliveryStatus='" + deliveryStatus + '\'' +
", deliveryMethod='" + deliveryMethod + '\'' +
", deliveryAttemptNumber=" + deliveryAttemptNumber +
", userInteractionType='" + userInteractionType + '\'' +
", errorCode='" + errorCode + '\'' +
", isSuccessful=" + isSuccessful() +
", hasUserInteraction=" + hasUserInteraction() +
'}';
}
}

View File

@@ -0,0 +1,538 @@
/**
* DailyNotificationStorageRoom.java
*
* Room-based storage implementation for the DailyNotification plugin
* Provides enterprise-grade data management with encryption, retention policies, and analytics
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.storage;
import android.content.Context;
import android.util.Log;
import com.timesafari.dailynotification.database.DailyNotificationDatabase;
import com.timesafari.dailynotification.dao.NotificationContentDao;
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
import com.timesafari.dailynotification.dao.NotificationConfigDao;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Room-based storage implementation for the DailyNotification plugin
*
* This class provides:
* - Enterprise-grade data persistence with Room database
* - Encryption support for sensitive notification content
* - Automatic retention policy enforcement
* - Comprehensive analytics and reporting
* - Background thread execution for all database operations
* - Migration support from SharedPreferences-based storage
*/
public class DailyNotificationStorageRoom {
private static final String TAG = "DailyNotificationStorageRoom";
// Database and DAOs
private DailyNotificationDatabase database;
private NotificationContentDao contentDao;
private NotificationDeliveryDao deliveryDao;
private NotificationConfigDao configDao;
// Thread pool for database operations
private final ExecutorService executorService;
// Plugin version for migration tracking
private static final String PLUGIN_VERSION = "1.0.0";
/**
* Constructor
*
* @param context Application context
*/
public DailyNotificationStorageRoom(Context context) {
this.database = DailyNotificationDatabase.getInstance(context);
this.contentDao = database.notificationContentDao();
this.deliveryDao = database.notificationDeliveryDao();
this.configDao = database.notificationConfigDao();
this.executorService = Executors.newFixedThreadPool(4);
Log.d(TAG, "Room-based storage initialized");
}
// ===== NOTIFICATION CONTENT OPERATIONS =====
/**
* Save notification content to Room database
*
* @param content Notification content to save
* @return CompletableFuture with success status
*/
public CompletableFuture<Boolean> saveNotificationContent(NotificationContentEntity content) {
return CompletableFuture.supplyAsync(() -> {
try {
content.pluginVersion = PLUGIN_VERSION;
content.touch();
contentDao.insertNotification(content);
Log.d(TAG, "Saved notification content: " + content.id);
return true;
} catch (Exception e) {
Log.e(TAG, "Failed to save notification content: " + content.id, e);
return false;
}
}, executorService);
}
/**
* Get notification content by ID
*
* @param id Notification ID
* @return CompletableFuture with notification content
*/
public CompletableFuture<NotificationContentEntity> getNotificationContent(String id) {
return CompletableFuture.supplyAsync(() -> {
try {
return contentDao.getNotificationById(id);
} catch (Exception e) {
Log.e(TAG, "Failed to get notification content: " + id, e);
return null;
}
}, executorService);
}
/**
* Get all notification content for a TimeSafari user
*
* @param timesafariDid TimeSafari DID
* @return CompletableFuture with list of notifications
*/
public CompletableFuture<List<NotificationContentEntity>> getNotificationsByTimeSafariDid(String timesafariDid) {
return CompletableFuture.supplyAsync(() -> {
try {
return contentDao.getNotificationsByTimeSafariDid(timesafariDid);
} catch (Exception e) {
Log.e(TAG, "Failed to get notifications for DID: " + timesafariDid, e);
return null;
}
}, executorService);
}
/**
* Get notifications ready for delivery
*
* @return CompletableFuture with list of ready notifications
*/
public CompletableFuture<List<NotificationContentEntity>> getNotificationsReadyForDelivery() {
return CompletableFuture.supplyAsync(() -> {
try {
long currentTime = System.currentTimeMillis();
return contentDao.getNotificationsReadyForDelivery(currentTime);
} catch (Exception e) {
Log.e(TAG, "Failed to get notifications ready for delivery", e);
return null;
}
}, executorService);
}
/**
* Update notification delivery status
*
* @param id Notification ID
* @param deliveryStatus New delivery status
* @return CompletableFuture with success status
*/
public CompletableFuture<Boolean> updateNotificationDeliveryStatus(String id, String deliveryStatus) {
return CompletableFuture.supplyAsync(() -> {
try {
NotificationContentEntity content = contentDao.getNotificationById(id);
if (content != null) {
content.deliveryStatus = deliveryStatus;
content.touch();
contentDao.updateNotification(content);
Log.d(TAG, "Updated delivery status for notification: " + id + " to " + deliveryStatus);
return true;
}
return false;
} catch (Exception e) {
Log.e(TAG, "Failed to update delivery status for notification: " + id, e);
return false;
}
}, executorService);
}
/**
* Record user interaction with notification
*
* @param id Notification ID
* @return CompletableFuture with success status
*/
public CompletableFuture<Boolean> recordUserInteraction(String id) {
return CompletableFuture.supplyAsync(() -> {
try {
NotificationContentEntity content = contentDao.getNotificationById(id);
if (content != null) {
content.recordUserInteraction();
contentDao.updateNotification(content);
Log.d(TAG, "Recorded user interaction for notification: " + id);
return true;
}
return false;
} catch (Exception e) {
Log.e(TAG, "Failed to record user interaction for notification: " + id, e);
return false;
}
}, executorService);
}
// ===== DELIVERY TRACKING OPERATIONS =====
/**
* Record notification delivery attempt
*
* @param delivery Delivery tracking entity
* @return CompletableFuture with success status
*/
public CompletableFuture<Boolean> recordDeliveryAttempt(NotificationDeliveryEntity delivery) {
return CompletableFuture.supplyAsync(() -> {
try {
deliveryDao.insertDelivery(delivery);
Log.d(TAG, "Recorded delivery attempt: " + delivery.id);
return true;
} catch (Exception e) {
Log.e(TAG, "Failed to record delivery attempt: " + delivery.id, e);
return false;
}
}, executorService);
}
/**
* Get delivery history for a notification
*
* @param notificationId Notification ID
* @return CompletableFuture with delivery history
*/
public CompletableFuture<List<NotificationDeliveryEntity>> getDeliveryHistory(String notificationId) {
return CompletableFuture.supplyAsync(() -> {
try {
return deliveryDao.getDeliveriesByNotificationId(notificationId);
} catch (Exception e) {
Log.e(TAG, "Failed to get delivery history for notification: " + notificationId, e);
return null;
}
}, executorService);
}
/**
* Get delivery analytics for a TimeSafari user
*
* @param timesafariDid TimeSafari DID
* @return CompletableFuture with delivery analytics
*/
public CompletableFuture<DeliveryAnalytics> getDeliveryAnalytics(String timesafariDid) {
return CompletableFuture.supplyAsync(() -> {
try {
List<NotificationDeliveryEntity> deliveries = deliveryDao.getDeliveriesByTimeSafariDid(timesafariDid);
int totalDeliveries = deliveries.size();
int successfulDeliveries = 0;
int failedDeliveries = 0;
long totalDuration = 0;
int userInteractions = 0;
for (NotificationDeliveryEntity delivery : deliveries) {
if (delivery.isSuccessful()) {
successfulDeliveries++;
totalDuration += delivery.deliveryDurationMs;
} else {
failedDeliveries++;
}
if (delivery.hasUserInteraction()) {
userInteractions++;
}
}
double successRate = totalDeliveries > 0 ? (double) successfulDeliveries / totalDeliveries : 0.0;
double averageDuration = successfulDeliveries > 0 ? (double) totalDuration / successfulDeliveries : 0.0;
double interactionRate = totalDeliveries > 0 ? (double) userInteractions / totalDeliveries : 0.0;
return new DeliveryAnalytics(
totalDeliveries,
successfulDeliveries,
failedDeliveries,
successRate,
averageDuration,
userInteractions,
interactionRate
);
} catch (Exception e) {
Log.e(TAG, "Failed to get delivery analytics for DID: " + timesafariDid, e);
return null;
}
}, executorService);
}
// ===== CONFIGURATION OPERATIONS =====
/**
* Save configuration value
*
* @param timesafariDid TimeSafari DID (null for global settings)
* @param configType Configuration type
* @param configKey Configuration key
* @param configValue Configuration value
* @return CompletableFuture with success status
*/
public CompletableFuture<Boolean> saveConfiguration(String timesafariDid, String configType,
String configKey, Object configValue) {
return CompletableFuture.supplyAsync(() -> {
try {
String id = timesafariDid != null ? timesafariDid + "_" + configKey : configKey;
NotificationConfigEntity config = new NotificationConfigEntity(
id, timesafariDid, configType, configKey, null, null
);
config.setTypedValue(configValue);
config.touch();
configDao.insertConfig(config);
Log.d(TAG, "Saved configuration: " + configKey + " = " + configValue);
return true;
} catch (Exception e) {
Log.e(TAG, "Failed to save configuration: " + configKey, e);
return false;
}
}, executorService);
}
/**
* Get configuration value
*
* @param timesafariDid TimeSafari DID (null for global settings)
* @param configKey Configuration key
* @return CompletableFuture with configuration value
*/
public CompletableFuture<Object> getConfiguration(String timesafariDid, String configKey) {
return CompletableFuture.supplyAsync(() -> {
try {
NotificationConfigEntity config = configDao.getConfigByKeyAndDid(configKey, timesafariDid);
if (config != null && config.isActive && !config.isExpired()) {
return config.getParsedValue();
}
return null;
} catch (Exception e) {
Log.e(TAG, "Failed to get configuration: " + configKey, e);
return null;
}
}, executorService);
}
/**
* Get user preferences
*
* @param timesafariDid TimeSafari DID
* @return CompletableFuture with user preferences
*/
public CompletableFuture<List<NotificationConfigEntity>> getUserPreferences(String timesafariDid) {
return CompletableFuture.supplyAsync(() -> {
try {
return configDao.getUserPreferences(timesafariDid);
} catch (Exception e) {
Log.e(TAG, "Failed to get user preferences for DID: " + timesafariDid, e);
return null;
}
}, executorService);
}
// ===== CLEANUP OPERATIONS =====
/**
* Clean up expired data
*
* @return CompletableFuture with cleanup results
*/
public CompletableFuture<CleanupResults> cleanupExpiredData() {
return CompletableFuture.supplyAsync(() -> {
try {
long currentTime = System.currentTimeMillis();
int deletedNotifications = contentDao.deleteExpiredNotifications(currentTime);
int deletedDeliveries = deliveryDao.deleteOldDeliveries(currentTime - (30L * 24 * 60 * 60 * 1000));
int deletedConfigs = configDao.deleteExpiredConfigs(currentTime);
Log.d(TAG, "Cleanup completed: " + deletedNotifications + " notifications, " +
deletedDeliveries + " deliveries, " + deletedConfigs + " configs");
return new CleanupResults(deletedNotifications, deletedDeliveries, deletedConfigs);
} catch (Exception e) {
Log.e(TAG, "Failed to cleanup expired data", e);
return new CleanupResults(0, 0, 0);
}
}, executorService);
}
/**
* Clear all data for a TimeSafari user
*
* @param timesafariDid TimeSafari DID
* @return CompletableFuture with success status
*/
public CompletableFuture<Boolean> clearUserData(String timesafariDid) {
return CompletableFuture.supplyAsync(() -> {
try {
int deletedNotifications = contentDao.deleteNotificationsByTimeSafariDid(timesafariDid);
int deletedDeliveries = deliveryDao.deleteDeliveriesByTimeSafariDid(timesafariDid);
int deletedConfigs = configDao.deleteConfigsByTimeSafariDid(timesafariDid);
Log.d(TAG, "Cleared user data for DID: " + timesafariDid +
" (" + deletedNotifications + " notifications, " +
deletedDeliveries + " deliveries, " + deletedConfigs + " configs)");
return true;
} catch (Exception e) {
Log.e(TAG, "Failed to clear user data for DID: " + timesafariDid, e);
return false;
}
}, executorService);
}
// ===== ANALYTICS OPERATIONS =====
/**
* Get comprehensive plugin analytics
*
* @return CompletableFuture with plugin analytics
*/
public CompletableFuture<PluginAnalytics> getPluginAnalytics() {
return CompletableFuture.supplyAsync(() -> {
try {
int totalNotifications = contentDao.getTotalNotificationCount();
int totalDeliveries = deliveryDao.getTotalDeliveryCount();
int totalConfigs = configDao.getTotalConfigCount();
int successfulDeliveries = deliveryDao.getSuccessfulDeliveryCount();
int failedDeliveries = deliveryDao.getFailedDeliveryCount();
int userInteractions = deliveryDao.getUserInteractionCount();
double successRate = totalDeliveries > 0 ? (double) successfulDeliveries / totalDeliveries : 0.0;
double interactionRate = totalDeliveries > 0 ? (double) userInteractions / totalDeliveries : 0.0;
return new PluginAnalytics(
totalNotifications,
totalDeliveries,
totalConfigs,
successfulDeliveries,
failedDeliveries,
successRate,
userInteractions,
interactionRate
);
} catch (Exception e) {
Log.e(TAG, "Failed to get plugin analytics", e);
return null;
}
}, executorService);
}
// ===== DATA CLASSES =====
/**
* Delivery analytics data class
*/
public static class DeliveryAnalytics {
public final int totalDeliveries;
public final int successfulDeliveries;
public final int failedDeliveries;
public final double successRate;
public final double averageDuration;
public final int userInteractions;
public final double interactionRate;
public DeliveryAnalytics(int totalDeliveries, int successfulDeliveries, int failedDeliveries,
double successRate, double averageDuration, int userInteractions, double interactionRate) {
this.totalDeliveries = totalDeliveries;
this.successfulDeliveries = successfulDeliveries;
this.failedDeliveries = failedDeliveries;
this.successRate = successRate;
this.averageDuration = averageDuration;
this.userInteractions = userInteractions;
this.interactionRate = interactionRate;
}
@Override
public String toString() {
return String.format("DeliveryAnalytics{total=%d, successful=%d, failed=%d, successRate=%.2f%%, avgDuration=%.2fms, interactions=%d, interactionRate=%.2f%%}",
totalDeliveries, successfulDeliveries, failedDeliveries, successRate * 100, averageDuration, userInteractions, interactionRate * 100);
}
}
/**
* Cleanup results data class
*/
public static class CleanupResults {
public final int deletedNotifications;
public final int deletedDeliveries;
public final int deletedConfigs;
public CleanupResults(int deletedNotifications, int deletedDeliveries, int deletedConfigs) {
this.deletedNotifications = deletedNotifications;
this.deletedDeliveries = deletedDeliveries;
this.deletedConfigs = deletedConfigs;
}
@Override
public String toString() {
return String.format("CleanupResults{notifications=%d, deliveries=%d, configs=%d}",
deletedNotifications, deletedDeliveries, deletedConfigs);
}
}
/**
* Plugin analytics data class
*/
public static class PluginAnalytics {
public final int totalNotifications;
public final int totalDeliveries;
public final int totalConfigs;
public final int successfulDeliveries;
public final int failedDeliveries;
public final double successRate;
public final int userInteractions;
public final double interactionRate;
public PluginAnalytics(int totalNotifications, int totalDeliveries, int totalConfigs,
int successfulDeliveries, int failedDeliveries, double successRate,
int userInteractions, double interactionRate) {
this.totalNotifications = totalNotifications;
this.totalDeliveries = totalDeliveries;
this.totalConfigs = totalConfigs;
this.successfulDeliveries = successfulDeliveries;
this.failedDeliveries = failedDeliveries;
this.successRate = successRate;
this.userInteractions = userInteractions;
this.interactionRate = interactionRate;
}
@Override
public String toString() {
return String.format("PluginAnalytics{notifications=%d, deliveries=%d, configs=%d, successRate=%.2f%%, interactions=%d, interactionRate=%.2f%%}",
totalNotifications, totalDeliveries, totalConfigs, successRate * 100, userInteractions, interactionRate * 100);
}
}
/**
* Close the storage and cleanup resources
*/
public void close() {
executorService.shutdown();
Log.d(TAG, "Room-based storage closed");
}
}