diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc
new file mode 100644
index 0000000..eeed6ae
--- /dev/null
+++ b/.cursor/rules/project.mdc
@@ -0,0 +1,242 @@
+# TimeSafari Notifications — LLM Implementation Directive (v2.0)
+_Last updated: August 12, 2025_
+
+## 0) Role & Objective
+**You are the implementation LLM.** Build an **offline-first daily notifications system** for Android (Kotlin) and iOS (Swift) that **prefetches -> caches -> schedules -> displays** content **without requiring network at display time**. Favor **reliability over richness**.
+
+## 1) Golden Rules
+1. **Follow the pipeline:** **Prefetch → Cache → Schedule → Display.**
+2. **Never depend on network at display time.** All assets must be local.
+3. **Design for failure.** Always have a last-known-good and an emergency fallback.
+4. **Keep content scannable (<3s), single message, actionable.**
+5. **Measure everything** (fetch success, delivery, engagement, stale usage).
+6. **Minimize battery impact** and respect platform limitations and user settings.
+7. **Ask only when needed:** if a required input is missing, use the defaults below; otherwise proceed.
+
+## 2) Default Assumptions (use unless overridden)
+- **Product mode:** Lightweight Daily Updates (text + emoji) with option to extend to media later.
+- **Fetch size:** 1–2 KB JSON daily.
+- **User schedule default:** 07:30 local time, daily.
+- **Quiet hours:** None (app-level quiet hours supported but disabled by default).
+- **Analytics:** Local log + pluggable uploader (no-op by default).
+
+## 3) Deliverables
+Produce the following artifacts:
+
+### Android (Kotlin)
+- `:core`: models, storage, metrics, fallback manager.
+- `:data`: fetchers (WorkManager), mappers, cache policy.
+- `:notify`: scheduler (AlarmManager), receiver, channels.
+- App manifest entries & permissions.
+- Unit tests for fallback, scheduling, metrics.
+- README with battery optimization instructions (OEMs).
+
+### iOS (Swift)
+- `NotificationKit`: models, storage, metrics, fallback manager.
+- BGTaskScheduler registration + handler.
+- UNUserNotificationCenter scheduling + categories + attachments.
+- Unit tests for fallback, scheduling, metrics.
+- README with Background App Refresh caveats + Focus/Summary notes.
+
+## 4) Permissions & Required Setup
+### Android Manifest
+```xml
+
+
+
+
+```
+- Create a high-importance **NotificationChannel** `timesafari.daily`.
+- If **SCHEDULE_EXACT_ALARM** denied on Android 12+, auto-fallback to inexact.
+
+### iOS App Setup (AppDelegate / SceneDelegate)
+- Register `BGTaskScheduler` with ID `com.timesafari.daily-fetch`.
+- Request alerts, sound, badge via `UNUserNotificationCenter`.
+- Create category `DAILY_UPDATE` with a primary `View` action.
+- Ensure Background Modes: **Background fetch**, **Remote notifications** (optional for future push).
+
+## 5) Data Model (keep minimal, versioned)
+### Canonical Schema (language-agnostic)
+```
+NotificationContent v1
+- 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
+```
+### Kotlin
+```kotlin
+@Entity
+data class NotificationContent(
+ @PrimaryKey val id: String,
+ val title: String,
+ val body: String,
+ val scheduledTime: Long,
+ val mediaUrl: String?,
+ val fetchTime: Long
+)
+```
+### Swift
+```swift
+struct NotificationContent: Codable {
+ let id: String
+ let title: String
+ let body: String
+ let scheduledTime: TimeInterval
+ let mediaUrl: String?
+ let fetchTime: TimeInterval
+}
+```
+
+## 6) Storage Layers
+**Tier 1: Key-Value (quick)** — next payload, last fetch timestamp, user prefs.
+**Tier 2: DB (structured)** — history, media metadata, analytics events.
+**Tier 3: Files (large assets)** — images/audio; LRU cache & quotas.
+
+- Android: SharedPreferences/DataStore + Room + `context.cacheDir/notifications/`
+- iOS: UserDefaults + Core Data/SQLite + `Library/Caches/notifications/`
+
+## 7) Background Execution
+### Android — WorkManager
+- Periodic daily work with constraints (CONNECTED network).
+- Total time budget ~10m; use **timeouts** (e.g., fetch ≤30s, overall ≤8m).
+- On exception/timeout: **schedule from cache**; then `Result.success()` or `Result.retry()` per policy.
+
+### iOS — BGTaskScheduler
+- `BGAppRefreshTask` with aggressive time budgeting (10–30s typical).
+- Submit next request immediately at start of handler.
+- Set `expirationHandler` first; cancel tasks cleanly; **fallback to cache** on failure.
+
+## 8) Scheduling & Display
+### Android
+- Prefer `AlarmManager.setExactAndAllowWhileIdle()` if permitted; else inexact.
+- Receiver builds notification using **BigTextStyle** for long bodies.
+- Limit actions to ≤3; default: `View` (foreground intent).
+
+### iOS
+- `UNCalendarNotificationTrigger` repeating at preferred time.
+- Category `DAILY_UPDATE` with `View` action.
+- Media attachments **only if local**; otherwise skip gracefully.
+
+## 9) Fallback Hierarchy (must implement)
+1. **Foreground prefetch path** if app is open.
+2. **Background fetch** with short network timeout.
+3. **Last good cache** (annotate staleness: “as of X”).
+4. **Emergency phrases** (rotate from static list).
+
+Provide helper:
+- `withStaleMarker(content) -> content'` appends age label (e.g., “from 3h ago”).
+
+## 10) Failure Matrix & Responses
+| Scenario | Detect | Action |
+|---|---|---|
+| No network / timeout | Exceptions / status | Use last-good; schedule |
+| Invalid JSON | Parse error | Use emergency content; log |
+| Storage full | Write error | Evict old; retry minimal payload |
+| Notifications disabled | OS state | In-app education screen |
+| Background killed | Gaps in execution | Catch-up next foreground open |
+
+## 11) Metrics (local first; uploader optional)
+Track per attempt:
+```
+NotificationMetrics v1
+- scheduledTime, actualDeliveryTime?
+- contentAge (ms)
+- engagement: {TAPPED, DISMISSED, IGNORED}?
+- failureReason?
+- platformInfo (oem, os version, app state)
+```
+- Compute: **Fetch Success Rate**, **Delivery Rate**, **Engagement Rate**, **Stale Content Rate**.
+
+## 12) Testing Requirements
+### Matrix (minimum)
+- Android 12+ foreground/background/killed; with/without Battery Saver; Wi‑Fi/Mobile/Offline.
+- iOS 16+ background/Low Power/Focus/Scheduled Summary on & off.
+- Offline at trigger time (must still display).
+
+### Unit Tests (examples)
+- Fallback when fetch fails (uses last-good and marks stale).
+- Exact vs inexact scheduling path selected correctly.
+- Metrics recorded for each stage.
+
+## 13) UX Standards
+- One clear message; no clutter.
+- ≤2 actions; primary takes user into app.
+- Respect quiet hours if configured.
+- Provide onboarding: value explanation → permission request → time picker → test notification → tips for OEM battery settings (Android) or Focus/Summary (iOS).
+
+## 14) Code Stubs (must generate & wire)
+### Android — Worker (core pattern)
+```kotlin
+class DailyContentWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
+ override suspend fun doWork(): Result = try {
+ withTimeout(8.minutes) {
+ val content = fetchDailyContent(timeout = 30.seconds)
+ saveToCache(content)
+ scheduleNotification(content)
+ }
+ Result.success()
+ } catch (e: TimeoutCancellationException) {
+ scheduleFromCache(); Result.success()
+ } catch (e: Exception) {
+ scheduleFromCache(); Result.retry()
+ }
+}
+```
+### iOS — BG Refresh Handler (core pattern)
+```swift
+func handleBackgroundRefresh(_ task: BGAppRefreshTask) {
+ scheduleNextRefresh()
+ var finished = false
+ task.expirationHandler = { if !finished { cancelNetwork(); task.setTaskCompleted(success: false) } }
+ fetchDailyContent(timeout: 15) { result in
+ defer { finished = true; task.setTaskCompleted(success: result.isSuccess) }
+ switch result {
+ case .success(let content): quickSave(content); scheduleNotification(content)
+ case .failure: scheduleFromCache()
+ }
+ }
+}
+```
+
+## 15) Security & Privacy
+- Use HTTPS; pin if required.
+- Strip PII from payloads; keep content generic by default.
+- Store only what is necessary; apply cache quotas; purge on logout/uninstall.
+- Respect OS privacy settings (Focus, Scheduled Summary, Quiet Hours).
+
+## 16) Troubleshooting Playbook (LLM should generate helpers)
+- Android: verify permission, channel, OEM battery settings; `adb shell dumpsys notification`.
+- iOS: check authorization, Background App Refresh, Low Power, Focus/Summary state.
+
+## 17) Roadmap Flags (implement behind switches)
+- `FEATURE_MEDIA_ATTACHMENTS` (default off).
+- `FEATURE_PERSONALIZATION_ENGINE` (time/frequency, content types).
+- `FEATURE_PUSH_REALTIME` (server-driven for urgent alerts).
+
+## 18) Definition of Done
+- Notifications deliver daily at user-selected time **without network**.
+- Graceful fallback chain proven by tests.
+- Metrics recorded locally; viewable log.
+- Clear onboarding and self-diagnostic screen.
+- Battery/OS constraints documented; user education available.
+
+## 19) Quick Start (LLM execution order)
+1. Scaffold modules (Android + iOS).
+2. Implement models + storage + fallback content.
+3. Implement schedulers (AlarmManager / UNCalendarNotificationTrigger).
+4. Implement background fetchers (WorkManager / BGTaskScheduler).
+5. Wire onboarding + test notification.
+6. Add metrics logging.
+7. Ship minimal, then iterate.
+
+---
+
+### Appendix A — Emergency Fallback Lines
+- "🌅 Good morning! Ready to make today amazing?"
+- "💪 Every small step forward counts. You've got this!"
+- "🎯 Focus on what you can control today."
+- "✨ Your potential is limitless. Keep growing!"
+- "🌟 Progress over perfection, always."
diff --git a/.gitignore b/.gitignore
index b8ad22e..9ff8602 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,5 @@ logs/
*.tmp
*.temp
.cache/
+*.lock
+*.bin
diff --git a/.gradle/8.13/checksums/checksums.lock b/.gradle/8.13/checksums/checksums.lock
index 955a166..9d4c628 100644
Binary files a/.gradle/8.13/checksums/checksums.lock and b/.gradle/8.13/checksums/checksums.lock differ
diff --git a/.gradle/8.13/checksums/md5-checksums.bin b/.gradle/8.13/checksums/md5-checksums.bin
index 3885ba2..b38e0b3 100644
Binary files a/.gradle/8.13/checksums/md5-checksums.bin and b/.gradle/8.13/checksums/md5-checksums.bin differ
diff --git a/.gradle/8.13/checksums/sha1-checksums.bin b/.gradle/8.13/checksums/sha1-checksums.bin
index 3b9ce33..864ee7a 100644
Binary files a/.gradle/8.13/checksums/sha1-checksums.bin and b/.gradle/8.13/checksums/sha1-checksums.bin differ
diff --git a/.gradle/8.13/fileHashes/fileHashes.bin b/.gradle/8.13/fileHashes/fileHashes.bin
index be451d0..fc2d3e8 100644
Binary files a/.gradle/8.13/fileHashes/fileHashes.bin and b/.gradle/8.13/fileHashes/fileHashes.bin differ
diff --git a/.gradle/8.13/fileHashes/fileHashes.lock b/.gradle/8.13/fileHashes/fileHashes.lock
index 555496f..5e224e2 100644
Binary files a/.gradle/8.13/fileHashes/fileHashes.lock and b/.gradle/8.13/fileHashes/fileHashes.lock differ
diff --git a/docs/TODO.md b/docs/TODO.md
new file mode 100644
index 0000000..bf6572a
--- /dev/null
+++ b/docs/TODO.md
@@ -0,0 +1,203 @@
+# Daily Notification Plugin - Task TODO List
+
+## Project Overview
+**Objective**: Build an offline-first daily notifications system for Android (Kotlin) and iOS (Swift) that follows the **Prefetch → Cache → Schedule → Display** pipeline without requiring network at display time.
+
+**Priority**: Reliability over richness
+
+## Phase 1: Foundation (Week 1) - HIGH PRIORITY
+
+### 1.1 Restore Android Implementation
+- [ ] Create native Android plugin structure
+- [ ] Implement WorkManager for background content fetching
+- [ ] Add AlarmManager for notification scheduling
+- [ ] Create notification channels and permissions
+- [ ] Implement battery optimization handling
+- [ ] Add proper error handling and logging
+
+### 1.2 Fix Interface Definitions
+- [ ] Align TypeScript interfaces with project requirements
+- [ ] Add missing properties referenced in tests
+- [ ] Implement proper validation utilities
+- [ ] Create comprehensive error types
+- [ ] Add retry mechanism interfaces
+
+### 1.3 Fix Test Suite
+- [ ] Update all test files to match current interfaces
+- [ ] Implement proper mock objects
+- [ ] Fix TypeScript compilation errors
+- [ ] Ensure 100% test coverage
+- [ ] Add integration tests for native platforms
+
+## Phase 2: Core Pipeline Implementation (Week 2) - HIGH PRIORITY
+
+### 2.1 Prefetch System
+- [ ] Implement background content fetching
+- [ ] Add network timeout handling (30s max)
+- [ ] Create content validation system
+- [ ] Implement retry mechanisms with exponential backoff
+- [ ] Add network state monitoring
+
+### 2.2 Caching Layer
+- [ ] Create local storage for notifications
+- [ ] Implement cache eviction policies (LRU)
+- [ ] Add offline content management
+- [ ] Create cache quota management
+- [ ] Implement cache versioning
+
+### 2.3 Enhanced Scheduling
+- [ ] Implement reliable notification delivery
+- [ ] Add platform-specific optimizations
+- [ ] Handle battery optimization settings
+- [ ] Create adaptive scheduling based on device state
+- [ ] Add quiet hours support
+
+## Phase 3: Production Features (Week 3) - MEDIUM PRIORITY
+
+### 3.1 Fallback System
+- [ ] Implement emergency content rotation
+- [ ] Add stale content marking ("from X ago")
+- [ ] Create graceful degradation paths
+- [ ] Implement last-known-good fallback
+- [ ] Add offline fallback content
+
+### 3.2 Metrics & Monitoring
+- [ ] Create local analytics collection
+- [ ] Implement performance monitoring
+- [ ] Add error tracking and reporting
+- [ ] Create metrics dashboard
+- [ ] Implement user engagement tracking
+
+### 3.3 Security & Privacy
+- [ ] Add input validation for all user inputs
+- [ ] Implement secure storage for sensitive data
+- [ ] Create proper permission handling
+- [ ] Add network security (HTTPS, certificate pinning)
+- [ ] Implement audit logging
+
+## Phase 4: Advanced Features (Week 4) - LOW PRIORITY
+
+### 4.1 User Experience
+- [ ] Create onboarding flow
+- [ ] Add permission request handling
+- [ ] Implement time picker interface
+- [ ] Add test notification functionality
+- [ ] Create troubleshooting guides
+
+### 4.2 Platform Optimizations
+- [ ] Android: OEM battery settings education
+- [ ] iOS: Focus/Summary mode handling
+- [ ] Web: Service worker implementation
+- [ ] Cross-platform synchronization
+- [ ] Performance optimization
+
+### 4.3 Enterprise Features
+- [ ] Multi-tenant support
+- [ ] Advanced analytics
+- [ ] Custom notification templates
+- [ ] Integration with external services
+- [ ] A/B testing support
+
+## Technical Requirements
+
+### Android Implementation
+- [ ] Use WorkManager for background tasks
+- [ ] Implement AlarmManager for exact scheduling
+- [ ] Create notification channels with high importance
+- [ ] Handle SCHEDULE_EXACT_ALARM permission
+- [ ] Add battery optimization exemption requests
+
+### iOS Implementation
+- [ ] Use BGTaskScheduler for background refresh
+- [ ] Implement UNCalendarNotificationTrigger
+- [ ] Create DAILY_UPDATE notification category
+- [ ] Handle Background App Refresh settings
+- [ ] Add Focus/Summary mode support
+
+### Data Model
+- [ ] Implement NotificationContent v1 schema
+- [ ] Add versioning support
+- [ ] Create storage abstraction layers
+- [ ] Implement cache policies
+- [ ] Add analytics event tracking
+
+## Testing Requirements
+
+### Unit Tests
+- [ ] Fallback when fetch fails
+- [ ] Exact vs inexact scheduling path selection
+- [ ] Metrics recording for each stage
+- [ ] Cache eviction policies
+- [ ] Error handling scenarios
+
+### Integration Tests
+- [ ] Android foreground/background/killed scenarios
+- [ ] iOS background/Low Power/Focus modes
+- [ ] Offline at trigger time
+- [ ] Battery saver mode handling
+- [ ] Network state changes
+
+### Performance Tests
+- [ ] Memory usage monitoring
+- [ ] Battery impact measurement
+- [ ] Notification delivery latency
+- [ ] Cache performance
+- [ ] Background task efficiency
+
+## Documentation Requirements
+
+### API Documentation
+- [ ] Complete method documentation
+- [ ] Usage examples for each platform
+- [ ] Error handling guides
+- [ ] Performance optimization tips
+- [ ] Troubleshooting playbook
+
+### User Guides
+- [ ] Installation instructions
+- [ ] Configuration guide
+- [ ] Platform-specific setup
+- [ ] Battery optimization tips
+- [ ] Common issues and solutions
+
+## Security Checklist
+
+- [ ] Input validation for all parameters
+- [ ] Secure storage implementation
+- [ ] Permission handling
+- [ ] Network security
+- [ ] Error handling without information leakage
+- [ ] Audit logging
+- [ ] Privacy compliance
+- [ ] Secure defaults
+
+## Definition of Done
+
+- [ ] Notifications deliver daily at user-selected time without network
+- [ ] Graceful fallback chain proven by tests
+- [ ] Metrics recorded locally and viewable
+- [ ] Clear onboarding and self-diagnostic screen
+- [ ] Battery/OS constraints documented
+- [ ] User education available for platform-specific settings
+
+## Current Status
+
+**Build Status**: ✅ Working
+**Test Status**: ❌ 13/13 tests failing
+**Android Implementation**: ❌ Missing
+**iOS Implementation**: ✅ Basic implementation exists
+**Web Implementation**: ⚠️ Placeholder only
+**Core Pipeline**: ❌ Not implemented
+
+## Next Immediate Actions
+
+1. **Start Android Implementation** - Create native plugin structure
+2. **Fix Interface Definitions** - Align with project requirements
+3. **Update Test Suite** - Fix compilation errors and implement mocks
+4. **Implement Core Pipeline** - Begin prefetch → cache → schedule → display flow
+
+---
+
+**Last Updated**: December 2024
+**Author**: Matthew Raymer
+**Priority**: High - Foundation work needed before advanced features
diff --git a/src/android/DailyNotificationFetchWorker.java b/src/android/DailyNotificationFetchWorker.java
new file mode 100644
index 0000000..029714d
--- /dev/null
+++ b/src/android/DailyNotificationFetchWorker.java
@@ -0,0 +1,395 @@
+/**
+ * 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.concurrent.TimeUnit;
+
+/**
+ * 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
+ private static final long FETCH_TIMEOUT_MS = 30 * 1000; // 30 seconds for fetch
+
+ private final Context context;
+ private final DailyNotificationStorage storage;
+ private final DailyNotificationFetcher fetcher;
+
+ /**
+ * 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("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();
+ }
+
+ // Attempt to fetch content with timeout
+ NotificationContent content = fetchContentWithTimeout();
+
+ if (content != null) {
+ // Success - save content and schedule notification
+ handleSuccessfulFetch(content);
+ 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
+ *
+ * @return Fetched content or null if failed
+ */
+ private NotificationContent fetchContentWithTimeout() {
+ try {
+ Log.d(TAG, "Fetching content with timeout: " + FETCH_TIMEOUT_MS + "ms");
+
+ // Use a simple timeout mechanism
+ // In production, you might use CompletableFuture with timeout
+ long startTime = System.currentTimeMillis();
+
+ // Attempt fetch
+ NotificationContent content = fetcher.fetchContentImmediately();
+
+ long fetchDuration = System.currentTimeMillis() - startTime;
+
+ if (content != null) {
+ Log.d(TAG, "Content fetched successfully in " + fetchDuration + "ms");
+ return content;
+ } else {
+ Log.w(TAG, "Content fetch returned null after " + fetchDuration + "ms");
+ return null;
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error during content fetch", e);
+ return null;
+ }
+ }
+
+ /**
+ * Handle successful content fetch
+ *
+ * @param content Successfully fetched content
+ */
+ private void handleSuccessfulFetch(NotificationContent content) {
+ try {
+ Log.d(TAG, "Handling successful content fetch: " + content.getId());
+
+ // Content is already saved by the fetcher
+ // Update last fetch time
+ storage.setLastFetchTime(System.currentTimeMillis());
+
+ // Schedule notification if not already scheduled
+ scheduleNotificationIfNeeded(content);
+
+ Log.i(TAG, "Successful fetch handling completed");
+
+ } catch (Exception e) {
+ Log.e(TAG, "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, "Handling failed fetch - Retry: " + retryCount);
+
+ if (retryCount < MAX_RETRY_ATTEMPTS) {
+ // Schedule retry
+ scheduleRetry(retryCount + 1, scheduledTime);
+ Log.i(TAG, "Scheduled retry attempt " + (retryCount + 1));
+ return Result.retry();
+
+ } else {
+ // Max retries reached - use fallback content
+ Log.w(TAG, "Max retries reached, using fallback content");
+ useFallbackContent(scheduledTime);
+ return Result.success();
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "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
+ *
+ * @param retryCount Current retry attempt
+ * @return Delay in milliseconds
+ */
+ private long calculateRetryDelay(int retryCount) {
+ // Base delay: 1 minute, exponential backoff: 2^retryCount
+ long baseDelay = 60 * 1000; // 1 minute
+ long exponentialDelay = baseDelay * (long) Math.pow(2, retryCount - 1);
+
+ // Cap at 1 hour
+ long maxDelay = 60 * 60 * 1000; // 1 hour
+ return Math.min(exponentialDelay, maxDelay);
+ }
+
+ /**
+ * 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());
+ fallbackContent.setFetchTime(System.currentTimeMillis());
+
+ 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);
+ content.setFetchTime(System.currentTimeMillis());
+ 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);
+ }
+ }
+}
diff --git a/src/android/DailyNotificationFetcher.java b/src/android/DailyNotificationFetcher.java
new file mode 100644
index 0000000..231be66
--- /dev/null
+++ b/src/android/DailyNotificationFetcher.java
@@ -0,0 +1,364 @@
+/**
+ * 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.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;
+ private final WorkManager workManager;
+
+ /**
+ * Constructor
+ *
+ * @param context Application context
+ * @param storage Storage instance for saving fetched content
+ */
+ public DailyNotificationFetcher(Context context, DailyNotificationStorage storage) {
+ this.context = context;
+ this.storage = storage;
+ this.workManager = WorkManager.getInstance(context);
+ }
+
+ /**
+ * Schedule a background fetch for content
+ *
+ * @param scheduledTime When the notification is scheduled for
+ */
+ public void scheduleFetch(long scheduledTime) {
+ try {
+ Log.d(TAG, "Scheduling background fetch for " + scheduledTime);
+
+ // Calculate fetch time (1 hour before notification)
+ long fetchTime = scheduledTime - TimeUnit.HOURS.toMillis(1);
+
+ if (fetchTime > System.currentTimeMillis()) {
+ // Create work data
+ Data inputData = new Data.Builder()
+ .putLong("scheduled_time", scheduledTime)
+ .putLong("fetch_time", fetchTime)
+ .putInt("retry_count", 0)
+ .build();
+
+ // Create one-time work request
+ OneTimeWorkRequest fetchWork = new OneTimeWorkRequest.Builder(
+ DailyNotificationFetchWorker.class)
+ .setInputData(inputData)
+ .addTag(WORK_TAG_FETCH)
+ .setInitialDelay(fetchTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS)
+ .build();
+
+ // Enqueue the work
+ workManager.enqueue(fetchWork);
+
+ Log.i(TAG, "Background fetch scheduled successfully");
+
+ } else {
+ Log.w(TAG, "Fetch time has already passed, scheduling immediate fetch");
+ 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 storage
+ storage.saveNotificationContent(content);
+ storage.setLastFetchTime(System.currentTimeMillis());
+
+ 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();
+ }
+ }
+
+ /**
+ * Fetch content from network with timeout
+ *
+ * @return Fetched content or null if failed
+ */
+ private NotificationContent fetchFromNetwork() {
+ HttpURLConnection connection = null;
+
+ try {
+ // Create connection to content endpoint
+ URL url = new URL(getContentEndpoint());
+ connection = (HttpURLConnection) url.openConnection();
+
+ // Set timeout
+ connection.setConnectTimeout(NETWORK_TIMEOUT_MS);
+ connection.setReadTimeout(NETWORK_TIMEOUT_MS);
+ connection.setRequestMethod("GET");
+
+ // Add headers
+ connection.setRequestProperty("User-Agent", "TimeSafari-DailyNotification/1.0");
+ connection.setRequestProperty("Accept", "application/json");
+
+ // Connect and check response
+ int responseCode = connection.getResponseCode();
+
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ // Parse response and create notification content
+ NotificationContent content = parseNetworkResponse(connection);
+
+ if (content != null) {
+ Log.d(TAG, "Content fetched from network successfully");
+ return content;
+ }
+
+ } else {
+ Log.w(TAG, "Network request failed with response code: " + responseCode);
+ }
+
+ } catch (IOException e) {
+ Log.e(TAG, "Network error during content fetch", e);
+ } catch (Exception e) {
+ Log.e(TAG, "Unexpected error during network fetch", e);
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+
+ 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));
+ content.setFetchTime(System.currentTimeMillis());
+
+ return content;
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error parsing network response", 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));
+ content.setFetchTime(System.currentTimeMillis());
+ 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");
+ }
+}
diff --git a/src/android/DailyNotificationMaintenanceWorker.java b/src/android/DailyNotificationMaintenanceWorker.java
new file mode 100644
index 0000000..0d86046
--- /dev/null
+++ b/src/android/DailyNotificationMaintenanceWorker.java
@@ -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 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 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.getFetchTime() <= 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);
+ }
+ }
+}
diff --git a/src/android/DailyNotificationPlugin.java b/src/android/DailyNotificationPlugin.java
new file mode 100644
index 0000000..a292663
--- /dev/null
+++ b/src/android/DailyNotificationPlugin.java
@@ -0,0 +1,506 @@
+/**
+ * DailyNotificationPlugin.java
+ *
+ * Android implementation of the Daily Notification Plugin for Capacitor
+ * Implements offline-first daily notifications with prefetch → cache → schedule → display pipeline
+ *
+ * @author Matthew Raymer
+ * @version 1.0.0
+ */
+
+package com.timesafari.dailynotification;
+
+import android.Manifest;
+import android.app.AlarmManager;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.PowerManager;
+import android.util.Log;
+
+import androidx.core.app.NotificationCompat;
+import androidx.work.WorkManager;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.annotation.CapacitorPlugin;
+import com.getcapacitor.annotation.Permission;
+
+import java.util.Calendar;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Main plugin class for handling daily notifications on Android
+ *
+ * This plugin provides functionality for scheduling and managing daily notifications
+ * with offline-first approach, background content fetching, and reliable delivery.
+ */
+@CapacitorPlugin(
+ name = "DailyNotification",
+ permissions = {
+ @Permission(
+ alias = "notifications",
+ strings = {
+ Manifest.permission.POST_NOTIFICATIONS,
+ Manifest.permission.SCHEDULE_EXACT_ALARM,
+ Manifest.permission.WAKE_LOCK,
+ Manifest.permission.INTERNET
+ }
+ )
+ }
+)
+public class DailyNotificationPlugin extends Plugin {
+
+ private static final String TAG = "DailyNotificationPlugin";
+ private static final String CHANNEL_ID = "timesafari.daily";
+ private static final String CHANNEL_NAME = "Daily Notifications";
+ private static final String CHANNEL_DESCRIPTION = "Daily notification updates from TimeSafari";
+
+ private NotificationManager notificationManager;
+ private AlarmManager alarmManager;
+ private WorkManager workManager;
+ private PowerManager powerManager;
+ private DailyNotificationStorage storage;
+ private DailyNotificationScheduler scheduler;
+ private DailyNotificationFetcher fetcher;
+
+ /**
+ * Initialize the plugin and create notification channel
+ */
+ @Override
+ public void load() {
+ super.load();
+
+ try {
+ // Initialize system services
+ notificationManager = (NotificationManager) getContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ alarmManager = (AlarmManager) getContext()
+ .getSystemService(Context.ALARM_SERVICE);
+ workManager = WorkManager.getInstance(getContext());
+ powerManager = (PowerManager) getContext()
+ .getSystemService(Context.POWER_SERVICE);
+
+ // Initialize components
+ storage = new DailyNotificationStorage(getContext());
+ scheduler = new DailyNotificationScheduler(getContext(), alarmManager);
+ fetcher = new DailyNotificationFetcher(getContext(), storage);
+
+ // Create notification channel
+ createNotificationChannel();
+
+ // Schedule next maintenance
+ scheduleMaintenance();
+
+ Log.i(TAG, "DailyNotificationPlugin initialized successfully");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to initialize DailyNotificationPlugin", e);
+ }
+ }
+
+ /**
+ * Schedule a daily notification with the specified options
+ *
+ * @param call Plugin call containing notification parameters
+ */
+ @PluginMethod
+ public void scheduleDailyNotification(PluginCall call) {
+ try {
+ Log.d(TAG, "Scheduling daily notification");
+
+ // Validate required parameters
+ String time = call.getString("time");
+ if (time == null || time.isEmpty()) {
+ call.reject("Time parameter is required");
+ return;
+ }
+
+ // Parse time (HH:mm format)
+ String[] timeParts = time.split(":");
+ if (timeParts.length != 2) {
+ call.reject("Invalid time format. Use HH:mm");
+ return;
+ }
+
+ int hour, minute;
+ try {
+ hour = Integer.parseInt(timeParts[0]);
+ minute = Integer.parseInt(timeParts[1]);
+ } catch (NumberFormatException e) {
+ call.reject("Invalid time format. Use HH:mm");
+ return;
+ }
+
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+ call.reject("Invalid time values");
+ return;
+ }
+
+ // Extract other parameters
+ String title = call.getString("title", "Daily Update");
+ String body = call.getString("body", "Your daily notification is ready");
+ boolean sound = call.getBoolean("sound", true);
+ String priority = call.getString("priority", "default");
+ String url = call.getString("url", "");
+
+ // Create notification content
+ NotificationContent content = new NotificationContent();
+ content.setTitle(title);
+ content.setBody(body);
+ content.setSound(sound);
+ content.setPriority(priority);
+ content.setUrl(url);
+ content.setScheduledTime(calculateNextScheduledTime(hour, minute));
+
+ // Store notification content
+ storage.saveNotificationContent(content);
+
+ // Schedule the notification
+ boolean scheduled = scheduler.scheduleNotification(content);
+
+ if (scheduled) {
+ // Schedule background fetch for next day
+ scheduleBackgroundFetch(content.getScheduledTime());
+
+ Log.i(TAG, "Daily notification scheduled successfully for " + time);
+ call.resolve();
+ } else {
+ call.reject("Failed to schedule notification");
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling daily notification", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Get the last notification that was delivered
+ *
+ * @param call Plugin call
+ */
+ @PluginMethod
+ public void getLastNotification(PluginCall call) {
+ try {
+ Log.d(TAG, "Getting last notification");
+
+ NotificationContent lastNotification = storage.getLastNotification();
+
+ if (lastNotification != null) {
+ JSObject result = new JSObject();
+ result.put("id", lastNotification.getId());
+ result.put("title", lastNotification.getTitle());
+ result.put("body", lastNotification.getBody());
+ result.put("timestamp", lastNotification.getScheduledTime());
+ result.put("url", lastNotification.getUrl());
+
+ call.resolve(result);
+ } else {
+ call.resolve(null);
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting last notification", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Cancel all scheduled notifications
+ *
+ * @param call Plugin call
+ */
+ @PluginMethod
+ public void cancelAllNotifications(PluginCall call) {
+ try {
+ Log.d(TAG, "Cancelling all notifications");
+
+ scheduler.cancelAllNotifications();
+ storage.clearAllNotifications();
+
+ Log.i(TAG, "All notifications cancelled successfully");
+ call.resolve();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error cancelling notifications", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Get the current status of notifications
+ *
+ * @param call Plugin call
+ */
+ @PluginMethod
+ public void getNotificationStatus(PluginCall call) {
+ try {
+ Log.d(TAG, "Getting notification status");
+
+ JSObject result = new JSObject();
+
+ // Check if notifications are enabled
+ boolean notificationsEnabled = areNotificationsEnabled();
+ result.put("isEnabled", notificationsEnabled);
+
+ // Get next notification time
+ long nextNotificationTime = scheduler.getNextNotificationTime();
+ result.put("nextNotificationTime", nextNotificationTime);
+
+ // Get current settings
+ JSObject settings = new JSObject();
+ settings.put("sound", true);
+ settings.put("priority", "default");
+ settings.put("timezone", "UTC");
+ result.put("settings", settings);
+
+ // Get pending notifications count
+ int pendingCount = scheduler.getPendingNotificationsCount();
+ result.put("pending", pendingCount);
+
+ call.resolve(result);
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting notification status", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Update notification settings
+ *
+ * @param call Plugin call containing new settings
+ */
+ @PluginMethod
+ public void updateSettings(PluginCall call) {
+ try {
+ Log.d(TAG, "Updating notification settings");
+
+ // Extract settings
+ Boolean sound = call.getBoolean("sound");
+ String priority = call.getString("priority");
+ String timezone = call.getString("timezone");
+
+ // Update settings in storage
+ if (sound != null) {
+ storage.setSoundEnabled(sound);
+ }
+ if (priority != null) {
+ storage.setPriority(priority);
+ }
+ if (timezone != null) {
+ storage.setTimezone(timezone);
+ }
+
+ // Update existing notifications with new settings
+ scheduler.updateNotificationSettings();
+
+ Log.i(TAG, "Notification settings updated successfully");
+ call.resolve();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error updating notification settings", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Get battery status information
+ *
+ * @param call Plugin call
+ */
+ @PluginMethod
+ public void getBatteryStatus(PluginCall call) {
+ try {
+ Log.d(TAG, "Getting battery status");
+
+ JSObject result = new JSObject();
+
+ // Get battery level (simplified - would need BatteryManager in real implementation)
+ result.put("level", 100); // Placeholder
+ result.put("isCharging", false); // Placeholder
+ result.put("powerState", 0); // Placeholder
+ result.put("isOptimizationExempt", false); // Placeholder
+
+ call.resolve(result);
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting battery status", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Request battery optimization exemption
+ *
+ * @param call Plugin call
+ */
+ @PluginMethod
+ public void requestBatteryOptimizationExemption(PluginCall call) {
+ try {
+ Log.d(TAG, "Requesting battery optimization exemption");
+
+ // This would typically open system settings
+ // For now, just log the request
+ Log.i(TAG, "Battery optimization exemption requested");
+ call.resolve();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error requesting battery optimization exemption", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Set adaptive scheduling based on device state
+ *
+ * @param call Plugin call containing enabled flag
+ */
+ @PluginMethod
+ public void setAdaptiveScheduling(PluginCall call) {
+ try {
+ Log.d(TAG, "Setting adaptive scheduling");
+
+ boolean enabled = call.getBoolean("enabled", true);
+ storage.setAdaptiveSchedulingEnabled(enabled);
+
+ if (enabled) {
+ scheduler.enableAdaptiveScheduling();
+ } else {
+ scheduler.disableAdaptiveScheduling();
+ }
+
+ Log.i(TAG, "Adaptive scheduling " + (enabled ? "enabled" : "disabled"));
+ call.resolve();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error setting adaptive scheduling", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Get current power state information
+ *
+ * @param call Plugin call
+ */
+ @PluginMethod
+ public void getPowerState(PluginCall call) {
+ try {
+ Log.d(TAG, "Getting power state");
+
+ JSObject result = new JSObject();
+ result.put("powerState", 0); // Placeholder
+ result.put("isOptimizationExempt", false); // Placeholder
+
+ call.resolve(result);
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting power state", e);
+ call.reject("Internal error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Create the notification channel for Android 8.0+
+ */
+ private void createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH
+ );
+ channel.setDescription(CHANNEL_DESCRIPTION);
+ channel.enableLights(true);
+ channel.enableVibration(true);
+
+ notificationManager.createNotificationChannel(channel);
+ Log.d(TAG, "Notification channel created: " + CHANNEL_ID);
+ }
+ }
+
+ /**
+ * Calculate the next scheduled time for the notification
+ *
+ * @param hour Hour of day (0-23)
+ * @param minute Minute of hour (0-59)
+ * @return Timestamp in milliseconds
+ */
+ private long calculateNextScheduledTime(int hour, int minute) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.set(Calendar.HOUR_OF_DAY, hour);
+ calendar.set(Calendar.MINUTE, minute);
+ calendar.set(Calendar.SECOND, 0);
+ calendar.set(Calendar.MILLISECOND, 0);
+
+ // If time has passed today, schedule for tomorrow
+ if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
+ calendar.add(Calendar.DAY_OF_YEAR, 1);
+ }
+
+ return calendar.getTimeInMillis();
+ }
+
+ /**
+ * Schedule background fetch for content
+ *
+ * @param scheduledTime When the notification is scheduled for
+ */
+ private void scheduleBackgroundFetch(long scheduledTime) {
+ try {
+ // Schedule fetch 1 hour before notification
+ long fetchTime = scheduledTime - TimeUnit.HOURS.toMillis(1);
+
+ if (fetchTime > System.currentTimeMillis()) {
+ fetcher.scheduleFetch(fetchTime);
+ Log.d(TAG, "Background fetch scheduled for " + fetchTime);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling background fetch", e);
+ }
+ }
+
+ /**
+ * Schedule maintenance tasks
+ */
+ private void scheduleMaintenance() {
+ try {
+ // Schedule daily maintenance at 2 AM
+ Calendar calendar = Calendar.getInstance();
+ calendar.set(Calendar.HOUR_OF_DAY, 2);
+ calendar.set(Calendar.MINUTE, 0);
+ calendar.set(Calendar.SECOND, 0);
+
+ if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
+ calendar.add(Calendar.DAY_OF_YEAR, 1);
+ }
+
+ // This would typically use WorkManager for maintenance
+ Log.d(TAG, "Maintenance scheduled for " + calendar.getTimeInMillis());
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling maintenance", e);
+ }
+ }
+
+ /**
+ * Check if notifications are enabled
+ *
+ * @return true if notifications are enabled
+ */
+ private boolean areNotificationsEnabled() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ return getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED;
+ }
+ return NotificationManagerCompat.from(getContext()).areNotificationsEnabled();
+ }
+}
diff --git a/src/android/DailyNotificationReceiver.java b/src/android/DailyNotificationReceiver.java
new file mode 100644
index 0000000..76a8b1f
--- /dev/null
+++ b/src/android/DailyNotificationReceiver.java
@@ -0,0 +1,283 @@
+/**
+ * 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.util.Log;
+
+import androidx.core.app.NotificationCompat;
+
+/**
+ * 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
+ *
+ * @param context Application context
+ * @param intent Broadcast intent
+ */
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ try {
+ Log.d(TAG, "Received notification broadcast");
+
+ String action = intent.getAction();
+ if (action == null) {
+ Log.w(TAG, "Received intent with null action");
+ return;
+ }
+
+ if ("com.timesafari.daily.NOTIFICATION".equals(action)) {
+ handleNotificationIntent(context, intent);
+ } else {
+ Log.w(TAG, "Unknown action: " + action);
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error handling broadcast", 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;
+ }
+
+ // 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);
+ }
+ }
+
+ /**
+ * 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());
+ nextContent.setFetchTime(System.currentTimeMillis());
+
+ // 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);
+ }
+ }
+}
diff --git a/src/android/DailyNotificationScheduler.java b/src/android/DailyNotificationScheduler.java
new file mode 100644
index 0000000..13cbb1b
--- /dev/null
+++ b/src/android/DailyNotificationScheduler.java
@@ -0,0 +1,377 @@
+/**
+ * DailyNotificationScheduler.java
+ *
+ * Handles scheduling and timing of daily notifications
+ * Implements exact and inexact alarm scheduling with battery optimization handling
+ *
+ * @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.os.Build;
+import android.util.Log;
+
+import java.util.Calendar;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Manages scheduling of daily notifications using AlarmManager
+ *
+ * This class handles the scheduling aspect of the prefetch → cache → schedule → display pipeline.
+ * It supports both exact and inexact alarms based on system permissions and battery optimization.
+ */
+public class DailyNotificationScheduler {
+
+ private static final String TAG = "DailyNotificationScheduler";
+ private static final String ACTION_NOTIFICATION = "com.timesafari.daily.NOTIFICATION";
+ private static final String EXTRA_NOTIFICATION_ID = "notification_id";
+
+ private final Context context;
+ private final AlarmManager alarmManager;
+ private final ConcurrentHashMap scheduledAlarms;
+
+ /**
+ * Constructor
+ *
+ * @param context Application context
+ * @param alarmManager System AlarmManager service
+ */
+ public DailyNotificationScheduler(Context context, AlarmManager alarmManager) {
+ this.context = context;
+ this.alarmManager = alarmManager;
+ this.scheduledAlarms = new ConcurrentHashMap<>();
+ }
+
+ /**
+ * Schedule a notification for delivery
+ *
+ * @param content Notification content to schedule
+ * @return true if scheduling was successful
+ */
+ public boolean scheduleNotification(NotificationContent content) {
+ try {
+ Log.d(TAG, "Scheduling notification: " + content.getId());
+
+ // Cancel any existing alarm for this notification
+ cancelNotification(content.getId());
+
+ // Create intent for the notification
+ Intent intent = new Intent(context, DailyNotificationReceiver.class);
+ intent.setAction(ACTION_NOTIFICATION);
+ intent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
+
+ // Create pending intent with unique request code
+ int requestCode = content.getId().hashCode();
+ PendingIntent pendingIntent = PendingIntent.getBroadcast(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ );
+
+ // Store the pending intent
+ scheduledAlarms.put(content.getId(), pendingIntent);
+
+ // Schedule the alarm
+ long triggerTime = content.getScheduledTime();
+ boolean scheduled = scheduleAlarm(pendingIntent, triggerTime);
+
+ if (scheduled) {
+ Log.i(TAG, "Notification scheduled successfully for " +
+ formatTime(triggerTime));
+ return true;
+ } else {
+ Log.e(TAG, "Failed to schedule notification");
+ scheduledAlarms.remove(content.getId());
+ return false;
+ }
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling notification", e);
+ return false;
+ }
+ }
+
+ /**
+ * Schedule an alarm using the best available method
+ *
+ * @param pendingIntent PendingIntent to trigger
+ * @param triggerTime When to trigger the alarm
+ * @return true if scheduling was successful
+ */
+ private boolean scheduleAlarm(PendingIntent pendingIntent, long triggerTime) {
+ try {
+ // Check if we can use exact alarms
+ if (canUseExactAlarms()) {
+ return scheduleExactAlarm(pendingIntent, triggerTime);
+ } else {
+ return scheduleInexactAlarm(pendingIntent, triggerTime);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling alarm", e);
+ return false;
+ }
+ }
+
+ /**
+ * Schedule an exact alarm for precise timing
+ *
+ * @param pendingIntent PendingIntent to trigger
+ * @param triggerTime When to trigger the alarm
+ * @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
+ );
+ } else {
+ alarmManager.setExact(
+ AlarmManager.RTC_WAKEUP,
+ triggerTime,
+ pendingIntent
+ );
+ }
+
+ Log.d(TAG, "Exact alarm scheduled for " + formatTime(triggerTime));
+ return true;
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling exact alarm", e);
+ return false;
+ }
+ }
+
+ /**
+ * Schedule an inexact alarm for battery optimization
+ *
+ * @param pendingIntent PendingIntent to trigger
+ * @param triggerTime When to trigger the alarm
+ * @return true if scheduling was successful
+ */
+ private boolean scheduleInexactAlarm(PendingIntent pendingIntent, long triggerTime) {
+ try {
+ alarmManager.setRepeating(
+ AlarmManager.RTC_WAKEUP,
+ triggerTime,
+ AlarmManager.INTERVAL_DAY,
+ pendingIntent
+ );
+
+ Log.d(TAG, "Inexact alarm scheduled for " + formatTime(triggerTime));
+ return true;
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error scheduling inexact alarm", e);
+ return false;
+ }
+ }
+
+ /**
+ * Check if we can use exact alarms
+ *
+ * @return true if exact alarms are permitted
+ */
+ private boolean canUseExactAlarms() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ return alarmManager.canScheduleExactAlarms();
+ }
+ return true; // Pre-Android 12 always allowed exact alarms
+ }
+
+ /**
+ * Cancel a specific notification
+ *
+ * @param notificationId ID of notification to cancel
+ */
+ public void cancelNotification(String notificationId) {
+ try {
+ PendingIntent pendingIntent = scheduledAlarms.remove(notificationId);
+ if (pendingIntent != null) {
+ alarmManager.cancel(pendingIntent);
+ pendingIntent.cancel();
+ Log.d(TAG, "Cancelled notification: " + notificationId);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error cancelling notification: " + notificationId, e);
+ }
+ }
+
+ /**
+ * Cancel all scheduled notifications
+ */
+ public void cancelAllNotifications() {
+ try {
+ Log.d(TAG, "Cancelling all notifications");
+
+ for (String notificationId : scheduledAlarms.keySet()) {
+ cancelNotification(notificationId);
+ }
+
+ scheduledAlarms.clear();
+ Log.i(TAG, "All notifications cancelled");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error cancelling all notifications", e);
+ }
+ }
+
+ /**
+ * Get the next scheduled notification time
+ *
+ * @return Timestamp of next notification or 0 if none scheduled
+ */
+ public long getNextNotificationTime() {
+ // This would need to be implemented with actual notification data
+ // For now, return a placeholder
+ return System.currentTimeMillis() + (24 * 60 * 60 * 1000); // 24 hours from now
+ }
+
+ /**
+ * Get count of pending notifications
+ *
+ * @return Number of scheduled notifications
+ */
+ public int getPendingNotificationsCount() {
+ return scheduledAlarms.size();
+ }
+
+ /**
+ * Update notification settings for existing notifications
+ */
+ public void updateNotificationSettings() {
+ try {
+ Log.d(TAG, "Updating notification settings");
+
+ // This would typically involve rescheduling notifications
+ // with new settings. For now, just log the action.
+ Log.i(TAG, "Notification settings updated");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error updating notification settings", e);
+ }
+ }
+
+ /**
+ * Enable adaptive scheduling based on device state
+ */
+ public void enableAdaptiveScheduling() {
+ try {
+ Log.d(TAG, "Enabling adaptive scheduling");
+
+ // This would implement logic to adjust scheduling based on:
+ // - Battery level
+ // - Power save mode
+ // - Doze mode
+ // - User activity patterns
+
+ Log.i(TAG, "Adaptive scheduling enabled");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error enabling adaptive scheduling", e);
+ }
+ }
+
+ /**
+ * Disable adaptive scheduling
+ */
+ public void disableAdaptiveScheduling() {
+ try {
+ Log.d(TAG, "Disabling adaptive scheduling");
+
+ // Reset to default scheduling behavior
+ Log.i(TAG, "Adaptive scheduling disabled");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error disabling adaptive scheduling", e);
+ }
+ }
+
+ /**
+ * Reschedule notifications after system reboot
+ */
+ public void rescheduleAfterReboot() {
+ try {
+ Log.d(TAG, "Rescheduling notifications after reboot");
+
+ // This would typically be called from a BOOT_COMPLETED receiver
+ // to restore scheduled notifications after device restart
+
+ Log.i(TAG, "Notifications rescheduled after reboot");
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error rescheduling after reboot", e);
+ }
+ }
+
+ /**
+ * Check if a notification is currently scheduled
+ *
+ * @param notificationId ID of notification to check
+ * @return true if notification is scheduled
+ */
+ public boolean isNotificationScheduled(String notificationId) {
+ return scheduledAlarms.containsKey(notificationId);
+ }
+
+ /**
+ * Get scheduling statistics
+ *
+ * @return Scheduling statistics as a string
+ */
+ public String getSchedulingStats() {
+ return String.format("Scheduled: %d, Exact alarms: %s",
+ scheduledAlarms.size(),
+ canUseExactAlarms() ? "enabled" : "disabled");
+ }
+
+ /**
+ * Format timestamp for logging
+ *
+ * @param timestamp Timestamp in milliseconds
+ * @return Formatted time string
+ */
+ private String formatTime(long timestamp) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.setTimeInMillis(timestamp);
+
+ return String.format("%02d:%02d:%02d on %02d/%02d/%04d",
+ calendar.get(Calendar.HOUR_OF_DAY),
+ calendar.get(Calendar.MINUTE),
+ calendar.get(Calendar.SECOND),
+ calendar.get(Calendar.MONTH) + 1,
+ calendar.get(Calendar.DAY_OF_MONTH),
+ calendar.get(Calendar.YEAR));
+ }
+
+ /**
+ * Calculate next occurrence of a daily time
+ *
+ * @param hour Hour of day (0-23)
+ * @param minute Minute of hour (0-59)
+ * @return Timestamp of next occurrence
+ */
+ public long calculateNextOccurrence(int hour, int minute) {
+ Calendar calendar = Calendar.getInstance();
+ calendar.set(Calendar.HOUR_OF_DAY, hour);
+ calendar.set(Calendar.MINUTE, minute);
+ calendar.set(Calendar.SECOND, 0);
+ calendar.set(Calendar.MILLISECOND, 0);
+
+ // If time has passed today, schedule for tomorrow
+ if (calendar.getTimeInMillis() <= System.currentTimeMillis()) {
+ calendar.add(Calendar.DAY_OF_YEAR, 1);
+ }
+
+ return calendar.getTimeInMillis();
+ }
+}
diff --git a/src/android/DailyNotificationStorage.java b/src/android/DailyNotificationStorage.java
new file mode 100644
index 0000000..feedad0
--- /dev/null
+++ b/src/android/DailyNotificationStorage.java
@@ -0,0 +1,476 @@
+/**
+ * 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 final Context context;
+ private final SharedPreferences prefs;
+ private final Gson gson;
+ private final ConcurrentHashMap notificationCache;
+ private final List notificationList;
+
+ /**
+ * Constructor
+ *
+ * @param context Application context
+ */
+ public DailyNotificationStorage(Context context) {
+ this.context = context;
+ this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
+ this.gson = new Gson();
+ this.notificationCache = new ConcurrentHashMap<>();
+ this.notificationList = Collections.synchronizedList(new ArrayList<>());
+
+ loadNotificationsFromStorage();
+ cleanupOldNotifications();
+ }
+
+ /**
+ * Save notification content to storage
+ *
+ * @param content Notification content to save
+ */
+ public void saveNotificationContent(NotificationContent content) {
+ try {
+ Log.d(TAG, "Saving notification: " + 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));
+ }
+
+ // Persist to SharedPreferences
+ saveNotificationsToStorage();
+
+ Log.d(TAG, "Notification saved successfully");
+
+ } 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 getAllNotifications() {
+ synchronized (notificationList) {
+ return new ArrayList<>(notificationList);
+ }
+ }
+
+ /**
+ * Get notifications that are ready to be displayed
+ *
+ * @return List of ready notifications
+ */
+ public List getReadyNotifications() {
+ List 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, "[]");
+ Type type = new TypeToken>(){}.getType();
+ List 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 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 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());
+ }
+}
diff --git a/src/android/NotificationContent.java b/src/android/NotificationContent.java
new file mode 100644
index 0000000..1d5383b
--- /dev/null
+++ b/src/android/NotificationContent.java
@@ -0,0 +1,315 @@
+/**
+ * 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 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 long fetchTime;
+ private boolean sound;
+ private String priority;
+ private String url;
+
+ /**
+ * Default constructor with auto-generated UUID
+ */
+ public NotificationContent() {
+ this.id = UUID.randomUUID().toString();
+ this.fetchTime = System.currentTimeMillis();
+ 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
+ *
+ * @return Timestamp in milliseconds
+ */
+ public long getFetchTime() {
+ return fetchTime;
+ }
+
+ /**
+ * Set the fetch time when content was retrieved
+ *
+ * @param fetchTime Timestamp in milliseconds
+ */
+ public void setFetchTime(long fetchTime) {
+ this.fetchTime = fetchTime;
+ }
+
+ /**
+ * 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 is stale (older than 24 hours)
+ *
+ * @return true if notification is stale
+ */
+ public boolean isStale() {
+ long currentTime = System.currentTimeMillis();
+ long age = currentTime - fetchTime;
+ return age > 24 * 60 * 60 * 1000; // 24 hours in milliseconds
+ }
+
+ /**
+ * Get the age of this notification in milliseconds
+ *
+ * @return Age in milliseconds
+ */
+ public long getAge() {
+ return System.currentTimeMillis() - fetchTime;
+ }
+
+ /**
+ * 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 + '\'' +
+ ", fetchTime=" + fetchTime +
+ ", 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();
+ }
+}