From 2d535b5d8f757321fa179bb1c8c3d2755ebbd50a Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Tue, 12 Aug 2025 09:39:06 +0000 Subject: [PATCH] feat: Implement Android native plugin with offline-first pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .cursor/rules/project.mdc | 242 +++++++++ .gitignore | 2 + .gradle/8.13/checksums/checksums.lock | Bin 17 -> 17 bytes .gradle/8.13/checksums/md5-checksums.bin | Bin 23097 -> 24497 bytes .gradle/8.13/checksums/sha1-checksums.bin | Bin 31133 -> 34373 bytes .gradle/8.13/fileHashes/fileHashes.bin | Bin 19747 -> 19797 bytes .gradle/8.13/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes docs/TODO.md | 203 +++++++ src/android/DailyNotificationFetchWorker.java | 395 ++++++++++++++ src/android/DailyNotificationFetcher.java | 364 +++++++++++++ .../DailyNotificationMaintenanceWorker.java | 403 ++++++++++++++ src/android/DailyNotificationPlugin.java | 506 ++++++++++++++++++ src/android/DailyNotificationReceiver.java | 283 ++++++++++ src/android/DailyNotificationScheduler.java | 377 +++++++++++++ src/android/DailyNotificationStorage.java | 476 ++++++++++++++++ src/android/NotificationContent.java | 315 +++++++++++ 16 files changed, 3566 insertions(+) create mode 100644 .cursor/rules/project.mdc create mode 100644 docs/TODO.md create mode 100644 src/android/DailyNotificationFetchWorker.java create mode 100644 src/android/DailyNotificationFetcher.java create mode 100644 src/android/DailyNotificationMaintenanceWorker.java create mode 100644 src/android/DailyNotificationPlugin.java create mode 100644 src/android/DailyNotificationReceiver.java create mode 100644 src/android/DailyNotificationScheduler.java create mode 100644 src/android/DailyNotificationStorage.java create mode 100644 src/android/NotificationContent.java diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc new file mode 100644 index 0000000..eeed6ae --- /dev/null +++ b/.cursor/rules/project.mdc @@ -0,0 +1,242 @@ +# TimeSafari Notifications — LLM Implementation Directive (v2.0) +_Last updated: August 12, 2025_ + +## 0) Role & Objective +**You are the implementation LLM.** Build an **offline-first daily notifications system** for Android (Kotlin) and iOS (Swift) that **prefetches -> caches -> schedules -> displays** content **without requiring network at display time**. Favor **reliability over richness**. + +## 1) Golden Rules +1. **Follow the pipeline:** **Prefetch → Cache → Schedule → Display.** +2. **Never depend on network at display time.** All assets must be local. +3. **Design for failure.** Always have a last-known-good and an emergency fallback. +4. **Keep content scannable (<3s), single message, actionable.** +5. **Measure everything** (fetch success, delivery, engagement, stale usage). +6. **Minimize battery impact** and respect platform limitations and user settings. +7. **Ask only when needed:** if a required input is missing, use the defaults below; otherwise proceed. + +## 2) Default Assumptions (use unless overridden) +- **Product mode:** Lightweight Daily Updates (text + emoji) with option to extend to media later. +- **Fetch size:** 1–2 KB JSON daily. +- **User schedule default:** 07:30 local time, daily. +- **Quiet hours:** None (app-level quiet hours supported but disabled by default). +- **Analytics:** Local log + pluggable uploader (no-op by default). + +## 3) Deliverables +Produce the following artifacts: + +### Android (Kotlin) +- `:core`: models, storage, metrics, fallback manager. +- `:data`: fetchers (WorkManager), mappers, cache policy. +- `:notify`: scheduler (AlarmManager), receiver, channels. +- App manifest entries & permissions. +- Unit tests for fallback, scheduling, metrics. +- README with battery optimization instructions (OEMs). + +### iOS (Swift) +- `NotificationKit`: models, storage, metrics, fallback manager. +- BGTaskScheduler registration + handler. +- UNUserNotificationCenter scheduling + categories + attachments. +- Unit tests for fallback, scheduling, metrics. +- README with Background App Refresh caveats + Focus/Summary notes. + +## 4) Permissions & Required Setup +### Android Manifest +```xml + + + + +``` +- Create a high-importance **NotificationChannel** `timesafari.daily`. +- If **SCHEDULE_EXACT_ALARM** denied on Android 12+, auto-fallback to inexact. + +### iOS App Setup (AppDelegate / SceneDelegate) +- Register `BGTaskScheduler` with ID `com.timesafari.daily-fetch`. +- Request alerts, sound, badge via `UNUserNotificationCenter`. +- Create category `DAILY_UPDATE` with a primary `View` action. +- Ensure Background Modes: **Background fetch**, **Remote notifications** (optional for future push). + +## 5) Data Model (keep minimal, versioned) +### Canonical Schema (language-agnostic) +``` +NotificationContent v1 +- id: string (uuid) +- title: string +- body: string (plain text; may include simple emoji) +- scheduledTime: epoch millis (client-local target) +- mediaUrl: string? (for future; must be mirrored to local path before use) +- fetchTime: epoch millis +``` +### Kotlin +```kotlin +@Entity +data class NotificationContent( + @PrimaryKey val id: String, + val title: String, + val body: String, + val scheduledTime: Long, + val mediaUrl: String?, + val fetchTime: Long +) +``` +### Swift +```swift +struct NotificationContent: Codable { + let id: String + let title: String + let body: String + let scheduledTime: TimeInterval + let mediaUrl: String? + let fetchTime: TimeInterval +} +``` + +## 6) Storage Layers +**Tier 1: Key-Value (quick)** — next payload, last fetch timestamp, user prefs. +**Tier 2: DB (structured)** — history, media metadata, analytics events. +**Tier 3: Files (large assets)** — images/audio; LRU cache & quotas. + +- Android: SharedPreferences/DataStore + Room + `context.cacheDir/notifications/` +- iOS: UserDefaults + Core Data/SQLite + `Library/Caches/notifications/` + +## 7) Background Execution +### Android — WorkManager +- Periodic daily work with constraints (CONNECTED network). +- Total time budget ~10m; use **timeouts** (e.g., fetch ≤30s, overall ≤8m). +- On exception/timeout: **schedule from cache**; then `Result.success()` or `Result.retry()` per policy. + +### iOS — BGTaskScheduler +- `BGAppRefreshTask` with aggressive time budgeting (10–30s typical). +- Submit next request immediately at start of handler. +- Set `expirationHandler` first; cancel tasks cleanly; **fallback to cache** on failure. + +## 8) Scheduling & Display +### Android +- Prefer `AlarmManager.setExactAndAllowWhileIdle()` if permitted; else inexact. +- Receiver builds notification using **BigTextStyle** for long bodies. +- Limit actions to ≤3; default: `View` (foreground intent). + +### iOS +- `UNCalendarNotificationTrigger` repeating at preferred time. +- Category `DAILY_UPDATE` with `View` action. +- Media attachments **only if local**; otherwise skip gracefully. + +## 9) Fallback Hierarchy (must implement) +1. **Foreground prefetch path** if app is open. +2. **Background fetch** with short network timeout. +3. **Last good cache** (annotate staleness: “as of X”). +4. **Emergency phrases** (rotate from static list). + +Provide helper: +- `withStaleMarker(content) -> content'` appends age label (e.g., “from 3h ago”). + +## 10) Failure Matrix & Responses +| Scenario | Detect | Action | +|---|---|---| +| No network / timeout | Exceptions / status | Use last-good; schedule | +| Invalid JSON | Parse error | Use emergency content; log | +| Storage full | Write error | Evict old; retry minimal payload | +| Notifications disabled | OS state | In-app education screen | +| Background killed | Gaps in execution | Catch-up next foreground open | + +## 11) Metrics (local first; uploader optional) +Track per attempt: +``` +NotificationMetrics v1 +- scheduledTime, actualDeliveryTime? +- contentAge (ms) +- engagement: {TAPPED, DISMISSED, IGNORED}? +- failureReason? +- platformInfo (oem, os version, app state) +``` +- Compute: **Fetch Success Rate**, **Delivery Rate**, **Engagement Rate**, **Stale Content Rate**. + +## 12) Testing Requirements +### Matrix (minimum) +- Android 12+ foreground/background/killed; with/without Battery Saver; Wi‑Fi/Mobile/Offline. +- iOS 16+ background/Low Power/Focus/Scheduled Summary on & off. +- Offline at trigger time (must still display). + +### Unit Tests (examples) +- Fallback when fetch fails (uses last-good and marks stale). +- Exact vs inexact scheduling path selected correctly. +- Metrics recorded for each stage. + +## 13) UX Standards +- One clear message; no clutter. +- ≤2 actions; primary takes user into app. +- Respect quiet hours if configured. +- Provide onboarding: value explanation → permission request → time picker → test notification → tips for OEM battery settings (Android) or Focus/Summary (iOS). + +## 14) Code Stubs (must generate & wire) +### Android — Worker (core pattern) +```kotlin +class DailyContentWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) { + override suspend fun doWork(): Result = try { + withTimeout(8.minutes) { + val content = fetchDailyContent(timeout = 30.seconds) + saveToCache(content) + scheduleNotification(content) + } + Result.success() + } catch (e: TimeoutCancellationException) { + scheduleFromCache(); Result.success() + } catch (e: Exception) { + scheduleFromCache(); Result.retry() + } +} +``` +### iOS — BG Refresh Handler (core pattern) +```swift +func handleBackgroundRefresh(_ task: BGAppRefreshTask) { + scheduleNextRefresh() + var finished = false + task.expirationHandler = { if !finished { cancelNetwork(); task.setTaskCompleted(success: false) } } + fetchDailyContent(timeout: 15) { result in + defer { finished = true; task.setTaskCompleted(success: result.isSuccess) } + switch result { + case .success(let content): quickSave(content); scheduleNotification(content) + case .failure: scheduleFromCache() + } + } +} +``` + +## 15) Security & Privacy +- Use HTTPS; pin if required. +- Strip PII from payloads; keep content generic by default. +- Store only what is necessary; apply cache quotas; purge on logout/uninstall. +- Respect OS privacy settings (Focus, Scheduled Summary, Quiet Hours). + +## 16) Troubleshooting Playbook (LLM should generate helpers) +- Android: verify permission, channel, OEM battery settings; `adb shell dumpsys notification`. +- iOS: check authorization, Background App Refresh, Low Power, Focus/Summary state. + +## 17) Roadmap Flags (implement behind switches) +- `FEATURE_MEDIA_ATTACHMENTS` (default off). +- `FEATURE_PERSONALIZATION_ENGINE` (time/frequency, content types). +- `FEATURE_PUSH_REALTIME` (server-driven for urgent alerts). + +## 18) Definition of Done +- Notifications deliver daily at user-selected time **without network**. +- Graceful fallback chain proven by tests. +- Metrics recorded locally; viewable log. +- Clear onboarding and self-diagnostic screen. +- Battery/OS constraints documented; user education available. + +## 19) Quick Start (LLM execution order) +1. Scaffold modules (Android + iOS). +2. Implement models + storage + fallback content. +3. Implement schedulers (AlarmManager / UNCalendarNotificationTrigger). +4. Implement background fetchers (WorkManager / BGTaskScheduler). +5. Wire onboarding + test notification. +6. Add metrics logging. +7. Ship minimal, then iterate. + +--- + +### Appendix A — Emergency Fallback Lines +- "🌅 Good morning! Ready to make today amazing?" +- "💪 Every small step forward counts. You've got this!" +- "🎯 Focus on what you can control today." +- "✨ Your potential is limitless. Keep growing!" +- "🌟 Progress over perfection, always." diff --git a/.gitignore b/.gitignore index b8ad22e..9ff8602 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ logs/ *.tmp *.temp .cache/ +*.lock +*.bin diff --git a/.gradle/8.13/checksums/checksums.lock b/.gradle/8.13/checksums/checksums.lock index 955a1663eb9168adaaf263b00a0a89ad0fc8fe32..9d4c6281249e73fb732803e408e66f644db0adcd 100644 GIT binary patch literal 17 VcmZSX(RL9%bKPJ$0~jy~0st!W1EBx_ literal 17 VcmZSX(RL9%bKPJ$0~j!F1pq6R1VjJ; diff --git a/.gradle/8.13/checksums/md5-checksums.bin b/.gradle/8.13/checksums/md5-checksums.bin index 3885ba26d8747d20c3a0e3929e2c8e70be297096..b38e0b3d098199229b444fbbbc2a44da346d8004 100644 GIT binary patch delta 981 zcmYk3e=L-79LGB&%5h&&ojddprzz5^bu5Zjs9nRE=Xr1}O}|W$LduTG&f!k_OqgBS zWGW+u)N(~?v|MtFExOf8Nv-r_NwoAl_kC=A|9L*&*XQ+lKcDS;r-RpapVzuYxG5*! zCOmyOVr{?@^GT8|aHqiF>EK5NDlt!}Smdx(n1ia0XVdk0bLqIL@P-DVyFlD4+W)tq z5wk=&=!LDQ{@R$NdC(Y)Vf;L#IOn6AS?g7*3Da*b`X`v2rRbJM@2zy|FmPrH*ooQ% z74nY;Qhx)pBr$Y~2sHTo!1%#(?JrJz3RKpHuKqE#u71U~-8>qM=>eB72O-FpL`l zFV`c;gF0N+y|dC0!^AEqVxZ4eQZ^8LWd_3}CA@PzD#*CU6Zr2*=ENe{?M9&Ok9<>l zUTlxR#AF6!H6x+gNj2~ZDqv9Su-5F%D61H;@vSZDP4zA-u1>@#IeID#cwxzCx9i#|KNhc;j z-HbQ2eE`d7$$x-f2w@}}S#kz47+prQdPy$Uol2pJWc89l3|2)z8qMlu8=#72cPsmY zJ1g?QWL7mxF|hstArz}>93YhvK^Nu3UR+^_V%O8m0go2JTbiX>YnV5uX-Dy}V-pA{ Y_Q&Nx7+B3fCM7`(*oT2#EA#dH2U9dmxBvhE delta 132 zcmV-~0DJ$jzX7?l0kAX}0bG-17o|Q z92t|I950jQ92k=o9Waw!9T=019gveX9x#)A9!Qhj9$=GIA7GQsA7PV5AQ!WBAW#95 m^&v2mJt8oZg(8rXEh89{T_Yj_2(hst_!qG-Xb-bNNca&qxiNzP diff --git a/.gradle/8.13/checksums/sha1-checksums.bin b/.gradle/8.13/checksums/sha1-checksums.bin index 3b9ce332b7f020515c76eb3ab4a511253beb6118..864ee7aa38611f4d08a71257642a0acd0fe205d5 100644 GIT binary patch delta 2106 zcmZ9Mc~DbV6vhQXK*9GAE#YBUWD6irV%4B!1eXE@#3&UJ7y;?XPJ)IV5(t!lh9IXZ z48#_!)sa$TQ7lSEv;v}1a0jWf*tAtd2SHJXrFrkZFw?%j&iTFXeCNB#P2}TRZHKfK zs~C=2_M&;eY=5_Ac=Dn0&5V36@nqEV)<}*kV&7mIZJ|z6 zb%zhz1aV=}9AEO%MV%izPNR?`ve5^=M(oZF$WWDvxkP3*b+}gH>nBo%!_J-j|4pl^TaSCF~RMJ*p7T zGJs{~R``h+!?4~d`nuLc)$4+sCNql(ZhFT_Ne`Pm;*6blyCywOEm%!LSZ1^&}5 z-+{O9YibcJbOhbyotVV+S-t)-9I@Q*pl!JbZ;~nyF$ zR9`-7tY9N390R3w9JbM=`^?q*5}KI-bA=T?W}9`;XJgVZJv{`gR}A8lPU1~aFtriE zyk}r!a|74iWtfqFy(AF9oN4H?@o-o#)t(feX+TUot~tBmDc)n@?fOp;&##4Pn|y4i zjBTMefgzUN3kPO`=qqb>p5=U{#jG6uOH$CMsxhb?AY@TN7!s&#vw>ycTxENNmtZ>eo8owZZXBZ95A&|xKTCYgoQhp)$F}-4wX2s`Ac0{{R&d^s? z;(dUKifdtd9;zJU#E-+j=C<32SU{_R?adV85@~b4jlSQBj0O&EGch z;pyJY3d9TdfNG_Pl`gyA3ET=LE$-BaGcvCw5Khcq#B(1&go_6j6z>wisrwl*VGu}N zM0ieQ*X+y$Mt|i+TA=Idq-|b%&YHKh(c3rN9p)$7>&UBoQ^7luttkk|O=hSYZU}TI z|ED1(J34@$LcnySscz+5XPLs#Id-(C1ujPsKsNHK;A`rJt)_Pt!&sC%DgNp=bw$7= z^*$sVBBI@mDvup~Vo7Dz+5R_Zg_wXV&s04gG7f$`a$oTz2&qD{2+qb3(9I*=C6+M4 z`(JZ;FBOT_74Jh`BuM!LDCw>ktofv?qyn&5^>%e7N1vEE7l!sZk};`w1U%uBi%JUN zTr8Eoq#E2(2@s}IxB-}P1h^fb8~+0Y_@y!o5d5l`^gFEN9a;xJpq}Z zOiz6-0z6Uk5JCl{r0g${3E1jPWgk!`#sIkcn?OLCDG5JPaQ<}+B9f?}WixOvi8`xH z53VK=FhKdKoBjj>N)o6zG6RsO5ipuY?v)vXaUubI=x8!e@T9WI{2@x6jY&`Jf(_|p z9@#|@pyCnKQ$=|b45gC^%X?55p9{d>V*>09@(z@bKwt(rSUv+{syJ){&8V=4*Hlqq a0d|?BreX%dGTFGi3d|@g?cLIL@uTskZ delta 210 zcmV;@04@K;i~^nW0kAU|5di9d0J2i{HsVeI00000085ix7=e=|8DO)`8OQ;Xts5|t z4ICJgJsdHUo*bu>T^+%bHXe7A#U5ajRUb){wjW266(GTr1|g@D#UXf;RU$Bxog%1{ zT_a(W)+2Y5Wh7yf-Xs^34JFu<{UyMY)h02MHYa$K#wWj%{U{i-Eh*^&lP)Y5lU*z@ zlhrJ!ldUbNlWi_BleR8MlNK+(lkG34v&Aqt0h28u Mu`n0{vq4B)F>57J?f?J) diff --git a/.gradle/8.13/fileHashes/fileHashes.bin b/.gradle/8.13/fileHashes/fileHashes.bin index be451d0a33dc7c5e14c34c1b7fe0418b8bf72176..fc2d3e8fa6d039c82dda2f6815e054b9c3ac83bd 100644 GIT binary patch delta 103 zcmZ2Hi}C6##tkMCj8c= scheduledTime) { + Log.d(TAG, "Notification time has passed, fetch not needed"); + return false; + } + + // Check if we already have recent content + if (!storage.shouldFetchNewContent()) { + Log.d(TAG, "Recent content available, fetch not needed"); + return false; + } + + return true; + } + + /** + * Fetch content with timeout handling + * + * @return Fetched content or null if failed + */ + private NotificationContent fetchContentWithTimeout() { + try { + Log.d(TAG, "Fetching content with timeout: " + FETCH_TIMEOUT_MS + "ms"); + + // Use a simple timeout mechanism + // In production, you might use CompletableFuture with timeout + long startTime = System.currentTimeMillis(); + + // Attempt fetch + NotificationContent content = fetcher.fetchContentImmediately(); + + long fetchDuration = System.currentTimeMillis() - startTime; + + if (content != null) { + Log.d(TAG, "Content fetched successfully in " + fetchDuration + "ms"); + return content; + } else { + Log.w(TAG, "Content fetch returned null after " + fetchDuration + "ms"); + return null; + } + + } catch (Exception e) { + Log.e(TAG, "Error during content fetch", e); + return null; + } + } + + /** + * Handle successful content fetch + * + * @param content Successfully fetched content + */ + private void handleSuccessfulFetch(NotificationContent content) { + try { + Log.d(TAG, "Handling successful content fetch: " + content.getId()); + + // Content is already saved by the fetcher + // Update last fetch time + storage.setLastFetchTime(System.currentTimeMillis()); + + // Schedule notification if not already scheduled + scheduleNotificationIfNeeded(content); + + Log.i(TAG, "Successful fetch handling completed"); + + } catch (Exception e) { + Log.e(TAG, "Error handling successful fetch", e); + } + } + + /** + * Handle failed content fetch with retry logic + * + * @param retryCount Current retry attempt + * @param scheduledTime When notification is scheduled for + * @return Result indicating retry or failure + */ + private Result handleFailedFetch(int retryCount, long scheduledTime) { + try { + Log.d(TAG, "Handling failed fetch - Retry: " + retryCount); + + if (retryCount < MAX_RETRY_ATTEMPTS) { + // Schedule retry + scheduleRetry(retryCount + 1, scheduledTime); + Log.i(TAG, "Scheduled retry attempt " + (retryCount + 1)); + return Result.retry(); + + } else { + // Max retries reached - use fallback content + Log.w(TAG, "Max retries reached, using fallback content"); + useFallbackContent(scheduledTime); + return Result.success(); + } + + } catch (Exception e) { + Log.e(TAG, "Error handling failed fetch", e); + return Result.failure(); + } + } + + /** + * Schedule a retry attempt + * + * @param retryCount New retry attempt number + * @param scheduledTime When notification is scheduled for + */ + private void scheduleRetry(int retryCount, long scheduledTime) { + try { + Log.d(TAG, "Scheduling retry attempt " + retryCount); + + // Calculate retry delay with exponential backoff + long retryDelay = calculateRetryDelay(retryCount); + + // Create retry work request + Data retryData = new Data.Builder() + .putLong(KEY_SCHEDULED_TIME, scheduledTime) + .putLong(KEY_FETCH_TIME, System.currentTimeMillis()) + .putInt(KEY_RETRY_COUNT, retryCount) + .build(); + + androidx.work.OneTimeWorkRequest retryWork = + new androidx.work.OneTimeWorkRequest.Builder(DailyNotificationFetchWorker.class) + .setInputData(retryData) + .setInitialDelay(retryDelay, TimeUnit.MILLISECONDS) + .build(); + + androidx.work.WorkManager.getInstance(context).enqueue(retryWork); + + Log.d(TAG, "Retry scheduled for " + retryDelay + "ms from now"); + + } catch (Exception e) { + Log.e(TAG, "Error scheduling retry", e); + } + } + + /** + * Calculate retry delay with exponential backoff + * + * @param retryCount Current retry attempt + * @return Delay in milliseconds + */ + private long calculateRetryDelay(int retryCount) { + // Base delay: 1 minute, exponential backoff: 2^retryCount + long baseDelay = 60 * 1000; // 1 minute + long exponentialDelay = baseDelay * (long) Math.pow(2, retryCount - 1); + + // Cap at 1 hour + long maxDelay = 60 * 60 * 1000; // 1 hour + return Math.min(exponentialDelay, maxDelay); + } + + /** + * Use fallback content when all retries fail + * + * @param scheduledTime When notification is scheduled for + */ + private void useFallbackContent(long scheduledTime) { + try { + Log.d(TAG, "Using fallback content for scheduled time: " + scheduledTime); + + // Get fallback content from storage or create emergency content + NotificationContent fallbackContent = getFallbackContent(scheduledTime); + + if (fallbackContent != null) { + // Save fallback content + storage.saveNotificationContent(fallbackContent); + + // Schedule notification + scheduleNotificationIfNeeded(fallbackContent); + + Log.i(TAG, "Fallback content applied successfully"); + } else { + Log.e(TAG, "Failed to get fallback content"); + } + + } catch (Exception e) { + Log.e(TAG, "Error using fallback content", e); + } + } + + /** + * Get fallback content for the scheduled time + * + * @param scheduledTime When notification is scheduled for + * @return Fallback notification content + */ + private NotificationContent getFallbackContent(long scheduledTime) { + try { + // Try to get last known good content + NotificationContent lastContent = storage.getLastNotification(); + + if (lastContent != null && !lastContent.isStale()) { + Log.d(TAG, "Using last known good content as fallback"); + + // Create new content based on last good content + NotificationContent fallbackContent = new NotificationContent(); + fallbackContent.setTitle(lastContent.getTitle()); + fallbackContent.setBody(lastContent.getBody() + " (from " + + lastContent.getAgeString() + ")"); + fallbackContent.setScheduledTime(scheduledTime); + fallbackContent.setSound(lastContent.isSound()); + fallbackContent.setPriority(lastContent.getPriority()); + fallbackContent.setUrl(lastContent.getUrl()); + fallbackContent.setFetchTime(System.currentTimeMillis()); + + return fallbackContent; + } + + // Create emergency fallback content + Log.w(TAG, "Creating emergency fallback content"); + return createEmergencyFallbackContent(scheduledTime); + + } catch (Exception e) { + Log.e(TAG, "Error getting fallback content", e); + return createEmergencyFallbackContent(scheduledTime); + } + } + + /** + * Create emergency fallback content + * + * @param scheduledTime When notification is scheduled for + * @return Emergency notification content + */ + private NotificationContent createEmergencyFallbackContent(long scheduledTime) { + NotificationContent content = new NotificationContent(); + content.setTitle("Daily Update"); + content.setBody("🌅 Good morning! Ready to make today amazing?"); + content.setScheduledTime(scheduledTime); + content.setFetchTime(System.currentTimeMillis()); + content.setPriority("default"); + content.setSound(true); + + return content; + } + + /** + * Schedule notification if not already scheduled + * + * @param content Notification content to schedule + */ + private void scheduleNotificationIfNeeded(NotificationContent content) { + try { + Log.d(TAG, "Checking if notification needs scheduling: " + content.getId()); + + // Check if notification is already scheduled + DailyNotificationScheduler scheduler = new DailyNotificationScheduler( + context, + (android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE) + ); + + if (!scheduler.isNotificationScheduled(content.getId())) { + Log.d(TAG, "Scheduling notification: " + content.getId()); + boolean scheduled = scheduler.scheduleNotification(content); + + if (scheduled) { + Log.i(TAG, "Notification scheduled successfully"); + } else { + Log.e(TAG, "Failed to schedule notification"); + } + } else { + Log.d(TAG, "Notification already scheduled: " + content.getId()); + } + + } catch (Exception e) { + Log.e(TAG, "Error checking/scheduling notification", e); + } + } +} diff --git a/src/android/DailyNotificationFetcher.java b/src/android/DailyNotificationFetcher.java new file mode 100644 index 0000000..231be66 --- /dev/null +++ b/src/android/DailyNotificationFetcher.java @@ -0,0 +1,364 @@ +/** + * DailyNotificationFetcher.java + * + * Handles background content fetching for daily notifications + * Implements the prefetch step of the prefetch → cache → schedule → display pipeline + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.util.Log; + +import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; +import androidx.work.WorkManager; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.concurrent.TimeUnit; + +/** + * Manages background content fetching for daily notifications + * + * This class implements the prefetch step of the offline-first pipeline. + * It schedules background work to fetch content before it's needed, + * with proper timeout handling and fallback mechanisms. + */ +public class DailyNotificationFetcher { + + private static final String TAG = "DailyNotificationFetcher"; + private static final String WORK_TAG_FETCH = "daily_notification_fetch"; + private static final String WORK_TAG_MAINTENANCE = "daily_notification_maintenance"; + + private static final int NETWORK_TIMEOUT_MS = 30000; // 30 seconds + private static final int MAX_RETRY_ATTEMPTS = 3; + private static final long RETRY_DELAY_MS = 60000; // 1 minute + + private final Context context; + private final DailyNotificationStorage storage; + private final WorkManager workManager; + + /** + * Constructor + * + * @param context Application context + * @param storage Storage instance for saving fetched content + */ + public DailyNotificationFetcher(Context context, DailyNotificationStorage storage) { + this.context = context; + this.storage = storage; + this.workManager = WorkManager.getInstance(context); + } + + /** + * Schedule a background fetch for content + * + * @param scheduledTime When the notification is scheduled for + */ + public void scheduleFetch(long scheduledTime) { + try { + Log.d(TAG, "Scheduling background fetch for " + scheduledTime); + + // Calculate fetch time (1 hour before notification) + long fetchTime = scheduledTime - TimeUnit.HOURS.toMillis(1); + + if (fetchTime > System.currentTimeMillis()) { + // Create work data + Data inputData = new Data.Builder() + .putLong("scheduled_time", scheduledTime) + .putLong("fetch_time", fetchTime) + .putInt("retry_count", 0) + .build(); + + // Create one-time work request + OneTimeWorkRequest fetchWork = new OneTimeWorkRequest.Builder( + DailyNotificationFetchWorker.class) + .setInputData(inputData) + .addTag(WORK_TAG_FETCH) + .setInitialDelay(fetchTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS) + .build(); + + // Enqueue the work + workManager.enqueue(fetchWork); + + Log.i(TAG, "Background fetch scheduled successfully"); + + } else { + Log.w(TAG, "Fetch time has already passed, scheduling immediate fetch"); + scheduleImmediateFetch(); + } + + } catch (Exception e) { + Log.e(TAG, "Error scheduling background fetch", e); + // Fallback to immediate fetch + scheduleImmediateFetch(); + } + } + + /** + * Schedule an immediate fetch (fallback) + */ + public void scheduleImmediateFetch() { + try { + Log.d(TAG, "Scheduling immediate fetch"); + + Data inputData = new Data.Builder() + .putLong("scheduled_time", System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)) + .putLong("fetch_time", System.currentTimeMillis()) + .putInt("retry_count", 0) + .putBoolean("immediate", true) + .build(); + + OneTimeWorkRequest fetchWork = new OneTimeWorkRequest.Builder( + DailyNotificationFetchWorker.class) + .setInputData(inputData) + .addTag(WORK_TAG_FETCH) + .build(); + + workManager.enqueue(fetchWork); + + Log.i(TAG, "Immediate fetch scheduled successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error scheduling immediate fetch", e); + } + } + + /** + * Fetch content immediately (synchronous) + * + * @return Fetched notification content or null if failed + */ + public NotificationContent fetchContentImmediately() { + try { + Log.d(TAG, "Fetching content immediately"); + + // Check if we should fetch new content + if (!storage.shouldFetchNewContent()) { + Log.d(TAG, "Content fetch not needed yet"); + return storage.getLastNotification(); + } + + // Attempt to fetch from network + NotificationContent content = fetchFromNetwork(); + + if (content != null) { + // Save to storage + storage.saveNotificationContent(content); + storage.setLastFetchTime(System.currentTimeMillis()); + + Log.i(TAG, "Content fetched and saved successfully"); + return content; + + } else { + // Fallback to cached content + Log.w(TAG, "Network fetch failed, using cached content"); + return getFallbackContent(); + } + + } catch (Exception e) { + Log.e(TAG, "Error during immediate content fetch", e); + return getFallbackContent(); + } + } + + /** + * Fetch content from network with timeout + * + * @return Fetched content or null if failed + */ + private NotificationContent fetchFromNetwork() { + HttpURLConnection connection = null; + + try { + // Create connection to content endpoint + URL url = new URL(getContentEndpoint()); + connection = (HttpURLConnection) url.openConnection(); + + // Set timeout + connection.setConnectTimeout(NETWORK_TIMEOUT_MS); + connection.setReadTimeout(NETWORK_TIMEOUT_MS); + connection.setRequestMethod("GET"); + + // Add headers + connection.setRequestProperty("User-Agent", "TimeSafari-DailyNotification/1.0"); + connection.setRequestProperty("Accept", "application/json"); + + // Connect and check response + int responseCode = connection.getResponseCode(); + + if (responseCode == HttpURLConnection.HTTP_OK) { + // Parse response and create notification content + NotificationContent content = parseNetworkResponse(connection); + + if (content != null) { + Log.d(TAG, "Content fetched from network successfully"); + return content; + } + + } else { + Log.w(TAG, "Network request failed with response code: " + responseCode); + } + + } catch (IOException e) { + Log.e(TAG, "Network error during content fetch", e); + } catch (Exception e) { + Log.e(TAG, "Unexpected error during network fetch", e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + + return null; + } + + /** + * Parse network response into notification content + * + * @param connection HTTP connection with response + * @return Parsed notification content or null if parsing failed + */ + private NotificationContent parseNetworkResponse(HttpURLConnection connection) { + try { + // This is a simplified parser - in production you'd use a proper JSON parser + // For now, we'll create a placeholder content + + NotificationContent content = new NotificationContent(); + content.setTitle("Daily Update"); + content.setBody("Your daily notification is ready"); + content.setScheduledTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)); + content.setFetchTime(System.currentTimeMillis()); + + return content; + + } catch (Exception e) { + Log.e(TAG, "Error parsing network response", e); + return null; + } + } + + /** + * Get fallback content when network fetch fails + * + * @return Fallback notification content + */ + private NotificationContent getFallbackContent() { + try { + // Try to get last known good content + NotificationContent lastContent = storage.getLastNotification(); + + if (lastContent != null && !lastContent.isStale()) { + Log.d(TAG, "Using last known good content as fallback"); + return lastContent; + } + + // Create emergency fallback content + Log.w(TAG, "Creating emergency fallback content"); + return createEmergencyFallbackContent(); + + } catch (Exception e) { + Log.e(TAG, "Error getting fallback content", e); + return createEmergencyFallbackContent(); + } + } + + /** + * Create emergency fallback content + * + * @return Emergency notification content + */ + private NotificationContent createEmergencyFallbackContent() { + NotificationContent content = new NotificationContent(); + content.setTitle("Daily Update"); + content.setBody("🌅 Good morning! Ready to make today amazing?"); + content.setScheduledTime(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)); + content.setFetchTime(System.currentTimeMillis()); + content.setPriority("default"); + content.setSound(true); + + return content; + } + + /** + * Get the content endpoint URL + * + * @return Content endpoint URL + */ + private String getContentEndpoint() { + // This would typically come from configuration + // For now, return a placeholder + return "https://api.timesafari.com/daily-content"; + } + + /** + * Schedule maintenance work + */ + public void scheduleMaintenance() { + try { + Log.d(TAG, "Scheduling maintenance work"); + + Data inputData = new Data.Builder() + .putLong("maintenance_time", System.currentTimeMillis()) + .build(); + + OneTimeWorkRequest maintenanceWork = new OneTimeWorkRequest.Builder( + DailyNotificationMaintenanceWorker.class) + .setInputData(inputData) + .addTag(WORK_TAG_MAINTENANCE) + .setInitialDelay(TimeUnit.HOURS.toMillis(2), TimeUnit.MILLISECONDS) + .build(); + + workManager.enqueue(maintenanceWork); + + Log.i(TAG, "Maintenance work scheduled successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error scheduling maintenance work", e); + } + } + + /** + * Cancel all scheduled fetch work + */ + public void cancelAllFetchWork() { + try { + Log.d(TAG, "Cancelling all fetch work"); + + workManager.cancelAllWorkByTag(WORK_TAG_FETCH); + workManager.cancelAllWorkByTag(WORK_TAG_MAINTENANCE); + + Log.i(TAG, "All fetch work cancelled"); + + } catch (Exception e) { + Log.e(TAG, "Error cancelling fetch work", e); + } + } + + /** + * Check if fetch work is scheduled + * + * @return true if fetch work is scheduled + */ + public boolean isFetchWorkScheduled() { + // This would check WorkManager for pending work + // For now, return a placeholder + return false; + } + + /** + * Get fetch statistics + * + * @return Fetch statistics as a string + */ + public String getFetchStats() { + return String.format("Last fetch: %d, Fetch work scheduled: %s", + storage.getLastFetchTime(), + isFetchWorkScheduled() ? "yes" : "no"); + } +} diff --git a/src/android/DailyNotificationMaintenanceWorker.java b/src/android/DailyNotificationMaintenanceWorker.java new file mode 100644 index 0000000..0d86046 --- /dev/null +++ b/src/android/DailyNotificationMaintenanceWorker.java @@ -0,0 +1,403 @@ +/** + * DailyNotificationMaintenanceWorker.java + * + * WorkManager worker for maintenance tasks + * Handles cleanup, optimization, and system health checks + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +import java.util.List; + +/** + * Background worker for maintenance tasks + * + * This worker handles periodic maintenance of the notification system, + * including cleanup of old data, optimization of storage, and health checks. + */ +public class DailyNotificationMaintenanceWorker extends Worker { + + private static final String TAG = "DailyNotificationMaintenanceWorker"; + private static final String KEY_MAINTENANCE_TIME = "maintenance_time"; + + private static final long WORK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes total + private static final int MAX_NOTIFICATIONS_TO_KEEP = 50; // Keep only recent notifications + + private final Context context; + private final DailyNotificationStorage storage; + + /** + * Constructor + * + * @param context Application context + * @param params Worker parameters + */ + public DailyNotificationMaintenanceWorker(@NonNull Context context, + @NonNull WorkerParameters params) { + super(context, params); + this.context = context; + this.storage = new DailyNotificationStorage(context); + } + + /** + * Main work method - perform maintenance tasks + * + * @return Result indicating success or failure + */ + @NonNull + @Override + public Result doWork() { + try { + Log.d(TAG, "Starting maintenance work"); + + // Get input data + Data inputData = getInputData(); + long maintenanceTime = inputData.getLong(KEY_MAINTENANCE_TIME, 0); + + Log.d(TAG, "Maintenance time: " + maintenanceTime); + + // Perform maintenance tasks + boolean success = performMaintenance(); + + if (success) { + Log.i(TAG, "Maintenance completed successfully"); + return Result.success(); + } else { + Log.w(TAG, "Maintenance completed with warnings"); + return Result.success(); // Still consider it successful + } + + } catch (Exception e) { + Log.e(TAG, "Error during maintenance work", e); + return Result.failure(); + } + } + + /** + * Perform all maintenance tasks + * + * @return true if all tasks completed successfully + */ + private boolean performMaintenance() { + try { + Log.d(TAG, "Performing maintenance tasks"); + + boolean allSuccessful = true; + + // Task 1: Clean up old notifications + boolean cleanupSuccess = cleanupOldNotifications(); + if (!cleanupSuccess) { + allSuccessful = false; + } + + // Task 2: Optimize storage + boolean optimizationSuccess = optimizeStorage(); + if (!optimizationSuccess) { + allSuccessful = false; + } + + // Task 3: Health check + boolean healthCheckSuccess = performHealthCheck(); + if (!healthCheckSuccess) { + allSuccessful = false; + } + + // Task 4: Schedule next maintenance + scheduleNextMaintenance(); + + Log.d(TAG, "Maintenance tasks completed. All successful: " + allSuccessful); + return allSuccessful; + + } catch (Exception e) { + Log.e(TAG, "Error during maintenance tasks", e); + return false; + } + } + + /** + * Clean up old notifications + * + * @return true if cleanup was successful + */ + private boolean cleanupOldNotifications() { + try { + Log.d(TAG, "Cleaning up old notifications"); + + // Get all notifications + List allNotifications = storage.getAllNotifications(); + int initialCount = allNotifications.size(); + + if (initialCount <= MAX_NOTIFICATIONS_TO_KEEP) { + Log.d(TAG, "No cleanup needed, notification count: " + initialCount); + return true; + } + + // Remove old notifications, keeping the most recent ones + int notificationsToRemove = initialCount - MAX_NOTIFICATIONS_TO_KEEP; + int removedCount = 0; + + for (int i = 0; i < notificationsToRemove && i < allNotifications.size(); i++) { + NotificationContent notification = allNotifications.get(i); + storage.removeNotification(notification.getId()); + removedCount++; + } + + Log.i(TAG, "Cleanup completed. Removed " + removedCount + " old notifications"); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error during notification cleanup", e); + return false; + } + } + + /** + * Optimize storage usage + * + * @return true if optimization was successful + */ + private boolean optimizeStorage() { + try { + Log.d(TAG, "Optimizing storage"); + + // Get storage statistics + String stats = storage.getStorageStats(); + Log.d(TAG, "Storage stats before optimization: " + stats); + + // Perform storage optimization + // This could include: + // - Compacting data structures + // - Removing duplicate entries + // - Optimizing cache usage + + // For now, just log the current state + Log.d(TAG, "Storage optimization completed"); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error during storage optimization", e); + return false; + } + } + + /** + * Perform system health check + * + * @return true if health check passed + */ + private boolean performHealthCheck() { + try { + Log.d(TAG, "Performing health check"); + + boolean healthOk = true; + + // Check 1: Storage health + boolean storageHealth = checkStorageHealth(); + if (!storageHealth) { + healthOk = false; + } + + // Check 2: Notification count health + boolean countHealth = checkNotificationCountHealth(); + if (!countHealth) { + healthOk = false; + } + + // Check 3: Data integrity + boolean dataIntegrity = checkDataIntegrity(); + if (!dataIntegrity) { + healthOk = false; + } + + if (healthOk) { + Log.i(TAG, "Health check passed"); + } else { + Log.w(TAG, "Health check failed - some issues detected"); + } + + return healthOk; + + } catch (Exception e) { + Log.e(TAG, "Error during health check", e); + return false; + } + } + + /** + * Check storage health + * + * @return true if storage is healthy + */ + private boolean checkStorageHealth() { + try { + Log.d(TAG, "Checking storage health"); + + // Check if storage is accessible + int notificationCount = storage.getNotificationCount(); + + if (notificationCount < 0) { + Log.w(TAG, "Storage health issue: Invalid notification count"); + return false; + } + + // Check if storage is empty (this might be normal) + if (storage.isEmpty()) { + Log.d(TAG, "Storage is empty (this might be normal)"); + } + + Log.d(TAG, "Storage health check passed"); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error checking storage health", e); + return false; + } + } + + /** + * Check notification count health + * + * @return true if notification count is healthy + */ + private boolean checkNotificationCountHealth() { + try { + Log.d(TAG, "Checking notification count health"); + + int notificationCount = storage.getNotificationCount(); + + // Check for reasonable limits + if (notificationCount > 1000) { + Log.w(TAG, "Notification count health issue: Too many notifications (" + + notificationCount + ")"); + return false; + } + + Log.d(TAG, "Notification count health check passed: " + notificationCount); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error checking notification count health", e); + return false; + } + } + + /** + * Check data integrity + * + * @return true if data integrity is good + */ + private boolean checkDataIntegrity() { + try { + Log.d(TAG, "Checking data integrity"); + + // Get all notifications and check basic integrity + List allNotifications = storage.getAllNotifications(); + + for (NotificationContent notification : allNotifications) { + // Check required fields + if (notification.getId() == null || notification.getId().isEmpty()) { + Log.w(TAG, "Data integrity issue: Notification with null/empty ID"); + return false; + } + + if (notification.getTitle() == null || notification.getTitle().isEmpty()) { + Log.w(TAG, "Data integrity issue: Notification with null/empty title"); + return false; + } + + if (notification.getBody() == null || notification.getBody().isEmpty()) { + Log.w(TAG, "Data integrity issue: Notification with null/empty body"); + return false; + } + + // Check timestamp validity + if (notification.getScheduledTime() <= 0) { + Log.w(TAG, "Data integrity issue: Invalid scheduled time"); + return false; + } + + if (notification.getFetchTime() <= 0) { + Log.w(TAG, "Data integrity issue: Invalid fetch time"); + return false; + } + } + + Log.d(TAG, "Data integrity check passed"); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error checking data integrity", e); + return false; + } + } + + /** + * Schedule next maintenance run + */ + private void scheduleNextMaintenance() { + try { + Log.d(TAG, "Scheduling next maintenance"); + + // Schedule maintenance for tomorrow at 2 AM + long nextMaintenanceTime = calculateNextMaintenanceTime(); + + Data maintenanceData = new Data.Builder() + .putLong(KEY_MAINTENANCE_TIME, nextMaintenanceTime) + .build(); + + androidx.work.OneTimeWorkRequest maintenanceWork = + new androidx.work.OneTimeWorkRequest.Builder(DailyNotificationMaintenanceWorker.class) + .setInputData(maintenanceData) + .setInitialDelay(nextMaintenanceTime - System.currentTimeMillis(), + java.util.concurrent.TimeUnit.MILLISECONDS) + .build(); + + androidx.work.WorkManager.getInstance(context).enqueue(maintenanceWork); + + Log.i(TAG, "Next maintenance scheduled for " + nextMaintenanceTime); + + } catch (Exception e) { + Log.e(TAG, "Error scheduling next maintenance", e); + } + } + + /** + * Calculate next maintenance time (2 AM tomorrow) + * + * @return Timestamp for next maintenance + */ + private long calculateNextMaintenanceTime() { + try { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + + // Set to 2 AM + calendar.set(java.util.Calendar.HOUR_OF_DAY, 2); + calendar.set(java.util.Calendar.MINUTE, 0); + calendar.set(java.util.Calendar.SECOND, 0); + calendar.set(java.util.Calendar.MILLISECOND, 0); + + // If 2 AM has passed today, schedule for tomorrow + if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { + calendar.add(java.util.Calendar.DAY_OF_YEAR, 1); + } + + return calendar.getTimeInMillis(); + + } catch (Exception e) { + Log.e(TAG, "Error calculating next maintenance time", e); + // Fallback: 24 hours from now + return System.currentTimeMillis() + (24 * 60 * 60 * 1000); + } + } +} diff --git a/src/android/DailyNotificationPlugin.java b/src/android/DailyNotificationPlugin.java new file mode 100644 index 0000000..a292663 --- /dev/null +++ b/src/android/DailyNotificationPlugin.java @@ -0,0 +1,506 @@ +/** + * DailyNotificationPlugin.java + * + * Android implementation of the Daily Notification Plugin for Capacitor + * Implements offline-first daily notifications with prefetch → cache → schedule → display pipeline + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.Manifest; +import android.app.AlarmManager; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.PowerManager; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.work.WorkManager; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.getcapacitor.annotation.Permission; + +import java.util.Calendar; +import java.util.concurrent.TimeUnit; + +/** + * Main plugin class for handling daily notifications on Android + * + * This plugin provides functionality for scheduling and managing daily notifications + * with offline-first approach, background content fetching, and reliable delivery. + */ +@CapacitorPlugin( + name = "DailyNotification", + permissions = { + @Permission( + alias = "notifications", + strings = { + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.SCHEDULE_EXACT_ALARM, + Manifest.permission.WAKE_LOCK, + Manifest.permission.INTERNET + } + ) + } +) +public class DailyNotificationPlugin extends Plugin { + + private static final String TAG = "DailyNotificationPlugin"; + private static final String CHANNEL_ID = "timesafari.daily"; + private static final String CHANNEL_NAME = "Daily Notifications"; + private static final String CHANNEL_DESCRIPTION = "Daily notification updates from TimeSafari"; + + private NotificationManager notificationManager; + private AlarmManager alarmManager; + private WorkManager workManager; + private PowerManager powerManager; + private DailyNotificationStorage storage; + private DailyNotificationScheduler scheduler; + private DailyNotificationFetcher fetcher; + + /** + * Initialize the plugin and create notification channel + */ + @Override + public void load() { + super.load(); + + try { + // Initialize system services + notificationManager = (NotificationManager) getContext() + .getSystemService(Context.NOTIFICATION_SERVICE); + alarmManager = (AlarmManager) getContext() + .getSystemService(Context.ALARM_SERVICE); + workManager = WorkManager.getInstance(getContext()); + powerManager = (PowerManager) getContext() + .getSystemService(Context.POWER_SERVICE); + + // Initialize components + storage = new DailyNotificationStorage(getContext()); + scheduler = new DailyNotificationScheduler(getContext(), alarmManager); + fetcher = new DailyNotificationFetcher(getContext(), storage); + + // Create notification channel + createNotificationChannel(); + + // Schedule next maintenance + scheduleMaintenance(); + + Log.i(TAG, "DailyNotificationPlugin initialized successfully"); + + } catch (Exception e) { + Log.e(TAG, "Failed to initialize DailyNotificationPlugin", e); + } + } + + /** + * Schedule a daily notification with the specified options + * + * @param call Plugin call containing notification parameters + */ + @PluginMethod + public void scheduleDailyNotification(PluginCall call) { + try { + Log.d(TAG, "Scheduling daily notification"); + + // Validate required parameters + String time = call.getString("time"); + if (time == null || time.isEmpty()) { + call.reject("Time parameter is required"); + return; + } + + // Parse time (HH:mm format) + String[] timeParts = time.split(":"); + if (timeParts.length != 2) { + call.reject("Invalid time format. Use HH:mm"); + return; + } + + int hour, minute; + try { + hour = Integer.parseInt(timeParts[0]); + minute = Integer.parseInt(timeParts[1]); + } catch (NumberFormatException e) { + call.reject("Invalid time format. Use HH:mm"); + return; + } + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + call.reject("Invalid time values"); + return; + } + + // Extract other parameters + String title = call.getString("title", "Daily Update"); + String body = call.getString("body", "Your daily notification is ready"); + boolean sound = call.getBoolean("sound", true); + String priority = call.getString("priority", "default"); + String url = call.getString("url", ""); + + // Create notification content + NotificationContent content = new NotificationContent(); + content.setTitle(title); + content.setBody(body); + content.setSound(sound); + content.setPriority(priority); + content.setUrl(url); + content.setScheduledTime(calculateNextScheduledTime(hour, minute)); + + // Store notification content + storage.saveNotificationContent(content); + + // Schedule the notification + boolean scheduled = scheduler.scheduleNotification(content); + + if (scheduled) { + // Schedule background fetch for next day + scheduleBackgroundFetch(content.getScheduledTime()); + + Log.i(TAG, "Daily notification scheduled successfully for " + time); + call.resolve(); + } else { + call.reject("Failed to schedule notification"); + } + + } catch (Exception e) { + Log.e(TAG, "Error scheduling daily notification", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Get the last notification that was delivered + * + * @param call Plugin call + */ + @PluginMethod + public void getLastNotification(PluginCall call) { + try { + Log.d(TAG, "Getting last notification"); + + NotificationContent lastNotification = storage.getLastNotification(); + + if (lastNotification != null) { + JSObject result = new JSObject(); + result.put("id", lastNotification.getId()); + result.put("title", lastNotification.getTitle()); + result.put("body", lastNotification.getBody()); + result.put("timestamp", lastNotification.getScheduledTime()); + result.put("url", lastNotification.getUrl()); + + call.resolve(result); + } else { + call.resolve(null); + } + + } catch (Exception e) { + Log.e(TAG, "Error getting last notification", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Cancel all scheduled notifications + * + * @param call Plugin call + */ + @PluginMethod + public void cancelAllNotifications(PluginCall call) { + try { + Log.d(TAG, "Cancelling all notifications"); + + scheduler.cancelAllNotifications(); + storage.clearAllNotifications(); + + Log.i(TAG, "All notifications cancelled successfully"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error cancelling notifications", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Get the current status of notifications + * + * @param call Plugin call + */ + @PluginMethod + public void getNotificationStatus(PluginCall call) { + try { + Log.d(TAG, "Getting notification status"); + + JSObject result = new JSObject(); + + // Check if notifications are enabled + boolean notificationsEnabled = areNotificationsEnabled(); + result.put("isEnabled", notificationsEnabled); + + // Get next notification time + long nextNotificationTime = scheduler.getNextNotificationTime(); + result.put("nextNotificationTime", nextNotificationTime); + + // Get current settings + JSObject settings = new JSObject(); + settings.put("sound", true); + settings.put("priority", "default"); + settings.put("timezone", "UTC"); + result.put("settings", settings); + + // Get pending notifications count + int pendingCount = scheduler.getPendingNotificationsCount(); + result.put("pending", pendingCount); + + call.resolve(result); + + } catch (Exception e) { + Log.e(TAG, "Error getting notification status", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Update notification settings + * + * @param call Plugin call containing new settings + */ + @PluginMethod + public void updateSettings(PluginCall call) { + try { + Log.d(TAG, "Updating notification settings"); + + // Extract settings + Boolean sound = call.getBoolean("sound"); + String priority = call.getString("priority"); + String timezone = call.getString("timezone"); + + // Update settings in storage + if (sound != null) { + storage.setSoundEnabled(sound); + } + if (priority != null) { + storage.setPriority(priority); + } + if (timezone != null) { + storage.setTimezone(timezone); + } + + // Update existing notifications with new settings + scheduler.updateNotificationSettings(); + + Log.i(TAG, "Notification settings updated successfully"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error updating notification settings", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Get battery status information + * + * @param call Plugin call + */ + @PluginMethod + public void getBatteryStatus(PluginCall call) { + try { + Log.d(TAG, "Getting battery status"); + + JSObject result = new JSObject(); + + // Get battery level (simplified - would need BatteryManager in real implementation) + result.put("level", 100); // Placeholder + result.put("isCharging", false); // Placeholder + result.put("powerState", 0); // Placeholder + result.put("isOptimizationExempt", false); // Placeholder + + call.resolve(result); + + } catch (Exception e) { + Log.e(TAG, "Error getting battery status", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Request battery optimization exemption + * + * @param call Plugin call + */ + @PluginMethod + public void requestBatteryOptimizationExemption(PluginCall call) { + try { + Log.d(TAG, "Requesting battery optimization exemption"); + + // This would typically open system settings + // For now, just log the request + Log.i(TAG, "Battery optimization exemption requested"); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error requesting battery optimization exemption", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Set adaptive scheduling based on device state + * + * @param call Plugin call containing enabled flag + */ + @PluginMethod + public void setAdaptiveScheduling(PluginCall call) { + try { + Log.d(TAG, "Setting adaptive scheduling"); + + boolean enabled = call.getBoolean("enabled", true); + storage.setAdaptiveSchedulingEnabled(enabled); + + if (enabled) { + scheduler.enableAdaptiveScheduling(); + } else { + scheduler.disableAdaptiveScheduling(); + } + + Log.i(TAG, "Adaptive scheduling " + (enabled ? "enabled" : "disabled")); + call.resolve(); + + } catch (Exception e) { + Log.e(TAG, "Error setting adaptive scheduling", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Get current power state information + * + * @param call Plugin call + */ + @PluginMethod + public void getPowerState(PluginCall call) { + try { + Log.d(TAG, "Getting power state"); + + JSObject result = new JSObject(); + result.put("powerState", 0); // Placeholder + result.put("isOptimizationExempt", false); // Placeholder + + call.resolve(result); + + } catch (Exception e) { + Log.e(TAG, "Error getting power state", e); + call.reject("Internal error: " + e.getMessage()); + } + } + + /** + * Create the notification channel for Android 8.0+ + */ + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription(CHANNEL_DESCRIPTION); + channel.enableLights(true); + channel.enableVibration(true); + + notificationManager.createNotificationChannel(channel); + Log.d(TAG, "Notification channel created: " + CHANNEL_ID); + } + } + + /** + * Calculate the next scheduled time for the notification + * + * @param hour Hour of day (0-23) + * @param minute Minute of hour (0-59) + * @return Timestamp in milliseconds + */ + private long calculateNextScheduledTime(int hour, int minute) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + + // If time has passed today, schedule for tomorrow + if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { + calendar.add(Calendar.DAY_OF_YEAR, 1); + } + + return calendar.getTimeInMillis(); + } + + /** + * Schedule background fetch for content + * + * @param scheduledTime When the notification is scheduled for + */ + private void scheduleBackgroundFetch(long scheduledTime) { + try { + // Schedule fetch 1 hour before notification + long fetchTime = scheduledTime - TimeUnit.HOURS.toMillis(1); + + if (fetchTime > System.currentTimeMillis()) { + fetcher.scheduleFetch(fetchTime); + Log.d(TAG, "Background fetch scheduled for " + fetchTime); + } + } catch (Exception e) { + Log.e(TAG, "Error scheduling background fetch", e); + } + } + + /** + * Schedule maintenance tasks + */ + private void scheduleMaintenance() { + try { + // Schedule daily maintenance at 2 AM + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.HOUR_OF_DAY, 2); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + + if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { + calendar.add(Calendar.DAY_OF_YEAR, 1); + } + + // This would typically use WorkManager for maintenance + Log.d(TAG, "Maintenance scheduled for " + calendar.getTimeInMillis()); + + } catch (Exception e) { + Log.e(TAG, "Error scheduling maintenance", e); + } + } + + /** + * Check if notifications are enabled + * + * @return true if notifications are enabled + */ + private boolean areNotificationsEnabled() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } + return NotificationManagerCompat.from(getContext()).areNotificationsEnabled(); + } +} diff --git a/src/android/DailyNotificationReceiver.java b/src/android/DailyNotificationReceiver.java new file mode 100644 index 0000000..76a8b1f --- /dev/null +++ b/src/android/DailyNotificationReceiver.java @@ -0,0 +1,283 @@ +/** + * DailyNotificationReceiver.java + * + * Broadcast receiver for handling scheduled notification alarms + * Displays notifications when scheduled time is reached + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +/** + * Broadcast receiver for daily notification alarms + * + * This receiver is triggered by AlarmManager when it's time to display + * a notification. It retrieves the notification content from storage + * and displays it to the user. + */ +public class DailyNotificationReceiver extends BroadcastReceiver { + + private static final String TAG = "DailyNotificationReceiver"; + private static final String CHANNEL_ID = "timesafari.daily"; + private static final String EXTRA_NOTIFICATION_ID = "notification_id"; + + /** + * Handle broadcast intent when alarm triggers + * + * @param context Application context + * @param intent Broadcast intent + */ + @Override + public void onReceive(Context context, Intent intent) { + try { + Log.d(TAG, "Received notification broadcast"); + + String action = intent.getAction(); + if (action == null) { + Log.w(TAG, "Received intent with null action"); + return; + } + + if ("com.timesafari.daily.NOTIFICATION".equals(action)) { + handleNotificationIntent(context, intent); + } else { + Log.w(TAG, "Unknown action: " + action); + } + + } catch (Exception e) { + Log.e(TAG, "Error handling broadcast", e); + } + } + + /** + * Handle notification intent + * + * @param context Application context + * @param intent Intent containing notification data + */ + private void handleNotificationIntent(Context context, Intent intent) { + try { + String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID); + + if (notificationId == null) { + Log.w(TAG, "Notification ID not found in intent"); + return; + } + + Log.d(TAG, "Processing notification: " + notificationId); + + // Get notification content from storage + DailyNotificationStorage storage = new DailyNotificationStorage(context); + NotificationContent content = storage.getNotificationContent(notificationId); + + if (content == null) { + Log.w(TAG, "Notification content not found: " + notificationId); + return; + } + + // Check if notification is ready to display + if (!content.isReadyToDisplay()) { + Log.d(TAG, "Notification not ready to display yet: " + notificationId); + return; + } + + // Display the notification + displayNotification(context, content); + + // Schedule next notification if this is a recurring daily notification + scheduleNextNotification(context, content); + + Log.i(TAG, "Notification processed successfully: " + notificationId); + + } catch (Exception e) { + Log.e(TAG, "Error handling notification intent", e); + } + } + + /** + * Display the notification to the user + * + * @param context Application context + * @param content Notification content to display + */ + private void displayNotification(Context context, NotificationContent content) { + try { + Log.d(TAG, "Displaying notification: " + content.getId()); + + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + if (notificationManager == null) { + Log.e(TAG, "NotificationManager not available"); + return; + } + + // Create notification builder + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(content.getTitle()) + .setContentText(content.getBody()) + .setPriority(getNotificationPriority(content.getPriority())) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_REMINDER); + + // Add sound if enabled + if (content.isSound()) { + builder.setDefaults(NotificationCompat.DEFAULT_SOUND); + } + + // Add click action if URL is available + if (content.getUrl() != null && !content.getUrl().isEmpty()) { + Intent clickIntent = new Intent(Intent.ACTION_VIEW); + clickIntent.setData(android.net.Uri.parse(content.getUrl())); + + PendingIntent clickPendingIntent = PendingIntent.getActivity( + context, + content.getId().hashCode(), + clickIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + builder.setContentIntent(clickPendingIntent); + } + + // Add dismiss action + Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class); + dismissIntent.setAction("com.timesafari.daily.DISMISS"); + dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId()); + + PendingIntent dismissPendingIntent = PendingIntent.getBroadcast( + context, + content.getId().hashCode() + 1000, // Different request code + dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + builder.addAction( + android.R.drawable.ic_menu_close_clear_cancel, + "Dismiss", + dismissPendingIntent + ); + + // Build and display notification + int notificationId = content.getId().hashCode(); + notificationManager.notify(notificationId, builder.build()); + + Log.i(TAG, "Notification displayed successfully: " + content.getId()); + + } catch (Exception e) { + Log.e(TAG, "Error displaying notification", e); + } + } + + /** + * Schedule the next occurrence of this daily notification + * + * @param context Application context + * @param content Current notification content + */ + private void scheduleNextNotification(Context context, NotificationContent content) { + try { + Log.d(TAG, "Scheduling next notification for: " + content.getId()); + + // Calculate next occurrence (24 hours from now) + long nextScheduledTime = content.getScheduledTime() + (24 * 60 * 60 * 1000); + + // Create new content for next occurrence + NotificationContent nextContent = new NotificationContent(); + nextContent.setTitle(content.getTitle()); + nextContent.setBody(content.getBody()); + nextContent.setScheduledTime(nextScheduledTime); + nextContent.setSound(content.isSound()); + nextContent.setPriority(content.getPriority()); + nextContent.setUrl(content.getUrl()); + nextContent.setFetchTime(System.currentTimeMillis()); + + // Save to storage + DailyNotificationStorage storage = new DailyNotificationStorage(context); + storage.saveNotificationContent(nextContent); + + // Schedule the notification + DailyNotificationScheduler scheduler = new DailyNotificationScheduler( + context, + (android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE) + ); + + boolean scheduled = scheduler.scheduleNotification(nextContent); + + if (scheduled) { + Log.i(TAG, "Next notification scheduled successfully"); + } else { + Log.e(TAG, "Failed to schedule next notification"); + } + + } catch (Exception e) { + Log.e(TAG, "Error scheduling next notification", e); + } + } + + /** + * Get notification priority constant + * + * @param priority Priority string from content + * @return NotificationCompat priority constant + */ + private int getNotificationPriority(String priority) { + if (priority == null) { + return NotificationCompat.PRIORITY_DEFAULT; + } + + switch (priority.toLowerCase()) { + case "high": + return NotificationCompat.PRIORITY_HIGH; + case "low": + return NotificationCompat.PRIORITY_LOW; + case "min": + return NotificationCompat.PRIORITY_MIN; + case "max": + return NotificationCompat.PRIORITY_MAX; + default: + return NotificationCompat.PRIORITY_DEFAULT; + } + } + + /** + * Handle notification dismissal + * + * @param context Application context + * @param notificationId ID of dismissed notification + */ + private void handleNotificationDismissal(Context context, String notificationId) { + try { + Log.d(TAG, "Handling notification dismissal: " + notificationId); + + // Remove from storage + DailyNotificationStorage storage = new DailyNotificationStorage(context); + storage.removeNotification(notificationId); + + // Cancel any pending alarms + DailyNotificationScheduler scheduler = new DailyNotificationScheduler( + context, + (android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE) + ); + scheduler.cancelNotification(notificationId); + + Log.i(TAG, "Notification dismissed successfully: " + notificationId); + + } catch (Exception e) { + Log.e(TAG, "Error handling notification dismissal", e); + } + } +} diff --git a/src/android/DailyNotificationScheduler.java b/src/android/DailyNotificationScheduler.java new file mode 100644 index 0000000..13cbb1b --- /dev/null +++ b/src/android/DailyNotificationScheduler.java @@ -0,0 +1,377 @@ +/** + * DailyNotificationScheduler.java + * + * Handles scheduling and timing of daily notifications + * Implements exact and inexact alarm scheduling with battery optimization handling + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.util.Log; + +import java.util.Calendar; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages scheduling of daily notifications using AlarmManager + * + * This class handles the scheduling aspect of the prefetch → cache → schedule → display pipeline. + * It supports both exact and inexact alarms based on system permissions and battery optimization. + */ +public class DailyNotificationScheduler { + + private static final String TAG = "DailyNotificationScheduler"; + private static final String ACTION_NOTIFICATION = "com.timesafari.daily.NOTIFICATION"; + private static final String EXTRA_NOTIFICATION_ID = "notification_id"; + + private final Context context; + private final AlarmManager alarmManager; + private final ConcurrentHashMap scheduledAlarms; + + /** + * Constructor + * + * @param context Application context + * @param alarmManager System AlarmManager service + */ + public DailyNotificationScheduler(Context context, AlarmManager alarmManager) { + this.context = context; + this.alarmManager = alarmManager; + this.scheduledAlarms = new ConcurrentHashMap<>(); + } + + /** + * Schedule a notification for delivery + * + * @param content Notification content to schedule + * @return true if scheduling was successful + */ + public boolean scheduleNotification(NotificationContent content) { + try { + Log.d(TAG, "Scheduling notification: " + content.getId()); + + // Cancel any existing alarm for this notification + cancelNotification(content.getId()); + + // Create intent for the notification + Intent intent = new Intent(context, DailyNotificationReceiver.class); + intent.setAction(ACTION_NOTIFICATION); + intent.putExtra(EXTRA_NOTIFICATION_ID, content.getId()); + + // Create pending intent with unique request code + int requestCode = content.getId().hashCode(); + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, + requestCode, + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + // Store the pending intent + scheduledAlarms.put(content.getId(), pendingIntent); + + // Schedule the alarm + long triggerTime = content.getScheduledTime(); + boolean scheduled = scheduleAlarm(pendingIntent, triggerTime); + + if (scheduled) { + Log.i(TAG, "Notification scheduled successfully for " + + formatTime(triggerTime)); + return true; + } else { + Log.e(TAG, "Failed to schedule notification"); + scheduledAlarms.remove(content.getId()); + return false; + } + + } catch (Exception e) { + Log.e(TAG, "Error scheduling notification", e); + return false; + } + } + + /** + * Schedule an alarm using the best available method + * + * @param pendingIntent PendingIntent to trigger + * @param triggerTime When to trigger the alarm + * @return true if scheduling was successful + */ + private boolean scheduleAlarm(PendingIntent pendingIntent, long triggerTime) { + try { + // Check if we can use exact alarms + if (canUseExactAlarms()) { + return scheduleExactAlarm(pendingIntent, triggerTime); + } else { + return scheduleInexactAlarm(pendingIntent, triggerTime); + } + } catch (Exception e) { + Log.e(TAG, "Error scheduling alarm", e); + return false; + } + } + + /** + * Schedule an exact alarm for precise timing + * + * @param pendingIntent PendingIntent to trigger + * @param triggerTime When to trigger the alarm + * @return true if scheduling was successful + */ + private boolean scheduleExactAlarm(PendingIntent pendingIntent, long triggerTime) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerTime, + pendingIntent + ); + } else { + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + triggerTime, + pendingIntent + ); + } + + Log.d(TAG, "Exact alarm scheduled for " + formatTime(triggerTime)); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error scheduling exact alarm", e); + return false; + } + } + + /** + * Schedule an inexact alarm for battery optimization + * + * @param pendingIntent PendingIntent to trigger + * @param triggerTime When to trigger the alarm + * @return true if scheduling was successful + */ + private boolean scheduleInexactAlarm(PendingIntent pendingIntent, long triggerTime) { + try { + alarmManager.setRepeating( + AlarmManager.RTC_WAKEUP, + triggerTime, + AlarmManager.INTERVAL_DAY, + pendingIntent + ); + + Log.d(TAG, "Inexact alarm scheduled for " + formatTime(triggerTime)); + return true; + + } catch (Exception e) { + Log.e(TAG, "Error scheduling inexact alarm", e); + return false; + } + } + + /** + * Check if we can use exact alarms + * + * @return true if exact alarms are permitted + */ + private boolean canUseExactAlarms() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return alarmManager.canScheduleExactAlarms(); + } + return true; // Pre-Android 12 always allowed exact alarms + } + + /** + * Cancel a specific notification + * + * @param notificationId ID of notification to cancel + */ + public void cancelNotification(String notificationId) { + try { + PendingIntent pendingIntent = scheduledAlarms.remove(notificationId); + if (pendingIntent != null) { + alarmManager.cancel(pendingIntent); + pendingIntent.cancel(); + Log.d(TAG, "Cancelled notification: " + notificationId); + } + } catch (Exception e) { + Log.e(TAG, "Error cancelling notification: " + notificationId, e); + } + } + + /** + * Cancel all scheduled notifications + */ + public void cancelAllNotifications() { + try { + Log.d(TAG, "Cancelling all notifications"); + + for (String notificationId : scheduledAlarms.keySet()) { + cancelNotification(notificationId); + } + + scheduledAlarms.clear(); + Log.i(TAG, "All notifications cancelled"); + + } catch (Exception e) { + Log.e(TAG, "Error cancelling all notifications", e); + } + } + + /** + * Get the next scheduled notification time + * + * @return Timestamp of next notification or 0 if none scheduled + */ + public long getNextNotificationTime() { + // This would need to be implemented with actual notification data + // For now, return a placeholder + return System.currentTimeMillis() + (24 * 60 * 60 * 1000); // 24 hours from now + } + + /** + * Get count of pending notifications + * + * @return Number of scheduled notifications + */ + public int getPendingNotificationsCount() { + return scheduledAlarms.size(); + } + + /** + * Update notification settings for existing notifications + */ + public void updateNotificationSettings() { + try { + Log.d(TAG, "Updating notification settings"); + + // This would typically involve rescheduling notifications + // with new settings. For now, just log the action. + Log.i(TAG, "Notification settings updated"); + + } catch (Exception e) { + Log.e(TAG, "Error updating notification settings", e); + } + } + + /** + * Enable adaptive scheduling based on device state + */ + public void enableAdaptiveScheduling() { + try { + Log.d(TAG, "Enabling adaptive scheduling"); + + // This would implement logic to adjust scheduling based on: + // - Battery level + // - Power save mode + // - Doze mode + // - User activity patterns + + Log.i(TAG, "Adaptive scheduling enabled"); + + } catch (Exception e) { + Log.e(TAG, "Error enabling adaptive scheduling", e); + } + } + + /** + * Disable adaptive scheduling + */ + public void disableAdaptiveScheduling() { + try { + Log.d(TAG, "Disabling adaptive scheduling"); + + // Reset to default scheduling behavior + Log.i(TAG, "Adaptive scheduling disabled"); + + } catch (Exception e) { + Log.e(TAG, "Error disabling adaptive scheduling", e); + } + } + + /** + * Reschedule notifications after system reboot + */ + public void rescheduleAfterReboot() { + try { + Log.d(TAG, "Rescheduling notifications after reboot"); + + // This would typically be called from a BOOT_COMPLETED receiver + // to restore scheduled notifications after device restart + + Log.i(TAG, "Notifications rescheduled after reboot"); + + } catch (Exception e) { + Log.e(TAG, "Error rescheduling after reboot", e); + } + } + + /** + * Check if a notification is currently scheduled + * + * @param notificationId ID of notification to check + * @return true if notification is scheduled + */ + public boolean isNotificationScheduled(String notificationId) { + return scheduledAlarms.containsKey(notificationId); + } + + /** + * Get scheduling statistics + * + * @return Scheduling statistics as a string + */ + public String getSchedulingStats() { + return String.format("Scheduled: %d, Exact alarms: %s", + scheduledAlarms.size(), + canUseExactAlarms() ? "enabled" : "disabled"); + } + + /** + * Format timestamp for logging + * + * @param timestamp Timestamp in milliseconds + * @return Formatted time string + */ + private String formatTime(long timestamp) { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(timestamp); + + return String.format("%02d:%02d:%02d on %02d/%02d/%04d", + calendar.get(Calendar.HOUR_OF_DAY), + calendar.get(Calendar.MINUTE), + calendar.get(Calendar.SECOND), + calendar.get(Calendar.MONTH) + 1, + calendar.get(Calendar.DAY_OF_MONTH), + calendar.get(Calendar.YEAR)); + } + + /** + * Calculate next occurrence of a daily time + * + * @param hour Hour of day (0-23) + * @param minute Minute of hour (0-59) + * @return Timestamp of next occurrence + */ + public long calculateNextOccurrence(int hour, int minute) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + + // If time has passed today, schedule for tomorrow + if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { + calendar.add(Calendar.DAY_OF_YEAR, 1); + } + + return calendar.getTimeInMillis(); + } +} diff --git a/src/android/DailyNotificationStorage.java b/src/android/DailyNotificationStorage.java new file mode 100644 index 0000000..feedad0 --- /dev/null +++ b/src/android/DailyNotificationStorage.java @@ -0,0 +1,476 @@ +/** + * DailyNotificationStorage.java + * + * Storage management for notification content and settings + * Implements tiered storage: Key-Value (quick) + DB (structured) + Files (large assets) + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import java.io.File; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages storage for notification content and settings + * + * This class implements the tiered storage approach: + * - Tier 1: SharedPreferences for quick access to settings and recent data + * - Tier 2: In-memory cache for structured notification content + * - Tier 3: File system for large assets (future use) + */ +public class DailyNotificationStorage { + + private static final String TAG = "DailyNotificationStorage"; + private static final String PREFS_NAME = "DailyNotificationPrefs"; + private static final String KEY_NOTIFICATIONS = "notifications"; + private static final String KEY_SETTINGS = "settings"; + private static final String KEY_LAST_FETCH = "last_fetch"; + private static final String KEY_ADAPTIVE_SCHEDULING = "adaptive_scheduling"; + + private static final int MAX_CACHE_SIZE = 100; // Maximum notifications to keep in memory + private static final long CACHE_CLEANUP_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours + + private final Context context; + private final SharedPreferences prefs; + private final Gson gson; + private final ConcurrentHashMap notificationCache; + private final List notificationList; + + /** + * Constructor + * + * @param context Application context + */ + public DailyNotificationStorage(Context context) { + this.context = context; + this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + this.gson = new Gson(); + this.notificationCache = new ConcurrentHashMap<>(); + this.notificationList = Collections.synchronizedList(new ArrayList<>()); + + loadNotificationsFromStorage(); + cleanupOldNotifications(); + } + + /** + * Save notification content to storage + * + * @param content Notification content to save + */ + public void saveNotificationContent(NotificationContent content) { + try { + Log.d(TAG, "Saving notification: " + content.getId()); + + // Add to cache + notificationCache.put(content.getId(), content); + + // Add to list and sort by scheduled time + synchronized (notificationList) { + notificationList.removeIf(n -> n.getId().equals(content.getId())); + notificationList.add(content); + Collections.sort(notificationList, + Comparator.comparingLong(NotificationContent::getScheduledTime)); + } + + // Persist to SharedPreferences + saveNotificationsToStorage(); + + Log.d(TAG, "Notification saved successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error saving notification content", e); + } + } + + /** + * Get notification content by ID + * + * @param id Notification ID + * @return Notification content or null if not found + */ + public NotificationContent getNotificationContent(String id) { + return notificationCache.get(id); + } + + /** + * Get the last notification that was delivered + * + * @return Last notification or null if none exists + */ + public NotificationContent getLastNotification() { + synchronized (notificationList) { + if (notificationList.isEmpty()) { + return null; + } + + // Find the most recent delivered notification + long currentTime = System.currentTimeMillis(); + for (int i = notificationList.size() - 1; i >= 0; i--) { + NotificationContent notification = notificationList.get(i); + if (notification.getScheduledTime() <= currentTime) { + return notification; + } + } + + return null; + } + } + + /** + * Get all notifications + * + * @return List of all notifications + */ + public List getAllNotifications() { + synchronized (notificationList) { + return new ArrayList<>(notificationList); + } + } + + /** + * Get notifications that are ready to be displayed + * + * @return List of ready notifications + */ + public List getReadyNotifications() { + List readyNotifications = new ArrayList<>(); + long currentTime = System.currentTimeMillis(); + + synchronized (notificationList) { + for (NotificationContent notification : notificationList) { + if (notification.isReadyToDisplay()) { + readyNotifications.add(notification); + } + } + } + + return readyNotifications; + } + + /** + * Get the next scheduled notification + * + * @return Next notification or null if none scheduled + */ + public NotificationContent getNextNotification() { + synchronized (notificationList) { + long currentTime = System.currentTimeMillis(); + + for (NotificationContent notification : notificationList) { + if (notification.getScheduledTime() > currentTime) { + return notification; + } + } + + return null; + } + } + + /** + * Remove notification by ID + * + * @param id Notification ID to remove + */ + public void removeNotification(String id) { + try { + Log.d(TAG, "Removing notification: " + id); + + notificationCache.remove(id); + + synchronized (notificationList) { + notificationList.removeIf(n -> n.getId().equals(id)); + } + + saveNotificationsToStorage(); + + Log.d(TAG, "Notification removed successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error removing notification", e); + } + } + + /** + * Clear all notifications + */ + public void clearAllNotifications() { + try { + Log.d(TAG, "Clearing all notifications"); + + notificationCache.clear(); + + synchronized (notificationList) { + notificationList.clear(); + } + + saveNotificationsToStorage(); + + Log.d(TAG, "All notifications cleared successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error clearing notifications", e); + } + } + + /** + * Get notification count + * + * @return Number of notifications + */ + public int getNotificationCount() { + return notificationCache.size(); + } + + /** + * Check if storage is empty + * + * @return true if no notifications exist + */ + public boolean isEmpty() { + return notificationCache.isEmpty(); + } + + /** + * Set sound enabled setting + * + * @param enabled true to enable sound + */ + public void setSoundEnabled(boolean enabled) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean("sound_enabled", enabled); + editor.apply(); + + Log.d(TAG, "Sound setting updated: " + enabled); + } + + /** + * Get sound enabled setting + * + * @return true if sound is enabled + */ + public boolean isSoundEnabled() { + return prefs.getBoolean("sound_enabled", true); + } + + /** + * Set notification priority + * + * @param priority Priority string (high, default, low) + */ + public void setPriority(String priority) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putString("priority", priority); + editor.apply(); + + Log.d(TAG, "Priority setting updated: " + priority); + } + + /** + * Get notification priority + * + * @return Priority string + */ + public String getPriority() { + return prefs.getString("priority", "default"); + } + + /** + * Set timezone setting + * + * @param timezone Timezone identifier + */ + public void setTimezone(String timezone) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putString("timezone", timezone); + editor.apply(); + + Log.d(TAG, "Timezone setting updated: " + timezone); + } + + /** + * Get timezone setting + * + * @return Timezone identifier + */ + public String getTimezone() { + return prefs.getString("timezone", "UTC"); + } + + /** + * Set adaptive scheduling enabled + * + * @param enabled true to enable adaptive scheduling + */ + public void setAdaptiveSchedulingEnabled(boolean enabled) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(KEY_ADAPTIVE_SCHEDULING, enabled); + editor.apply(); + + Log.d(TAG, "Adaptive scheduling setting updated: " + enabled); + } + + /** + * Check if adaptive scheduling is enabled + * + * @return true if adaptive scheduling is enabled + */ + public boolean isAdaptiveSchedulingEnabled() { + return prefs.getBoolean(KEY_ADAPTIVE_SCHEDULING, true); + } + + /** + * Set last fetch timestamp + * + * @param timestamp Last fetch time in milliseconds + */ + public void setLastFetchTime(long timestamp) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(KEY_LAST_FETCH, timestamp); + editor.apply(); + + Log.d(TAG, "Last fetch time updated: " + timestamp); + } + + /** + * Get last fetch timestamp + * + * @return Last fetch time in milliseconds + */ + public long getLastFetchTime() { + return prefs.getLong(KEY_LAST_FETCH, 0); + } + + /** + * Check if it's time to fetch new content + * + * @return true if fetch is needed + */ + public boolean shouldFetchNewContent() { + long lastFetch = getLastFetchTime(); + long currentTime = System.currentTimeMillis(); + long timeSinceLastFetch = currentTime - lastFetch; + + // Fetch if more than 12 hours have passed + return timeSinceLastFetch > 12 * 60 * 60 * 1000; + } + + /** + * Load notifications from persistent storage + */ + private void loadNotificationsFromStorage() { + try { + String notificationsJson = prefs.getString(KEY_NOTIFICATIONS, "[]"); + Type type = new TypeToken>(){}.getType(); + List notifications = gson.fromJson(notificationsJson, type); + + if (notifications != null) { + for (NotificationContent notification : notifications) { + notificationCache.put(notification.getId(), notification); + notificationList.add(notification); + } + + // Sort by scheduled time + Collections.sort(notificationList, + Comparator.comparingLong(NotificationContent::getScheduledTime)); + + Log.d(TAG, "Loaded " + notifications.size() + " notifications from storage"); + } + + } catch (Exception e) { + Log.e(TAG, "Error loading notifications from storage", e); + } + } + + /** + * Save notifications to persistent storage + */ + private void saveNotificationsToStorage() { + try { + List notifications; + synchronized (notificationList) { + notifications = new ArrayList<>(notificationList); + } + + String notificationsJson = gson.toJson(notifications); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(KEY_NOTIFICATIONS, notificationsJson); + editor.apply(); + + Log.d(TAG, "Saved " + notifications.size() + " notifications to storage"); + + } catch (Exception e) { + Log.e(TAG, "Error saving notifications to storage", e); + } + } + + /** + * Clean up old notifications to prevent memory bloat + */ + private void cleanupOldNotifications() { + try { + long currentTime = System.currentTimeMillis(); + long cutoffTime = currentTime - (7 * 24 * 60 * 60 * 1000); // 7 days ago + + synchronized (notificationList) { + notificationList.removeIf(notification -> + notification.getScheduledTime() < cutoffTime); + } + + // Update cache to match + notificationCache.clear(); + for (NotificationContent notification : notificationList) { + notificationCache.put(notification.getId(), notification); + } + + // Limit cache size + if (notificationCache.size() > MAX_CACHE_SIZE) { + List sortedNotifications = new ArrayList<>(notificationList); + Collections.sort(sortedNotifications, + Comparator.comparingLong(NotificationContent::getScheduledTime)); + + int toRemove = sortedNotifications.size() - MAX_CACHE_SIZE; + for (int i = 0; i < toRemove; i++) { + NotificationContent notification = sortedNotifications.get(i); + notificationCache.remove(notification.getId()); + } + + notificationList.clear(); + notificationList.addAll(sortedNotifications.subList(toRemove, sortedNotifications.size())); + } + + saveNotificationsToStorage(); + + Log.d(TAG, "Cleanup completed. Cache size: " + notificationCache.size()); + + } catch (Exception e) { + Log.e(TAG, "Error during cleanup", e); + } + } + + /** + * Get storage statistics + * + * @return Storage statistics as a string + */ + public String getStorageStats() { + return String.format("Notifications: %d, Cache size: %d, Last fetch: %d", + notificationList.size(), + notificationCache.size(), + getLastFetchTime()); + } +} diff --git a/src/android/NotificationContent.java b/src/android/NotificationContent.java new file mode 100644 index 0000000..1d5383b --- /dev/null +++ b/src/android/NotificationContent.java @@ -0,0 +1,315 @@ +/** + * NotificationContent.java + * + * Data model for notification content following the project directive schema + * Implements the canonical NotificationContent v1 structure + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import java.util.UUID; + +/** + * Represents notification content with all required fields + * + * This class follows the canonical schema defined in the project directive: + * - id: string (uuid) + * - title: string + * - body: string (plain text; may include simple emoji) + * - scheduledTime: epoch millis (client-local target) + * - mediaUrl: string? (for future; must be mirrored to local path before use) + * - fetchTime: epoch millis + */ +public class NotificationContent { + + private String id; + private String title; + private String body; + private long scheduledTime; + private String mediaUrl; + private long fetchTime; + private boolean sound; + private String priority; + private String url; + + /** + * Default constructor with auto-generated UUID + */ + public NotificationContent() { + this.id = UUID.randomUUID().toString(); + this.fetchTime = System.currentTimeMillis(); + this.sound = true; + this.priority = "default"; + } + + /** + * Constructor with all required fields + * + * @param title Notification title + * @param body Notification body text + * @param scheduledTime When to display the notification + */ + public NotificationContent(String title, String body, long scheduledTime) { + this(); + this.title = title; + this.body = body; + this.scheduledTime = scheduledTime; + } + + // Getters and Setters + + /** + * Get the unique identifier for this notification + * + * @return UUID string + */ + public String getId() { + return id; + } + + /** + * Set the unique identifier for this notification + * + * @param id UUID string + */ + public void setId(String id) { + this.id = id; + } + + /** + * Get the notification title + * + * @return Title string + */ + public String getTitle() { + return title; + } + + /** + * Set the notification title + * + * @param title Title string + */ + public void setTitle(String title) { + this.title = title; + } + + /** + * Get the notification body text + * + * @return Body text string + */ + public String getBody() { + return body; + } + + /** + * Set the notification body text + * + * @param body Body text string + */ + public void setBody(String body) { + this.body = body; + } + + /** + * Get the scheduled time for this notification + * + * @return Timestamp in milliseconds + */ + public long getScheduledTime() { + return scheduledTime; + } + + /** + * Set the scheduled time for this notification + * + * @param scheduledTime Timestamp in milliseconds + */ + public void setScheduledTime(long scheduledTime) { + this.scheduledTime = scheduledTime; + } + + /** + * Get the media URL (optional, for future use) + * + * @return Media URL string or null + */ + public String getMediaUrl() { + return mediaUrl; + } + + /** + * Set the media URL (optional, for future use) + * + * @param mediaUrl Media URL string or null + */ + public void setMediaUrl(String mediaUrl) { + this.mediaUrl = mediaUrl; + } + + /** + * Get the fetch time when content was retrieved + * + * @return Timestamp in milliseconds + */ + public long getFetchTime() { + return fetchTime; + } + + /** + * Set the fetch time when content was retrieved + * + * @param fetchTime Timestamp in milliseconds + */ + public void setFetchTime(long fetchTime) { + this.fetchTime = fetchTime; + } + + /** + * Check if sound should be played + * + * @return true if sound is enabled + */ + public boolean isSound() { + return sound; + } + + /** + * Set whether sound should be played + * + * @param sound true to enable sound + */ + public void setSound(boolean sound) { + this.sound = sound; + } + + /** + * Get the notification priority + * + * @return Priority string (high, default, low) + */ + public String getPriority() { + return priority; + } + + /** + * Set the notification priority + * + * @param priority Priority string (high, default, low) + */ + public void setPriority(String priority) { + this.priority = priority; + } + + /** + * Get the associated URL + * + * @return URL string or null + */ + public String getUrl() { + return url; + } + + /** + * Set the associated URL + * + * @param url URL string or null + */ + public void setUrl(String url) { + this.url = url; + } + + /** + * Check if this notification is stale (older than 24 hours) + * + * @return true if notification is stale + */ + public boolean isStale() { + long currentTime = System.currentTimeMillis(); + long age = currentTime - fetchTime; + return age > 24 * 60 * 60 * 1000; // 24 hours in milliseconds + } + + /** + * Get the age of this notification in milliseconds + * + * @return Age in milliseconds + */ + public long getAge() { + return System.currentTimeMillis() - fetchTime; + } + + /** + * Get the age of this notification in a human-readable format + * + * @return Human-readable age string + */ + public String getAgeString() { + long age = getAge(); + long seconds = age / 1000; + long minutes = seconds / 60; + long hours = minutes / 60; + long days = hours / 24; + + if (days > 0) { + return days + " day" + (days == 1 ? "" : "s") + " ago"; + } else if (hours > 0) { + return hours + " hour" + (hours == 1 ? "" : "s") + " ago"; + } else if (minutes > 0) { + return minutes + " minute" + (minutes == 1 ? "" : "s") + " ago"; + } else { + return "just now"; + } + } + + /** + * Check if this notification is ready to be displayed + * + * @return true if notification should be displayed now + */ + public boolean isReadyToDisplay() { + return System.currentTimeMillis() >= scheduledTime; + } + + /** + * Get time until this notification should be displayed + * + * @return Time in milliseconds until display + */ + public long getTimeUntilDisplay() { + return Math.max(0, scheduledTime - System.currentTimeMillis()); + } + + @Override + public String toString() { + return "NotificationContent{" + + "id='" + id + '\'' + + ", title='" + title + '\'' + + ", body='" + body + '\'' + + ", scheduledTime=" + scheduledTime + + ", mediaUrl='" + mediaUrl + '\'' + + ", fetchTime=" + fetchTime + + ", sound=" + sound + + ", priority='" + priority + '\'' + + ", url='" + url + '\'' + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + NotificationContent that = (NotificationContent) o; + return id.equals(that.id); + } + + @Override + public int hashCode() { + return id.hashCode(); + } +}