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