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
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
.cache/
|
.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