Browse Source
- Add DailyNotificationPlugin main class with Capacitor integration - Implement NotificationContent model following project directive schema - Create DailyNotificationStorage with tiered storage approach - Add DailyNotificationScheduler with exact/inexact alarm support - Implement DailyNotificationFetcher for background content retrieval - Create DailyNotificationReceiver for alarm handling - Add WorkManager workers for background tasks and maintenance - Implement prefetch → cache → schedule → display pipeline - Add comprehensive error handling and logging - Support battery optimization and adaptive schedulingmaster
16 changed files with 3566 additions and 0 deletions
@ -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 |
|||
<uses-permission android:name="android.permission.INTERNET" /> |
|||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> |
|||
<uses-permission android:name="android.permission/SCHEDULE_EXACT_ALARM" /> |
|||
<uses-permission android:name="android.permission.WAKE_LOCK" /> |
|||
``` |
|||
- 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." |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 |
@ -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); |
|||
} |
|||
} |
|||
} |
@ -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"); |
|||
} |
|||
} |
@ -0,0 +1,403 @@ |
|||
/** |
|||
* DailyNotificationMaintenanceWorker.java |
|||
* |
|||
* WorkManager worker for maintenance tasks |
|||
* Handles cleanup, optimization, and system health checks |
|||
* |
|||
* @author Matthew Raymer |
|||
* @version 1.0.0 |
|||
*/ |
|||
|
|||
package com.timesafari.dailynotification; |
|||
|
|||
import android.content.Context; |
|||
import android.util.Log; |
|||
|
|||
import androidx.annotation.NonNull; |
|||
import androidx.work.Data; |
|||
import androidx.work.Worker; |
|||
import androidx.work.WorkerParameters; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* Background worker for maintenance tasks |
|||
* |
|||
* This worker handles periodic maintenance of the notification system, |
|||
* including cleanup of old data, optimization of storage, and health checks. |
|||
*/ |
|||
public class DailyNotificationMaintenanceWorker extends Worker { |
|||
|
|||
private static final String TAG = "DailyNotificationMaintenanceWorker"; |
|||
private static final String KEY_MAINTENANCE_TIME = "maintenance_time"; |
|||
|
|||
private static final long WORK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes total
|
|||
private static final int MAX_NOTIFICATIONS_TO_KEEP = 50; // Keep only recent notifications
|
|||
|
|||
private final Context context; |
|||
private final DailyNotificationStorage storage; |
|||
|
|||
/** |
|||
* Constructor |
|||
* |
|||
* @param context Application context |
|||
* @param params Worker parameters |
|||
*/ |
|||
public DailyNotificationMaintenanceWorker(@NonNull Context context, |
|||
@NonNull WorkerParameters params) { |
|||
super(context, params); |
|||
this.context = context; |
|||
this.storage = new DailyNotificationStorage(context); |
|||
} |
|||
|
|||
/** |
|||
* Main work method - perform maintenance tasks |
|||
* |
|||
* @return Result indicating success or failure |
|||
*/ |
|||
@NonNull |
|||
@Override |
|||
public Result doWork() { |
|||
try { |
|||
Log.d(TAG, "Starting maintenance work"); |
|||
|
|||
// Get input data
|
|||
Data inputData = getInputData(); |
|||
long maintenanceTime = inputData.getLong(KEY_MAINTENANCE_TIME, 0); |
|||
|
|||
Log.d(TAG, "Maintenance time: " + maintenanceTime); |
|||
|
|||
// Perform maintenance tasks
|
|||
boolean success = performMaintenance(); |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "Maintenance completed successfully"); |
|||
return Result.success(); |
|||
} else { |
|||
Log.w(TAG, "Maintenance completed with warnings"); |
|||
return Result.success(); // Still consider it successful
|
|||
} |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error during maintenance work", e); |
|||
return Result.failure(); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Perform all maintenance tasks |
|||
* |
|||
* @return true if all tasks completed successfully |
|||
*/ |
|||
private boolean performMaintenance() { |
|||
try { |
|||
Log.d(TAG, "Performing maintenance tasks"); |
|||
|
|||
boolean allSuccessful = true; |
|||
|
|||
// Task 1: Clean up old notifications
|
|||
boolean cleanupSuccess = cleanupOldNotifications(); |
|||
if (!cleanupSuccess) { |
|||
allSuccessful = false; |
|||
} |
|||
|
|||
// Task 2: Optimize storage
|
|||
boolean optimizationSuccess = optimizeStorage(); |
|||
if (!optimizationSuccess) { |
|||
allSuccessful = false; |
|||
} |
|||
|
|||
// Task 3: Health check
|
|||
boolean healthCheckSuccess = performHealthCheck(); |
|||
if (!healthCheckSuccess) { |
|||
allSuccessful = false; |
|||
} |
|||
|
|||
// Task 4: Schedule next maintenance
|
|||
scheduleNextMaintenance(); |
|||
|
|||
Log.d(TAG, "Maintenance tasks completed. All successful: " + allSuccessful); |
|||
return allSuccessful; |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error during maintenance tasks", e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Clean up old notifications |
|||
* |
|||
* @return true if cleanup was successful |
|||
*/ |
|||
private boolean cleanupOldNotifications() { |
|||
try { |
|||
Log.d(TAG, "Cleaning up old notifications"); |
|||
|
|||
// Get all notifications
|
|||
List<NotificationContent> allNotifications = storage.getAllNotifications(); |
|||
int initialCount = allNotifications.size(); |
|||
|
|||
if (initialCount <= MAX_NOTIFICATIONS_TO_KEEP) { |
|||
Log.d(TAG, "No cleanup needed, notification count: " + initialCount); |
|||
return true; |
|||
} |
|||
|
|||
// Remove old notifications, keeping the most recent ones
|
|||
int notificationsToRemove = initialCount - MAX_NOTIFICATIONS_TO_KEEP; |
|||
int removedCount = 0; |
|||
|
|||
for (int i = 0; i < notificationsToRemove && i < allNotifications.size(); i++) { |
|||
NotificationContent notification = allNotifications.get(i); |
|||
storage.removeNotification(notification.getId()); |
|||
removedCount++; |
|||
} |
|||
|
|||
Log.i(TAG, "Cleanup completed. Removed " + removedCount + " old notifications"); |
|||
return true; |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error during notification cleanup", e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Optimize storage usage |
|||
* |
|||
* @return true if optimization was successful |
|||
*/ |
|||
private boolean optimizeStorage() { |
|||
try { |
|||
Log.d(TAG, "Optimizing storage"); |
|||
|
|||
// Get storage statistics
|
|||
String stats = storage.getStorageStats(); |
|||
Log.d(TAG, "Storage stats before optimization: " + stats); |
|||
|
|||
// Perform storage optimization
|
|||
// This could include:
|
|||
// - Compacting data structures
|
|||
// - Removing duplicate entries
|
|||
// - Optimizing cache usage
|
|||
|
|||
// For now, just log the current state
|
|||
Log.d(TAG, "Storage optimization completed"); |
|||
return true; |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error during storage optimization", e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Perform system health check |
|||
* |
|||
* @return true if health check passed |
|||
*/ |
|||
private boolean performHealthCheck() { |
|||
try { |
|||
Log.d(TAG, "Performing health check"); |
|||
|
|||
boolean healthOk = true; |
|||
|
|||
// Check 1: Storage health
|
|||
boolean storageHealth = checkStorageHealth(); |
|||
if (!storageHealth) { |
|||
healthOk = false; |
|||
} |
|||
|
|||
// Check 2: Notification count health
|
|||
boolean countHealth = checkNotificationCountHealth(); |
|||
if (!countHealth) { |
|||
healthOk = false; |
|||
} |
|||
|
|||
// Check 3: Data integrity
|
|||
boolean dataIntegrity = checkDataIntegrity(); |
|||
if (!dataIntegrity) { |
|||
healthOk = false; |
|||
} |
|||
|
|||
if (healthOk) { |
|||
Log.i(TAG, "Health check passed"); |
|||
} else { |
|||
Log.w(TAG, "Health check failed - some issues detected"); |
|||
} |
|||
|
|||
return healthOk; |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error during health check", e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Check storage health |
|||
* |
|||
* @return true if storage is healthy |
|||
*/ |
|||
private boolean checkStorageHealth() { |
|||
try { |
|||
Log.d(TAG, "Checking storage health"); |
|||
|
|||
// Check if storage is accessible
|
|||
int notificationCount = storage.getNotificationCount(); |
|||
|
|||
if (notificationCount < 0) { |
|||
Log.w(TAG, "Storage health issue: Invalid notification count"); |
|||
return false; |
|||
} |
|||
|
|||
// Check if storage is empty (this might be normal)
|
|||
if (storage.isEmpty()) { |
|||
Log.d(TAG, "Storage is empty (this might be normal)"); |
|||
} |
|||
|
|||
Log.d(TAG, "Storage health check passed"); |
|||
return true; |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error checking storage health", e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Check notification count health |
|||
* |
|||
* @return true if notification count is healthy |
|||
*/ |
|||
private boolean checkNotificationCountHealth() { |
|||
try { |
|||
Log.d(TAG, "Checking notification count health"); |
|||
|
|||
int notificationCount = storage.getNotificationCount(); |
|||
|
|||
// Check for reasonable limits
|
|||
if (notificationCount > 1000) { |
|||
Log.w(TAG, "Notification count health issue: Too many notifications (" + |
|||
notificationCount + ")"); |
|||
return false; |
|||
} |
|||
|
|||
Log.d(TAG, "Notification count health check passed: " + notificationCount); |
|||
return true; |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error checking notification count health", e); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Check data integrity |
|||
* |
|||
* @return true if data integrity is good |
|||
*/ |
|||
private boolean checkDataIntegrity() { |
|||
try { |
|||
Log.d(TAG, "Checking data integrity"); |
|||
|
|||
// Get all notifications and check basic integrity
|
|||
List<NotificationContent> allNotifications = storage.getAllNotifications(); |
|||
|
|||
for (NotificationContent notification : allNotifications) { |
|||
// Check required fields
|
|||
if (notification.getId() == null || notification.getId().isEmpty()) { |
|||
Log.w(TAG, "Data integrity issue: Notification with null/empty ID"); |
|||
return false; |
|||
} |
|||
|
|||
if (notification.getTitle() == null || notification.getTitle().isEmpty()) { |
|||
Log.w(TAG, "Data integrity issue: Notification with null/empty title"); |
|||
return false; |
|||
} |
|||
|
|||
if (notification.getBody() == null || notification.getBody().isEmpty()) { |
|||
Log.w(TAG, "Data integrity issue: Notification with null/empty body"); |
|||
return false; |
|||
} |
|||
|
|||
// Check timestamp validity
|
|||
if (notification.getScheduledTime() <= 0) { |
|||
Log.w(TAG, "Data integrity issue: Invalid scheduled time"); |
|||
return false; |
|||
} |
|||
|
|||
if (notification.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); |
|||
} |
|||
} |
|||
} |
@ -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(); |
|||
} |
|||
} |
@ -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); |
|||
} |
|||
} |
|||
} |
@ -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<String, PendingIntent> 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(); |
|||
} |
|||
} |
@ -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<String, NotificationContent> notificationCache; |
|||
private final List<NotificationContent> notificationList; |
|||
|
|||
/** |
|||
* Constructor |
|||
* |
|||
* @param context Application context |
|||
*/ |
|||
public DailyNotificationStorage(Context context) { |
|||
this.context = context; |
|||
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); |
|||
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<NotificationContent> getAllNotifications() { |
|||
synchronized (notificationList) { |
|||
return new ArrayList<>(notificationList); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get notifications that are ready to be displayed |
|||
* |
|||
* @return List of ready notifications |
|||
*/ |
|||
public List<NotificationContent> getReadyNotifications() { |
|||
List<NotificationContent> readyNotifications = new ArrayList<>(); |
|||
long currentTime = System.currentTimeMillis(); |
|||
|
|||
synchronized (notificationList) { |
|||
for (NotificationContent notification : notificationList) { |
|||
if (notification.isReadyToDisplay()) { |
|||
readyNotifications.add(notification); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return readyNotifications; |
|||
} |
|||
|
|||
/** |
|||
* Get the next scheduled notification |
|||
* |
|||
* @return Next notification or null if none scheduled |
|||
*/ |
|||
public NotificationContent getNextNotification() { |
|||
synchronized (notificationList) { |
|||
long currentTime = System.currentTimeMillis(); |
|||
|
|||
for (NotificationContent notification : notificationList) { |
|||
if (notification.getScheduledTime() > currentTime) { |
|||
return notification; |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Remove notification by ID |
|||
* |
|||
* @param id Notification ID to remove |
|||
*/ |
|||
public void removeNotification(String id) { |
|||
try { |
|||
Log.d(TAG, "Removing notification: " + id); |
|||
|
|||
notificationCache.remove(id); |
|||
|
|||
synchronized (notificationList) { |
|||
notificationList.removeIf(n -> n.getId().equals(id)); |
|||
} |
|||
|
|||
saveNotificationsToStorage(); |
|||
|
|||
Log.d(TAG, "Notification removed successfully"); |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error removing notification", e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Clear all notifications |
|||
*/ |
|||
public void clearAllNotifications() { |
|||
try { |
|||
Log.d(TAG, "Clearing all notifications"); |
|||
|
|||
notificationCache.clear(); |
|||
|
|||
synchronized (notificationList) { |
|||
notificationList.clear(); |
|||
} |
|||
|
|||
saveNotificationsToStorage(); |
|||
|
|||
Log.d(TAG, "All notifications cleared successfully"); |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error clearing notifications", e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get notification count |
|||
* |
|||
* @return Number of notifications |
|||
*/ |
|||
public int getNotificationCount() { |
|||
return notificationCache.size(); |
|||
} |
|||
|
|||
/** |
|||
* Check if storage is empty |
|||
* |
|||
* @return true if no notifications exist |
|||
*/ |
|||
public boolean isEmpty() { |
|||
return notificationCache.isEmpty(); |
|||
} |
|||
|
|||
/** |
|||
* Set sound enabled setting |
|||
* |
|||
* @param enabled true to enable sound |
|||
*/ |
|||
public void setSoundEnabled(boolean enabled) { |
|||
SharedPreferences.Editor editor = prefs.edit(); |
|||
editor.putBoolean("sound_enabled", enabled); |
|||
editor.apply(); |
|||
|
|||
Log.d(TAG, "Sound setting updated: " + enabled); |
|||
} |
|||
|
|||
/** |
|||
* Get sound enabled setting |
|||
* |
|||
* @return true if sound is enabled |
|||
*/ |
|||
public boolean isSoundEnabled() { |
|||
return prefs.getBoolean("sound_enabled", true); |
|||
} |
|||
|
|||
/** |
|||
* Set notification priority |
|||
* |
|||
* @param priority Priority string (high, default, low) |
|||
*/ |
|||
public void setPriority(String priority) { |
|||
SharedPreferences.Editor editor = prefs.edit(); |
|||
editor.putString("priority", priority); |
|||
editor.apply(); |
|||
|
|||
Log.d(TAG, "Priority setting updated: " + priority); |
|||
} |
|||
|
|||
/** |
|||
* Get notification priority |
|||
* |
|||
* @return Priority string |
|||
*/ |
|||
public String getPriority() { |
|||
return prefs.getString("priority", "default"); |
|||
} |
|||
|
|||
/** |
|||
* Set timezone setting |
|||
* |
|||
* @param timezone Timezone identifier |
|||
*/ |
|||
public void setTimezone(String timezone) { |
|||
SharedPreferences.Editor editor = prefs.edit(); |
|||
editor.putString("timezone", timezone); |
|||
editor.apply(); |
|||
|
|||
Log.d(TAG, "Timezone setting updated: " + timezone); |
|||
} |
|||
|
|||
/** |
|||
* Get timezone setting |
|||
* |
|||
* @return Timezone identifier |
|||
*/ |
|||
public String getTimezone() { |
|||
return prefs.getString("timezone", "UTC"); |
|||
} |
|||
|
|||
/** |
|||
* Set adaptive scheduling enabled |
|||
* |
|||
* @param enabled true to enable adaptive scheduling |
|||
*/ |
|||
public void setAdaptiveSchedulingEnabled(boolean enabled) { |
|||
SharedPreferences.Editor editor = prefs.edit(); |
|||
editor.putBoolean(KEY_ADAPTIVE_SCHEDULING, enabled); |
|||
editor.apply(); |
|||
|
|||
Log.d(TAG, "Adaptive scheduling setting updated: " + enabled); |
|||
} |
|||
|
|||
/** |
|||
* Check if adaptive scheduling is enabled |
|||
* |
|||
* @return true if adaptive scheduling is enabled |
|||
*/ |
|||
public boolean isAdaptiveSchedulingEnabled() { |
|||
return prefs.getBoolean(KEY_ADAPTIVE_SCHEDULING, true); |
|||
} |
|||
|
|||
/** |
|||
* Set last fetch timestamp |
|||
* |
|||
* @param timestamp Last fetch time in milliseconds |
|||
*/ |
|||
public void setLastFetchTime(long timestamp) { |
|||
SharedPreferences.Editor editor = prefs.edit(); |
|||
editor.putLong(KEY_LAST_FETCH, timestamp); |
|||
editor.apply(); |
|||
|
|||
Log.d(TAG, "Last fetch time updated: " + timestamp); |
|||
} |
|||
|
|||
/** |
|||
* Get last fetch timestamp |
|||
* |
|||
* @return Last fetch time in milliseconds |
|||
*/ |
|||
public long getLastFetchTime() { |
|||
return prefs.getLong(KEY_LAST_FETCH, 0); |
|||
} |
|||
|
|||
/** |
|||
* Check if it's time to fetch new content |
|||
* |
|||
* @return true if fetch is needed |
|||
*/ |
|||
public boolean shouldFetchNewContent() { |
|||
long lastFetch = getLastFetchTime(); |
|||
long currentTime = System.currentTimeMillis(); |
|||
long timeSinceLastFetch = currentTime - lastFetch; |
|||
|
|||
// Fetch if more than 12 hours have passed
|
|||
return timeSinceLastFetch > 12 * 60 * 60 * 1000; |
|||
} |
|||
|
|||
/** |
|||
* Load notifications from persistent storage |
|||
*/ |
|||
private void loadNotificationsFromStorage() { |
|||
try { |
|||
String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]"); |
|||
Type type = new TypeToken<ArrayList<NotificationContent>>(){}.getType(); |
|||
List<NotificationContent> notifications = gson.fromJson(notificationsJson, type); |
|||
|
|||
if (notifications != null) { |
|||
for (NotificationContent notification : notifications) { |
|||
notificationCache.put(notification.getId(), notification); |
|||
notificationList.add(notification); |
|||
} |
|||
|
|||
// Sort by scheduled time
|
|||
Collections.sort(notificationList, |
|||
Comparator.comparingLong(NotificationContent::getScheduledTime)); |
|||
|
|||
Log.d(TAG, "Loaded " + notifications.size() + " notifications from storage"); |
|||
} |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error loading notifications from storage", e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Save notifications to persistent storage |
|||
*/ |
|||
private void saveNotificationsToStorage() { |
|||
try { |
|||
List<NotificationContent> notifications; |
|||
synchronized (notificationList) { |
|||
notifications = new ArrayList<>(notificationList); |
|||
} |
|||
|
|||
String notificationsJson = gson.toJson(notifications); |
|||
SharedPreferences.Editor editor = prefs.edit(); |
|||
editor.putString(KEY_NOTIFICATIONS, notificationsJson); |
|||
editor.apply(); |
|||
|
|||
Log.d(TAG, "Saved " + notifications.size() + " notifications to storage"); |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error saving notifications to storage", e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Clean up old notifications to prevent memory bloat |
|||
*/ |
|||
private void cleanupOldNotifications() { |
|||
try { |
|||
long currentTime = System.currentTimeMillis(); |
|||
long cutoffTime = currentTime - (7 * 24 * 60 * 60 * 1000); // 7 days ago
|
|||
|
|||
synchronized (notificationList) { |
|||
notificationList.removeIf(notification -> |
|||
notification.getScheduledTime() < cutoffTime); |
|||
} |
|||
|
|||
// Update cache to match
|
|||
notificationCache.clear(); |
|||
for (NotificationContent notification : notificationList) { |
|||
notificationCache.put(notification.getId(), notification); |
|||
} |
|||
|
|||
// Limit cache size
|
|||
if (notificationCache.size() > MAX_CACHE_SIZE) { |
|||
List<NotificationContent> sortedNotifications = new ArrayList<>(notificationList); |
|||
Collections.sort(sortedNotifications, |
|||
Comparator.comparingLong(NotificationContent::getScheduledTime)); |
|||
|
|||
int toRemove = sortedNotifications.size() - MAX_CACHE_SIZE; |
|||
for (int i = 0; i < toRemove; i++) { |
|||
NotificationContent notification = sortedNotifications.get(i); |
|||
notificationCache.remove(notification.getId()); |
|||
} |
|||
|
|||
notificationList.clear(); |
|||
notificationList.addAll(sortedNotifications.subList(toRemove, sortedNotifications.size())); |
|||
} |
|||
|
|||
saveNotificationsToStorage(); |
|||
|
|||
Log.d(TAG, "Cleanup completed. Cache size: " + notificationCache.size()); |
|||
|
|||
} catch (Exception e) { |
|||
Log.e(TAG, "Error during cleanup", e); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Get storage statistics |
|||
* |
|||
* @return Storage statistics as a string |
|||
*/ |
|||
public String getStorageStats() { |
|||
return String.format("Notifications: %d, Cache size: %d, Last fetch: %d", |
|||
notificationList.size(), |
|||
notificationCache.size(), |
|||
getLastFetchTime()); |
|||
} |
|||
} |
@ -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(); |
|||
} |
|||
} |
Loading…
Reference in new issue