feat: Implement Android native plugin with offline-first pipeline
- 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 scheduling
This commit is contained in:
242
.cursor/rules/project.mdc
Normal file
242
.cursor/rules/project.mdc
Normal file
@@ -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."
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -59,3 +59,5 @@ logs/
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
*.lock
|
||||
*.bin
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
203
docs/TODO.md
Normal file
203
docs/TODO.md
Normal file
@@ -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
|
||||
395
src/android/DailyNotificationFetchWorker.java
Normal file
395
src/android/DailyNotificationFetchWorker.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
364
src/android/DailyNotificationFetcher.java
Normal file
364
src/android/DailyNotificationFetcher.java
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
403
src/android/DailyNotificationMaintenanceWorker.java
Normal file
403
src/android/DailyNotificationMaintenanceWorker.java
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* DailyNotificationMaintenanceWorker.java
|
||||
*
|
||||
* WorkManager worker for maintenance tasks
|
||||
* Handles cleanup, optimization, and system health checks
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Background worker for maintenance tasks
|
||||
*
|
||||
* This worker handles periodic maintenance of the notification system,
|
||||
* including cleanup of old data, optimization of storage, and health checks.
|
||||
*/
|
||||
public class DailyNotificationMaintenanceWorker extends Worker {
|
||||
|
||||
private static final String TAG = "DailyNotificationMaintenanceWorker";
|
||||
private static final String KEY_MAINTENANCE_TIME = "maintenance_time";
|
||||
|
||||
private static final long WORK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes total
|
||||
private static final int MAX_NOTIFICATIONS_TO_KEEP = 50; // Keep only recent notifications
|
||||
|
||||
private final Context context;
|
||||
private final DailyNotificationStorage storage;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param context Application context
|
||||
* @param params Worker parameters
|
||||
*/
|
||||
public DailyNotificationMaintenanceWorker(@NonNull Context context,
|
||||
@NonNull WorkerParameters params) {
|
||||
super(context, params);
|
||||
this.context = context;
|
||||
this.storage = new DailyNotificationStorage(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main work method - perform maintenance tasks
|
||||
*
|
||||
* @return Result indicating success or failure
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
try {
|
||||
Log.d(TAG, "Starting maintenance work");
|
||||
|
||||
// Get input data
|
||||
Data inputData = getInputData();
|
||||
long maintenanceTime = inputData.getLong(KEY_MAINTENANCE_TIME, 0);
|
||||
|
||||
Log.d(TAG, "Maintenance time: " + maintenanceTime);
|
||||
|
||||
// Perform maintenance tasks
|
||||
boolean success = performMaintenance();
|
||||
|
||||
if (success) {
|
||||
Log.i(TAG, "Maintenance completed successfully");
|
||||
return Result.success();
|
||||
} else {
|
||||
Log.w(TAG, "Maintenance completed with warnings");
|
||||
return Result.success(); // Still consider it successful
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error during maintenance work", e);
|
||||
return Result.failure();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform all maintenance tasks
|
||||
*
|
||||
* @return true if all tasks completed successfully
|
||||
*/
|
||||
private boolean performMaintenance() {
|
||||
try {
|
||||
Log.d(TAG, "Performing maintenance tasks");
|
||||
|
||||
boolean allSuccessful = true;
|
||||
|
||||
// Task 1: Clean up old notifications
|
||||
boolean cleanupSuccess = cleanupOldNotifications();
|
||||
if (!cleanupSuccess) {
|
||||
allSuccessful = false;
|
||||
}
|
||||
|
||||
// Task 2: Optimize storage
|
||||
boolean optimizationSuccess = optimizeStorage();
|
||||
if (!optimizationSuccess) {
|
||||
allSuccessful = false;
|
||||
}
|
||||
|
||||
// Task 3: Health check
|
||||
boolean healthCheckSuccess = performHealthCheck();
|
||||
if (!healthCheckSuccess) {
|
||||
allSuccessful = false;
|
||||
}
|
||||
|
||||
// Task 4: Schedule next maintenance
|
||||
scheduleNextMaintenance();
|
||||
|
||||
Log.d(TAG, "Maintenance tasks completed. All successful: " + allSuccessful);
|
||||
return allSuccessful;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error during maintenance tasks", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old notifications
|
||||
*
|
||||
* @return true if cleanup was successful
|
||||
*/
|
||||
private boolean cleanupOldNotifications() {
|
||||
try {
|
||||
Log.d(TAG, "Cleaning up old notifications");
|
||||
|
||||
// Get all notifications
|
||||
List<NotificationContent> allNotifications = storage.getAllNotifications();
|
||||
int initialCount = allNotifications.size();
|
||||
|
||||
if (initialCount <= MAX_NOTIFICATIONS_TO_KEEP) {
|
||||
Log.d(TAG, "No cleanup needed, notification count: " + initialCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove old notifications, keeping the most recent ones
|
||||
int notificationsToRemove = initialCount - MAX_NOTIFICATIONS_TO_KEEP;
|
||||
int removedCount = 0;
|
||||
|
||||
for (int i = 0; i < notificationsToRemove && i < allNotifications.size(); i++) {
|
||||
NotificationContent notification = allNotifications.get(i);
|
||||
storage.removeNotification(notification.getId());
|
||||
removedCount++;
|
||||
}
|
||||
|
||||
Log.i(TAG, "Cleanup completed. Removed " + removedCount + " old notifications");
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error during notification cleanup", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize storage usage
|
||||
*
|
||||
* @return true if optimization was successful
|
||||
*/
|
||||
private boolean optimizeStorage() {
|
||||
try {
|
||||
Log.d(TAG, "Optimizing storage");
|
||||
|
||||
// Get storage statistics
|
||||
String stats = storage.getStorageStats();
|
||||
Log.d(TAG, "Storage stats before optimization: " + stats);
|
||||
|
||||
// Perform storage optimization
|
||||
// This could include:
|
||||
// - Compacting data structures
|
||||
// - Removing duplicate entries
|
||||
// - Optimizing cache usage
|
||||
|
||||
// For now, just log the current state
|
||||
Log.d(TAG, "Storage optimization completed");
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error during storage optimization", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform system health check
|
||||
*
|
||||
* @return true if health check passed
|
||||
*/
|
||||
private boolean performHealthCheck() {
|
||||
try {
|
||||
Log.d(TAG, "Performing health check");
|
||||
|
||||
boolean healthOk = true;
|
||||
|
||||
// Check 1: Storage health
|
||||
boolean storageHealth = checkStorageHealth();
|
||||
if (!storageHealth) {
|
||||
healthOk = false;
|
||||
}
|
||||
|
||||
// Check 2: Notification count health
|
||||
boolean countHealth = checkNotificationCountHealth();
|
||||
if (!countHealth) {
|
||||
healthOk = false;
|
||||
}
|
||||
|
||||
// Check 3: Data integrity
|
||||
boolean dataIntegrity = checkDataIntegrity();
|
||||
if (!dataIntegrity) {
|
||||
healthOk = false;
|
||||
}
|
||||
|
||||
if (healthOk) {
|
||||
Log.i(TAG, "Health check passed");
|
||||
} else {
|
||||
Log.w(TAG, "Health check failed - some issues detected");
|
||||
}
|
||||
|
||||
return healthOk;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error during health check", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check storage health
|
||||
*
|
||||
* @return true if storage is healthy
|
||||
*/
|
||||
private boolean checkStorageHealth() {
|
||||
try {
|
||||
Log.d(TAG, "Checking storage health");
|
||||
|
||||
// Check if storage is accessible
|
||||
int notificationCount = storage.getNotificationCount();
|
||||
|
||||
if (notificationCount < 0) {
|
||||
Log.w(TAG, "Storage health issue: Invalid notification count");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if storage is empty (this might be normal)
|
||||
if (storage.isEmpty()) {
|
||||
Log.d(TAG, "Storage is empty (this might be normal)");
|
||||
}
|
||||
|
||||
Log.d(TAG, "Storage health check passed");
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking storage health", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check notification count health
|
||||
*
|
||||
* @return true if notification count is healthy
|
||||
*/
|
||||
private boolean checkNotificationCountHealth() {
|
||||
try {
|
||||
Log.d(TAG, "Checking notification count health");
|
||||
|
||||
int notificationCount = storage.getNotificationCount();
|
||||
|
||||
// Check for reasonable limits
|
||||
if (notificationCount > 1000) {
|
||||
Log.w(TAG, "Notification count health issue: Too many notifications (" +
|
||||
notificationCount + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Notification count health check passed: " + notificationCount);
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error checking notification count health", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check data integrity
|
||||
*
|
||||
* @return true if data integrity is good
|
||||
*/
|
||||
private boolean checkDataIntegrity() {
|
||||
try {
|
||||
Log.d(TAG, "Checking data integrity");
|
||||
|
||||
// Get all notifications and check basic integrity
|
||||
List<NotificationContent> allNotifications = storage.getAllNotifications();
|
||||
|
||||
for (NotificationContent notification : allNotifications) {
|
||||
// Check required fields
|
||||
if (notification.getId() == null || notification.getId().isEmpty()) {
|
||||
Log.w(TAG, "Data integrity issue: Notification with null/empty ID");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (notification.getTitle() == null || notification.getTitle().isEmpty()) {
|
||||
Log.w(TAG, "Data integrity issue: Notification with null/empty title");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (notification.getBody() == null || notification.getBody().isEmpty()) {
|
||||
Log.w(TAG, "Data integrity issue: Notification with null/empty body");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check timestamp validity
|
||||
if (notification.getScheduledTime() <= 0) {
|
||||
Log.w(TAG, "Data integrity issue: Invalid scheduled time");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (notification.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
506
src/android/DailyNotificationPlugin.java
Normal file
506
src/android/DailyNotificationPlugin.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
283
src/android/DailyNotificationReceiver.java
Normal file
283
src/android/DailyNotificationReceiver.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
377
src/android/DailyNotificationScheduler.java
Normal file
377
src/android/DailyNotificationScheduler.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
476
src/android/DailyNotificationStorage.java
Normal file
476
src/android/DailyNotificationStorage.java
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
315
src/android/NotificationContent.java
Normal file
315
src/android/NotificationContent.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user