72 Commits

Author SHA1 Message Date
a5395082f6 update main README 2026-04-22 16:22:50 -06:00
Jose Olarte III
b6f663121d chore(release): bump to 3.0.1
Set package and lockfile to 3.0.1 and document the Android dual-schedule
empty native fetch fix in CHANGELOG.
2026-04-16 17:07:36 +08:00
Jose Olarte III
5756178c23 fix(android): skip dual notify when native fetch is empty
Dual native prefetch used to map an empty NotificationContent list to
synthetic JSON and still arm the chained notify alarm, which led hosts
such as TimeSafari to show marketing copy via show_default even when the
API had no rows.

Persist {"skipNotification":true} for an empty native result, skip
DualScheduleNotifyScheduler for that successful cycle while still
enqueueing dual fetch recovery, and teach DualScheduleHelper to return
no content for fresh skip payloads and for stale cache when
fallbackBehavior is skip. Add Robolectric tests for DualScheduleHelper
and the skip payload helper.
2026-04-16 17:05:15 +08:00
Jose Olarte III
fbb5a94071 chore(release): v3.0.0 — iOS native fetcher, starred plans, chained dual (iOS + Android)
BREAKING CHANGE (iOS): configureNativeFetcher now requires
DailyNotificationPlugin.registerNativeFetcher(_) first, aligned with Android.

iOS:
- Add NativeNotificationContentFetcher SPI, registry, FetchContext, timeout helper
- Add updateStarredPlans / getStarredPlans; persist daily_notification_timesafari.starredPlanIds
- Chained dual: prefetch only on scheduleDualNotification; arm one-shot UN after fetch
- configureNativeFetcher invokes fetcher.configure; BG fetch prefers registered fetcher
- Public NotificationContent for host implementations

Android:
- Dual notify alarm scheduled after dual FetchWorker completes (DualScheduleNotifyScheduler)
- Persist dual_notify_schedule_id; remove upfront NotifyReceiver for dual setup

Docs: CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md; CHANGELOG 3.0.0
Made-with: Cursor
2026-04-02 16:48:06 +08:00
Jose Olarte III
9121b1e0f7 feat(configureNativeFetcher): optional JWT pool for background native fetch
Add jwtTokens / jwtTokenPoolJson to the TypeScript API, parse and validate
(max 128) on Android and iOS, persist jwtTokenPool with native_fetcher_config
when persistToken is true (Android), and extend NativeNotificationContentFetcher
with a four-argument configure overload delegating to the existing three-arg
default. iOS stores the pool in UserDefaults JSON and uses primary jwt or first
pool entry in the plugin background fetch path. Bump version to 2.2.0. Update
TestNativeFetcher to exercise the new configure overload.
2026-03-27 16:30:31 +08:00
Jose Olarte III
469167a55f feat(android): dual prefetch delay, native fetcher, scoped content cache
- Schedule dual content fetch with WorkManager initialDelay to the next
  contentFetch cron; reschedule from prefs after success and on boot when
  dual_fetch_* exists (DualScheduleFetchRecovery + ReactivationManager).
- When contentFetch has no URL, call NativeNotificationContentFetcher with
  FetchContext (prefetch + next notify time); else keep HTTP/mock behavior.
- Add content_cache.cacheScope (dual|daily|legacy), Room v4 migration,
  getLatestByScope; DualScheduleHelper reads dual only; daily fetch paths
  write daily; NotifyReceiver prefers daily/legacy for legacy cache reads.
- Extract ScheduleCronUtils.calculateNextRunTimeMillis for shared cron math.
- Document in README/CHANGELOG; bump package to 2.1.5.
2026-03-25 18:05:57 +08:00
Jose Olarte III
a5c5a7e74e docs(android): add dual schedule native fetch and cache scope plan
Add ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md describing the
pre-implementation plan: WorkManager initial delay for dual prefetch,
NativeNotificationContentFetcher when URL is absent, and cacheScope on
ContentCache to separate dual vs daily reminder cache rows.
2026-03-25 16:05:20 +08:00
Jose Olarte III
fc1cebd720 chore(release): 2.1.4
Bump package, lockfile, podspec, definitions header, and FetchWorker mock
payload. Changelog section retitled to 2.1.4 (Android dual-schedule JSON
parsing fixes and README notes).
2026-03-20 21:21:52 +08:00
Jose Olarte III
5f12b69d2a fix(android): parse optional userNotification fields for dual/user schedule
parseUserNotificationConfig used getBoolean/getString for title, body, sound,
vibration, and priority; missing keys threw JSONException though TS marks them
optional. Add optBooleanOrNull/optStringOrNull (same pattern as optIntOrNull) and
defer to existing NotifyReceiver/DualScheduleHelper defaults.

Document in README; extend CHANGELOG [2.1.3].
2026-03-20 21:19:27 +08:00
Jose Olarte III
4dd1aea002 chore(release): 2.1.3
- Bump package, lockfile, iOS DailyNotificationPlugin.podspec, and synced
  version strings (definitions header, FetchWorker mock payload).
- Add CHANGELOG [2.1.3] for Android optional contentFetch fields and README.
2026-03-20 19:26:46 +08:00
Jose Olarte III
33010ad7cf fix(android): parse optional contentFetch timeout/retry fields for dual schedule
JSONObject.getInt threw when timeout/retryAttempts/retryDelay were omitted, but
TS ContentFetchConfig marks them optional. Use optIntOrNull so null passes
through and FetchWorker keeps its existing defaults.

Document omitted-field behavior in README under scheduleDualNotification.
2026-03-20 19:25:04 +08:00
Jose Olarte III
ba1186c057 chore(release): bump @timesafari/daily-notification-plugin to 2.1.2 2026-03-20 16:47:32 +08:00
Jose Olarte III
757263c073 fix(android): remove duplicate scheduleId locals in DailyNotificationWorker
handleDisplayNotification already reads schedule_id after getInputData().
Inner branches redeclared String scheduleId, which javac rejects in the
same method scope. Drop the redundant lines; behavior unchanged.
2026-03-20 16:45:39 +08:00
Jose Olarte III
539b011fa8 chore(release): bump version to 2.1.1
Patch release for iOS build fix: unwrap optional title/body in
dual-notification path (NotificationContent).
2026-03-19 15:13:41 +08:00
Jose Olarte III
d3ade1f27a fix(ios): unwrap optional title/body in dual-notification path
NotificationContent.title and .body are String?; assigning them to
non-optional String caused Swift build errors. Use ?? with the same
defaults as the config fallback so both branches yield non-optional
title/body.
2026-03-19 15:10:38 +08:00
Jose Olarte III
21ab05d63b docs(completion-plan): add app-side implementation blurb for Cursor
Add "For app-side implementation" paragraph so the completion plan can
be used in the app repo: focus §2/§3, plugin v2.1.0+, link/build check,
Edit flow with updateDualScheduleConfig, and key app file paths.
2026-03-19 14:33:50 +08:00
Jose Olarte III
87d24ca506 chore(release): bump plugin version to 2.1.0 2026-03-19 14:22:59 +08:00
Jose Olarte III
7b41ca9e0b feat(dual): complete scheduleDualNotification; add relationship (contentTimeout/fallbackBehavior)
Plugin (iOS):
- Real cron parsing in calculateNextRunTime(from:); stable dual id + replace semantics; UNCalendarNotificationTrigger for daily
- cancelDualSchedule() and updateDualScheduleConfig(); persist/clear dual config for relationship

Plugin (Android):
- cancelDualSchedule() and updateDualScheduleConfig(); FetchWorker.scheduleFetchForDual; ScheduleHelper.cancelDualSchedule; dual_notify_* id
- Persist dual config; DualScheduleHelper + Worker dual branch for relationship at fire time

Relationship:
- iOS: replace pending dual notification when fetch completes (contentTimeout/fallbackBehavior)
- Android: resolve config + content cache in Worker for dual_notify_*; show resolved title/body

Doc: COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md (two types, Edit/updateDualScheduleConfig, §1.3a, status)
2026-03-18 21:10:49 +08:00
Jose Olarte III
7a1e58a4b6 doc: add completion plan for scheduleDualNotification (iOS/Android)
- Checklist for completing dual-schedule (New Activity) on plugin and app
- Context: two notification types (Daily Reminder vs New Activity), isolation
- iOS: cron parsing, relationship, cancelDualSchedule, updateDualScheduleConfig
- Android: cancelDualSchedule; updateDualScheduleConfig for Edit time
- Consuming app: link/build verification, Edit flow use updateDualScheduleConfig
- Replace semantics and refs to plugin and app code
2026-03-18 17:41:49 +08:00
4a1d476528 change more com.timesafari to org.timesafari 2026-03-14 20:47:34 -06:00
11561991bd rename 'docs' directory to 'doc' 2026-03-14 19:52:40 -06:00
Jose Olarte III
ca6a75ded8 chore(release): bump plugin version to 2.0.0
- package.json, package-lock.json
- ios/Plugin/Info.plist (CFBundleShortVersionString)
- ios/DailyNotificationPlugin.podspec
- Android: DailyNotificationPlugin.kt, NotifyReceiver.kt, FetchWorker.kt,
  ReactivationManager.kt (header @version and runtime version payloads)
- src/observability.ts (@version)

Major bump for com→org package rename (breaking change for consumers).
2026-03-12 14:37:24 +08:00
Jose Olarte III
d8a0eaf413 refactor(android,ios): rename package com.timesafari to org.timesafari.dailynotification
- Android: move plugin source to org/timesafari/dailynotification, update
  namespace, manifest package, and all package/imports; change intent actions
  to org.timesafari.daily.NOTIFICATION and DISMISS
- iOS: update bundle IDs, BGTask identifiers, subsystem labels, and queue
  names in Plugin and Xcode projects
- Capacitor: update plugin class registration and appIds in configs
- Test apps (android-test-app, daily-notification-test, ios-test-app):
  applicationId/bundleId, manifests, ProGuard, scripts, and docs
- Docs: bulk update references; add CONSUMING_APP_MIGRATION_COM_TO_ORG.md
  for consuming app migration

BREAKING CHANGE: Consuming apps must update plugin class to
org.timesafari.dailynotification.DailyNotificationPlugin, manifest
receivers/actions, and iOS BGTask identifiers per migration doc.
2026-03-12 14:26:07 +08:00
Jose Olarte III
b8d9b6247d chore(release): bump plugin version to 1.3.3
Sync version in package.json, package-lock.json, Android/Kotlin sources,
iOS Info.plist, and ios/DailyNotificationPlugin.podspec.
2026-03-09 20:32:02 +08:00
Jose Olarte III
6df1d4a7c6 fix(android): stop auto-opening Settings for exact alarm in scheduleDailyNotification
Remove the guard that opened system Settings and rejected when exact alarms
were not granted. Scheduling now proceeds using inexact/windowed fallback;
consuming apps can handle UX (e.g. optional hint or openExactAlarmSettings()).
2026-03-09 20:29:04 +08:00
Jose Olarte III
daaf7aa62a chore(release): bump version to 1.3.2
Removes exact alarms (Android); delivery is now inexact/schedule-based.

- package.json, observability, ios Info.plist, android plugin version refs
2026-03-09 18:29:56 +08:00
1dc0052b39 remove references to USE_EXACT_ALARM for Android 2026-03-06 21:11:27 -07:00
Jose Olarte III
6ad7ff5fe1 docs: reorganize docs into subdirs and fix links
- Keep only index, getting-started, invariants, performance,
  troubleshooting, and file-organization-summary in docs/ root
- Add docs/architecture/ (storage, database interfaces, native fetcher)
- Add docs/deployment/ (deployment-guide, DEPLOYMENT_CHECKLIST)
- Add docs/compliance/ (accessibility, legal, observability)
- Move integration guides and host-app docs to docs/integration/
- Move design/planning and prefetch docs to docs/design/
- Move Android consuming-app and comparison docs to docs/platform/android/
- Move DEPLOYMENT_SUMMARY and TODO-CLASSIFICATION to docs/progress/
- Archive deprecated platform-capability-reference to docs/_archive/
- Point platform-capability links to alarms/01-platform-capability-reference.md
- Update docs/00-INDEX.md with new sections and paths
- Fix cross-references in README, deployment, progress, design, testing,
  and test-app docs
- Remove one-off COMMIT_MESSAGE.txt
2026-03-06 19:51:13 +08:00
Jose Olarte III
f58eeda8a7 docs: move 6 root .md files into docs/progress and docs/_archive
- Move TODO.md, TODAY_SUMMARY.md, SESSION_RECONSTITUTION.md,
  BATCH_A_COMPLETION_SUMMARY.md to docs/progress/
- Move PR_DESCRIPTION.md, MERGE_READY_SUMMARY.md to docs/_archive/
- Update docs/00-INDEX.md with new progress and archive entries
- Note moves in docs/_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md

Root keeps only README, CHANGELOG, CONTRIBUTING, SECURITY, BUILDING,
ARCHITECTURE, API, USAGE.
2026-03-06 19:27:21 +08:00
Jose Olarte III
36356e0aca docs: point repo URLs to Gitea
Replace github.com/timesafari/daily-notification-plugin with
gitea.anomalistdesign.com/trent_larson/daily-notification-plugin
2026-03-06 19:16:30 +08:00
Jose Olarte III
6f4d946662 chore: bump plugin version to 1.3.1
Align package.json and all plugin version references (Android, TS
definitions, observability, web) to 1.3.1 after rollover-interval fixes.
2026-03-04 21:28:20 +08:00
Jose Olarte III
c38f235647 fix(android): apply rollover interval for daily_rollover_* and allow ROLLOVER_ON_FIRE updates
- Bug 1: When the firing run used schedule_id daily_rollover_*, resolve the
  canonical notify schedule (first enabled with rolloverIntervalMinutes > 0)
  and use it to read the interval so the next run is current + interval
  instead of +24h. Add ScheduleHelper.getCanonicalRolloverScheduleBlocking().
- Bug 2: For ROLLOVER_ON_FIRE, do not skip scheduling when an existing
  PendingIntent is found for the same schedule id: cancel the existing alarm
  and set the new trigger time so the rollover chain (e.g. 21:10 → 21:20)
  is updated instead of treated as duplicate.
2026-03-04 21:17:26 +08:00
Jose Olarte III
2714480070 chore: bump plugin version to 1.3.0
Align package.json and all plugin version references (Android entity
strings and file headers, TypeScript definitions/observability/web)
with 1.3.0 for the rolloverIntervalMinutes release.
2026-03-03 17:48:18 +08:00
Jose Olarte III
e873a46bbd feat(plugin): add optional rolloverIntervalMinutes for dev/testing
Add optional rolloverIntervalMinutes to scheduleDailyNotification so the
next occurrence can be scheduled N minutes after the current trigger
(e.g. 10 minutes) instead of 24 hours. Value is persisted and used on
rollover and after reboot.

- TypeScript: NotificationOptions.rolloverIntervalMinutes?: number
- Android: Schedule.rolloverIntervalMinutes in Room (migration 2→3);
  Plugin and ScheduleHelper persist it; Worker uses it in rollover and
  updates nextRunAt; ReactivationManager uses it in boot recovery
- iOS: NotificationContent.rolloverIntervalMinutes (Codable); Plugin
  passes it into content; Scheduler uses it in calculateNextScheduledTime
  and copies to nextContent on rollover

When absent or ≤0, behavior unchanged (24h). App can clear by calling
scheduleDailyNotification without the parameter.
2026-03-03 17:45:45 +08:00
Jose Olarte III
aa0eaa5389 chore: bump plugin version to 1.2.1
Update package.json, iOS podspec, and Android plugin-version references
after fix for duplicate fallback notifications (cancel fetch-related
WorkManager jobs when scheduling daily notification).
2026-03-02 16:44:06 +08:00
Jose Olarte III
c36781e440 fix(android): cancel only fetch-related WorkManager jobs when scheduling daily notification
Prevents a second notification (UUID alarm) with fallback or placeholder text by
cancelling pending prefetch/fetch work when the user schedules or reschedules.
cleanupExistingNotificationSchedules only cancels alarms for DB schedule IDs;
alarms from DailyNotificationFetchWorker use a UUID and were never cancelled.

Add ScheduleHelper.cancelFetchRelatedWorkManagerJobs() to cancel only the
prefetch and daily_notification_fetch tags (not display, dismiss, or maintenance).
Call it after cleanup and before scheduleDailyNotification. Future fetched-content
flows can use distinct WorkManager tags and will not be affected by this path.
2026-03-02 16:41:14 +08:00
Jose Olarte III
cff7b659dc chore(version): bump plugin to 1.2.0
- package.json, README.md, podspec
- src: definitions.ts, observability.ts, web.ts
- android: DailyNotificationPlugin.kt, DailyNotificationWorker.java,
  FetchWorker.kt, NotifyReceiver.kt, ReactivationManager.kt,
  DailyNotificationStorageRoom.java
2026-02-26 18:30:32 +08:00
Jose Olarte III
d3df4d9115 fix(android): single rollover alarm, user content, no main-thread DB
- Receiver: stop reading Room on main thread; pass schedule_id to Worker
  so title/body are resolved on a background thread (fixes
  db_fallback_failed / "Cannot access database on the main thread").
- Worker: use stable schedule_id for rollover so one alarm per reminder
  and reschedule cancels it; resolve user title/body by schedule_id when
  Intent lacks them; skip prefetch for static reminders to avoid a
  second alarm.
- ScheduleHelper: persist NotificationContentEntity for scheduleId when
  scheduling daily notification so rollover and post-reboot show user
  text.

Refs: plugin-feedback-android-rollover-double-fire-and-user-content
2026-02-26 18:28:40 +08:00
Jose Olarte III
bc3bf484cc chore: bump plugin version to 1.1.9 2026-02-24 19:20:45 +08:00
Jose Olarte III
25f83cf1fa fix(android): always reschedule alarm on boot by skipping PendingIntent idempotence
Boot recovery was skipping reschedule when it found an "existing" PendingIntent.
AlarmManager alarms are not guaranteed to persist across reboot; on devices that
clear them, the skip caused the next notification (initial or rollover) to never
fire until the app was opened. Pass skipPendingIntentIdempotence = true for all
BOOT_RECOVERY call sites (BootReceiver, ReactivationManager.rescheduleAlarmForBoot)
so the alarm is always re-registered after reboot. Setting the same PendingIntent
again replaces any existing alarm, so no duplicate alarms.
2026-02-24 19:19:22 +08:00
Jose Olarte III
7188d32ae6 chore: bump plugin version to 1.1.8
- package.json: 1.1.7 → 1.1.8
- Android: ReactivationManager, FetchWorker, NotifyReceiver,
  DailyNotificationStorageRoom, DailyNotificationWorker (entity pluginVersion)
- TypeScript: web.ts, observability.ts, definitions.ts (@version)
2026-02-23 18:37:02 +08:00
Jose Olarte III
1157a0f1ef fix(android): restore user title/body after reboot so notification doesn't show fallback text
After device restart, PendingIntent extras (title, body, is_static_reminder) can be
missing when the alarm fires, so the worker took the Room/JIT path and showed
fallback text instead of the user's message.

- DailyNotificationReceiver: when intent has notification_id but missing title/body,
  load NotificationContentEntity from Room and pass title/body into Worker input
  with is_static_reminder=true.
- ReactivationManager: add getTitleBodyForSchedule(); use persisted title/body in
  rescheduleAlarm and rescheduleAlarmForBoot (and inner boot helper) instead of
  hardcoded "Daily Notification" / "Your daily update is ready".
- BootReceiver: use ReactivationManager.getTitleBodyForSchedule() when building
  UserNotificationConfig for notify schedules after boot.
- DailyNotificationWorker: when content from Room has both title and body, skip
  performJITFreshnessCheck so user text is not overwritten by fetcher placeholder.

Ref: plugin-feedback-android-post-reboot-fallback-text (crowd-funder-for-time-pwa)
2026-02-23 18:00:36 +08:00
Jose Olarte III
c2b1a60804 chore(release): 1.1.7 2026-02-18 18:30:26 +08:00
Jose Olarte III
fa8028a698 fix(android): prevent duplicate reminder notification on first-time setup
Do not enqueue DailyNotificationFetchWorker for static reminder schedules.
Display is already handled by the single NotifyReceiver alarm; prefetch was
using fallback content and scheduling a second alarm via legacy
DailyNotificationScheduler, causing two notifications at fire time.
2026-02-18 18:29:11 +08:00
Jose Olarte III
9feaf60c84 chore: bump plugin version to 1.1.6 2026-02-16 19:19:09 +08:00
Jose Olarte III
aaeb71d31d fix(android): do not cancel PendingIntent before setAlarmClock on reschedule
Remove existingPendingIntent.cancel() in the "cancel existing alarm before
rescheduling" block. The cached PendingIntent can be the same instance passed
to setAlarmClock; cancelling it can prevent the new alarm from firing. Keep
only alarmManager.cancel(existingPendingIntent) to clear the previous alarm.
2026-02-16 19:16:18 +08:00
Jose Olarte III
531ce9f709 chore: bump plugin version to 1.1.5 2026-02-16 18:18:09 +08:00
Jose Olarte III
0b61d33f21 fix(android): avoid overwriting app schedule when rollover uses daily_rollover_ id
NotifyReceiver's post-schedule DB update no longer uses the "first enabled
notify schedule" fallback when stableScheduleId starts with "daily_rollover_".
That fallback was updating the app's schedule row (e.g. daily_timesafari_reminder)
with the rollover time and could leave the app's next alarm in a bad state after
a notification fired.

Add docs/CONSUMING_APP_ANDROID_NOTES.md with notes for consuming apps: debounce
double scheduleDailyNotification calls, and include DailyNotificationReceiver
in logcat when debugging alarms that are scheduled but do not fire.
2026-02-16 18:16:20 +08:00
Jose Olarte III
02a44a3e7b chore(release): bump plugin version to 1.1.4
Align version across package.json, iOS podspec, Android/TS sources,
README, and CHANGELOG after 1.1.4 fixes (reset alarm, static rollover,
cancelDailyReminder).

Changes:
- package.json: 1.1.3 → 1.1.4
- ios/DailyNotificationPlugin.podspec: 1.1.1 → 1.1.4
- Android: NotifyReceiver, ReactivationManager, FetchWorker,
  DailyNotificationStorageRoom — plugin version strings/comments
- src: web.ts, observability.ts, definitions.ts — @version headers
- README.md: version line
- CHANGELOG.md: add [1.1.4] - 2026-02-16 entry (fixes + cancelDailyReminder)

Files modified:
- package.json
- ios/DailyNotificationPlugin.podspec
- android/.../NotifyReceiver.kt, ReactivationManager.kt, FetchWorker.kt,
  storage/DailyNotificationStorageRoom.java
- src/web.ts, observability.ts, definitions.ts
- README.md, CHANGELOG.md
2026-02-16 17:02:38 +08:00
Jose Olarte III
cb3cb5a78e fix(android): reset alarm and static reminder rollover; add cancelDailyReminder
Fixes two integration bugs with the consuming app (Time Safari) and adds
Android parity for cancel-by-id.

Problem:
- Re-setting a daily notification (edit/save same time) could cancel the
  alarm then skip re-scheduling because DB idempotence still ran and
  treated the update as a duplicate.
- After the first fire, rollover scheduled the next run with
  isStaticReminder=false, so title/body reverted to fallback.
- App calls cancelDailyReminder({ reminderId }) but Android had no
  implementation (only cancelAllNotifications and scheduleDailyReminder).

Changes:
- NotifyReceiver.kt: Run DB idempotence only when
  !skipPendingIntentIdempotence. When true (e.g. app reset flow), skip
  the check and log; prevents "no alarm" after cancel-then-schedule.
- DailyNotificationWorker.java: In scheduleNextNotification(), read
  is_static_reminder from WorkManager input; keep stable scheduleId for
  static reminders; pass preserveStaticReminder and reminderId into
  scheduleExactNotification(); add DN|ROLLOVER log.
- DailyNotificationPlugin.kt: Add cancelDailyReminder(call) that parses
  reminderId (or id, reminder_id, scheduleId), calls
  NotifyReceiver.cancelNotification(context, scheduleId), and does
  best-effort DB cleanup (setEnabled false, updateRunTimes null).

Files modified:
- android/.../NotifyReceiver.kt
- android/.../DailyNotificationWorker.java
- android/.../DailyNotificationPlugin.kt
2026-02-16 16:57:01 +08:00
Jose Olarte III
a62f54b8a8 fix(android): Java call sites for scheduleExactNotification need 8th parameter
Add skipPendingIntentIdempotence (false) to NotifyReceiver.scheduleExactNotification
calls in DailyNotificationReceiver.java and DailyNotificationWorker.java so
consuming apps compile. Rollover paths do not skip idempotence.

- DailyNotificationReceiver: scheduleNextNotification() — add 8th arg false
- DailyNotificationWorker: scheduleNextNotification() — add 8th arg false
- Bump version to 1.1.3 (package.json, CHANGELOG, native/TS refs)
2026-02-13 19:51:49 +08:00
Jose Olarte III
7702bd3b81 fix(android): second daily notification not firing after reschedule
Cancel-then-schedule was skipped because the idempotence check still
found the cancelled PendingIntent in Android's cache. Skip
PendingIntent idempotence on the cancel-then-schedule path so the
new schedule is always set.

- NotifyReceiver.scheduleExactNotification: add
  skipPendingIntentIdempotence (used only from scheduleDailyNotification)
- ScheduleHelper: pass skipPendingIntentIdempotence=true after
  cancelNotification(scheduleId)
- Version 1.1.2: package.json, CHANGELOG, README, TS/Android refs
- docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md: optional app
  cleanup to use one stable id on both platforms
2026-02-13 19:26:09 +08:00
Jose Olarte III
602eafc892 docs(testing): add PHYSICAL_DEVICE_GUIDE for Android hardware testing
Covers USB debugging setup, battery optimization settings for major OEMs
(Samsung, Xiaomi, OnePlus, Huawei, Oppo), log monitoring, and
troubleshooting. Complements EMULATOR_GUIDE for real-device validation.
2026-02-12 17:19:45 +08:00
Jose Olarte III
a77f08052f chore(version): bump to 1.1.1 after Android alarm fix and EMULATOR_GUIDE docs 2026-02-05 19:36:33 +08:00
Jose Olarte III
442b826401 Merge branch 'android-fixes-2026-02' 2026-02-05 19:29:16 +08:00
Jose Olarte III
bf90f158ac chore(version): bump to 1.1.0 after ios-2 merge
Version bump reflects new features merged from ios-2 branch:
- iOS rollover recovery for background/inactive app scenarios
- Build script improvements and iOS support
- Test app enhancements and UI improvements

This is a MINOR version bump per semantic versioning due to
backward-compatible feature additions.
2026-01-22 15:18:00 +08:00
Jose Olarte III
5dbe0d1455 Merge branch 'ios-2' 2026-01-22 14:43:52 +08:00
Jose Olarte III
7f79c5990b fix: include source files and build configs in package files for git installs 2026-01-19 18:57:53 +08:00
Jose Olarte III
bef88ad844 feat: add prepare script for automatic build on git install 2026-01-19 18:47:32 +08:00
Matthew Raymer
839e167c98 chore: sync test app UI with improved recovery timing
- Update test app index.html with improved UI refresh timing after force-stop
- Includes immediate + delayed refresh pattern for better recovery detection
- Better error handling for database not ready scenarios
2025-12-30 10:26:24 +00:00
Matthew Raymer
f40562b68a fix: improve UI refresh timing after force-stop recovery
- Increase recovery delay from 1s to 3s (force-stop recovery can take time)
- Add immediate refresh + delayed refresh to catch recovery at different stages
- Better error handling for database not ready yet (shows 'Checking...' instead of error)
- Add logging to indicate when config not found is normal (after uninstall/reinstall)

Previously, after force-stop recovery:
- Configuration check happened too early (1s delay not enough)
- UI showed error immediately if database not ready
- Single refresh might miss recovery completion

The fix:
- Immediate refresh when app becomes visible (catches fast recovery)
- Delayed refresh after 3 seconds (catches slower recovery)
- Better error messages indicating database might not be ready yet
- Note that config not found is normal after uninstall/reinstall (database wiped)

This ensures UI properly refreshes configuration status and notification info
after force-stop recovery completes.
2025-12-30 10:07:14 +00:00
Matthew Raymer
f1830e5f6f fix: properly cancel alarms using FLAG_NO_CREATE and pendingIntent.cancel()
- Use FLAG_NO_CREATE to get existing PendingIntent instead of creating new one
- Call both alarmManager.cancel() AND pendingIntent.cancel() (matches scheduleExactNotification pattern)
- Fixes issue where cancellation failed, causing idempotence check to skip scheduling

Previously, cancelNotification():
- Used FLAG_UPDATE_CURRENT which could create new PendingIntent
- Only called alarmManager.cancel(), not pendingIntent.cancel()
- Result: PendingIntent still existed after cancellation, causing 'duplicate schedule' skip

The fix:
- Use FLAG_NO_CREATE to get existing PendingIntent (don't create if missing)
- Call both alarmManager.cancel() and pendingIntent.cancel() (matches pattern in scheduleExactNotification)
- This ensures proper cancellation so new alarms can be scheduled

This fixes Test 1 failure where alarms weren't appearing after scheduling.
2025-12-30 09:50:29 +00:00
Matthew Raymer
f38b06abed chore: sync test app UI with improved logging
- Update test app index.html with JSON.stringify improvements for better log readability
- This file is synced from www/index.html during build process
- Includes date formatting improvements for debugging UI refresh issues
2025-12-30 09:40:38 +00:00
Matthew Raymer
ea4bc88808 fix: cancel existing alarm before scheduling new one for same scheduleId
- Cancel existing alarm for scheduleId before scheduling new one in scheduleDailyNotification
- Add verification logging to cancelNotification to confirm cancellation worked
- Ensures 'one per day' semantics when updating schedule time

Previously, when updating a schedule time:
- cleanupExistingNotificationSchedules() only canceled OTHER schedules (excluded current scheduleId)
- Old alarm for same scheduleId with different trigger time was not canceled
- Result: 2 alarms existed (old + new) violating 'one per day' semantics

The fix:
- In ScheduleHelper.scheduleDailyNotification(), cancel existing alarm for scheduleId BEFORE scheduling new one
- This ensures when updating schedule time, old alarm is canceled first
- Enhanced logging with DNP-CANCEL tag to track cancellation and verify it worked

Test 2 should now pass: updating schedule time will cancel old alarm before scheduling new one.
2025-12-30 09:17:18 +00:00
Matthew Raymer
63e5b4535e fix: restore and display configuration status after force-stop
- Add loadConfigurationStatus() function to check if plugin/fetcher are configured
- Call loadConfigurationStatus() on page load and app resume (visibility change)
- Add logging to getConfig() to track configuration restoration from database
- UI now shows  Configured status after force-stop recovery

Previously, after force-stop recovery:
- Configuration was stored in database but UI didn't check for it
- configStatus and fetcherStatus remained as 'Not configured' even though config existed
- No logging to track when configuration was restored

The fix:
- loadConfigurationStatus() queries database for native_fetcher_config
- Updates UI status indicators (configStatus, fetcherStatus) based on database state
- Called automatically on page load and when app becomes visible
- Android logs 'DNP-CONFIG: Configuration restored from database' when config is loaded

This ensures the 'Ready to test...' UI block shows correct configuration status
after force-stop recovery, matching the actual database state.
2025-12-30 09:15:25 +00:00
Matthew Raymer
d913f03e23 fix: refresh UI on app resume after force-stop recovery
- Add visibility change handler to refresh UI when app becomes visible
- Check for recent notifications and show indicator if notification was received
- Update lastKnownNextNotificationTime on app resume
- Auto-enable fire verification in Test 1 if alarm is within 5 minutes

Previously, after force-stop recovery:
- UI only refreshed on page load (which doesn't fire again after force-stop)
- Notification received indicator didn't show if notification fired while app was stopped
- Test 1 didn't verify that restored alarms actually fire

The fix:
- Listen for visibilitychange event to detect app resume
- Refresh all status displays (plugin, permissions, channel) when app becomes visible
- Check for notifications received in last 2 minutes and show indicator
- Wait 1 second after visibility change to allow recovery to complete
- Test 1 now automatically verifies restored alarms fire if within 5 minutes

This ensures the UI stays in sync after force-stop recovery and shows
notification indicators for notifications that fired while the app was stopped.
2025-12-30 08:58:11 +00:00
Matthew Raymer
4c1281754e fix: update existing schedule instead of creating new one during rollover
- Find existing enabled notify schedule and update its nextRunAt
- Fallback to finding schedule by kind='notify' && enabled=true if not found by ID
- This ensures getNotificationStatus() finds the updated schedule, not a stale one

Previously, during rollover we were creating a new schedule with a different ID
(daily_rollover_...), but getNotificationStatus() was finding the original schedule
(with ID like notify_... or daily_notification) which still had the old nextRunAt.

The fix:
- First try to find schedule by the provided stableScheduleId
- If not found, find the existing enabled notify schedule (there should only be one)
- Update that schedule's nextRunAt instead of creating a new one
- This ensures getNotificationStatus() returns the correct nextNotificationTime

This matches the pattern used in getNotificationStatus() which finds schedules
with kind='notify' && enabled=true.
2025-12-30 08:28:00 +00:00
Matthew Raymer
9655fa10f8 fix: correct Schedule class reference in NotifyReceiver
- Change DatabaseSchema.Schedule to Schedule (same package, no prefix needed)
- Fixes compilation error: Unresolved reference: DatabaseSchema

Since both NotifyReceiver.kt and DatabaseSchema.kt are in the same
package (com.timesafari.dailynotification), Schedule can be referenced
directly without the DatabaseSchema prefix.
2025-12-30 08:18:36 +00:00
Matthew Raymer
6ac7b35566 fix: update database schedule nextRunAt after scheduling alarm in rollover
- Add database schedule update after successfully scheduling alarm
- Update existing schedule's nextRunAt or create new schedule entry
- Extract cron expression and clockTime from trigger time
- This ensures getNotificationStatus() returns correct nextNotificationTime after rollover

Previously, scheduleExactNotification() scheduled the alarm correctly but
didn't update the database schedule's nextRunAt. This caused getNotificationStatus()
to return stale data, making the UI show the old notification time even though
the alarm was correctly rescheduled for tomorrow.

The fix:
- After successfully scheduling the alarm, update or create the schedule in the database
- Set nextRunAt to the triggerAtMillis value
- Extract hour:minute from trigger time to populate cron and clockTime fields
- Use runBlocking to call suspend function from non-suspend context
- Log but don't fail if DB update fails (alarm is already scheduled)

This matches the pattern used in ReactivationManager for boot/app launch recovery.
2025-12-30 08:13:58 +00:00
Matthew Raymer
62559cd546 fix: improve UI logging to show actual nextNotificationTime values
- Use JSON.stringify() to log actual values instead of [object Object]
- Add ISO date strings for nextNotificationTime and lastNotificationTime
- Add raw timestamp values alongside formatted dates
- Log pending count and other status fields for debugging

This will help diagnose why the UI isn't updating after rollover
by showing the actual values returned from getNotificationStatus().
2025-12-30 08:05:37 +00:00
Matthew Raymer
7b1f1200bc fix: improve rollover date comparison logic in Test 0
- Fix date comparison to check alarm date against current date, not initial alarm date
- Remove false warning when alarm date unchanged but scheduled for future (correct behavior)
- Only warn if alarm date is in the past (actual failure case)
- Improve logic flow: date change confirms rollover, but rollover logs are primary indicator
- When dates match but alarm is scheduled for today/future, defer to rollover log check

This fixes the confusing warning message that appeared when:
- Initial alarm scheduled for tomorrow (2025-12-31)
- Notification fires and rollover reschedules for same tomorrow date
- Rollover logs confirm rollover occurred, but date comparison incorrectly warned

The test now correctly recognizes that an alarm scheduled for the future
is valid, and relies on rollover logs as the primary verification method.
2025-12-30 07:53:23 +00:00
Matthew Raymer
39eed856f5 Merge branch 'ios-2' 2025-12-30 06:28:23 +00:00
331 changed files with 40887 additions and 116740 deletions

View File

@@ -1239,10 +1239,10 @@ dependencies {
-keep @androidx.room.Dao class *
# Plugin classes
-keep class com.timesafari.dailynotification.** { *; }
-keep class org.timesafari.dailynotification.** { *; }
# Capacitor plugin
-keep class com.timesafari.dailynotification.DailyNotificationPlugin { *; }
-keep class org.timesafari.dailynotification.DailyNotificationPlugin { *; }
# Encryption
-keep class javax.crypto.** { *; }

View File

@@ -653,7 +653,7 @@ public class MainActivity extends BridgeActivity {
{
"plugins": {
"DailyNotification": {
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
}
}
}
@@ -728,7 +728,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
{
"id": "DailyNotification",
"name": "DailyNotification",
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
}
]
```
@@ -899,7 +899,7 @@ npm install @timesafari/daily-notification-plugin
npm install /path/to/daily-notification-plugin
# Install from git repository
npm install git+https://github.com/timesafari/daily-notification-plugin.git
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
```
#### 3. Integration in Host Applications
@@ -1128,7 +1128,7 @@ npx cap sync android
#### AAR Duplicate Class Issues
```bash
# Problem: Duplicate class errors when integrating plugin AAR
# Error: "Duplicate class com.timesafari.dailynotification.BootReceiver found in modules"
# Error: "Duplicate class org.timesafari.dailynotification.BootReceiver found in modules"
# Root Cause: Plugin being included both as project reference and as AAR file
# Solution 1: Use Project Reference Approach (Recommended)
@@ -1215,7 +1215,7 @@ daily-notification-plugin/
├── scripts/ # Build scripts and automation
├── test-apps/ # Test applications
│ └── daily-notification-test/ # Vue 3 test app
├── docs/ # Documentation
├── doc/ # Documentation
├── examples/ # Usage examples
├── tests/ # Test files
├── package.json # Node.js dependencies
@@ -1310,7 +1310,7 @@ scripts/
### Getting Help
- Check the [troubleshooting section](#troubleshooting)
- Review [GitHub issues](https://github.com/timesafari/daily-notification-plugin/issues)
- Review [GitHub issues](https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin/issues)
- Consult [Capacitor documentation](https://capacitorjs.com/docs)
- Ask in [Capacitor community](https://github.com/ionic-team/capacitor/discussions)

View File

@@ -5,6 +5,108 @@ All notable changes to the Daily Notification Plugin will be documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.1] - 2026-04-16
### Fixed
- **Android**: Dual native prefetch with an empty `NotificationContent` list no longer maps to placeholder title/body or arms the chained notify alarm for that cycle. The cache stores `skipNotification`, `DualScheduleHelper` skips display for fresh payloads (and for stale cache when `relationship.fallbackBehavior` is `skip`), and `DualScheduleFetchRecovery` still schedules the next prefetch.
### Added
- **Android**: Unit tests (`DualScheduleHelperTest`) for dual empty-cache resolution and skip payload detection.
## [3.0.0] - 2026-04-02
### Added
- **iOS**: `NativeNotificationContentFetcher` SPI, `FetchContext`, `NativeNotificationFetcherRegistry`, and `DailyNotificationPlugin.registerNativeFetcher(_:)` for host-provided fetch (parity with Android `setNativeFetcher`).
- **iOS**: `updateStarredPlans` / `getStarredPlans` plugin methods; starred IDs stored under UserDefaults key `daily_notification_timesafari.starredPlanIds` (JSON array string).
- **Android**: `DualScheduleNotifyScheduler` and `DUAL_NOTIFY_SCHEDULE_ID_KEY` to arm the dual user notification **after** prefetch completes.
- **Docs**: `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` for consuming apps.
### Changed
- **iOS**: `configureNativeFetcher` requires a registered native fetcher (matches Android); calls `configure` on the fetcher; background fetch prefers registered fetcher with timeout, then legacy in-plugin HTTP when no fetcher + config exists.
- **iOS**: Dual (`scheduleDualNotification`) uses **chained** scheduling: prefetch BG task only, then one-shot user notification after fetch (`armChainedDualNotificationAfterPrefetch`), with max slip before fallback copy.
- **iOS**: `NotificationContent` is `public` for host fetcher implementations.
- **Android**: Dual notify exact alarm is no longer scheduled in `ScheduleHelper.scheduleDualNotification`; it is scheduled when `FetchWorker` completes (`max(nextNotifyAt, now)`), with recovery enqueue unchanged.
### Breaking
- **iOS**: `configureNativeFetcher` rejects if `registerNativeFetcher` was not called first.
## [2.1.5] - 2026-03-25
### Changed
- **Android**: Dual (`scheduleDualNotification`) content prefetch uses **WorkManager** with **`initialDelay`** to the next `contentFetch.schedule` occurrence (not an immediate fetch at setup). After each successful dual fetch, the next prefetch is re-enqueued from persisted dual config.
- **Android**: Dual prefetch with no `contentFetch.url` invokes the registered **`NativeNotificationContentFetcher`** when present (same SPI as `DailyNotificationFetchWorker`); otherwise mock JSON is used for development.
- **Android**: `content_cache` rows include **`cacheScope`** (`dual` | `daily` | `legacy`). Dual notify resolution reads only **`dual`**; daily reminder fetches write **`daily`**, avoiding cross-feature overwrites. Database version **4** with migration from v3.
### Documentation
- **Android**: `doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md` (implementation plan; see repo for details).
## [2.1.4] - 2026-03-20
### Fixed
- **Android**: `scheduleDualNotification` / `parseContentFetchConfig` no longer requires `timeout`, `retryAttempts`, and `retryDelay` in `contentFetch` (optional fields per TypeScript). Omitted values defer to `FetchWorker` defaults.
- **Android**: `parseUserNotificationConfig` no longer uses strict `getBoolean` / `getString` for optional `userNotification` fields (`title`, `body`, `sound`, `vibration`, `priority`). Omitted keys no longer throw `JSONException`; native scheduling applies existing defaults (`NotifyReceiver` / `DualScheduleHelper`).
### Documentation
- **README**: Notes for omitted `contentFetch` and optional `userNotification` fields on Android.
## [1.1.6] - 2026-02-16
### Fixed
- **Android**: Alarm set after edit/reschedule now fires. Removed `existingPendingIntent.cancel()` in the "cancel existing alarm before rescheduling" path so the PendingIntent passed to `setAlarmClock` is not cancelled (only `alarmManager.cancel()` is used), fixing no-fire on some devices.
## [1.1.5] - 2026-02-16
### Fixed
- **Android**: Rollover work using a `daily_rollover_*` schedule id no longer overwrites the app's schedule row in the DB. `NotifyReceiver` post-schedule update skips the "first enabled notify" fallback when `stableScheduleId` starts with `daily_rollover_`, so the app's reminder (e.g. `daily_timesafari_reminder`) keeps the correct `nextRunAt` after a notification fires.
### Added
- **Docs**: `doc/platform/android/CONSUMING_APP_ANDROID_NOTES.md` — notes for consuming apps on debouncing double `scheduleDailyNotification` calls and debugging alarms that are scheduled but do not fire (logcat with `DailyNotificationReceiver`).
## [1.1.4] - 2026-02-16
### Fixed
- **Android**: Re-setting a daily notification (edit/save same time) no longer cancels the alarm and then skips re-scheduling. DB idempotence in `NotifyReceiver.scheduleExactNotification()` now runs only when `!skipPendingIntentIdempotence`, so the app reset flow can re-register the alarm.
- **Android**: Static reminder title/body no longer revert to fallback after the first fire. `DailyNotificationWorker.scheduleNextNotification()` now preserves `is_static_reminder` and stable `scheduleId` on rollover so the next occurrence keeps custom text.
### Added
- **Android**: `cancelDailyReminder(call)` in `DailyNotificationPlugin.kt` for parity with iOS. Accepts `reminderId` (or `id`, `reminder_id`, `scheduleId`), cancels the AlarmManager alarm for that id, and performs best-effort DB cleanup (`setEnabled` false, `updateRunTimes` null).
## [1.1.3] - 2026-02-13
### Fixed
- **Android (Java)**: Java call sites for `NotifyReceiver.scheduleExactNotification()` now pass the 8th parameter `skipPendingIntentIdempotence`, fixing "actual and formal argument lists differ in length" when building consuming apps. Updated `DailyNotificationReceiver.java` and `DailyNotificationWorker.java`.
## [1.1.2] - 2026-02-13
### Fixed
- **Android**: Second daily notification not firing after reschedule. After cancel-then-schedule, the idempotence check could still see the cancelled PendingIntent in Android's cache and skip the new schedule. The cancel-then-schedule path now skips PendingIntent-based idempotence so the new alarm is always registered.
## [1.1.1] - 2026-02-05
### Fixed
- **Android**: Target alarm broadcast to app package so receiver is triggered correctly
### Documentation
- EMULATOR_GUIDE: prerequisites, API 35, Apple Silicon; build.sh Android-only sync
## [2.1.0] - 2025-01-02
### Added

View File

@@ -1,46 +0,0 @@
docs(building): update BUILDING.md with iOS prerequisites and clean-build script
Updates BUILDING.md to reflect recent changes in build-native.sh, especially
the Xcode Command Line Tools prerequisite check and the clean-build script.
Problem:
- BUILDING.md didn't mention Xcode Command Line Tools prerequisite
(recently added to build-native.sh)
- clean-build.sh script exists but wasn't documented
- iOS build troubleshooting lacked Command Line Tools guidance
Changes:
- Add Xcode Command Line Tools to Prerequisites section
- Document installation command (xcode-select --install)
- Include verification steps (xcode-select -p, xcodebuild -version)
- Note that build script automatically checks for these tools
- Explain that sqlite3 is part of Command Line Tools
- Document clean-build.sh script in Build Scripts section
- Basic usage: ./scripts/clean-build.sh
- All options: --all, --clean-gradle-cache, --clean-derived-data,
--reinstall-node
- Explain when to use clean builds
- Enhance iOS Native Build Process section
- Add prerequisite note about Command Line Tools
- Include troubleshooting commands for pod install issues
- Reference prerequisites section for details
- Add comprehensive troubleshooting sections
- Clean Build section at start of Troubleshooting
- Recommends clean-build as first step for many issues
- Lists when to use clean builds
- iOS Build Issues section
- Command Line Tools configuration errors
- SQLite/linker issues and pkgx conflicts
- CocoaPods installation problems
- All with clear solutions and commands
The documentation now accurately reflects:
- Xcode Command Line Tools as required iOS prerequisite
- clean-build.sh as available build tool
- Complete iOS troubleshooting workflow
Files modified:
- BUILDING.md

130
README.md
View File

@@ -1,24 +1,35 @@
# Daily Notification Plugin
**Author**: Matthew Raymer
**Version**: 1.0.11 (see `package.json` for source of truth)
**Created**: 2025-09-22 09:22:32 UTC
**Last Updated**: 2025-12-23 UTC
## Overview
The Daily Notification Plugin is a comprehensive Capacitor plugin that provides enterprise-grade daily notification functionality across Android, iOS, and Electron platforms. It features dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability.
The Daily Notification Plugin is a Capacitor plugin that provides daily notification functionality following local-first principles across Android, iOS, and Electron platforms.
This is to support apps that allow users to own their data. This approach is in contrast to standard server-managed notifications; they have the advantage of trustworthy delivery, but they have the following downsides:
* Users must store their search terms and notification preferences on the server.
* Users are not able to move their notifications elsewhere, and cannot take control of their notifications with their own apps.
* Peer-to-peer network scenarios are not supported.
There are two types of notifications supported:
* Periodic static reminder messages
* Periodic API requests, then notifying the user if there is new content
## Quick Start
**New to the plugin?** Start here:
1. **[Installation & Setup](./docs/GETTING_STARTED.md)** — Installation, platform setup, and basic usage
2. **[Quick Start Guide](./docs/examples/QUICK_START.md)** — Minimal working example
3. **[Common Patterns](./docs/examples/COMMON_PATTERNS.md)** — Common integration patterns
4. **[Troubleshooting](./docs/TROUBLESHOOTING.md)** — Common issues and solutions
1. **[Installation & Setup](./doc/GETTING_STARTED.md)** — Installation, platform setup, and basic usage
2. **[Quick Start Guide](./doc/examples/QUICK_START.md)** — Minimal working example
3. **[Common Patterns](./doc/examples/COMMON_PATTERNS.md)** — Common integration patterns
4. **[Troubleshooting](./doc/TROUBLESHOOTING.md)** — Common issues and solutions
For complete documentation, see the [Documentation Index](./docs/00-INDEX.md).
For complete documentation, see the [Documentation Index](./doc/00-INDEX.md).
### 🎯 **Native-First Architecture**
@@ -27,7 +38,7 @@ The plugin has been optimized for **native-first deployment** with the following
**Platform Support:**
-**Android**: WorkManager + AlarmManager + SQLite
-**iOS**: BGTaskScheduler + UNUserNotificationCenter + Core Data
- **Electron**: Desktop notifications + SQLite/LocalStorage
- **Electron**: Desktop notifications + SQLite/LocalStorage (someday)
-**Web (PWA)**: Removed for native-first focus
**Key Benefits:**
@@ -40,12 +51,8 @@ The plugin has been optimized for **native-first deployment** with the following
### **Overview**
Dec 17
- test-apps
- android has been seen to work
- ios is being developed (Jose)
- after ios, will work on daily-notification-test (that includes Vue)
- need to test with real data in the API
Stand-alone tests are found in the test-apps directory.
- The daily-notification-test (that includes Vue) has worked but is not tested extensively.
### ✅ **Phase 2 Complete - Production Ready**
@@ -66,7 +73,6 @@ Dec 17
The plugin guarantees the following behaviors:
- **Monotonic Watermark**: Watermark values are strictly monotonic (never decrease)
- **Idempotency**: Operations with the same idempotency key are safe to retry
- **TTL Semantics**: Content with expired TTL is not delivered
- **Schedule Persistence**: Schedules persist across app restarts
@@ -80,7 +86,7 @@ The following behaviors are best-effort and may vary by platform:
- **Background Fetch Timing**: Exact timing depends on OS scheduling
- **Battery Optimization**: May be affected by device battery optimization settings
### 🧪 **Testing & Quality**
### **Testing & Quality**
- **Test Coverage**: 58 tests across 4 test suites ✅
- **Build Status**: TypeScript compilation and Rollup bundling ✅
@@ -89,7 +95,7 @@ The following behaviors are best-effort and may vary by platform:
## Features
### 🚀 **Core Features**
### **Core Features**
- **Dual Scheduling**: Separate content fetch and user notification scheduling
- **TTL-at-Fire Logic**: Content validity checking at notification time
@@ -98,25 +104,19 @@ The following behaviors are best-effort and may vary by platform:
- **Static Daily Reminders**: Simple daily notifications without network content
- **Cross-Platform**: Android, iOS, and Electron implementations
### 📱 **Platform Support**
- **Android**: WorkManager + AlarmManager + SQLite (Room)
- **iOS**: BGTaskScheduler + UNUserNotificationCenter + Core Data
- **Web**: ❌ Removed (native-first architecture)
### 🔧 **Enterprise Features**
### **Enterprise Features**
- **Observability**: Structured logging with event codes
- **Health Monitoring**: Comprehensive status and performance metrics
- **Error Handling**: Exponential backoff and retry logic
- **Security**: Encrypted storage and secure callback handling
- **Database Access**: Full TypeScript interfaces for plugin database access
- See [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) for complete API reference
- See [docs/00-INDEX.md](docs/00-INDEX.md) for complete documentation index
- See [`doc/architecture/DATABASE_INTERFACES.md`](doc/architecture/DATABASE_INTERFACES.md) for complete API reference
- See [doc/00-INDEX.md](doc/00-INDEX.md) for complete documentation index
- Plugin owns its SQLite database - access via Capacitor interfaces
- Supports schedules, content cache, callbacks, history, and configuration
### **Static Daily Reminders**
### **Static Daily Reminders**
- **No Network Required**: Completely offline reminder notifications
- **Simple Scheduling**: Easy daily reminder setup with HH:mm time format
@@ -134,18 +134,14 @@ npm install @timesafari/daily-notification-plugin
Or install from Git repository:
```bash
npm install git+https://github.com/timesafari/daily-notification-plugin.git
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
```
The plugin follows the standard Capacitor Android structure - no additional path configuration needed!
## Documentation
**📚 Complete Documentation Index**: See [docs/00-INDEX.md](./docs/00-INDEX.md) for organized access to all documentation.
## Quick Integration
**New to the plugin?** Start with the [Quick Integration Guide](./docs/integration/QUICK_START.md) for step-by-step setup instructions.
**New to the plugin?** Start with the [Quick Integration Guide](./doc/integration/QUICK_START.md) for step-by-step setup instructions.
The quick guide covers:
- Installation and setup
@@ -154,7 +150,7 @@ The quick guide covers:
- Basic usage examples
- Troubleshooting common issues
**For AI Agents**: See [AI Integration Guide](./docs/ai/AI_INTEGRATION_GUIDE.md) for explicit, machine-readable instructions with verification steps, error handling, and decision trees.
**For AI Agents**: See [AI Integration Guide](./doc/ai/AI_INTEGRATION_GUIDE.md) for explicit, machine-readable instructions with verification steps, error handling, and decision trees.
## Quick Start
@@ -320,6 +316,12 @@ await DailyNotification.scheduleDualNotification({
});
```
If `contentFetch` omits `timeout`, `retryAttempts`, or `retryDelay`, Android applies defaults when scheduling fetch work (currently 30000 ms, 3 attempts, 1000 ms between attempts; see `FetchWorker`).
If `userNotification` omits optional fields (`title`, `body`, `sound`, `vibration`, `priority`), Android parses them as omitted; scheduling uses the same defaults as `NotifyReceiver` / `DualScheduleHelper` (e.g. sound and vibration default to on, priority to `normal` where applicable).
**Android (dual prefetch timing & cache):** Prefetch work is scheduled with a delay to the next `contentFetch.schedule` instant (best-effort under Doze/OEM). Fetched content is stored in a **scoped** cache row (`dual`) so it is not overwritten by the daily reminder fetch (`daily`). With no `contentFetch.url`, the host apps **`NativeNotificationContentFetcher`** is used when registered.
### Callback Methods
#### `registerCallback(name, config)`
@@ -410,13 +412,13 @@ console.log(`Will fire at: ${new Date(result.triggerAtMillis).toLocaleString()}`
For immediate validation of plugin functionality:
- **Android**: [Manual Smoke Test - Android](./docs/testing/MANUAL_SMOKE_TEST.md#android-platform-testing)
- **iOS**: [Manual Smoke Test - iOS](./docs/testing/MANUAL_SMOKE_TEST.md#ios-platform-testing)
- **Electron**: [Manual Smoke Test - Electron](./docs/testing/MANUAL_SMOKE_TEST.md#electron-platform-testing)
- **Android**: [Manual Smoke Test - Android](./doc/testing/MANUAL_SMOKE_TEST.md#android-platform-testing)
- **iOS**: [Manual Smoke Test - iOS](./doc/testing/MANUAL_SMOKE_TEST.md#ios-platform-testing)
- **Electron**: [Manual Smoke Test - Electron](./doc/testing/MANUAL_SMOKE_TEST.md#electron-platform-testing)
### Manual Smoke Test Documentation
Complete testing procedures: [docs/testing/MANUAL_SMOKE_TEST.md](./docs/testing/MANUAL_SMOKE_TEST.md)
Complete testing procedures: [doc/testing/MANUAL_SMOKE_TEST.md](./doc/testing/MANUAL_SMOKE_TEST.md)
## Compatibility Matrix
@@ -559,18 +561,17 @@ await DailyNotification.updateDailyReminder('morning_checkin', {
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- NotifyReceiver for AlarmManager-based notifications -->
<!-- REQUIRED: Without this, alarms fire but notifications won't display -->
<receiver android:name="com.timesafari.dailynotification.NotifyReceiver"
<receiver android:name="org.timesafari.dailynotification.NotifyReceiver"
android:enabled="true"
android:exported="false">
</receiver>
<receiver android:name="com.timesafari.dailynotification.BootReceiver"
<receiver android:name="org.timesafari.dailynotification.BootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
@@ -605,8 +606,8 @@ dependencies {
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.timesafari.dailynotification.content-fetch</string>
<string>com.timesafari.dailynotification.notification-delivery</string>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
</array>
```
@@ -795,7 +796,7 @@ console.log('Callbacks:', callbacks);
### Development Setup
```bash
git clone https://github.com/timesafari/daily-notification-plugin.git
git clone https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
cd daily-notification-plugin
npm install
npm run build
@@ -818,29 +819,25 @@ npm test
5. Ensure all tests pass
6. Submit a pull request
## License
MIT License - see [LICENSE](LICENSE) file for details.
## Support
### Documentation
**📚 [Complete Documentation Index](./docs/00-INDEX.md)** - Central hub for all project documentation
**[Complete Documentation Index](./doc/00-INDEX.md)** - Central hub for all project documentation
**Key Documentation:**
- **Integration**: [Integration Guide](./docs/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
- **Integration**: [Integration Guide](./doc/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
- **Platform Guides**:
- [iOS Platform Docs](./docs/platform/ios/) - iOS implementation, migration, and troubleshooting
- [Android Platform Docs](./docs/platform/android/) - Android implementation and directives
- **Testing**: [Testing Documentation](./docs/testing/) - Comprehensive testing guides and procedures
- **Alarms**: [Alarm System Docs](./docs/alarms/) - Alarm system documentation
- **Database Interfaces**: [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) - Complete guide to accessing plugin database from TypeScript/webview
- **Database Implementation**: [`docs/DATABASE_INTERFACES_IMPLEMENTATION.md`](docs/DATABASE_INTERFACES_IMPLEMENTATION.md) - Implementation summary and status
- **Database Consolidation Plan**: [`docs/platform/android/DATABASE_CONSOLIDATION_PLAN.md`](docs/platform/android/DATABASE_CONSOLIDATION_PLAN.md) - Database schema consolidation roadmap
- [iOS Platform Docs](./doc/platform/ios/) - iOS implementation, migration, and troubleshooting
- [Android Platform Docs](./doc/platform/android/) - Android implementation and directives
- **Testing**: [Testing Documentation](./doc/testing/) - Comprehensive testing guides and procedures
- **Alarms**: [Alarm System Docs](./doc/alarms/) - Alarm system documentation
- **Database Interfaces**: [`doc/architecture/DATABASE_INTERFACES.md`](doc/architecture/DATABASE_INTERFACES.md) - Complete guide to accessing plugin database from TypeScript/webview
- **Database Implementation**: [`doc/DATABASE_INTERFACES_IMPLEMENTATION.md`](doc/DATABASE_INTERFACES_IMPLEMENTATION.md) - Implementation summary and status
- **Database Consolidation Plan**: [`doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md`](doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md) - Database schema consolidation roadmap
- **Building Guide**: [BUILDING.md](BUILDING.md) - Comprehensive build instructions and troubleshooting
- **Design & Research**: [Design Documentation](./docs/design/) - Design research and implementation guides
- **Archive**: [Legacy Documentation](./docs/archive/2025-legacy-doc/) - Historical documentation preserved for reference
- **Design & Research**: [Design Documentation](./doc/design/) - Design research and implementation guides
- **Archive**: [Legacy Documentation](./doc/archive/2025-legacy-doc/) - Historical documentation preserved for reference
### Community
@@ -853,10 +850,3 @@ MIT License - see [LICENSE](LICENSE) file for details.
- **Custom Implementations**: Tailored solutions for enterprise needs
- **Integration Support**: Help with complex integrations
- **Performance Optimization**: Custom performance tuning
---
**Version**: 2.0.0
**Last Updated**: 2025-09-22 09:22:32 UTC
**Status**: Phase 2 Complete - Production Ready
**Author**: Matthew Raymer

View File

@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

View File

@@ -0,0 +1,16 @@
{
"appId": "org.timesafari.dailynotification",
"appName": "DailyNotification Test App",
"webDir": "www",
"server": {
"androidScheme": "https"
},
"plugins": {
"DailyNotification": {
"fetchUrl": "https://api.example.com/daily-content",
"scheduleTime": "09:00",
"enableNotifications": true,
"debugMode": true
}
}
}

View File

@@ -0,0 +1 @@
[]

View File

View File

View File

@@ -0,0 +1,475 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>DailyNotification Plugin Test</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: white;
}
.container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
h1 {
margin-bottom: 30px;
font-size: 2.5em;
}
.button {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px 30px;
margin: 10px;
border-radius: 25px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
.button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.status {
margin-top: 30px;
padding: 20px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
font-family: monospace;
}
</style>
</head>
<body>
<div class="container">
<div id="statusCard" class="status" style="margin-bottom: 20px; font-size: 14px;">
<strong>Plugin Status</strong><br>
<div style="margin-top: 10px;">
⚙️ Plugin Settings: <span id="configStatus">Not configured</span><br>
🔌 Native Fetcher: <span id="fetcherStatus">Not configured</span><br>
🔔 Notifications: <span id="notificationPermStatus">Checking...</span><br>
⏰ Exact Alarms: <span id="exactAlarmPermStatus">Checking...</span><br>
📢 Channel: <span id="channelStatus">Checking...</span><br>
<div id="pluginStatusContent" style="margin-top: 8px;">
Loading plugin status...
</div>
<div id="notificationReceivedIndicator" style="margin-top: 8px; padding: 8px; background: rgba(0, 255, 0, 0.2); border-radius: 5px; display: none;">
<strong>🔔 Notification Received!</strong><br>
<span id="notificationReceivedTime"></span><br>
<small>Check the top of your screen for the notification banner</small>
</div>
</div>
</div>
<button class="button" onclick="configurePlugin()">Configure Plugin</button>
<button class="button" onclick="requestPermissions()">Request Permissions</button>
<button class="button" onclick="testNotification()">Test Notification</button>
<button class="button" onclick="checkComprehensiveStatus()">Full System Status</button>
<div id="status" class="status">
Ready to test...
</div>
</div>
<script>
console.log('Script loading...');
console.log('JavaScript is working!');
// Use real DailyNotification plugin
console.log('Using real DailyNotification plugin...');
window.DailyNotification = window.Capacitor.Plugins.DailyNotification;
// Define functions immediately and attach to window
function configurePlugin() {
console.log('configurePlugin called');
const status = document.getElementById('status');
const configStatus = document.getElementById('configStatus');
const fetcherStatus = document.getElementById('fetcherStatus');
status.innerHTML = 'Configuring plugin...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
// Update top status to show configuring
configStatus.innerHTML = '⏳ Configuring...';
fetcherStatus.innerHTML = '⏳ Waiting...';
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
configStatus.innerHTML = '❌ Plugin unavailable';
fetcherStatus.innerHTML = '❌ Plugin unavailable';
return;
}
// Configure plugin settings
window.DailyNotification.configure({
storage: 'tiered',
ttlSeconds: 86400,
prefetchLeadMinutes: 60,
maxNotificationsPerDay: 3,
retentionDays: 7
})
.then(() => {
console.log('Plugin settings configured, now configuring native fetcher...');
// Update top status
configStatus.innerHTML = '✅ Configured';
// Configure native fetcher with demo credentials
// Note: DemoNativeFetcher uses hardcoded mock data, so this is optional
// but demonstrates the API. In production, this would be real credentials.
return window.DailyNotification.configureNativeFetcher({
apiBaseUrl: 'http://10.0.2.2:3000', // Android emulator → host localhost
activeDid: 'did:ethr:0xDEMO1234567890', // Demo DID
jwtSecret: 'demo-jwt-secret-for-development-testing'
});
})
.then(() => {
// Update top status
fetcherStatus.innerHTML = '✅ Configured';
// Update bottom status for user feedback
status.innerHTML = 'Plugin configured successfully!';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
// Update top status with error
if (configStatus.innerHTML.includes('Configuring')) {
configStatus.innerHTML = '❌ Failed';
}
if (fetcherStatus.innerHTML.includes('Waiting') || fetcherStatus.innerHTML.includes('Configuring')) {
fetcherStatus.innerHTML = '❌ Failed';
}
status.innerHTML = `Configuration failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
configStatus.innerHTML = '❌ Error';
fetcherStatus.innerHTML = '❌ Error';
status.innerHTML = `Configuration failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function loadPluginStatus() {
console.log('loadPluginStatus called');
const pluginStatusContent = document.getElementById('pluginStatusContent');
const statusCard = document.getElementById('statusCard');
try {
if (!window.DailyNotification) {
pluginStatusContent.innerHTML = '❌ DailyNotification plugin not available';
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.getNotificationStatus()
.then(result => {
const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled';
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
const statusIcon = hasSchedules ? '✅' : '⏸️';
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
📅 Next Notification: ${nextTime}<br>
⏳ Pending: ${result.pending || 0}`;
statusCard.style.background = hasSchedules ?
'rgba(0, 255, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // Green if active, light gray if none
})
.catch(error => {
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
// Notification test functions
function testNotification() {
console.log('testNotification called');
// Quick sanity check - test plugin availability
if (window.Capacitor && window.Capacitor.isPluginAvailable) {
const isAvailable = window.Capacitor.isPluginAvailable('DailyNotification');
console.log('is plugin available?', isAvailable);
}
const status = document.getElementById('status');
status.innerHTML = 'Testing plugin connection...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
// Test the notification method directly
console.log('Testing notification scheduling...');
const now = new Date();
const notificationTime = new Date(now.getTime() + 240000); // 4 minutes from now
const prefetchTime = new Date(now.getTime() + 120000); // 2 minutes from now (2 min before notification)
const notificationTimeString = notificationTime.getHours().toString().padStart(2, '0') + ':' +
notificationTime.getMinutes().toString().padStart(2, '0');
const prefetchTimeString = prefetchTime.getHours().toString().padStart(2, '0') + ':' +
prefetchTime.getMinutes().toString().padStart(2, '0');
window.DailyNotification.scheduleDailyNotification({
time: notificationTimeString,
title: 'Test Notification',
body: 'This is a test notification from the DailyNotification plugin!',
sound: true,
priority: 'high'
})
.then(() => {
const prefetchTimeReadable = prefetchTime.toLocaleTimeString();
const notificationTimeReadable = notificationTime.toLocaleTimeString();
status.innerHTML = '✅ Notification scheduled!<br>' +
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' +
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')<br><br>' +
'<small>💡 When the notification fires, look for a banner at the <strong>top of your screen</strong>.</small>';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
// Refresh plugin status display
setTimeout(() => loadPluginStatus(), 500);
})
.catch(error => {
// Check if this is an exact alarm permission error
if (error.code === 'EXACT_ALARM_PERMISSION_REQUIRED' ||
error.message.includes('Exact alarm permission') ||
error.message.includes('Alarms & reminders')) {
status.innerHTML = '⚠️ Exact Alarm Permission Required<br><br>' +
'Settings opened automatically.<br>' +
'Please enable "Allow exact alarms" and return to try again.';
status.style.background = 'rgba(255, 165, 0, 0.3)'; // Orange background
} else {
status.innerHTML = `❌ Notification failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
});
} catch (error) {
status.innerHTML = `Notification test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
// Permission management functions
function requestPermissions() {
console.log('requestPermissions called');
const status = document.getElementById('status');
status.innerHTML = 'Requesting permissions...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.requestNotificationPermissions()
.then(() => {
status.innerHTML = 'Permission request completed! Check your device settings if needed.';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
// Refresh permission and channel status display after request
setTimeout(() => {
loadPermissionStatus();
loadChannelStatus();
}, 1000);
})
.catch(error => {
status.innerHTML = `Permission request failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Permission request failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function loadChannelStatus() {
const channelStatus = document.getElementById('channelStatus');
try {
if (!window.DailyNotification) {
channelStatus.innerHTML = '❌ Plugin unavailable';
return;
}
window.DailyNotification.isChannelEnabled()
.then(result => {
const importanceText = getImportanceText(result.importance);
if (result.enabled) {
channelStatus.innerHTML = `✅ Enabled (${importanceText})`;
} else {
channelStatus.innerHTML = `❌ Disabled (${importanceText})`;
}
})
.catch(error => {
channelStatus.innerHTML = '⚠️ Error';
});
} catch (error) {
channelStatus.innerHTML = '⚠️ Error';
}
}
function checkComprehensiveStatus() {
const status = document.getElementById('status');
status.innerHTML = 'Checking comprehensive status...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.checkStatus()
.then(result => {
const canSchedule = result.canScheduleNow;
const issues = [];
if (!result.postNotificationsGranted) {
issues.push('POST_NOTIFICATIONS permission');
}
if (!result.channelEnabled) {
issues.push('notification channel disabled');
}
if (!result.exactAlarmsGranted) {
issues.push('exact alarm permission');
}
let statusText = `Status: ${canSchedule ? 'Ready to schedule' : 'Issues found'}`;
if (issues.length > 0) {
statusText += `\nIssues: ${issues.join(', ')}`;
}
statusText += `\nChannel: ${getImportanceText(result.channelImportance)}`;
statusText += `\nChannel ID: ${result.channelId}`;
status.innerHTML = statusText;
status.style.background = canSchedule ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)';
})
.catch(error => {
status.innerHTML = `Status check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Status check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function getImportanceText(importance) {
switch (importance) {
case 0: return 'None (blocked)';
case 1: return 'Min';
case 2: return 'Low';
case 3: return 'Default';
case 4: return 'High';
case 5: return 'Max';
default: return `Unknown (${importance})`;
}
}
// Attach to window object
window.configurePlugin = configurePlugin;
window.testNotification = testNotification;
window.requestPermissions = requestPermissions;
window.checkComprehensiveStatus = checkComprehensiveStatus;
function loadPermissionStatus() {
const notificationPermStatus = document.getElementById('notificationPermStatus');
const exactAlarmPermStatus = document.getElementById('exactAlarmPermStatus');
try {
if (!window.DailyNotification) {
notificationPermStatus.innerHTML = '❌ Plugin unavailable';
exactAlarmPermStatus.innerHTML = '❌ Plugin unavailable';
return;
}
window.DailyNotification.checkPermissionStatus()
.then(result => {
notificationPermStatus.innerHTML = result.notificationsEnabled ? '✅ Granted' : '❌ Not granted';
exactAlarmPermStatus.innerHTML = result.exactAlarmEnabled ? '✅ Granted' : '❌ Not granted';
})
.catch(error => {
notificationPermStatus.innerHTML = '⚠️ Error';
exactAlarmPermStatus.innerHTML = '⚠️ Error';
});
} catch (error) {
notificationPermStatus.innerHTML = '⚠️ Error';
exactAlarmPermStatus.innerHTML = '⚠️ Error';
}
}
// Check for notification delivery periodically
function checkNotificationDelivery() {
if (!window.DailyNotification) return;
window.DailyNotification.getNotificationStatus()
.then(result => {
if (result.lastNotificationTime) {
const lastTime = new Date(result.lastNotificationTime);
const now = new Date();
const timeDiff = now - lastTime;
// If notification was received in the last 2 minutes, show indicator
if (timeDiff > 0 && timeDiff < 120000) {
const indicator = document.getElementById('notificationReceivedIndicator');
const timeSpan = document.getElementById('notificationReceivedTime');
if (indicator && timeSpan) {
indicator.style.display = 'block';
timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString()}`;
// Hide after 30 seconds
setTimeout(() => {
indicator.style.display = 'none';
}, 30000);
}
}
}
})
.catch(error => {
// Silently fail - this is just for visual feedback
});
}
// Load plugin status automatically on page load
window.addEventListener('load', () => {
console.log('Page loaded, loading plugin status...');
// Small delay to ensure Capacitor is ready
setTimeout(() => {
loadPluginStatus();
loadPermissionStatus();
loadChannelStatus();
// Check for notification delivery every 5 seconds
setInterval(checkNotificationDelivery, 5000);
}, 500);
});
console.log('Functions attached to window:', {
configurePlugin: typeof window.configurePlugin,
testNotification: typeof window.testNotification
});
</script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
<access origin="*" />
</widget>

View File

@@ -14,7 +14,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
android {
namespace "com.timesafari.dailynotification.plugin"
namespace "org.timesafari.dailynotification.plugin"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {

View File

@@ -0,0 +1,59 @@
ext {
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
}
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
}
}
apply plugin: 'com.android.library'
android {
namespace "capacitor.cordova.android.plugins"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
versionCode 1
versionName "1.0"
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
repositories {
google()
mavenCentral()
flatDir{
dirs 'src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(dir: 'src/main/libs', include: ['*.jar'])
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "org.apache.cordova:framework:$cordovaAndroidVersion"
// SUB-PROJECT DEPENDENCIES START
// SUB-PROJECT DEPENDENCIES END
}
// PLUGIN GRADLE EXTENSIONS START
apply from: "cordova.variables.gradle"
// PLUGIN GRADLE EXTENSIONS END
for (def func : cdvPluginPostBuildExtras) {
func()
}

View File

@@ -0,0 +1,7 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
ext {
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
// Plugin gradle extensions can append to this to have code run at the end.
cdvPluginPostBuildExtras = []
cordovaConfig = [:]
}

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
<application >
</application>
</manifest>

View File

@@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

View File

@@ -2,7 +2,7 @@
# These rules are applied to consuming apps when they use this plugin
# Keep plugin classes
-keep class com.timesafari.dailynotification.** { *; }
-keep class org.timesafari.dailynotification.** { *; }
# Keep Capacitor plugin interface
-keep class com.getcapacitor.Plugin { *; }

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.timesafari.dailynotification.plugin">
package="org.timesafari.dailynotification.plugin">
<!-- Plugin receivers are declared in consuming app's manifest -->
<!-- This manifest is optional and mainly for library metadata -->

View File

@@ -2,7 +2,7 @@
{
"pkg": "@timesafari/daily-notification-plugin",
"name": "DailyNotification",
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
}
]

View File

@@ -1,4 +1,4 @@
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.content.BroadcastReceiver
import android.content.Context
@@ -76,21 +76,24 @@ class BootReceiver : BroadcastReceiver() {
// Reschedule AlarmManager notification
val nextRunTime = calculateNextRunTime(schedule)
if (nextRunTime > System.currentTimeMillis()) {
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
title = title,
body = body,
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY
source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
)
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
}

View File

@@ -1,4 +1,4 @@
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
@@ -43,7 +43,7 @@ public class ChannelManager {
Log.d(TAG, "Ensuring notification channel exists");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
if (channel == null) {
Log.d(TAG, "Creating notification channel");
@@ -72,7 +72,7 @@ public class ChannelManager {
public boolean isChannelEnabled() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
if (channel == null) {
Log.w(TAG, "Channel does not exist");
return false;
@@ -99,7 +99,7 @@ public class ChannelManager {
public int getChannelImportance() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
if (channel != null) {
return channel.getImportance();
}
@@ -117,7 +117,7 @@ public class ChannelManager {
* @return true if settings intent was launched, false otherwise
*/
public boolean openChannelSettings() {
return openChannelSettings(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
return openChannelSettings(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
}
/**
@@ -142,7 +142,7 @@ public class ChannelManager {
try {
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId != null ? channelId : com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID)
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId != null ? channelId : org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
@@ -180,11 +180,11 @@ public class ChannelManager {
private void createDefaultChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID,
com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_NAME,
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID,
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_DESCRIPTION);
channel.setDescription(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_DESCRIPTION);
channel.enableLights(true);
channel.enableVibration(true);
channel.setShowBadge(true);
@@ -200,7 +200,7 @@ public class ChannelManager {
* @return the default channel ID
*/
public String getDefaultChannelId() {
return com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID;
return org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID;
}
/**
@@ -209,7 +209,7 @@ public class ChannelManager {
public void logChannelStatus() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
if (channel != null) {
Log.i(TAG, "Channel Status - ID: " + channel.getId() +
", Importance: " + channel.getImportance() +

View File

@@ -0,0 +1,11 @@
package org.timesafari.dailynotification
/**
* [org.timesafari.dailynotification.ContentCache] row discriminator so dual-schedule
* prefetch does not overwrite daily-reminder cache (and vice versa).
*/
object ContentCacheScope {
const val DUAL = "dual"
const val DAILY = "daily"
const val LEGACY = "legacy"
}

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification
package org.timesafari.dailynotification
/**
* Centralized constants for Daily Notification Plugin
@@ -22,6 +22,12 @@ object DailyNotificationConstants {
// Permission Request Codes
// ============================================================
/**
* Maximum number of distinct JWT strings allowed in [configureNativeFetcher] `jwtTokens` / pool.
* Host apps (e.g. TimeSafari) use a pool for background prefetch; cap avoids oversized bridge payloads.
*/
const val JWT_TOKEN_POOL_MAX = 128
/**
* Request code for notification permission requests
* Used by ActivityCompat.requestPermissions()
@@ -56,7 +62,7 @@ object DailyNotificationConstants {
* Action string for notification broadcast intents
* Used by AlarmManager PendingIntents
*/
const val ACTION_NOTIFICATION = "com.timesafari.daily.NOTIFICATION"
const val ACTION_NOTIFICATION = "org.timesafari.daily.NOTIFICATION"
// ============================================================
// Intent Extras Keys
@@ -140,6 +146,28 @@ object DailyNotificationConstants {
* Used when user doesn't provide a custom ID
*/
const val DEFAULT_SCHEDULE_ID = "daily_notification"
/**
* SharedPreferences name for dual (New Activity) schedule config.
* Used by plugin to persist config and by Worker to resolve relationship (contentTimeout/fallbackBehavior).
*/
const val DUAL_SCHEDULE_PREFS = "daily_notification_dual"
/**
* Key for persisted dual schedule config JSON (userNotification + relationship).
*/
const val DUAL_SCHEDULE_CONFIG_KEY = "dual_schedule_config"
/**
* Stable dual notify [Schedule.id] persisted when [ScheduleHelper.scheduleDualNotification] runs.
* The user-visible alarm is scheduled after prefetch completes ([DualScheduleNotifyScheduler]).
*/
const val DUAL_NOTIFY_SCHEDULE_ID_KEY = "dual_notify_schedule_id"
/**
* Prefix for dual notify schedule IDs. Receiver uses this to apply relationship at fire time.
*/
const val DUAL_NOTIFY_SCHEDULE_ID_PREFIX = "dual_notify_"
// ============================================================
// Request Code Versioning

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;
@@ -42,7 +42,7 @@ public class DailyNotificationFetcher {
private final Context context;
private final DailyNotificationStorage storage; // Deprecated path (kept for transitional read paths)
private final com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
private final org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
private final WorkManager workManager;
// ETag manager for efficient fetching
@@ -60,7 +60,7 @@ public class DailyNotificationFetcher {
public DailyNotificationFetcher(Context context,
DailyNotificationStorage storage,
com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
this.context = context;
this.storage = storage;
this.roomStorage = roomStorage;
@@ -220,8 +220,8 @@ public class DailyNotificationFetcher {
return;
}
try {
com.timesafari.dailynotification.entities.NotificationContentEntity entity =
new com.timesafari.dailynotification.entities.NotificationContentEntity(
org.timesafari.dailynotification.entities.NotificationContentEntity entity =
new org.timesafari.dailynotification.entities.NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0",
null,

View File

@@ -9,7 +9,7 @@
* @created 2025-10-03 06:53:30 UTC
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.util.Log;
import android.content.Context;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.ContentValues;
import android.content.Context;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.os.Debug;

View File

@@ -1,4 +1,4 @@
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.Manifest
import android.app.Activity
@@ -18,7 +18,7 @@ import androidx.work.WorkManager
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Data
import java.util.concurrent.TimeUnit
import com.timesafari.dailynotification.DailyNotificationFetchWorker
import org.timesafari.dailynotification.DailyNotificationFetchWorker
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
@@ -35,7 +35,7 @@ import org.json.JSONObject
* Bridges Capacitor calls to native Android functionality
*
* @author Matthew Raymer
* @version 1.1.0
* @version 2.0.0
*/
@CapacitorPlugin(name = "DailyNotification")
open class DailyNotificationPlugin : Plugin() {
@@ -519,12 +519,22 @@ open class DailyNotificationPlugin : Plugin() {
val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required")
val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required")
val jwtTokenPool: List<String>? = try {
parseJwtTokenPool(options)
} catch (e: IllegalArgumentException) {
return call.reject(e.message ?: "Invalid jwt token pool")
}
val nativeFetcher = getNativeFetcherStatic()
if (nativeFetcher == null) {
return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.")
}
Log.i(TAG, "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid")
Log.i(
TAG,
"Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid" +
if (jwtTokenPool != null) ", jwtTokenPoolSize=${jwtTokenPool.size}" else ""
)
// Call the native fetcher's configure method FIRST
// This configures the fetcher instance with API credentials for background operations
@@ -533,7 +543,7 @@ open class DailyNotificationPlugin : Plugin() {
try {
Log.d(TAG, "FETCHER|CONFIGURE_START apiBaseUrl=$apiBaseUrl, activeDid=${activeDid.take(30)}...")
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken)
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken, jwtTokenPool)
configureSuccess = true
Log.i(TAG, "FETCHER|CONFIGURE_COMPLETE success=true")
} catch (e: Exception) {
@@ -566,6 +576,12 @@ open class DailyNotificationPlugin : Plugin() {
// Only store JWT token if explicitly opted-in
if (persistToken) {
put("jwtToken", jwtToken)
if (jwtTokenPool != null && jwtTokenPool.isNotEmpty()) {
put("jwtTokenPool", JSONArray(jwtTokenPool))
Log.w(TAG, "JWT token pool stored (size=${jwtTokenPool.size}, persistToken=true).")
} else {
put("jwtTokenPool", JSONArray())
}
Log.w(TAG, "JWT token stored in database (persistToken=true). " +
"Database is NOT encrypted - token is stored in plain text.")
} else {
@@ -585,7 +601,7 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch {
try {
val config = com.timesafari.dailynotification.entities.NotificationConfigEntity(
val config = org.timesafari.dailynotification.entities.NotificationConfigEntity(
configId, null, "native_fetcher", "config", configValue, "json"
)
getDatabase().notificationConfigDao().insertConfig(config)
@@ -606,6 +622,34 @@ open class DailyNotificationPlugin : Plugin() {
call.reject("Configuration error: ${e.message}")
}
}
/**
* Optional JWT pool from `jwtTokens` (bridge array) or `jwtTokenPoolJson` (JSON array string).
* Prefer `jwtTokens` when both are present. Empty array null (same as omitting).
*/
private fun parseJwtTokenPool(options: JSObject): List<String>? {
val arr: JSONArray = when {
options.has("jwtTokens") -> options.optJSONArray("jwtTokens")
!options.optString("jwtTokenPoolJson", "").isNullOrBlank() ->
JSONArray(options.getString("jwtTokenPoolJson"))
else -> null
} ?: return null
if (arr.length() == 0) return null
if (arr.length() > DailyNotificationConstants.JWT_TOKEN_POOL_MAX) {
throw IllegalArgumentException(
"jwtTokens must have at most ${DailyNotificationConstants.JWT_TOKEN_POOL_MAX} entries"
)
}
val out = ArrayList<String>(arr.length())
for (i in 0 until arr.length()) {
if (arr.isNull(i)) continue
val s = arr.optString(i)
if (s.isNotEmpty()) {
out.add(s)
}
}
return if (out.isEmpty()) null else out
}
@PluginMethod
fun getNotificationStatus(call: PluginCall) {
@@ -706,6 +750,34 @@ open class DailyNotificationPlugin : Plugin() {
scheduleDailyNotification(call)
}
@PluginMethod
fun cancelDailyReminder(call: PluginCall) {
try {
val reminderId = call.getString("reminderId")
?: call.getString("id")
?: call.getString("reminder_id")
?: call.getString("scheduleId")
if (reminderId.isNullOrBlank()) {
call.reject("cancelDailyReminder: missing reminderId")
return
}
NotifyReceiver.cancelNotification(context, scheduleId = reminderId)
try {
kotlinx.coroutines.runBlocking {
val db = getDatabase()
db.scheduleDao().setEnabled(reminderId, false)
db.scheduleDao().updateRunTimes(reminderId, null, null)
}
} catch (dbErr: Exception) {
Log.w(TAG, "cancelDailyReminder: failed DB update for $reminderId", dbErr)
}
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "cancelDailyReminder failed", e)
call.reject("cancelDailyReminder failed: ${e.message}")
}
}
/**
* Check if exact alarms can be scheduled
* Helper method for internal use
@@ -1027,51 +1099,14 @@ open class DailyNotificationPlugin : Plugin() {
return call.reject("Context not available")
}
// Check if exact alarms can be scheduled
// Do not open Settings or reject when exact alarms are not granted.
// Proceed with scheduling; underlying layer uses inexact/windowed alarms when exact is unavailable.
// Apps that want to prompt for exact alarm can use openExactAlarmSettings() or requestExactAlarmPermission().
if (!canScheduleExactAlarms(context)) {
// Permission not granted - request it
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
// Open Settings to let user grant permission
try {
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
call.reject(
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
"EXACT_ALARM_PERMISSION_REQUIRED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open exact alarm settings", e)
call.reject("Failed to open exact alarm settings: ${e.message}")
return
}
} else {
// Permission permanently denied - direct to app settings
try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
call.reject(
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
"PERMISSION_DENIED"
)
return
} catch (e: Exception) {
Log.e(TAG, "Failed to open app settings", e)
call.reject("Failed to open app settings: ${e.message}")
return
}
}
Log.i(TAG, "Exact alarm permission not granted; scheduling will use inexact/windowed fallback.")
}
// Permission granted - proceed with exact alarm scheduling
// Proceed with scheduling (exact when granted, otherwise inexact/windowed)
// Capacitor passes the object directly via call.data
val options = call.data ?: return call.reject("Options are required")
@@ -1081,8 +1116,13 @@ open class DailyNotificationPlugin : Plugin() {
val sound = options.getBoolean("sound") ?: true
val priority = options.getString("priority") ?: "default"
val url = options.getString("url") // Optional URL for prefetch (not used in helper yet)
val rolloverIntervalMinutes = try {
(options.getInt("rolloverIntervalMinutes") ?: 0).takeIf { it > 0 }
} catch (_: Exception) {
null
}
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title")
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title, rolloverIntervalMinutes=$rolloverIntervalMinutes")
// Convert HH:mm time to cron expression (daily at specified time)
val cronExpression = convertTimeToCron(time)
@@ -1109,6 +1149,14 @@ open class DailyNotificationPlugin : Plugin() {
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
}
// Cancel only fetch-related WorkManager jobs so they cannot create a second (UUID) alarm
// with fallback or placeholder text. Does not cancel display/dismiss; future fetched-content
// flows should use distinct tags so they are not affected.
val workCancelled = ScheduleHelper.cancelFetchRelatedWorkManagerJobs(context)
if (workCancelled) {
Log.i(TAG, "scheduleDailyNotification: Cancelled pending prefetch/fetch WorkManager jobs")
}
val config = UserNotificationConfig(
enabled = true,
schedule = cronExpression,
@@ -1126,7 +1174,8 @@ open class DailyNotificationPlugin : Plugin() {
scheduleId,
config,
time,
::calculateNextRunTime
::calculateNextRunTime,
rolloverIntervalMinutes
)
if (success) {
@@ -1390,17 +1439,16 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch {
try {
// Delegate to ScheduleHelper
val success = ScheduleHelper.scheduleDualNotification(
context,
getDatabase(),
contentFetchConfig,
userNotificationConfig,
FetchWorker::scheduleFetch,
::calculateNextRunTime
)
if (success) {
saveDualScheduleConfig(context!!, configJson)
call.resolve()
} else {
call.reject("Dual notification scheduling failed")
@@ -1416,12 +1464,34 @@ open class DailyNotificationPlugin : Plugin() {
}
}
private fun saveDualScheduleConfig(context: Context, configJson: JSObject) {
try {
val str = configJson.toString()
if (str.isNotEmpty()) {
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, str)
.apply()
}
} catch (e: Exception) {
Log.w(TAG, "saveDualScheduleConfig failed", e)
}
}
private fun clearDualScheduleConfig(context: Context) {
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.remove(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY)
.remove(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY)
.apply()
}
@PluginMethod
fun getDualScheduleStatus(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
val enabledSchedules = getDatabase().scheduleDao().getEnabled()
val latestCache = getDatabase().contentCacheDao().getLatest()
val latestCache = getDatabase().contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
val recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
val status = JSObject().apply {
@@ -1441,6 +1511,73 @@ open class DailyNotificationPlugin : Plugin() {
}
}
}
@PluginMethod
fun cancelDualSchedule(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
if (context == null) {
call.reject("Context not available")
return@launch
}
val ctx = context!!
val db = getDatabase()
ScheduleHelper.cancelDualSchedule(ctx, db)
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
clearDualScheduleConfig(ctx)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "cancelDualSchedule failed", e)
call.reject("Cancel dual schedule failed: ${e.message}")
}
}
}
@PluginMethod
fun updateDualScheduleConfig(call: PluginCall) {
val configJson = call.getObject("config") ?: run {
call.reject("Config is required")
return
}
val contentFetchObj = configJson.getJSObject("contentFetch") ?: run {
call.reject("contentFetch config is required")
return
}
val userNotificationObj = configJson.getJSObject("userNotification") ?: run {
call.reject("userNotification config is required")
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
if (context == null) {
call.reject("Context not available")
return@launch
}
val ctx = context!!
val db = getDatabase()
ScheduleHelper.cancelDualSchedule(ctx, db)
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
val contentFetchConfig = parseContentFetchConfig(contentFetchObj)
val userNotificationConfig = parseUserNotificationConfig(userNotificationObj)
val success = ScheduleHelper.scheduleDualNotification(
ctx,
db,
contentFetchConfig,
userNotificationConfig,
::calculateNextRunTime
)
if (success) {
saveDualScheduleConfig(ctx, configJson)
call.resolve()
} else {
call.reject("Update dual schedule failed")
}
} catch (e: Exception) {
Log.e(TAG, "updateDualScheduleConfig failed", e)
call.reject("Update dual schedule failed: ${e.message}")
}
}
}
@PluginMethod
fun registerCallback(call: PluginCall) {
@@ -1802,12 +1939,15 @@ open class DailyNotificationPlugin : Plugin() {
val ttlSeconds = contentJson.getInt("ttlSeconds")
?: return@launch call.reject("TTL seconds is required")
val scope = contentJson.getString("cacheScope")?.takeIf { it.isNotEmpty() }
?: ContentCacheScope.LEGACY
val cache = ContentCache(
id = id,
fetchedAt = System.currentTimeMillis(),
ttlSeconds = ttlSeconds,
payload = payload.toByteArray(),
meta = contentJson.getString("meta")
meta = contentJson.getString("meta"),
cacheScope = scope
)
getDatabase().contentCacheDao().upsert(cache)
@@ -2072,6 +2212,8 @@ open class DailyNotificationPlugin : Plugin() {
val options = call.getObject("options")
val timesafariDid = options?.getString("timesafariDid")
Log.d(TAG, "DNP-CONFIG: Loading config from database: key=$key, timesafariDid=${timesafariDid?.take(20)}...")
val entity = if (timesafariDid != null) {
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
} else {
@@ -2079,8 +2221,10 @@ open class DailyNotificationPlugin : Plugin() {
}
if (entity != null) {
Log.i(TAG, "DNP-CONFIG: Configuration restored from database: key=$key, configType=${entity.configType}, hasValue=${entity.configValue.isNotEmpty()}")
call.resolve(configToJson(entity))
} else {
Log.d(TAG, "DNP-CONFIG: Configuration not found in database: key=$key")
call.resolve(JSObject().apply { put("config", null) })
}
} catch (e: Exception) {
@@ -2144,7 +2288,7 @@ open class DailyNotificationPlugin : Plugin() {
?: return@launch call.reject("Config value is required")
val configDataType = configJson.getString("configDataType", "string")
val entity = com.timesafari.dailynotification.entities.NotificationConfigEntity(
val entity = org.timesafari.dailynotification.entities.NotificationConfigEntity(
id, timesafariDid, configType, configKey, configValue, configDataType
)
@@ -2250,6 +2394,7 @@ open class DailyNotificationPlugin : Plugin() {
put("ttlSeconds", cache.ttlSeconds)
put("payload", String(cache.payload))
put("meta", cache.meta)
put("cacheScope", cache.cacheScope)
}
}
@@ -2276,7 +2421,7 @@ open class DailyNotificationPlugin : Plugin() {
}
}
private fun configToJson(config: com.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
private fun configToJson(config: org.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
return JSObject().apply {
put("id", config.id)
put("timesafariDid", config.timesafariDid)
@@ -2295,15 +2440,30 @@ open class DailyNotificationPlugin : Plugin() {
}
// Helper methods
/**
* Optional int from JSON: absent or JSON-null null (aligns with TS `ContentFetchConfig` optional fields).
* [FetchWorker] applies defaults (e.g. 30000 / 3 / 1000) when null.
*/
private fun JSObject.optIntOrNull(key: String): Int? =
if (has(key) && !isNull(key)) optInt(key) else null
/** Optional boolean: absent or JSON-null → null (aligns with TS optional fields). */
private fun JSObject.optBooleanOrNull(key: String): Boolean? =
if (has(key) && !isNull(key)) optBoolean(key) else null
/** Optional string: absent or JSON-null → null. Present empty string is preserved. */
private fun JSObject.optStringOrNull(key: String): String? =
if (has(key) && !isNull(key)) optString(key) else null
private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
val callbacksObj = configJson.getJSObject("callbacks")
return ContentFetchConfig(
enabled = configJson.getBoolean("enabled") ?: true,
schedule = configJson.getString("schedule") ?: "0 9 * * *",
url = configJson.getString("url"),
timeout = configJson.getInt("timeout"),
retryAttempts = configJson.getInt("retryAttempts"),
retryDelay = configJson.getInt("retryDelay"),
timeout = configJson.optIntOrNull("timeout"),
retryAttempts = configJson.optIntOrNull("retryAttempts"),
retryDelay = configJson.optIntOrNull("retryDelay"),
callbacks = CallbackConfig(
apiService = callbacksObj?.getString("apiService"),
database = callbacksObj?.getString("database"),
@@ -2316,57 +2476,16 @@ open class DailyNotificationPlugin : Plugin() {
return UserNotificationConfig(
enabled = configJson.getBoolean("enabled") ?: true,
schedule = configJson.getString("schedule") ?: "0 9 * * *",
title = configJson.getString("title"),
body = configJson.getString("body"),
sound = configJson.getBoolean("sound"),
vibration = configJson.getBoolean("vibration"),
priority = configJson.getString("priority")
title = configJson.optStringOrNull("title"),
body = configJson.optStringOrNull("body"),
sound = configJson.optBooleanOrNull("sound"),
vibration = configJson.optBooleanOrNull("vibration"),
priority = configJson.optStringOrNull("priority")
)
}
private fun calculateNextRunTime(schedule: String): Long {
// Parse cron expression: "minute hour * * *" (daily schedule)
// Example: "9 7 * * *" = 07:09 daily
try {
val parts = schedule.trim().split("\\s+".toRegex())
if (parts.size < 2) {
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
val minute = parts[0].toIntOrNull() ?: 0
val hour = parts[1].toIntOrNull() ?: 9
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
// Calculate next occurrence of this time
val calendar = java.util.Calendar.getInstance()
val now = calendar.timeInMillis
// Set to today at the specified time
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
calendar.set(java.util.Calendar.MINUTE, minute)
calendar.set(java.util.Calendar.SECOND, 0)
calendar.set(java.util.Calendar.MILLISECOND, 0)
var nextRun = calendar.timeInMillis
// If the time has already passed today, schedule for tomorrow
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
nextRun = calendar.timeInMillis
}
Log.d(TAG, "Calculated next run time: cron=$schedule, nextRun=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(nextRun))}")
return nextRun
} catch (e: Exception) {
Log.e(TAG, "Error calculating next run time for schedule: $schedule", e)
// Fallback: 24 hours from now
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
return ScheduleCronUtils.calculateNextRunTimeMillis(schedule)
}
/**
@@ -2465,7 +2584,7 @@ object TestDataHelper {
suspend fun injectInvalidNotificationData(database: DailyNotificationDatabase): Boolean {
return try {
val invalidNotification =
com.timesafari.dailynotification.entities.NotificationContentEntity()
org.timesafari.dailynotification.entities.NotificationContentEntity()
invalidNotification.id = "" // Empty ID - should be skipped by recovery
invalidNotification.title = "Test Invalid Notification"
invalidNotification.body = "This has an empty ID"
@@ -2618,6 +2737,7 @@ object ScheduleHelper {
* @param config User notification configuration
* @param clockTime Original HH:mm time string
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @param rolloverIntervalMinutes When > 0, next occurrence is this many minutes after trigger (dev/testing). Null/0 = 24h.
* @return true if successful, false otherwise
*/
suspend fun scheduleDailyNotification(
@@ -2626,82 +2746,131 @@ object ScheduleHelper {
scheduleId: String,
config: UserNotificationConfig,
clockTime: String,
calculateNextRunTime: (String) -> Long
calculateNextRunTime: (String) -> Long,
rolloverIntervalMinutes: Int? = null
): Boolean {
return try {
val nextRunTime = calculateNextRunTime(config.schedule)
// CRITICAL: Cancel any existing alarm for this scheduleId BEFORE scheduling new one
// This ensures "one per day" semantics - when updating schedule time, old alarm is canceled
// The cleanupExistingNotificationSchedules() above only cancels OTHER schedules, not the current one
NotifyReceiver.cancelNotification(context, scheduleId)
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
// Schedule AlarmManager notification as static reminder
// (doesn't require cached content)
// (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
// this scheduleId and Android may still return the cancelled PendingIntent from cache.
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
context,
nextRunTime,
config,
isStaticReminder = true,
reminderId = scheduleId,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
source = ScheduleSource.INITIAL_SETUP,
skipPendingIntentIdempotence = true
)
// Always schedule prefetch 2 minutes before notification
// (URL is optional - native fetcher will be used if registered)
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before
val delayMs = fetchTime - System.currentTimeMillis()
// Do not enqueue prefetch for static reminders: display is already in the NotifyReceiver
// alarm. Prefetch is for "fetch content then show"; for static reminders there is nothing
// to fetch. Enqueueing prefetch would cause the worker to use fallback content and
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
// notifications at fire time.
if (delayMs > 0) {
// Schedule delayed prefetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", fetchTime)
.putInt("retry_count", 0)
.putBoolean("immediate", false)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i("ScheduleHelper", "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs")
} else {
// Fetch time is in the past, schedule immediate fetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", System.currentTimeMillis())
.putInt("retry_count", 0)
.putBoolean("immediate", true)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i("ScheduleHelper", "Immediate prefetch scheduled: notificationTime=$nextRunTime")
}
// Store schedule in database
// Store schedule in database (include rollover interval for dev/testing; survives reboot)
val schedule = Schedule(
id = scheduleId,
kind = "notify",
cron = config.schedule,
clockTime = clockTime,
enabled = true,
nextRunAt = nextRunTime
nextRunAt = nextRunTime,
rolloverIntervalMinutes = rolloverIntervalMinutes
)
database.scheduleDao().upsert(schedule)
// Persist title/body for this scheduleId so rollover and post-reboot resolve user content
// (see plugin-feedback-android-rollover-double-fire-and-user-content)
try {
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
scheduleId,
"1.3.1",
null,
"daily",
config.title ?: "Daily Notification",
config.body ?: "",
nextRunTime,
java.time.ZoneId.systemDefault().id
)
entity.soundEnabled = config.sound ?: true
entity.vibrationEnabled = config.vibration ?: true
entity.priority = when (config.priority) {
"high", "max" -> 2
"low", "min" -> -1
else -> 0
}
entity.createdAt = System.currentTimeMillis()
entity.updatedAt = System.currentTimeMillis()
database.notificationContentDao().insertNotification(entity)
Log.d("ScheduleHelper", "Persisted title/body for scheduleId=$scheduleId (rollover/post-reboot)")
} catch (e: Exception) {
Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
}
true
} catch (e: Exception) {
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
false
}
}
/**
* Blocking load of a schedule by id (for use from Java Worker / rollover path).
*/
@JvmStatic
fun getScheduleBlocking(context: Context, scheduleId: String): Schedule? {
return kotlinx.coroutines.runBlocking {
try {
DailyNotificationDatabase.getDatabase(context).scheduleDao().getById(scheduleId)
} catch (e: Exception) {
Log.w("ScheduleHelper", "getScheduleBlocking failed: $scheduleId", e)
null
}
}
}
/**
* Blocking: first enabled notify schedule with rolloverIntervalMinutes > 0 (canonical for rollover chain).
* Used when the firing run has schedule_id = daily_rollover_* so we can still apply the interval.
*/
@JvmStatic
fun getCanonicalRolloverScheduleBlocking(context: Context): Schedule? {
return kotlinx.coroutines.runBlocking {
try {
DailyNotificationDatabase.getDatabase(context).scheduleDao()
.getByKindAndEnabled("notify", true)
.firstOrNull { it.rolloverIntervalMinutes != null && it.rolloverIntervalMinutes > 0 }
} catch (e: Exception) {
Log.w("ScheduleHelper", "getCanonicalRolloverScheduleBlocking failed", e)
null
}
}
}
/**
* Blocking update of schedule next run time (for use from Java Worker after rollover).
*/
@JvmStatic
fun updateScheduleNextRunTimeBlocking(context: Context, scheduleId: String, lastRunAt: Long?, nextRunAt: Long) {
kotlinx.coroutines.runBlocking {
try {
DailyNotificationDatabase.getDatabase(context).scheduleDao().updateRunTimes(scheduleId, lastRunAt, nextRunAt)
} catch (e: Exception) {
Log.w("ScheduleHelper", "updateScheduleNextRunTimeBlocking failed: $scheduleId", e)
}
}
}
/**
* Schedule dual notification (fetch + notify)
@@ -2712,7 +2881,6 @@ object ScheduleHelper {
* @param database Database instance
* @param contentFetchConfig Content fetch configuration
* @param userNotificationConfig User notification configuration
* @param scheduleFetch Function to schedule fetch (FetchWorker.scheduleFetch)
* @param calculateNextRunTime Function to calculate next run time from cron expression
* @return true if successful, false otherwise
*/
@@ -2721,23 +2889,25 @@ object ScheduleHelper {
database: DailyNotificationDatabase,
contentFetchConfig: ContentFetchConfig,
userNotificationConfig: UserNotificationConfig,
scheduleFetch: (Context, ContentFetchConfig) -> Unit,
calculateNextRunTime: (String) -> Long
): Boolean {
return try {
// Schedule fetch
scheduleFetch(context, contentFetchConfig)
// Schedule notification
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
val scheduleId = "notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
userNotificationConfig,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
val nextFetchAt = calculateNextRunTime(contentFetchConfig.schedule)
val nextNotifyAt = calculateNextRunTime(userNotificationConfig.schedule)
FetchWorker.enqueueDualFetch(
context,
contentFetchConfig,
nextFetchAt,
nextNotifyAt
)
// Chained dual: user notification is armed from FetchWorker after prefetch (see DualScheduleNotifyScheduler).
val nextRunTime = nextNotifyAt
val scheduleId = "${DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX}${System.currentTimeMillis()}"
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, scheduleId)
.apply()
// Store both schedules
val fetchSchedule = Schedule(
@@ -2745,10 +2915,10 @@ object ScheduleHelper {
kind = "fetch",
cron = contentFetchConfig.schedule,
enabled = contentFetchConfig.enabled,
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
nextRunAt = nextFetchAt
)
val notifySchedule = Schedule(
id = "dual_notify_${System.currentTimeMillis()}",
id = scheduleId,
kind = "notify",
cron = userNotificationConfig.schedule,
enabled = userNotificationConfig.enabled,
@@ -2814,9 +2984,57 @@ object ScheduleHelper {
}
}
/**
* Cancel only the dual (New Activity) schedule: alarms for dual_fetch_* / dual_notify_* and DB rows.
* Does not cancel Daily Reminder or other schedules. Caller must also cancel WorkManager unique work
* FetchWorker.WORK_NAME_DUAL.
*
* @param context Application context
* @param database Database instance
* @return Number of dual schedules removed
*/
suspend fun cancelDualSchedule(context: Context, database: DailyNotificationDatabase): Int {
return try {
val all = database.scheduleDao().getAll()
val dualSchedules = all.filter { it.id.startsWith("dual_fetch_") || it.id.startsWith("dual_notify_") }
if (dualSchedules.isEmpty()) {
Log.d("ScheduleHelper", "cancelDualSchedule: no dual schedules found")
return 0
}
cancelAlarmsForSchedules(context, dualSchedules)
dualSchedules.forEach { database.scheduleDao().deleteById(it.id) }
Log.i("ScheduleHelper", "cancelDualSchedule: cancelled and removed ${dualSchedules.size} dual schedule(s)")
dualSchedules.size
} catch (e: Exception) {
Log.e("ScheduleHelper", "cancelDualSchedule failed", e)
0
}
}
/**
* Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
* prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.
* Use this when handling scheduleDailyNotification so pending prefetch does not run and create
* a duplicate alarm; future fetched-content flows should use distinct tags so they are not affected.
*
* @param context Application context
* @return true if cancellation was successful
*/
suspend fun cancelFetchRelatedWorkManagerJobs(context: Context): Boolean {
return try {
val workManager = WorkManager.getInstance(context)
workManager.cancelAllWorkByTag("prefetch")
workManager.cancelAllWorkByTag("daily_notification_fetch")
true
} catch (e: Exception) {
Log.w("ScheduleHelper", "Failed to cancel fetch-related WorkManager jobs", e)
false
}
}
/**
* Cancel all WorkManager jobs by tags
*
*
* @param context Application context
* @return true if cancellation was successful
*/

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.BroadcastReceiver;
import android.content.Context;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.NotificationManager;
import android.app.PendingIntent;
@@ -59,7 +59,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
return;
}
if ("com.timesafari.daily.NOTIFICATION".equals(action)) {
if ("org.timesafari.daily.NOTIFICATION".equals(action)) {
// Parse intent and enqueue work - keep receiver ultra-light
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId == null) {
@@ -72,7 +72,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
enqueueNotificationWork(context, notificationId, intent);
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
} else if ("com.timesafari.daily.DISMISS".equals(action)) {
} else if ("org.timesafari.daily.DISMISS".equals(action)) {
// Handle dismissal - also lightweight
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId != null) {
@@ -107,9 +107,11 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
// Create unique work name based on notification ID to prevent duplicates
// WorkManager will automatically skip if work with this name already exists
String workName = "display_" + notificationId;
// Extract static reminder extras from intent if present
// Static reminders have title/body in Intent extras, not in storage
// Static reminders have title/body in Intent extras, not in storage.
// Do NOT access DB on main thread here (Room disallows it); Worker will resolve
// missing title/body by schedule_id on a background thread (see plugin-feedback-android-rollover-double-fire).
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
String title = intent.getStringExtra("title");
String body = intent.getStringExtra("body");
@@ -119,13 +121,17 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
if (priority == null) {
priority = "normal";
}
String scheduleId = intent.getStringExtra("schedule_id");
Data.Builder dataBuilder = new Data.Builder()
.putString("notification_id", notificationId)
.putString("action", "display")
.putBoolean("is_static_reminder", isStaticReminder);
// Add static reminder data if present
if (scheduleId != null && !scheduleId.isEmpty()) {
dataBuilder.putString("schedule_id", scheduleId);
}
// Add static reminder data when present (from Intent; Worker resolves from DB by schedule_id if missing)
if (isStaticReminder && title != null && body != null) {
dataBuilder.putString("title", title)
.putString("body", body)
@@ -134,7 +140,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
.putString("priority", priority);
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder id=" + notificationId);
}
Data inputData = dataBuilder.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
@@ -195,7 +201,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
}
}
/**
* Handle notification intent
*
@@ -356,7 +362,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
// Add dismiss action
Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class);
dismissIntent.setAction("com.timesafari.daily.DISMISS");
dismissIntent.setAction("org.timesafari.daily.DISMISS");
dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
@@ -426,8 +432,8 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
}
// Create config for next notification
com.timesafari.dailynotification.UserNotificationConfig config =
new com.timesafari.dailynotification.UserNotificationConfig(
org.timesafari.dailynotification.UserNotificationConfig config =
new org.timesafari.dailynotification.UserNotificationConfig(
true, // enabled
cronExpression,
content.getTitle() != null ? content.getTitle() : "Daily Notification",
@@ -438,14 +444,15 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
);
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
context,
nextScheduledTime,
config,
false, // isStaticReminder
null, // reminderId
scheduleId,
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
false // skipPendingIntentIdempotence rollover path does not skip
);
Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId);

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;
@@ -157,8 +157,8 @@ public class DailyNotificationScheduler {
// Create intent for the notification; setPackage ensures AlarmManager delivery on all OEMs
Intent intent = new Intent(context, DailyNotificationReceiver.class);
intent.setPackage(context.getPackageName());
intent.setAction(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
intent.putExtra(com.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
intent.setAction(org.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
intent.putExtra(org.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
// Check if this is a static reminder
if (content.getId().startsWith("reminder_") || content.getId().contains("_reminder")) {
@@ -481,7 +481,7 @@ public class DailyNotificationScheduler {
try {
Log.d(TAG, "Scheduling test alarm in " + secondsFromNow + " seconds");
// Delegate to NotifyReceiver.testAlarm()
com.timesafari.dailynotification.NotifyReceiver.Companion.testAlarm(context, secondsFromNow);
org.timesafari.dailynotification.NotifyReceiver.Companion.testAlarm(context, secondsFromNow);
Log.i(TAG, "Test alarm scheduled successfully");
} catch (Exception e) {
Log.e(TAG, "Error scheduling test alarm", e);
@@ -591,7 +591,7 @@ public class DailyNotificationScheduler {
// Note: NotifyReceiver.isAlarmScheduled is a Kotlin companion object function with default parameters
// From Java, we need to use Companion and provide explicit values (null is acceptable for optional params)
// Kotlin Long? maps to java.lang.Long in Java
return com.timesafari.dailynotification.NotifyReceiver.Companion.isAlarmScheduled(
return org.timesafari.dailynotification.NotifyReceiver.Companion.isAlarmScheduled(
context,
scheduleId,
triggerAtMillis
@@ -624,7 +624,7 @@ public class DailyNotificationScheduler {
// Delegate to NotifyReceiver which checks actual AlarmManager state
// Note: NotifyReceiver.getNextAlarmTime is a Kotlin companion object function
// Kotlin Long? maps to java.lang.Long in Java
return com.timesafari.dailynotification.NotifyReceiver.Companion.getNextAlarmTime(context);
return org.timesafari.dailynotification.NotifyReceiver.Companion.getNextAlarmTime(context);
} catch (Exception e) {
Log.e(TAG, "Error getting next alarm time", e);
return null;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.NotificationManager;
import android.app.PendingIntent;
@@ -30,9 +30,9 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ConcurrentHashMap;
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.DailyNotificationFetcher;
import org.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
import org.timesafari.dailynotification.entities.NotificationContentEntity;
import org.timesafari.dailynotification.DailyNotificationFetcher;
/**
* WorkManager worker for processing daily notifications
@@ -127,13 +127,29 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
// Check if this is a static reminder (title/body in input data, not storage)
Data inputData = getInputData();
String scheduleId = inputData.getString("schedule_id");
// Dual (New Activity): resolve title/body from persisted config + content cache (relationship: contentTimeout, fallbackBehavior)
if (scheduleId != null && scheduleId.startsWith(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX)) {
NotificationContent content = DualScheduleHelper.resolveDualContentBlocking(getApplicationContext(), notificationId);
if (content != null) {
boolean displayed = displayNotification(content);
if (displayed) {
Log.i(TAG, "DN|DISPLAY_OK dual id=" + notificationId);
return Result.success();
}
}
Log.w(TAG, "DN|DISPLAY_SKIP dual_no_content id=" + notificationId);
return Result.success();
}
// Check if this is a static reminder (title/body in input data, not storage)
boolean isStaticReminder = inputData.getBoolean("is_static_reminder", false);
NotificationContent content;
if (isStaticReminder) {
// Static reminder: create NotificationContent from input data
// Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
String title = inputData.getString("title");
String body = inputData.getString("body");
boolean sound = inputData.getBoolean("sound", true);
@@ -142,7 +158,17 @@ public class DailyNotificationWorker extends Worker {
if (priority == null) {
priority = "normal";
}
// Post-reboot/rollover: Intent may lack title/body; resolve from DB by canonical schedule_id
if ((title == null || title.isEmpty() || body == null || body.isEmpty()) && scheduleId != null) {
NotificationContent canonical = getContentByScheduleId(scheduleId);
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
title = canonical.getTitle();
body = canonical.getBody();
sound = canonical.isSound();
priority = canonical.getPriority() != null ? canonical.getPriority() : "normal";
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER_FROM_DB id=" + notificationId + " schedule_id=" + scheduleId);
}
}
if (title == null || body == null) {
Log.w(TAG, "DN|DISPLAY_SKIP static_reminder_missing_data id=" + notificationId);
return Result.success();
@@ -160,25 +186,34 @@ public class DailyNotificationWorker extends Worker {
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title);
} else {
// Regular notification: load from storage
// Prefer Room storage; fallback to legacy SharedPreferences storage
// Regular notification: load from storage (by notification_id, then by schedule_id for rollover/user content)
content = getContentFromRoomOrLegacy(notificationId);
if (content == null) {
// Content not found - likely removed during deduplication or cleanup
// Return success instead of failure to prevent retries for intentionally removed notifications
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
return Result.success(); // Success prevents retry loops for removed notifications
}
// Check if notification is ready to display
if (!content.isReadyToDisplay()) {
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
return Result.success();
}
// JIT Freshness Re-check (Soft TTL) - skip for static reminders
content = performJITFreshnessCheck(content);
// Rollover/notify_* runs: prefer canonical reminder content by schedule_id so user text is shown
if (scheduleId != null && (content == null || content.getTitle() == null || content.getTitle().isEmpty()
|| content.getBody() == null || content.getBody().isEmpty())) {
NotificationContent canonical = getContentByScheduleId(scheduleId);
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
content = canonical;
content.setId(notificationId); // keep run id for display/dismiss
Log.d(TAG, "DN|DISPLAY_USE_CANONICAL id=" + notificationId + " schedule_id=" + scheduleId);
}
}
if (content == null) {
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
return Result.success();
}
if (!content.isReadyToDisplay()) {
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
return Result.success();
}
// JIT Freshness Re-check (Soft TTL) - skip when content has title/body from Room
boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
&& content.getBody() != null && !content.getBody().isEmpty();
if (!hasTitleBody) {
content = performJITFreshnessCheck(content);
} else {
Log.d(TAG, "DN|DISPLAY_USE_ROOM_CONTENT id=" + notificationId + " (skip JIT)");
}
}
// Display the notification
@@ -361,7 +396,7 @@ public class DailyNotificationWorker extends Worker {
// Create one-time work request
androidx.work.OneTimeWorkRequest softRefetchWork = new androidx.work.OneTimeWorkRequest.Builder(
com.timesafari.dailynotification.SoftRefetchWorker.class)
org.timesafari.dailynotification.SoftRefetchWorker.class)
.setConstraints(constraints)
.setInputData(inputData)
.setInitialDelay(softRefetchTime - System.currentTimeMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
@@ -448,7 +483,7 @@ public class DailyNotificationWorker extends Worker {
// Add action buttons
// 1. Dismiss action
Intent dismissIntent = new Intent(getApplicationContext(), DailyNotificationReceiver.class);
dismissIntent.setAction("com.timesafari.daily.DISMISS");
dismissIntent.setAction("org.timesafari.daily.DISMISS");
dismissIntent.putExtra("notification_id", content.getId());
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
@@ -514,8 +549,41 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
// Calculate next occurrence using DST-safe ZonedDateTime
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
// Resolve schedule_id first so we can load rollover interval from DB
Data inputDataForSchedule = getInputData();
boolean preserveStaticReminder = inputDataForSchedule.getBoolean("is_static_reminder", false);
String scheduleIdForRollover = inputDataForSchedule.getString("schedule_id");
if (scheduleIdForRollover == null || scheduleIdForRollover.isEmpty()) {
String notificationId = content.getId();
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
scheduleIdForRollover = notificationId;
} else if (notificationId != null && notificationId.startsWith("daily_")) {
scheduleIdForRollover = notificationId;
}
}
// When firing run used daily_rollover_* id, resolve canonical schedule so we still apply rolloverIntervalMinutes
String logicalScheduleIdForRollover = scheduleIdForRollover;
if (scheduleIdForRollover != null && scheduleIdForRollover.startsWith("daily_rollover_")) {
org.timesafari.dailynotification.Schedule canonical = org.timesafari.dailynotification.ScheduleHelper.getCanonicalRolloverScheduleBlocking(getApplicationContext());
if (canonical != null) {
logicalScheduleIdForRollover = canonical.getId();
}
}
Integer rolloverMinutes = null;
if (logicalScheduleIdForRollover != null && !logicalScheduleIdForRollover.isEmpty()) {
org.timesafari.dailynotification.Schedule s = org.timesafari.dailynotification.ScheduleHelper.getScheduleBlocking(getApplicationContext(), logicalScheduleIdForRollover);
if (s != null && s.getRolloverIntervalMinutes() != null && s.getRolloverIntervalMinutes() > 0) {
rolloverMinutes = s.getRolloverIntervalMinutes();
Log.d(TAG, "DN|ROLLOVER_INTERVAL scheduleId=" + logicalScheduleIdForRollover + " minutes=" + rolloverMinutes);
}
}
long nextScheduledTime;
if (rolloverMinutes != null && rolloverMinutes > 0) {
nextScheduledTime = addMinutesToTime(content.getScheduledTime(), rolloverMinutes);
Log.d(TAG, "DN|ROLLOVER_NEXT using_interval_minutes=" + rolloverMinutes + " next=" + nextScheduledTime);
} else {
nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
}
// Check for existing notification at the same time to prevent duplicates
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
@@ -540,37 +608,35 @@ public class DailyNotificationWorker extends Worker {
return;
}
// Extract scheduleId from notificationId pattern or use fallback
// Notification IDs are often "daily_${scheduleId}"
String scheduleId = null;
String cronExpression = null;
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
String notificationId = content.getId();
if (notificationId != null && notificationId.startsWith("daily_")) {
scheduleId = notificationId; // Use notificationId as scheduleId
} else {
String scheduleId = scheduleIdForRollover;
if (scheduleId == null || scheduleId.isEmpty()) {
scheduleId = "daily_rollover_" + System.currentTimeMillis();
}
// Calculate cron from current scheduled time (extract hour:minute)
try {
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.setTimeInMillis(content.getScheduledTime());
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
int minute = cal.get(java.util.Calendar.MINUTE);
cronExpression = String.format("%d %d * * *", minute, hour);
// Recalculate next run time from cron (tomorrow at same time)
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
} catch (Exception e) {
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
cronExpression = "0 9 * * *"; // Default to 9 AM
// When using rollover interval, next time already set; otherwise compute from cron (tomorrow same time)
if (rolloverMinutes == null || rolloverMinutes <= 0) {
try {
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.setTimeInMillis(content.getScheduledTime());
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
int minute = cal.get(java.util.Calendar.MINUTE);
cronExpression = String.format("%d %d * * *", minute, hour);
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
} catch (Exception e) {
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
cronExpression = "0 9 * * *";
}
} else {
cronExpression = String.format("%d %d * * *",
java.util.Calendar.getInstance().get(java.util.Calendar.MINUTE),
java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY));
}
// Create config for next notification
com.timesafari.dailynotification.UserNotificationConfig config =
new com.timesafari.dailynotification.UserNotificationConfig(
org.timesafari.dailynotification.UserNotificationConfig config =
new org.timesafari.dailynotification.UserNotificationConfig(
true, // enabled
cronExpression,
content.getTitle() != null ? content.getTitle() : "Daily Notification",
@@ -581,48 +647,50 @@ public class DailyNotificationWorker extends Worker {
);
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
Log.d(TAG, "DN|ROLLOVER next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
getApplicationContext(),
nextScheduledTime,
config,
false, // isStaticReminder
null, // reminderId
preserveStaticReminder, // isStaticReminder preserve so next run keeps title/body
preserveStaticReminder ? scheduleId : null, // reminderId
scheduleId,
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
false // skipPendingIntentIdempotence rollover path does not skip
);
if (scheduleId != null && !scheduleId.startsWith("daily_rollover_")) {
org.timesafari.dailynotification.ScheduleHelper.updateScheduleNextRunTimeBlocking(
getApplicationContext(), scheduleId, content.getScheduledTime(), nextScheduledTime);
}
// Log next scheduled time in readable format
String nextTimeStr = formatScheduledTime(nextScheduledTime);
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
// Schedule background fetch for next notification (5 minutes before scheduled time)
try {
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
getApplicationContext(),
storageForFetcher,
roomStorageForFetcher
);
// Calculate fetch time (5 minutes before notification)
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
long currentTime = System.currentTimeMillis();
if (fetchTime > currentTime) {
fetcher.scheduleFetch(fetchTime);
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() +
" next_fetch=" + fetchTime +
" next_notification=" + nextScheduledTime);
} else {
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
" fetch_time=" + fetchTime +
" current=" + currentTime);
fetcher.scheduleImmediateFetch();
// Do not schedule prefetch for static reminders (single NotifyReceiver alarm is enough; avoids second alarm)
if (preserveStaticReminder) {
Log.d(TAG, "DN|RESCHEDULE_SKIP_PREFETCH static_reminder scheduleId=" + scheduleId);
} else {
// Schedule background fetch for next notification (5 minutes before scheduled time)
try {
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
getApplicationContext(),
storageForFetcher,
roomStorageForFetcher
);
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
long currentTime = System.currentTimeMillis();
if (fetchTime > currentTime) {
fetcher.scheduleFetch(fetchTime);
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() + " next_fetch=" + fetchTime + " next_notification=" + nextScheduledTime);
} else {
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + " fetch_time=" + fetchTime + " current=" + currentTime);
fetcher.scheduleImmediateFetch();
}
} catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + " error scheduling prefetch", e);
}
} catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
" error scheduling prefetch", e);
}
} catch (Exception e) {
@@ -632,6 +700,28 @@ public class DailyNotificationWorker extends Worker {
}
}
/**
* Load notification content by canonical schedule id (for static reminder / rollover user text).
* Tries id then "daily_" + id to match getTitleBodyForSchedule / BootReceiver.
*/
private NotificationContent getContentByScheduleId(String scheduleId) {
if (scheduleId == null || scheduleId.isEmpty()) return null;
try {
org.timesafari.dailynotification.DailyNotificationDatabase db =
org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(scheduleId);
if (entity == null) {
entity = db.notificationContentDao().getNotificationById("daily_" + scheduleId);
}
if (entity != null) {
return mapEntityToContent(entity);
}
} catch (Throwable t) {
Log.w(TAG, "DN|CANONICAL_READ_FAIL schedule_id=" + scheduleId + " err=" + t.getMessage());
}
return null;
}
/**
* Try to load content from Room; fallback to legacy storage
*/
@@ -640,8 +730,8 @@ public class DailyNotificationWorker extends Worker {
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// Use unified database (Kotlin schema with Java entities)
com.timesafari.dailynotification.DailyNotificationDatabase db =
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
org.timesafari.dailynotification.DailyNotificationDatabase db =
org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
if (entity != null) {
return mapEntityToContent(entity);
@@ -688,7 +778,7 @@ public class DailyNotificationWorker extends Worker {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
NotificationContentEntity entity = new NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0",
"1.2.1",
null,
"daily",
content.getTitle(),
@@ -725,6 +815,21 @@ public class DailyNotificationWorker extends Worker {
}
}
/**
* Add minutes to a timestamp (DST-safe via Calendar).
* Used for rollover interval (e.g. 10 minutes for testing).
*/
private long addMinutesToTime(long timeMillis, int minutes) {
try {
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.setTimeInMillis(timeMillis);
cal.add(java.util.Calendar.MINUTE, minutes);
return cal.getTimeInMillis();
} catch (Exception e) {
return timeMillis + (minutes * 60 * 1000L);
}
}
/**
* Calculate next scheduled time with DST-safe handling
*

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
/**
* Information about a scheduled daily reminder

View File

@@ -9,7 +9,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -1,15 +1,15 @@
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.timesafari.dailynotification.entities.NotificationContentEntity
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity
import com.timesafari.dailynotification.entities.NotificationConfigEntity
import com.timesafari.dailynotification.dao.NotificationContentDao
import com.timesafari.dailynotification.dao.NotificationDeliveryDao
import com.timesafari.dailynotification.dao.NotificationConfigDao
import org.timesafari.dailynotification.entities.NotificationContentEntity
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity
import org.timesafari.dailynotification.entities.NotificationConfigEntity
import org.timesafari.dailynotification.dao.NotificationContentDao
import org.timesafari.dailynotification.dao.NotificationDeliveryDao
import org.timesafari.dailynotification.dao.NotificationConfigDao
/**
* Unified SQLite schema for Daily Notification Plugin
@@ -33,7 +33,9 @@ data class ContentCache(
val fetchedAt: Long, // epoch ms
val ttlSeconds: Int,
val payload: ByteArray, // BLOB
val meta: String? = null
val meta: String? = null,
/** dual | daily | legacy — see [ContentCacheScope] */
val cacheScope: String = ContentCacheScope.LEGACY
)
@Entity(tableName = "schedules")
@@ -47,7 +49,9 @@ data class Schedule(
val nextRunAt: Long? = null,
val jitterMs: Int = 0,
val backoffPolicy: String = "exp",
val stateJson: String? = null
val stateJson: String? = null,
/** When > 0, next occurrence is this many minutes after current trigger (dev/testing). Null or 0 = 24h. */
val rolloverIntervalMinutes: Int? = null
)
@Entity(tableName = "callbacks")
@@ -83,7 +87,7 @@ data class History(
NotificationDeliveryEntity::class,
NotificationConfigEntity::class
],
version = 2, // Incremented for unified schema
version = 4, // 4: content_cache.cacheScope
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -118,7 +122,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
DailyNotificationDatabase::class.java,
DATABASE_NAME
)
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addCallback(roomCallback)
.build()
INSTANCE = instance
@@ -266,6 +270,24 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
""".trimIndent())
}
}
/**
* Migration from version 2 to 3: add rollover_interval_minutes to schedules
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE schedules ADD COLUMN rollover_interval_minutes INTEGER")
}
}
/** Add cacheScope to content_cache for dual vs daily isolation */
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE content_cache ADD COLUMN cacheScope TEXT NOT NULL DEFAULT 'legacy'"
)
}
}
}
}
@@ -276,6 +298,9 @@ interface ContentCacheDao {
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatest(): ContentCache?
@Query("SELECT * FROM content_cache WHERE cacheScope = :scope ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatestByScope(scope: String): ContentCache?
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT :limit")
suspend fun getHistory(limit: Int): List<ContentCache>

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.AlarmManager;
import android.content.Context;

View File

@@ -0,0 +1,82 @@
package org.timesafari.dailynotification
import android.content.Context
import android.util.Log
import org.json.JSONObject
/**
* Re-enqueues dual (New Activity) prefetch from persisted SharedPreferences config
* (boot recovery, after a successful dual fetch rollover).
*/
object DualScheduleFetchRecovery {
private const val TAG = "DNP-DUAL-RECOVER"
/**
* Parses [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY] and enqueues the next delayed dual fetch.
* @return true if a job was scheduled
*/
@JvmStatic
fun enqueueFromPersistedConfig(context: Context): Boolean {
return try {
val prefs = context.getSharedPreferences(
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
Context.MODE_PRIVATE
)
val jsonStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
?: return false
val root = JSONObject(jsonStr)
val contentFetchObj = root.optJSONObject("contentFetch") ?: return false
val userNotificationObj = root.optJSONObject("userNotification") ?: return false
val contentFetchConfig = parseContentFetchConfigJson(contentFetchObj)
val userNotificationConfig = parseUserNotificationConfigJson(userNotificationObj)
if (!contentFetchConfig.enabled) {
Log.d(TAG, "contentFetch disabled, skip dual fetch recovery")
return false
}
val nextFetchAt = ScheduleCronUtils.calculateNextRunTimeMillis(contentFetchConfig.schedule)
val nextNotifyAt = ScheduleCronUtils.calculateNextRunTimeMillis(userNotificationConfig.schedule)
FetchWorker.enqueueDualFetch(
context,
contentFetchConfig,
nextFetchAt,
nextNotifyAt
)
true
} catch (e: Exception) {
Log.w(TAG, "enqueueFromPersistedConfig failed", e)
false
}
}
private fun parseContentFetchConfigJson(configJson: JSONObject): ContentFetchConfig {
val callbacksObj = configJson.optJSONObject("callbacks")
return ContentFetchConfig(
enabled = configJson.optBoolean("enabled", true),
schedule = configJson.optString("schedule", "0 9 * * *"),
url = configJson.optString("url").takeIf { it.isNotEmpty() },
timeout = configJson.takeUnless { !it.has("timeout") || JSONObject.NULL == it.get("timeout") }
?.optInt("timeout"),
retryAttempts = configJson.takeUnless { !it.has("retryAttempts") || JSONObject.NULL == it.get("retryAttempts") }
?.optInt("retryAttempts"),
retryDelay = configJson.takeUnless { !it.has("retryDelay") || JSONObject.NULL == it.get("retryDelay") }
?.optInt("retryDelay"),
callbacks = CallbackConfig(
apiService = callbacksObj?.optString("apiService")?.takeIf { it.isNotEmpty() },
database = callbacksObj?.optString("database")?.takeIf { it.isNotEmpty() },
reporting = callbacksObj?.optString("reporting")?.takeIf { it.isNotEmpty() }
)
)
}
private fun parseUserNotificationConfigJson(configJson: JSONObject): UserNotificationConfig {
return UserNotificationConfig(
enabled = configJson.optBoolean("enabled", true),
schedule = configJson.optString("schedule", "0 9 * * *"),
title = configJson.optString("title").takeIf { it.isNotEmpty() },
body = configJson.optString("body").takeIf { it.isNotEmpty() },
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) configJson.optBoolean("sound") else null,
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) configJson.optBoolean("vibration") else null,
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
)
}
}

View File

@@ -0,0 +1,83 @@
package org.timesafari.dailynotification
import android.content.Context
import android.util.Log
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
/**
* Helper for resolving dual (New Activity) notification content at fire time.
* Applies relationship (contentTimeout, fallbackBehavior) using persisted config and content cache.
*/
object DualScheduleHelper {
private const val TAG = "DNP-DUAL"
/**
* Resolve title/body for a dual schedule: use cached content if within contentTimeout, else default from config.
* Call from Worker when schedule_id starts with DUAL_NOTIFY_SCHEDULE_ID_PREFIX.
*
* @param context Application context
* @param notificationId Notification run id for the display
* @return NotificationContent with resolved title/body, or null if no config or skip
*/
@JvmStatic
fun resolveDualContentBlocking(context: Context, notificationId: String): NotificationContent? {
return try {
val prefs = context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null) ?: return null
val config = JSONObject(configStr)
val userNotification = config.optJSONObject("userNotification") ?: return null
val relationship = config.optJSONObject("relationship")
val contentTimeoutMs = relationship?.optLong("contentTimeout", 300_000L) ?: 300_000L
val fallbackBehavior = relationship?.optString("fallbackBehavior", "show_default") ?: "show_default"
val defaultTitle = userNotification.optString("title", "Daily Notification")
val defaultBody = userNotification.optString("body", "Your daily update is ready")
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = runBlocking {
db.contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
}
val nowMs = System.currentTimeMillis()
val (title: String, body: String) = if (latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs) {
val payloadStr = String(latestCache.payload, Charsets.UTF_8)
try {
val payload = JSONObject(payloadStr)
if (payload.optBoolean("skipNotification", false)) {
return null
}
Pair(
payload.optString("title", defaultTitle),
payload.optString("body", payload.optString("content", defaultBody))
)
} catch (_: Exception) {
Pair(defaultTitle, defaultBody)
}
} else {
val staleSkip = latestCache?.let { cache ->
try {
JSONObject(String(cache.payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
} catch (_: Exception) {
false
}
} ?: false
if (staleSkip && fallbackBehavior == "skip") {
return null
}
if (fallbackBehavior != "show_default") return null
Pair(defaultTitle, defaultBody)
}
val content = NotificationContent(title, body, nowMs)
content.setId(notificationId)
content.setSound(userNotification.optBoolean("sound", true))
content.setPriority(userNotification.optString("priority", "normal"))
Log.d(TAG, "Resolved dual content: useCache=${latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs}")
content
} catch (e: Exception) {
Log.w(TAG, "resolveDualContentBlocking failed", e)
null
}
}
}

View File

@@ -0,0 +1,76 @@
package org.timesafari.dailynotification
import android.content.Context
import android.util.Log
import org.json.JSONObject
/**
* Arms the dual (New Activity) user notification **after** prefetch completes (chained schedule).
* Fires at [max]([nextNotifyAtMillis], now) so a late fetch delays delivery instead of showing stale API copy first.
*/
object DualScheduleNotifyScheduler {
private const val TAG = "DNP-DUAL-NOTIFY"
/**
* Schedule exact alarm for dual notify using persisted [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY]
* and [DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY].
*/
@JvmStatic
fun scheduleChainedNotifyAlarm(context: Context, nextNotifyAtMillis: Long) {
try {
val prefs = context.getSharedPreferences(
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
Context.MODE_PRIVATE
)
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
?: run {
Log.w(TAG, "No dual_schedule_config; skip chained notify")
return
}
val scheduleId = prefs.getString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, null)
?: run {
Log.w(TAG, "No dual_notify_schedule_id; skip chained notify")
return
}
val root = JSONObject(configStr)
val userObj = root.optJSONObject("userNotification") ?: run {
Log.w(TAG, "dual config missing userNotification")
return
}
val config = parseUserNotificationConfig(userObj)
val now = System.currentTimeMillis()
val triggerAt = maxOf(nextNotifyAtMillis, now + 500L)
Log.i(TAG, "Chained dual notify: scheduleId=$scheduleId triggerAt=$triggerAt (nextNotify=$nextNotifyAtMillis)")
NotifyReceiver.scheduleExactNotification(
context,
triggerAt,
config,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP,
skipPendingIntentIdempotence = true
)
} catch (e: Exception) {
Log.e(TAG, "scheduleChainedNotifyAlarm failed", e)
}
}
private fun parseUserNotificationConfig(configJson: JSONObject): UserNotificationConfig {
return UserNotificationConfig(
enabled = configJson.optBoolean("enabled", true),
schedule = configJson.optString("schedule", "0 9 * * *"),
title = configJson.optString("title").takeIf { it.isNotEmpty() },
body = configJson.optString("body").takeIf { it.isNotEmpty() },
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) {
configJson.optBoolean("sound")
} else {
null
},
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) {
configJson.optBoolean("vibration")
} else {
null
},
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
)
}
}

View File

@@ -9,7 +9,7 @@
* @created 2025-10-03 06:53:30 UTC
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -11,7 +11,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

View File

@@ -1,23 +1,24 @@
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.content.Context
import android.os.SystemClock
import android.util.Log
import androidx.work.*
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import kotlin.text.Charsets
import org.json.JSONObject
/**
* WorkManager implementation for content fetching
* Implements exponential backoff and network constraints
*
*
* @author Matthew Raymer
* @version 1.1.0
* @version 2.0.0
*/
class FetchWorker(
appContext: Context,
@@ -27,12 +28,85 @@ class FetchWorker(
companion object {
private const val TAG = "DNP-FETCH"
private const val WORK_NAME = "fetch_content"
/** Unique work name for dual (New Activity) schedule fetch; cancel via cancelDualSchedule only. */
const val WORK_NAME_DUAL = "fetch_dual"
private const val KEY_IS_DUAL = "is_dual"
private const val KEY_CACHE_SCOPE = "cache_scope"
private const val KEY_NEXT_NOTIFY_AT = "next_notify_at"
/**
* Persisted for dual native fetch when the host returns no rows.
* [DualScheduleHelper] must not display a notification; [doWork] skips chained notify.
*/
internal val dualEmptyNativeFetchPayload: ByteArray =
"""{"skipNotification":true}""".toByteArray(Charsets.UTF_8)
/** True when [payload] is the dual-cache sentinel for “API / native had no content”. */
@JvmStatic
fun isDualSkipNotificationPayload(payload: ByteArray): Boolean {
return try {
JSONObject(String(payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
} catch (_: Exception) {
false
}
}
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
enqueueFetch(context, config, WORK_NAME)
}
/**
* Dual (New Activity) prefetch: delayed to [nextFetchAtMillis], scoped cache, optional native fetcher.
*/
fun enqueueDualFetch(
context: Context,
contentFetchConfig: ContentFetchConfig,
nextFetchAtMillis: Long,
nextNotifyAtMillis: Long
) {
val now = System.currentTimeMillis()
val delayMs = (nextFetchAtMillis - now).coerceAtLeast(0L)
val requiresNetwork = !contentFetchConfig.url.isNullOrBlank() ||
DailyNotificationPlugin.getNativeFetcherStatic() != null
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (requiresNetwork) NetworkType.CONNECTED else NetworkType.NOT_REQUIRED
)
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.setInputData(
Data.Builder()
.putString("url", contentFetchConfig.url)
.putInt("timeout", contentFetchConfig.timeout ?: 30000)
.putInt("retryAttempts", contentFetchConfig.retryAttempts ?: 3)
.putInt("retryDelay", contentFetchConfig.retryDelay ?: 1000)
.putLong("notificationTime", 0L)
.putBoolean(KEY_IS_DUAL, true)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DUAL)
.putLong(KEY_NEXT_NOTIFY_AT, nextNotifyAtMillis)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME_DUAL, ExistingWorkPolicy.REPLACE, workRequest)
Log.i(
TAG,
"Dual fetch enqueued: delayMs=$delayMs, nextFetchAt=$nextFetchAtMillis, nextNotifyAt=$nextNotifyAtMillis"
)
}
private fun enqueueFetch(context: Context, config: ContentFetchConfig, workName: String) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
@@ -46,21 +120,18 @@ class FetchWorker(
.putInt("timeout", config.timeout ?: 30000)
.putInt("retryAttempts", config.retryAttempts ?: 3)
.putInt("retryDelay", config.retryDelay ?: 1000)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.REPLACE,
workRequest
)
.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
}
/**
* Schedule a delayed fetch for prefetch (5 minutes before notification)
*
*
* @param context Application context
* @param fetchTime When to fetch (in milliseconds since epoch)
* @param notificationTime When the notification will be shown (in milliseconds since epoch)
@@ -74,15 +145,15 @@ class FetchWorker(
) {
val currentTime = System.currentTimeMillis()
val delayMs = fetchTime - currentTime
Log.i(TAG, "Scheduling delayed prefetch: fetchTime=$fetchTime, notificationTime=$notificationTime, delayMs=$delayMs")
if (delayMs <= 0) {
Log.w(TAG, "Fetch time is in the past, scheduling immediate fetch")
scheduleImmediateFetch(context, notificationTime, url)
return
}
// Only require network if URL is provided (mock content doesn't need network)
val constraints = Constraints.Builder()
.apply {
@@ -94,11 +165,11 @@ class FetchWorker(
}
}
.build()
// Create unique work name based on notification time to prevent duplicate fetches
val notificationTimeMinutes = notificationTime / (60 * 1000)
val workName = "prefetch_${notificationTimeMinutes}"
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
@@ -115,21 +186,23 @@ class FetchWorker(
.putInt("timeout", 30000)
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.i(TAG, "Delayed prefetch scheduled: workName=$workName, delayMs=$delayMs")
}
/**
* Schedule an immediate fetch (fallback when delay is in the past)
*/
@@ -149,7 +222,7 @@ class FetchWorker(
}
}
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInputData(
@@ -160,14 +233,16 @@ class FetchWorker(
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.putBoolean("immediate", true)
.putBoolean(KEY_IS_DUAL, false)
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueue(workRequest)
Log.i(TAG, "Immediate prefetch scheduled")
}
}
@@ -179,33 +254,39 @@ class FetchWorker(
val retryAttempts = inputData.getInt("retryAttempts", 3)
val retryDelay = inputData.getInt("retryDelay", 1000)
val notificationTime = inputData.getLong("notificationTime", 0L)
val isDual = inputData.getBoolean(KEY_IS_DUAL, false)
val cacheScope = inputData.getString(KEY_CACHE_SCOPE) ?: ContentCacheScope.LEGACY
val nextNotifyAt = inputData.getLong(KEY_NEXT_NOTIFY_AT, 0L)
try {
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime")
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime, isDual=$isDual, scope=$cacheScope")
val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
val skipDualChainedNotify =
isDual && nextNotifyAt > 0L && isDualSkipNotificationPayload(payload)
val contentCache = ContentCache(
id = generateId(),
fetchedAt = System.currentTimeMillis(),
ttlSeconds = 3600, // 1 hour default TTL
payload = payload,
meta = "fetched_by_workmanager"
meta = "fetched_by_workmanager",
cacheScope = cacheScope
)
// Store in database
val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.contentCacheDao().upsert(contentCache)
// If this is a prefetch for a specific notification, create NotificationContentEntity
// so the notification worker can find it when the alarm fires
if (notificationTime > 0) {
try {
val notificationId = "notify_$notificationTime"
val (title, body) = parsePayload(payload)
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.0.2", // Plugin version
"2.1.0", // Plugin version
null, // timesafariDid - can be set if available
"daily",
title,
@@ -220,7 +301,7 @@ class FetchWorker(
entity.createdAt = System.currentTimeMillis()
entity.updatedAt = System.currentTimeMillis()
entity.ttlSeconds = contentCache.ttlSeconds.toLong()
// Save to Room database so notification worker can find it
db.notificationContentDao().insertNotification(entity)
Log.i(TAG, "Created NotificationContentEntity: id=$notificationId, scheduledTime=$notificationTime")
@@ -229,7 +310,7 @@ class FetchWorker(
// Continue - at least ContentCache was saved
}
}
// Record success in history
db.historyDao().insert(
History(
@@ -240,22 +321,91 @@ class FetchWorker(
outcome = "success"
)
)
Log.i(TAG, "Content fetch completed successfully")
if (isDual && nextNotifyAt > 0L) {
if (!skipDualChainedNotify) {
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
} else {
Log.i(TAG, "Dual fetch: empty native content — skip chained notify")
}
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
}
Result.success()
} catch (e: IOException) {
Log.w(TAG, "Network error during fetch", e)
recordFailure("network_error", start, e)
Result.retry()
} catch (e: Exception) {
Log.e(TAG, "Unexpected error during fetch", e)
recordFailure("unexpected_error", start, e)
if (isDual && nextNotifyAt > 0L) {
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
}
Result.failure()
}
}
private suspend fun resolvePayload(
url: String?,
timeout: Int,
retryAttempts: Int,
retryDelay: Int,
isDual: Boolean,
nextNotifyAt: Long
): ByteArray {
if (isDual && url.isNullOrBlank()) {
val native = DailyNotificationPlugin.getNativeFetcherStatic()
return if (native != null) {
fetchNativeDualPayload(native, timeout, nextNotifyAt)
} else {
Log.w(TAG, "Dual fetch with no URL and no native fetcher; using mock content")
generateMockContent()
}
}
return fetchContent(url, timeout, retryAttempts, retryDelay)
}
private suspend fun fetchNativeDualPayload(
native: NativeNotificationContentFetcher,
timeoutMs: Int,
nextNotifyAtMillis: Long
): ByteArray = withContext(Dispatchers.IO) {
val metadata = java.util.HashMap<String, Any>()
metadata["retryCount"] = 0
metadata["immediate"] = false
val scheduledTime: Long? = if (nextNotifyAtMillis > 0L) nextNotifyAtMillis else null
val ctx = FetchContext(
"prefetch",
scheduledTime,
System.currentTimeMillis(),
metadata
)
val future = native.fetchContent(ctx)
try {
val contents = future.get(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
val list = contents?.toList() ?: emptyList()
notificationContentsToDualPayloadBytes(list)
} catch (e: Exception) {
Log.e(TAG, "Native dual fetch failed", e)
throw IOException("native_fetch_failed: ${e.message}", e)
}
}
private fun notificationContentsToDualPayloadBytes(contents: List<NotificationContent>): ByteArray {
if (contents.isEmpty()) {
return dualEmptyNativeFetchPayload
}
val c = contents[0]
val title = c.getTitle() ?: "Daily Notification"
val body = c.getBody() ?: ""
val json = JSONObject()
json.put("title", title)
json.put("body", body)
json.put("content", body)
return json.toString().toByteArray(Charsets.UTF_8)
}
private suspend fun fetchContent(
url: String?,
timeout: Int,
@@ -266,23 +416,22 @@ class FetchWorker(
// Generate mock content for testing
return generateMockContent()
}
var lastException: Exception? = null
repeat(retryAttempts) { attempt ->
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = timeout
connection.readTimeout = timeout
connection.requestMethod = "GET"
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
return connection.inputStream.readBytes()
} else {
throw IOException("HTTP $responseCode: ${connection.responseMessage}")
}
} catch (e: Exception) {
lastException = e
if (attempt < retryAttempts - 1) {
@@ -291,22 +440,22 @@ class FetchWorker(
}
}
}
throw lastException ?: IOException("All retry attempts failed")
}
private fun generateMockContent(): ByteArray {
val mockData = """
{
"timestamp": ${System.currentTimeMillis()},
"content": "Daily notification content",
"source": "mock_generator",
"version": "1.1.0"
"version": "2.1.4"
}
""".trimIndent()
return mockData.toByteArray()
}
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
try {
val db = DailyNotificationDatabase.getDatabase(applicationContext)
@@ -324,22 +473,22 @@ class FetchWorker(
Log.e(TAG, "Failed to record failure", e)
}
}
private fun generateId(): String {
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
}
/**
* Parse payload to extract title and body
* Handles both JSON and plain text payloads
*
*
* @param payload Raw payload bytes
* @return Pair of (title, body)
*/
private fun parsePayload(payload: ByteArray): Pair<String, String> {
return try {
val payloadString = String(payload, Charsets.UTF_8)
// Try to parse as JSON
val json = JSONObject(payloadString)
val title = json.optString("title", "Daily Notification")

View File

@@ -15,9 +15,10 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -142,5 +143,21 @@ public interface NativeNotificationContentFetcher {
// This allows fetchers that don't need TypeScript-provided configuration
// to ignore this method without implementing an empty body.
}
/**
* Optional overload: distinct JWT strings for background use (e.g. one per day slot).
* Persisted when {@code persistToken} is true; host fetchers (e.g. TimeSafari) should choose
* which entry to use per fetch (e.g. day index modulo pool size).
* Default delegates to {@link #configure(String, String, String)} so existing fetchers stay compatible.
*
* @param jwtTokenPool validated list (max length 128), or null if the host omitted the pool or sent an empty array
*/
default void configure(
String apiBaseUrl,
String activeDid,
String jwtToken,
@Nullable List<String> jwtTokenPool) {
configure(apiBaseUrl, activeDid, jwtToken);
}
}

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.util.Log;
import java.util.UUID;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.NotificationManager;
import android.content.Context;
@@ -518,13 +518,13 @@ public class NotificationStatusChecker {
* @param database Database instance for querying schedules and history
* @return JSObject containing notification status (schedules, last notification time, etc.)
*/
public JSObject getNotificationStatus(com.timesafari.dailynotification.DailyNotificationDatabase database) {
public JSObject getNotificationStatus(org.timesafari.dailynotification.DailyNotificationDatabase database) {
try {
Log.d(TAG, "DN|NOTIFICATION_STATUS_START");
// Delegate to Kotlin helper function (uses runBlocking internally)
// This is safe because status checks are quick operations
return com.timesafari.dailynotification.NotificationStatusHelper.getNotificationStatusBlocking(database);
return org.timesafari.dailynotification.NotificationStatusHelper.getNotificationStatusBlocking(database);
} catch (Exception e) {
Log.e(TAG, "DN|NOTIFICATION_STATUS_ERR err=" + e.getMessage(), e);

View File

@@ -1,4 +1,4 @@
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.app.AlarmManager
import android.app.AlarmManager.AlarmClockInfo
@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
* Implements TTL-at-fire logic and notification delivery
*
* @author Matthew Raymer
* @version 1.1.0
* @version 2.0.0
*/
/**
* Source of schedule request - tracks which code path triggered scheduling
@@ -122,104 +122,119 @@ class NotifyReceiver : BroadcastReceiver() {
* @param reminderId Optional reminder ID for tracking (used as scheduleId if provided)
* @param scheduleId Stable identifier for the schedule (used for requestCode stability)
* @param source Source of the scheduling request (for debugging duplicate alarms)
* @param skipPendingIntentIdempotence If true, skip PendingIntent-based idempotence checks.
* Use when the caller has just cancelled this scheduleId (cancel-then-schedule path).
* Android may still return the cancelled PendingIntent from cache briefly, which would
* incorrectly cause the new schedule to be skipped.
*/
@JvmStatic
fun scheduleExactNotification(
context: Context,
context: Context,
triggerAtMillis: Long,
config: UserNotificationConfig,
isStaticReminder: Boolean = false,
reminderId: String? = null,
scheduleId: String? = null,
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE,
skipPendingIntentIdempotence: Boolean = false
) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// Generate stable scheduleId - prefer provided scheduleId, then reminderId, then generate from time
// This ensures same schedule always uses same ID for idempotence checks
val stableScheduleId = scheduleId ?: reminderId ?: "daily_${triggerAtMillis}"
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling
// This prevents duplicate alarms when multiple scheduling paths race
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
val requestCode = getRequestCode(stableScheduleId)
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
val checkIntent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION"
action = "org.timesafari.daily.NOTIFICATION"
}
// Check 1: Same scheduleId (stable requestCode) - most reliable
var existingPendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
checkIntent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
// This catches cases where different scheduleIds are used for the same time
// Try a range of request codes around the trigger time
if (existingPendingIntent == null) {
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
existingPendingIntent = PendingIntent.getBroadcast(
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling.
// Skip PendingIntent checks when caller just cancelled this schedule (Android may still
// return the cancelled PendingIntent from cache and cause the new schedule to be skipped).
if (!skipPendingIntentIdempotence) {
// Check 1: Same scheduleId (stable requestCode) - most reliable
var existingPendingIntent = PendingIntent.getBroadcast(
context,
timeBasedRequestCode,
requestCode,
checkIntent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
}
// Check 3: Also check if AlarmManager already has an alarm for this exact time
// This is a fallback for when PendingIntent checks fail but alarm still exists
// We check the next alarm clock time (Android 5.0+)
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val nextAlarm = alarmManager.nextAlarmClock
if (nextAlarm != null) {
val nextAlarmTime = nextAlarm.triggerTime
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
// If there's an alarm within 1 minute of our target time, consider it a duplicate
if (timeDiff < 60000) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
return
}
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
if (existingPendingIntent == null) {
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
existingPendingIntent = PendingIntent.getBroadcast(
context,
timeBasedRequestCode,
checkIntent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
}
}
if (existingPendingIntent != null) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
return
}
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
// This prevents logical duplicates before even hitting AlarmManager
try {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
// Check 3: AlarmManager next alarm (Android 5.0+)
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val nextAlarm = alarmManager.nextAlarmClock
if (nextAlarm != null) {
val nextAlarmTime = nextAlarm.triggerTime
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
if (timeDiff < 60000) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule for id=$stableScheduleId at $triggerTimeStr from source=$source")
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
return@runBlocking
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
return
}
}
}
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
if (existingPendingIntent != null) {
if (source == ScheduleSource.ROLLOVER_ON_FIRE) {
// Rollover chain: same schedule id, new trigger time — treat as update: cancel then set
Log.d(SCHEDULE_TAG, "ROLLOVER_ON_FIRE: cancelling existing alarm for id=$stableScheduleId to set new trigger")
alarmManager.cancel(existingPendingIntent)
} else {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
return
}
}
} else {
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
}
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
// When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't
// cancel the alarm and then skip re-scheduling, resulting in no alarm.
if (!skipPendingIntentIdempotence) {
try {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
if (timeDiff < 60000) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule for id=$stableScheduleId at $triggerTimeStr from source=$source")
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
return@runBlocking
}
}
}
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
}
} else {
Log.d(SCHEDULE_TAG, "Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId")
}
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
@@ -235,14 +250,16 @@ class NotifyReceiver : BroadcastReceiver() {
try {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
val contentCache = db.contentCacheDao().getLatest()
val contentCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
?: db.contentCacheDao().getLatest()
// Always create a notification content entity for recovery tracking
// Phase 1: Recovery needs NotificationContentEntity to detect missed notifications
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
val roomStorage = org.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.0.2", // Plugin version
"2.1.0", // Plugin version
null, // timesafariDid - can be set if available
"daily",
config.title,
@@ -273,9 +290,9 @@ class NotifyReceiver : BroadcastReceiver() {
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
// FIX: Set action to match manifest registration; setPackage() ensures AlarmManager
// delivery reaches this app on all OEMs (see daily-notification-plugin-android-receiver-issue)
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
action = "org.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
// Also preserve original extras for backward compatibility if needed
@@ -312,7 +329,8 @@ class NotifyReceiver : BroadcastReceiver() {
if (existingPendingIntent != null) {
Log.w(SCHEDULE_TAG, "Cancelling existing alarm before rescheduling: requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source")
alarmManager.cancel(existingPendingIntent)
existingPendingIntent.cancel()
// Do not call existingPendingIntent.cancel(): the cached PendingIntent may be the same
// object we pass to setAlarmClock below; cancelling it can prevent the new alarm from firing.
}
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "Failed to cancel existing alarm before scheduling: $stableScheduleId", e)
@@ -399,6 +417,63 @@ class NotifyReceiver : BroadcastReceiver() {
)
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
// Update database schedule with new nextRunAt so getNotificationStatus() returns correct value
// This is critical for rollover scenarios where the UI needs to show the updated time
// Strategy: Find existing enabled notify schedule and update it (there should only be one)
// This ensures getNotificationStatus() finds the updated schedule, not a stale one
try {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
// First, try to find schedule by the provided stableScheduleId
var scheduleToUpdate = db.scheduleDao().getById(stableScheduleId)
// If not found by ID, only use "first enabled notify" fallback when this is NOT
// a rollover id (daily_rollover_*). Rollover work may use a different notification_id
// (e.g. from recovery); updating the app's schedule row here would overwrite
// nextRunAt with the rollover time and can leave the app's alarm in a bad state.
if (scheduleToUpdate == null && !stableScheduleId.startsWith("daily_rollover_")) {
val allSchedules = db.scheduleDao().getAll()
scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled }
}
// Calculate cron expression from trigger time (HH:mm format)
val calendar = java.util.Calendar.getInstance().apply {
timeInMillis = triggerAtMillis
}
val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY)
val minute = calendar.get(java.util.Calendar.MINUTE)
val cronExpression = "${minute} ${hour} * * *"
val clockTime = String.format("%02d:%02d", hour, minute)
if (scheduleToUpdate != null) {
// Update existing schedule with new nextRunAt
// Use the existing schedule's ID (not stableScheduleId) to ensure we update the right one
db.scheduleDao().updateRunTimes(scheduleToUpdate.id, scheduleToUpdate.lastRunAt, triggerAtMillis)
Log.d(SCHEDULE_TAG, "Updated schedule in database: id=${scheduleToUpdate.id}, nextRunAt=$triggerAtMillis (rollover)")
} else {
// No existing schedule found - create new one (shouldn't happen in normal flow)
val newSchedule = Schedule(
id = stableScheduleId,
kind = "notify",
cron = cronExpression,
clockTime = clockTime,
enabled = true,
lastRunAt = null,
nextRunAt = triggerAtMillis,
jitterMs = 0,
backoffPolicy = "exp",
stateJson = null
)
db.scheduleDao().upsert(newSchedule)
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
}
}
} catch (e: Exception) {
// Log but don't fail - alarm is already scheduled, DB update is best-effort
Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
}
}
/**
@@ -411,9 +486,9 @@ class NotifyReceiver : BroadcastReceiver() {
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION"
action = "org.timesafari.daily.NOTIFICATION"
}
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)
@@ -423,14 +498,38 @@ class NotifyReceiver : BroadcastReceiver() {
return
}
}
val pendingIntent = PendingIntent.getBroadcast(
// CRITICAL: Use FLAG_NO_CREATE to get existing PendingIntent, don't create new one
// This matches the pattern used in scheduleExactNotification for proper cancellation
val existingPendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.i(TAG, "Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
if (existingPendingIntent != null) {
// Cancel both the alarm in AlarmManager AND the PendingIntent itself
// This matches the pattern in scheduleExactNotification (lines 311-312)
alarmManager.cancel(existingPendingIntent)
existingPendingIntent.cancel()
Log.i(TAG, "DNP-CANCEL: Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
// Verify cancellation by checking if alarm still exists
val verifyIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
if (verifyIntent == null) {
Log.d(TAG, "DNP-CANCEL: ✅ Cancellation verified - no PendingIntent found for requestCode=$requestCode")
} else {
Log.w(TAG, "DNP-CANCEL: ⚠️ Cancellation may have failed - PendingIntent still exists for requestCode=$requestCode")
}
} else {
Log.d(TAG, "DNP-CANCEL: No existing PendingIntent found to cancel: scheduleId=$scheduleId, requestCode=$requestCode")
}
}
/**
@@ -443,9 +542,9 @@ class NotifyReceiver : BroadcastReceiver() {
*/
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION"
action = "org.timesafari.daily.NOTIFICATION"
}
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)
@@ -573,7 +672,9 @@ class NotifyReceiver : BroadcastReceiver() {
// Existing cached content logic for regular notifications
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = db.contentCacheDao().getLatest()
val latestCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
?: db.contentCacheDao().getLatest()
if (latestCache == null) {
Log.w(TAG, "No cached content available for notification")

View File

@@ -1,4 +1,4 @@
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;

View File

@@ -8,7 +8,7 @@
* @version 2.0.0 - Modular Architecture
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.Manifest;
import android.content.Context;
@@ -87,7 +87,7 @@ public class PermissionManager {
androidx.core.app.ActivityCompat.requestPermissions(
activity,
new String[]{android.Manifest.permission.POST_NOTIFICATIONS},
com.timesafari.dailynotification.DailyNotificationConstants.PERMISSION_REQUEST_CODE // Centralized constant
org.timesafari.dailynotification.DailyNotificationConstants.PERMISSION_REQUEST_CODE // Centralized constant
);
Log.d(TAG, "Permission dialog shown, waiting for user response");
@@ -125,7 +125,7 @@ public class PermissionManager {
*
* @return PermissionStatus with all permission states
*/
public com.timesafari.dailynotification.PermissionStatus getPermissionStatus() {
public org.timesafari.dailynotification.PermissionStatus getPermissionStatus() {
boolean postNotificationsGranted = false;
boolean exactAlarmsGranted = false;
boolean notificationsEnabledAtOsLevel = false;
@@ -168,7 +168,7 @@ public class PermissionManager {
batteryOptimizationsIgnored = true; // Pre-Android 6, no battery optimization restrictions
}
return new com.timesafari.dailynotification.PermissionStatus(
return new org.timesafari.dailynotification.PermissionStatus(
postNotificationsGranted,
exactAlarmsGranted,
batteryOptimizationsIgnored,
@@ -187,7 +187,7 @@ public class PermissionManager {
try {
Log.d(TAG, "Checking permission status");
com.timesafari.dailynotification.PermissionStatus status = getPermissionStatus();
org.timesafari.dailynotification.PermissionStatus status = getPermissionStatus();
JSObject result = status.toJSObject();
result.put("success", true);

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification
package org.timesafari.dailynotification
/**
* Comprehensive permission status model

View File

@@ -1,4 +1,4 @@
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.app.PendingIntent
import android.content.Context
@@ -17,9 +17,9 @@ import java.util.concurrent.TimeUnit
* Phase 2: Force stop detection and recovery
*
* Implements:
* - [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start)
* - [Plugin Requirements §3.1.4 - Force Stop Recovery](../docs/alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
* Platform Reference: [Android §2.1.4](../docs/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
* - [Plugin Requirements §3.1.2 - App Cold Start](../../../../../../../doc/alarms/03-plugin-requirements.md#312-app-cold-start)
* - [Plugin Requirements §3.1.4 - Force Stop Recovery](../../../../../../../doc/alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
* Platform Reference: [Android §2.1.4](../../../../../../../doc/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
*
* @author Matthew Raymer
* @version 2.0.0 - Phase 2: Force stop detection
@@ -41,13 +41,33 @@ class ReactivationManager(private val context: Context) {
companion object {
private const val TAG = "DNP-REACTIVATION"
private const val RECOVERY_TIMEOUT_SECONDS = 2L
/**
* Load persisted title/body for a schedule from NotificationContentEntity (post-reboot recovery).
* Tries schedule.id then "daily_${schedule.id}" to match NotifyReceiver/ScheduleHelper id convention.
* Internal so BootReceiver can use when rescheduling after boot.
*/
internal fun getTitleBodyForSchedule(db: DailyNotificationDatabase, schedule: Schedule): Pair<String, String>? {
val entity = try {
db.notificationContentDao().getNotificationById(schedule.id)
} catch (_: Exception) {
null
} ?: try {
db.notificationContentDao().getNotificationById("daily_${schedule.id}")
} catch (_: Exception) {
null
} ?: return null
val t = entity.title?.takeIf { it.isNotBlank() } ?: return null
val b = entity.body?.takeIf { it.isNotBlank() } ?: return null
return Pair(t, b)
}
/**
* Run boot-time recovery
*
* Phase 3: Boot recovery that restores alarms after device reboot
*
* Implements: [Plugin Requirements §3.1.1 - Boot Event](../docs/alarms/03-plugin-requirements.md#311-boot-event-android-only)
* Implements: [Plugin Requirements §3.1.1 - Boot Event](../../../../../../../doc/alarms/03-plugin-requirements.md#311-boot-event-android-only)
*
* This method is called from BootReceiver when BOOT_COMPLETED is received.
* It runs asynchronously with timeout protection to avoid blocking boot.
@@ -105,8 +125,8 @@ class ReactivationManager(private val context: Context) {
markMissedNotificationForSchedule(schedule, nextRunTime, db)
missedCount++
// Schedule next occurrence if repeating
val nextOccurrence = calculateNextOccurrence(currentTime)
// Schedule next occurrence (use rollover interval if set, else 24h)
val nextOccurrence = calculateNextOccurrenceForSchedule(schedule, nextRunTime, currentTime)
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
rescheduledCount++
@@ -134,6 +154,12 @@ class ReactivationManager(private val context: Context) {
Log.e(TAG, "Failed to recover schedule ${schedule.id}", e)
}
}
if (enabledSchedules.any { it.id.startsWith("dual_fetch_") }) {
if (DualScheduleFetchRecovery.enqueueFromPersistedConfig(context)) {
Log.i(TAG, "Dual prefetch WorkManager re-enqueued from persisted config")
}
}
// Record recovery in history
val result = RecoveryResult(
@@ -218,10 +244,25 @@ class ReactivationManager(private val context: Context) {
}
private fun calculateNextOccurrence(fromTime: Long): Long {
// For daily schedules, add 24 hours
// This is simplified - production should handle weekly/monthly patterns
return fromTime + (24 * 60 * 60 * 1000L)
}
/**
* Next occurrence from a given trigger time. Uses schedule.rolloverIntervalMinutes when set and > 0 (dev/testing), else 24h.
* Advances until result > currentTime so we don't reschedule in the past.
*/
private fun calculateNextOccurrenceForSchedule(schedule: Schedule, fromTime: Long, currentTime: Long): Long {
val intervalMs = when {
schedule.rolloverIntervalMinutes != null && schedule.rolloverIntervalMinutes!! > 0 ->
schedule.rolloverIntervalMinutes!! * 60 * 1000L
else -> 24 * 60 * 60 * 1000L
}
var next = fromTime + intervalMs
while (next < currentTime) {
next += intervalMs
}
return next
}
private suspend fun markMissedNotificationForSchedule(
schedule: Schedule,
@@ -245,9 +286,9 @@ class ReactivationManager(private val context: Context) {
db.notificationContentDao().updateNotification(existing)
} else {
// Create new notification content entry for missed alarm
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.0.2", // Plugin version
"2.1.0", // Plugin version
null, // timesafariDid
"daily", // notificationType
"Daily Notification",
@@ -275,22 +316,25 @@ class ReactivationManager(private val context: Context) {
db: DailyNotificationDatabase
) {
try {
val (title, body) = getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
title = title,
body = body,
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY
source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
)
// Update schedule in database (best effort)
@@ -441,9 +485,9 @@ class ReactivationManager(private val context: Context) {
private fun alarmsExist(): Boolean {
return try {
// Check if any PendingIntent for our receiver exists (must match NotifyReceiver schedule path)
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION"
action = "org.timesafari.daily.NOTIFICATION"
}
val pendingIntent = PendingIntent.getBroadcast(
context,
@@ -816,13 +860,13 @@ class ReactivationManager(private val context: Context) {
db: DailyNotificationDatabase
) {
try {
// Use existing BootReceiver logic for calculating next run time
// For now, use schedule.nextRunAt directly
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
title = title,
body = body,
sound = true,
vibration = true,
priority = "normal"
@@ -1012,9 +1056,9 @@ class ReactivationManager(private val context: Context) {
db.notificationContentDao().updateNotification(existing)
} else {
// Create new notification content entry for missed alarm
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.0.2", // Plugin version
"2.1.0", // Plugin version
null, // timesafariDid
"daily", // notificationType
"Daily Notification",
@@ -1045,22 +1089,25 @@ class ReactivationManager(private val context: Context) {
db: DailyNotificationDatabase
) {
try {
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
title = title,
body = body,
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY
source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
)
// Update schedule in database (best effort)

View File

@@ -0,0 +1,59 @@
package org.timesafari.dailynotification
import android.util.Log
/**
* Shared cron → next wall-clock instant (daily "minute hour * * *" style).
* Used by dual prefetch scheduling, rollover, and [DailyNotificationPlugin] scheduling.
*/
object ScheduleCronUtils {
private const val TAG = "DNP-CRON"
/**
* Next occurrence of the given daily cron after "now" (same logic as DailyNotificationPlugin.calculateNextRunTime).
*/
@JvmStatic
fun calculateNextRunTimeMillis(schedule: String): Long {
try {
val parts = schedule.trim().split("\\s+".toRegex())
if (parts.size < 2) {
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
val minute = parts[0].toIntOrNull() ?: 0
val hour = parts[1].toIntOrNull() ?: 9
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
val calendar = java.util.Calendar.getInstance()
val now = calendar.timeInMillis
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
calendar.set(java.util.Calendar.MINUTE, minute)
calendar.set(java.util.Calendar.SECOND, 0)
calendar.set(java.util.Calendar.MILLISECOND, 0)
var nextRun = calendar.timeInMillis
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
nextRun = calendar.timeInMillis
}
Log.d(
TAG,
"Next run: cron=$schedule, nextRun=${
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(nextRun))
}"
)
return nextRun
} catch (e: Exception) {
Log.e(TAG, "Error calculating next run for schedule: $schedule", e)
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
}
}
}

View File

@@ -11,7 +11,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.os.Trace;

View File

@@ -24,7 +24,7 @@
* @version 1.0.0
*/
package com.timesafari.dailynotification;
package org.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -9,10 +9,10 @@
* @since 2025-10-20
*/
package com.timesafari.dailynotification.dao;
package org.timesafari.dailynotification.dao;
import androidx.room.*;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import org.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.List;

View File

@@ -9,10 +9,10 @@
* @since 2025-10-20
*/
package com.timesafari.dailynotification.dao;
package org.timesafari.dailynotification.dao;
import androidx.room.*;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import org.timesafari.dailynotification.entities.NotificationContentEntity;
import java.util.List;

View File

@@ -9,10 +9,10 @@
* @since 2025-10-20
*/
package com.timesafari.dailynotification.dao;
package org.timesafari.dailynotification.dao;
import androidx.room.*;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import java.util.List;

View File

@@ -9,7 +9,7 @@
* @since 2025-10-20
*/
package com.timesafari.dailynotification.entities;
package org.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;

View File

@@ -9,7 +9,7 @@
* @since 2025-10-20
*/
package com.timesafari.dailynotification.entities;
package org.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;

View File

@@ -9,7 +9,7 @@
* @since 2025-10-20
*/
package com.timesafari.dailynotification.entities;
package org.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;

View File

@@ -9,18 +9,18 @@
* @since 2025-10-20
*/
package com.timesafari.dailynotification.storage;
package org.timesafari.dailynotification.storage;
import android.content.Context;
import android.util.Log;
import com.timesafari.dailynotification.DailyNotificationDatabase;
import com.timesafari.dailynotification.dao.NotificationContentDao;
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
import com.timesafari.dailynotification.dao.NotificationConfigDao;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import org.timesafari.dailynotification.DailyNotificationDatabase;
import org.timesafari.dailynotification.dao.NotificationContentDao;
import org.timesafari.dailynotification.dao.NotificationDeliveryDao;
import org.timesafari.dailynotification.dao.NotificationConfigDao;
import org.timesafari.dailynotification.entities.NotificationContentEntity;
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import org.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
private final ExecutorService executorService;
// Plugin version for migration tracking
private static final String PLUGIN_VERSION = "1.0.0";
private static final String PLUGIN_VERSION = "1.2.1";
/**
* Constructor

View File

@@ -9,7 +9,7 @@
* @since 2025-12-22
*/
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.content.Context
import androidx.test.core.app.ApplicationProvider

View File

@@ -0,0 +1,143 @@
package org.timesafari.dailynotification
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.text.Charsets
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class DualScheduleHelperTest {
private lateinit var context: Context
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
runBlocking {
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
}
}
@After
fun tearDown() {
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
runBlocking {
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
}
}
@Test
fun freshCache_skipNotification_returnsNull_evenWithShowDefault() {
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":600000}""")
insertDualCache(
payload = FetchWorker.dualEmptyNativeFetchPayload,
fetchedAt = System.currentTimeMillis()
)
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
}
@Test
fun freshCache_skipNotification_returnsNull_withSkipFallback() {
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
insertDualCache(
payload = FetchWorker.dualEmptyNativeFetchPayload,
fetchedAt = System.currentTimeMillis()
)
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
}
@Test
fun staleCache_skipNotification_skipFallback_returnsNull() {
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":1000}""")
insertDualCache(
payload = FetchWorker.dualEmptyNativeFetchPayload,
fetchedAt = System.currentTimeMillis() - 60_000L
)
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
}
@Test
fun staleCache_skipNotification_showDefault_usesUserDefaults() {
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":1000}""")
insertDualCache(
payload = FetchWorker.dualEmptyNativeFetchPayload,
fetchedAt = System.currentTimeMillis() - 60_000L
)
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
assertNotNull(content)
assertTrue(content!!.title == "New Activity")
}
@Test
fun freshCache_realPayload_returnsContent() {
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
val json = JSONObject()
json.put("title", "API Title")
json.put("body", "API Body")
insertDualCache(
payload = json.toString().toByteArray(Charsets.UTF_8),
fetchedAt = System.currentTimeMillis()
)
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
assertNotNull(content)
assertTrue(content!!.title == "API Title" && content.body == "API Body")
}
@Test
fun isDualSkipNotificationPayload_emptyObject_false() {
assertFalse(FetchWorker.isDualSkipNotificationPayload("{}".toByteArray(Charsets.UTF_8)))
}
@Test
fun isDualSkipNotificationPayload_sentinel_true() {
assertTrue(FetchWorker.isDualSkipNotificationPayload(FetchWorker.dualEmptyNativeFetchPayload))
}
private fun putDualConfig(relationshipJson: String) {
val root = JSONObject()
val user = JSONObject()
user.put("enabled", true)
user.put("schedule", "0 9 * * *")
user.put("title", "New Activity")
user.put("body", "Check your starred projects for updates")
root.put("userNotification", user)
root.put("relationship", JSONObject(relationshipJson))
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, root.toString())
.commit()
}
private fun insertDualCache(payload: ByteArray, fetchedAt: Long) {
val cache = ContentCache(
id = "test_dual_${System.nanoTime()}",
fetchedAt = fetchedAt,
ttlSeconds = 3600,
payload = payload,
meta = "test",
cacheScope = ContentCacheScope.DUAL
)
runBlocking {
DailyNotificationDatabase.getDatabase(context).contentCacheDao().upsert(cache)
}
}
}

View File

@@ -12,7 +12,7 @@
* @since 2025-12-22
*/
package com.timesafari.dailynotification
package org.timesafari.dailynotification
import android.content.Context
import androidx.room.Room

View File

@@ -1,7 +1,7 @@
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.timesafari.dailynotification',
appId: 'org.timesafari.dailynotification',
appName: 'DailyNotification Test App',
webDir: 'www',
server: {

View File

@@ -1,6 +1,6 @@
[
{
"name": "DailyNotification",
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
}
]

View File

@@ -4,9 +4,9 @@
**Owner:** Development Team
**Last Updated:** 2025-12-23
**Status:** active
**Baseline:** See `docs/progress/00-STATUS.md` for current baseline tag
**Baseline:** See `doc/progress/00-STATUS.md` for current baseline tag
This index provides organized access to all documentation in the repository. For a complete audit trail of file movements, see [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md).
This index provides organized access to all documentation in the repository. For a complete audit trail of file movements, see [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md).
---
@@ -14,12 +14,12 @@ This index provides organized access to all documentation in the repository. For
These are **policy-as-code**. Any gate (push, release, publish) MUST call `./ci/run.sh`.
- **System Invariants:** `docs/SYSTEM_INVARIANTS.md` — Single authoritative document naming and explaining all enforced invariants
- **System Invariants:** `doc/SYSTEM_INVARIANTS.md` — Single authoritative document naming and explaining all enforced invariants
- **Local CI Contract:** `./ci/run.sh` — Single source of truth for CI/release gates
- **Verification / Invariants:** `./scripts/verify.sh` — Encodes packaging, core-purity, and build invariants
- **CI Usage & Setup:** `ci/README.md` — Local CI documentation
- **Performance Characteristics:** `docs/PERFORMANCE.md` — Performance characteristics and benchmarks
- **Troubleshooting Guide:** `docs/TROUBLESHOOTING.md` — Common issues and solutions
- **Performance Characteristics:** `doc/PERFORMANCE.md` — Performance characteristics and benchmarks
- **Troubleshooting Guide:** `doc/TROUBLESHOOTING.md` — Common issues and solutions
---
@@ -35,6 +35,10 @@ These files define the current truth about project state, decisions, and verific
- **[05-CHATGPT-FEEDBACK-PACKAGE.md](./progress/05-CHATGPT-FEEDBACK-PACKAGE.md)** — AI collaboration package
- **[P2-DESIGN.md](./progress/P2-DESIGN.md)** — P2 scope, invariants, and acceptance criteria (design-only)
- **[P2.1-REFACTORING-COMPLETE.md](./progress/P2.1-REFACTORING-COMPLETE.md)** — P2.1 native plugin refactoring complete summary (Android + iOS)
- **[TODO.md](./progress/TODO.md)** — Project TODO and improvement tracking
- **[TODAY_SUMMARY.md](./progress/TODAY_SUMMARY.md)** — Dated work summaries
- **[SESSION_RECONSTITUTION.md](./progress/SESSION_RECONSTITUTION.md)** — P2.1 Batch A session reconstitution
- **[BATCH_A_COMPLETION_SUMMARY.md](./progress/BATCH_A_COMPLETION_SUMMARY.md)** — P2.1 Batch A completion summary
---
@@ -51,14 +55,15 @@ These files define the current truth about project state, decisions, and verific
## Archive & Reference-only
- **`docs/_archive/`** — Historical artifacts, preserved for audit trail (not part of active doc surface)
- `docs/_archive/2025-legacy-doc/` — Legacy documentation from 2025
- **`doc/_archive/`** — Historical artifacts, preserved for audit trail (not part of active doc surface)
- `doc/_archive/PR_DESCRIPTION.md`, `MERGE_READY_SUMMARY.md` — One-off PR/merge artifacts (2025-10)
- `doc/_archive/2025-legacy-doc/` — Legacy documentation from 2025
- [IMPLEMENTATION_CHECKLIST_LEGACY.md](./_archive/2025-legacy-doc/IMPLEMENTATION_CHECKLIST_LEGACY.md) — iOS Phase 1 checklist (historical)
- `docs/_archive/2025-12-16-consolidation/` — 2025-12-16 consolidation artifacts (audit trail)
- `doc/_archive/2025-12-16-consolidation/` — 2025-12-16 consolidation artifacts (audit trail)
- [CONSOLIDATION_COMPLETE.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_COMPLETE.md) — Consolidation completion summary
- [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) — Complete file mapping (139 files)
- **`docs/_reference/`** — Reference templates (not used by current workflow)
- `docs/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
- **`doc/_reference/`** — Reference templates (not used by current workflow)
- `doc/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
---
@@ -68,7 +73,7 @@ These files define the current truth about project state, decisions, and verific
1. **[README.md](../README.md)** - Project overview and getting started
2. **[ARCHITECTURE.md](../ARCHITECTURE.md)** - System architecture
3. **[docs/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
3. **[doc/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
4. **[BUILDING.md](../BUILDING.md)** - Build instructions
---
@@ -90,7 +95,7 @@ These files define the current truth about project state, decisions, and verific
## Integration Documentation
**Location:** `docs/integration/`
**Location:** `doc/integration/`
- **[INTEGRATION_GUIDE.md](./integration/INTEGRATION_GUIDE.md)** - Complete integration guide
- **[QUICK_START.md](./integration/QUICK_START.md)** - Quick integration path
@@ -104,7 +109,7 @@ These files define the current truth about project state, decisions, and verific
### iOS
**Location:** `docs/platform/ios/`
**Location:** `doc/platform/ios/`
- **[IOS_IMPLEMENTATION_CHECKLIST.md](./platform/ios/IOS_IMPLEMENTATION_CHECKLIST.md)** - iOS implementation checklist
- **[IMPLEMENTATION_DIRECTIVE.md](./platform/ios/IMPLEMENTATION_DIRECTIVE.md)** - iOS implementation directive
@@ -119,7 +124,7 @@ These files define the current truth about project state, decisions, and verific
### Android
**Location:** `docs/platform/android/`
**Location:** `doc/platform/android/`
- **[IMPLEMENTATION_DIRECTIVE.md](./platform/android/IMPLEMENTATION_DIRECTIVE.md)** - Primary Android implementation directive
- **[PHASE1_DIRECTIVE.md](./platform/android/PHASE1_DIRECTIVE.md)** - Phase 1 directive
@@ -135,7 +140,7 @@ These files define the current truth about project state, decisions, and verific
## Testing Documentation
**Location:** `docs/testing/`
**Location:** `doc/testing/`
### General Testing
@@ -178,7 +183,7 @@ Test app-specific documentation remains with the test apps but is indexed here:
## Alarm System Documentation
**Location:** `docs/alarms/`
**Location:** `doc/alarms/`
The alarm system documentation is well-organized and kept in its current location:
@@ -198,7 +203,7 @@ The alarm system documentation is well-organized and kept in its current locatio
## Design & Research Documentation
**Location:** `docs/design/`
**Location:** `doc/design/`
- **[STARRED_PROJECTS_POLLING_IMPLEMENTATION.md](./design/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md)** - Starred projects polling implementation
- **[exploration-findings-initial.md](./design/exploration-findings-initial.md)** - Initial exploration findings
@@ -208,63 +213,83 @@ The alarm system documentation is well-organized and kept in its current locatio
---
## Feature-Specific Documentation
## Architecture (Storage & Core Tech)
**Location:** `docs/`
**Location:** `doc/architecture/`
### Storage & Database
- **[CROSS_PLATFORM_STORAGE_PATTERN.md](./architecture/CROSS_PLATFORM_STORAGE_PATTERN.md)** - Cross-platform storage pattern
- **[DATABASE_INTERFACES.md](./architecture/DATABASE_INTERFACES.md)** - Database interfaces
- **[DATABASE_INTERFACES_IMPLEMENTATION.md](./architecture/DATABASE_INTERFACES_IMPLEMENTATION.md)** - Database interfaces implementation
- **[NATIVE_FETCHER_CONFIGURATION.md](./architecture/NATIVE_FETCHER_CONFIGURATION.md)** - Native fetcher configuration
- **[CROSS_PLATFORM_STORAGE_PATTERN.md](./CROSS_PLATFORM_STORAGE_PATTERN.md)** - Cross-platform storage pattern
- **[DATABASE_INTERFACES.md](./DATABASE_INTERFACES.md)** - Database interfaces
- **[DATABASE_INTERFACES_IMPLEMENTATION.md](./DATABASE_INTERFACES_IMPLEMENTATION.md)** - Database interfaces implementation
---
### Native Fetcher
## Deployment
- **[NATIVE_FETCHER_CONFIGURATION.md](./NATIVE_FETCHER_CONFIGURATION.md)** - Native fetcher configuration
**Location:** `doc/deployment/`
### Prefetch & Scheduling
- **[deployment-guide.md](./deployment/deployment-guide.md)** - Deployment guide (primary)
- **[DEPLOYMENT_CHECKLIST.md](./deployment/DEPLOYMENT_CHECKLIST.md)** - Deployment checklist
- **[prefetch-scheduling-diagnosis.md](./prefetch-scheduling-diagnosis.md)** - Prefetch scheduling diagnosis
- **[prefetch-scheduling-trace.md](./prefetch-scheduling-trace.md)** - Prefetch scheduling trace
---
### Recovery & Startup
## Compliance & Operations
- **[app-startup-recovery-solution.md](./app-startup-recovery-solution.md)** - App startup recovery solution
**Location:** `doc/compliance/`
### Platform Capabilities
- **[accessibility-localization.md](./compliance/accessibility-localization.md)** - Accessibility and localization
- **[legal-store-compliance.md](./compliance/legal-store-compliance.md)** - Legal and store compliance
- **[observability-dashboards.md](./compliance/observability-dashboards.md)** - Observability dashboards
- **[platform-capability-reference.md](./platform-capability-reference.md)** - Platform capability reference
- **[plugin-requirements-implementation.md](./plugin-requirements-implementation.md)** - Plugin requirements implementation
---
### Feature Implementation
## Feature-Specific (Integration, Design, Progress)
- **[getting-valid-plan-ids.md](./getting-valid-plan-ids.md)** - Getting valid plan IDs
- **[host-request-configuration.md](./host-request-configuration.md)** - Host request configuration
- **[hydrate-plan-implementation-guide.md](./hydrate-plan-implementation-guide.md)** - Hydrate plan implementation guide
- **[user-zero-stars-implementation.md](./user-zero-stars-implementation.md)** - User zero stars implementation
### Integration (`doc/integration/`)
### Compliance & Operations
- **[getting-valid-plan-ids.md](./integration/getting-valid-plan-ids.md)** - Getting valid plan IDs
- **[host-request-configuration.md](./integration/host-request-configuration.md)** - Host request configuration
- **[hydrate-plan-implementation-guide.md](./integration/hydrate-plan-implementation-guide.md)** - Hydrate plan implementation guide
- **[user-zero-stars-implementation.md](./integration/user-zero-stars-implementation.md)** - User zero stars implementation
- **[capacitor-platform-service-clean-changes.md](./integration/capacitor-platform-service-clean-changes.md)** - Capacitor platform service changes
- **[ACTION_PLAN_INTEGRATION_FIXES.md](./integration/ACTION_PLAN_INTEGRATION_FIXES.md)** - Integration fixes action plan
- **[accessibility-localization.md](./accessibility-localization.md)** - Accessibility and localization
- **[legal-store-compliance.md](./legal-store-compliance.md)** - Legal and store compliance
- **[observability-dashboards.md](./observability-dashboards.md)** - Observability dashboards
### Design (`doc/design/`) — plans, prefetch, recovery
### Deployment
- **[P1.5-CONSOLIDATION-PLAN.md](./design/P1.5-CONSOLIDATION-PLAN.md)** - P1.5 consolidation plan
- **[P1.5-STEP4-CLUSTERS.md](./design/P1.5-STEP4-CLUSTERS.md)** - P1.5 step 4 clusters
- **[P1.5-STEP4-DECISIONS.md](./design/P1.5-STEP4-DECISIONS.md)** - P1.5 step 4 decisions
- **[P2.1-NATIVE-REFACTORING-ANALYSIS.md](./design/P2.1-NATIVE-REFACTORING-ANALYSIS.md)** - P2.1 native refactoring analysis
- **[FEEDBACK-RESPONSE-PLAN.md](./design/FEEDBACK-RESPONSE-PLAN.md)** - Feedback response plan
- **[prefetch-scheduling-diagnosis.md](./design/prefetch-scheduling-diagnosis.md)** - Prefetch scheduling diagnosis
- **[prefetch-scheduling-trace.md](./design/prefetch-scheduling-trace.md)** - Prefetch scheduling trace
- **[app-startup-recovery-solution.md](./design/app-startup-recovery-solution.md)** - App startup recovery solution
- **[plugin-requirements-implementation.md](./design/plugin-requirements-implementation.md)** - Plugin requirements implementation
- **[deployment-guide.md](./deployment-guide.md)** - Deployment guide (primary)
- **[DEPLOYMENT_CHECKLIST.md](./DEPLOYMENT_CHECKLIST.md)** - Deployment checklist
- **[DEPLOYMENT_SUMMARY.md](./DEPLOYMENT_SUMMARY.md)** - Deployment summary
### Progress (`doc/progress/`)
### Utilities
- **[DEPLOYMENT_SUMMARY.md](./progress/DEPLOYMENT_SUMMARY.md)** - Deployment summary
- **[TODO-CLASSIFICATION.md](./progress/TODO-CLASSIFICATION.md)** - TODO classification
### Platform — Android (`doc/platform/android/`)
- **[CONSUMING_APP_ANDROID_NOTES.md](./platform/android/CONSUMING_APP_ANDROID_NOTES.md)** - Consuming app Android notes
- **[CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md](./platform/android/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md)** - Optional Android ID cleanup
- **[TIMESAFARI_ANDROID_COMPARISON.md](./platform/android/TIMESAFARI_ANDROID_COMPARISON.md)** - TimeSafari Android comparison
### Platform Capabilities (canonical)
- **[01-platform-capability-reference.md](./alarms/01-platform-capability-reference.md)** - Platform capability reference (canonical; deprecated root copy archived in `_archive/`)
### Utilities (meta)
- **[file-organization-summary.md](./file-organization-summary.md)** - File organization summary
- **[capacitor-platform-service-clean-changes.md](./capacitor-platform-service-clean-changes.md)** - Capacitor platform service changes
---
## AI / Prompting / Automation Artifacts
**Location:** `docs/ai/`
**Location:** `doc/ai/`
These are derived operational artifacts for AI-assisted development:
@@ -280,9 +305,9 @@ These are derived operational artifacts for AI-assisted development:
## Archive Documentation
**Location:** `docs/archive/2025-legacy-doc/`
**Location:** `doc/archive/2025-legacy-doc/`
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
**Notable archived content:**
- Historical directives (`doc/directives/`)
@@ -300,18 +325,20 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
| Category | Count | Location |
|----------|-------|----------|
| **Core Documentation** | 8 | Root + `docs/` |
| **Integration** | 5 | `docs/integration/` |
| **Platform (iOS)** | 10 | `docs/platform/ios/` |
| **Platform (Android)** | 9 | `docs/platform/android/` |
| **Testing** | 13 | `docs/testing/` |
| **Alarms** | 11 | `docs/alarms/` |
| **Design & Research** | 5 | `docs/design/` |
| **Feature-Specific** | 18 | `docs/` |
| **AI Artifacts** | 7 | `docs/ai/` |
| **Deployment** | 3 | `docs/` |
| **Core Documentation** | 8 | Root + `doc/` |
| **Integration** | 5 | `doc/integration/` |
| **Platform (iOS)** | 10 | `doc/platform/ios/` |
| **Platform (Android)** | 9 | `doc/platform/android/` |
| **Testing** | 13 | `doc/testing/` |
| **Alarms** | 11 | `doc/alarms/` |
| **Design & Research** | 5 | `doc/design/` |
| **Architecture** | 4 | `doc/architecture/` |
| **Deployment** | 2 | `doc/deployment/` |
| **Compliance** | 3 | `doc/compliance/` |
| **Feature-Specific (integration, design, progress, platform)** | 14 | `doc/integration/`, `doc/design/`, `doc/progress/`, `doc/platform/android/` |
| **AI Artifacts** | 7 | `doc/ai/` |
| **Test Apps** | 20+ | `test-apps/*/` |
| **Archive** | 29 | `docs/archive/2025-legacy-doc/` |
| **Archive** | 29 | `doc/archive/2025-legacy-doc/` |
### By Status
@@ -334,13 +361,13 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
- **Test on Android** → See [Android Test App Docs](../test-apps/android-test-app/docs/)
- **Understand alarms** → Browse [Alarms Documentation](./alarms/)
- **Troubleshoot** → Check platform-specific troubleshooting guides
- **Deploy** → See [Deployment Guide](./deployment-guide.md)
- **Deploy** → See [Deployment Guide](./deployment/deployment-guide.md)
### By Platform
- **iOS**`docs/platform/ios/`
- **Android**`docs/platform/android/`
- **Cross-Platform**`docs/alarms/`, `docs/integration/`
- **iOS**`doc/platform/ios/`
- **Android**`doc/platform/android/`
- **Cross-Platform**`doc/alarms/`, `doc/integration/`
### By Phase
@@ -354,19 +381,19 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
### Updating This Index
**Index-first rule:** New docs must be linked from `docs/00-INDEX.md` or explicitly placed under `_archive/` / `_reference/`.
**Index-first rule:** New docs must be linked from `doc/00-INDEX.md` or explicitly placed under `_archive/` / `_reference/`.
When adding new documentation:
1. Place file in appropriate category directory
2. Add entry to this index in the correct section
3. Update the "Document Map by Category" table if needed
4. Update [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) if consolidating
4. Update [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) if consolidating
### Consolidation Reference
For complete consolidation audit trail, see:
- **[CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
- **[CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
---

View File

@@ -0,0 +1,173 @@
# Completion Plan: scheduleDualNotification (Plugin + Consuming App)
**Purpose:** Checklist of what needs to be done to complete the dual-schedule (New Activity) implementation in the plugin and in the consuming app. For review before making changes.
**Status:** Cron parsing, stable dual ID, cancelDualSchedule, and updateDualScheduleConfig are implemented on iOS and Android. The **relationship** (contentTimeout / fallbackBehavior) is implemented: dual config is persisted; on iOS the pending dual notification is updated when the fetch completes; on Android the Worker resolves config + cache at fire time and shows the resolved title/body.
**Related:** Consuming app feedback doc `plugin-feedback-ios-scheduleDualNotification.md`; plugin `src/definitions.ts` (`DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`).
**For app-side implementation (crowd-funder-for-time-pwa):** All plugin work below is **done**. Use this doc in the app repo to implement app changes. Require plugin **v2.1.0+** (or current local plugin with dual schedule + relationship). Focus on **§2** and the **Consuming app** rows in **§3**. Key tasks: (1) Verify plugin is linked and built so `scheduleDualNotification` is not UNIMPLEMENTED (§2.1); (2) In Edit-time flow, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time, with fallback to `scheduleDualNotification` (§2.4). App paths: `src/views/AccountViewView.vue` (e.g. `editNewActivityNotification()` ~15041520), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/NativeNotificationService.ts`.
---
## 0. Context: Two notification types in the consuming app
The consuming app (crowd-funder-for-time-pwa) has **two independent notification types**, both using a user-set time from the app DB and firing once a day at that time:
| Type | Content source | Plugin API | Cancel API |
|------|----------------|------------|------------|
| **Daily Reminder** | User-set text from app DB | `scheduleDailyReminder(id, title, body, time, …)` | `cancelDailyReminder({ reminderId })` |
| **New Activity** | API-fetched content (native fetcher) | `scheduleDualNotification({ config })` | `cancelDualSchedule()` |
- Both can be **on at the same time**; the app turns each on/off and sets its time independently.
- **Isolation requirement:** Cancelling one must not affect the other. So:
- `cancelDualSchedule()` must cancel **only** the dual schedule (content fetch + user notification for New Activity). It must **not** remove Daily Reminder notifications (iOS uses `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
- `cancelDailyReminder({ reminderId })` must cancel **only** that reminder; it must **not** cancel the dual content-fetch task or New Activity user notification.
The completion plan below assumes this separation. Any new plugin code (e.g. iOS `cancelDualSchedule`, or dual user-notification identifiers) must preserve it.
---
## 1. Plugin (daily-notification-plugin) — iOS
### 1.1 Fix UNIMPLEMENTED (bridge / integration)
- **Ensure the native method is actually invoked.** Capacitor returns `UNIMPLEMENTED` when the bridge doesn't call the native handler. In the **consuming app**:
- Confirm the app depends on this plugin (or an up-to-date fork) and that `npx cap sync ios` / build includes the plugin's native code.
- If they use Capacitor 6, check the [Capacitor 6 plugin registration / UNIMPLEMENTED issues](https://github.com/ionic-team/capacitor-docs/issues/325) and apply any required registration or build fixes so `scheduleDualNotification` is exposed and called.
- No code changes are required in the plugin for this; the handler and registration already exist.
### 1.2 Cron parsing (align with Android)
- **Replace the stub `calculateNextRunTime(from:)`** in `ios/Plugin/DailyNotificationPlugin.swift` (lines 767771) with real cron parsing.
- **Reference:** Android's `calculateNextRunTime(schedule: String)` in `DailyNotificationPlugin.kt` (lines 23362378): supports `"minute hour * * *"`, uses device timezone, returns next occurrence (today or tomorrow).
- **Behavior:** For a given cron string (e.g. `"25 18 * * *"`), compute the next run as `Date`/`TimeInterval` and use that for:
- `BGAppRefreshTaskRequest.earliestBeginDate`
- `UNCalendarNotificationTrigger` (or equivalent) for the user notification so it fires at the correct local time daily.
- Right now the implementation ignores the cron and always uses 86400 seconds, so schedules are wrong.
### 1.3 Use `relationship` (contentTimeout + fallbackBehavior) — implemented
- **Intent:** When the user notification fires at `userNotification.schedule`, show **API-derived content** if the fetch completed and is within `relationship.contentTimeout`; otherwise show `userNotification.title` / `userNotification.body` (per `fallbackBehavior: "show_default"`).
- **Implemented:** Dual config (userNotification + relationship) is persisted when scheduling/updating. On **iOS**, after the content fetch completes in `handleBackgroundFetch`, the plugin replaces the pending dual notification with resolved title/body (from cache if within contentTimeout, else default). On **Android**, when the Worker runs for a `dual_notify_*` schedule, it loads the persisted config and content cache and resolves title/body at fire time, then displays one notification with that content. See **§1.3a** for implementation details (retained for reference).
### 1.3a Implementation plan: relationship (contentTimeout / fallbackBehavior)
Implement when ready so the New Activity notification can show API content when the fetch succeeds in time, or default text otherwise.
**Prerequisite: persist dual config (both platforms)**
When `scheduleDualNotification` or `updateDualScheduleConfig` runs, persist enough of the config for later use:
- **userNotification:** `schedule` (cron), `title`, `body` (and any other fields needed to build the notification).
- **relationship:** `contentTimeout`, `fallbackBehavior`.
So when we later resolve content (after fetch or at fire time), we have the default text and the rules. No new API surface; store what we already receive.
- **iOS:** e.g. a single key in UserDefaults (or alongside `native_fetcher_config`), e.g. `dual_schedule_config`, with this structure (e.g. JSON).
- **Android:** e.g. SharedPreferences or a keyed config; the code that runs at notification time (or after fetch) must be able to read it.
**iOS: update the pending notification when the fetch completes**
- When the content fetch runs (e.g. in `handleBackgroundFetch`), we already store the result. After a successful fetch:
1. **Read the persisted dual config.** If none (no dual schedule or legacy flow), skip.
2. **Resolve content:** Load the content just stored (or latest from cache) and its timestamp. If content exists and `(now - contentTimestamp) <= relationship.contentTimeout`, use that title/body; else use `userNotification.title` / `userNotification.body`.
3. **Replace the pending dual notification:** Remove the pending request with identifier `dualNotificationRequestIdentifier`, then add a new `UNNotificationRequest` with the same identifier, the same trigger (recompute from `userNotification.schedule` in stored config), and the resolved title/body.
- **Edge cases:** If the fetch completes after the notification time (next trigger already in the past), do not replace. If the fetch fails, leave the existing pending notification as-is (it already has default title/body).
**Android: resolve content when the notification is about to fire**
- On Android the “notification” is an alarm that fires at notify time and then runs code (e.g. `NotifyReceiver` / `DailyNotificationReceiver`) to display the notification. We cannot change the alarms “content” after the fact the same way as on iOS; we decide what to show when the alarm fires.
- **Persist dual config** when scheduling (same as above), keyed so the receiver can find it (e.g. by schedule id or a single “current dual config” key).
- **When the receiver runs** for a dual schedule (e.g. for `dual_notify_*` or the known dual schedule id): load the persisted dual config, load the latest content from the content cache and its timestamp, apply relationship (use cache if within `contentTimeout`, else default), then show one notification with that resolved title/body.
- The receiver must be dual-aware: for dual schedules it resolves title/body from config + cache + relationship instead of using fixed payload from the alarm.
**Summary**
| Step | iOS | Android |
|------|-----|---------|
| 1. Persist dual config | Store `userNotification` + `relationship` when scheduling/updating dual (e.g. UserDefaults). | Same; store when scheduling dual (e.g. SharedPreferences), keyed for the receiver. |
| 2. Where relationship is applied | In **handleBackgroundFetch** after storing content: resolve cache vs default, then **replace** the pending dual notification (same id, same trigger, new title/body). | In the **receiver** at notify time: load config + cache, resolve cache vs default, then **show** the notification with that title/body. |
| 3. Edge cases | Do not replace if next trigger is in the past; if fetch fails, leave existing default notification. | Receiver runs at fire time; “too old” handled by contentTimeout; if no config, fall back to alarm payload. |
### 1.4 Implement and register `cancelDualSchedule()` on iOS
- **Current state:** `cancelDualSchedule` is in `definitions.ts` and the web implementation, but there is **no** `@objc func cancelDualSchedule(_ call: CAPPluginCall)` in the iOS plugin and **no** `CAPPluginMethod(name: "cancelDualSchedule", ...)` in the plugin's method list (around 21952199).
- **Required:**
- Add `cancelDualSchedule(_ call: CAPPluginCall)` that:
- Cancels the BGAppRefreshTaskRequest for the dual content fetch (e.g. cancel the task with `fetchTaskIdentifier` or the identifier used for the dual schedule).
- Cancels **only** the pending user notification(s) created for the dual (New Activity) schedule — e.g. by a **dedicated request identifier** (see below). Must **not** remove Daily Reminder notifications (those use identifier `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
- When implementing or refactoring the dual user notification in `scheduleUserNotification(config:)` (or the path used by `scheduleDualNotification`), use a **stable, dedicated identifier** for the dual notification (e.g. `"dual_daily_notification"` or `"new_activity"`) so `cancelDualSchedule` can remove only that request. Currently the code uses `"daily-notification-\(Date().timeIntervalSince1970)"`, which is unique per call and not suitable for targeted cancellation.
- Append `CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise)` in the same method list.
- **Result:** Turning off "New Activity" in the app and calling `DailyNotification.cancelDualSchedule()` will no longer get `UNIMPLEMENTED` and will clear only the dual schedule, leaving Daily Reminder untouched.
### 1.5 Implement `updateDualScheduleConfig` for Edit time (recommended)
- **Use case:** The consuming app has an **Edit** button for New Activity that lets the user **change the time** of the notification. That flow is exactly what `updateDualScheduleConfig(config: DualScheduleConfiguration)` is for: "update the existing dual schedule with new config" (same config shape as `scheduleDualNotification`).
- **Current app behavior:** Edit is implemented by calling `scheduleNewActivityDualNotification(timeText)` again (i.e. `scheduleDualNotification({ config })` with the new time). That can create duplicate pending notifications if the plugin does not replace the existing dual schedule (e.g. iOS currently uses a unique identifier per call: `daily-notification-<timestamp>`).
- **Recommendation:** Implement **`updateDualScheduleConfig`** on iOS (and Android) for clear semantics: "change time" → call `updateDualScheduleConfig(newConfig)`. Implementation can be cancel existing dual schedule then schedule with new config (same as cancel + scheduleDualNotification under the hood). The consuming app should then call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when the user saves a new time from the Edit dialog, instead of (or with fallback to) `scheduleDualNotification`.
- **Replace semantics:** Whether or not the app uses `updateDualScheduleConfig`, the plugin must ensure that when a dual schedule already exists and the app calls `scheduleDualNotification` again (e.g. on Edit), the result is **replace** not **add** — no duplicate content-fetch tasks or user notifications. Using a stable dual notification identifier (see 1.4) and replacing the existing request when scheduling achieves this.
- **Other optional methods:** `pauseDualSchedule` and `resumeDualSchedule` remain optional; they are in `definitions.ts` but not required for the current app flow.
### 1.6 Android parity
- **cancelDualSchedule / updateDualScheduleConfig:** Implemented; Android now exposes both methods and uses `FetchWorker.WORK_NAME_DUAL` so only dual fetch work is cancelled. For **relationship** (contentTimeout / fallbackBehavior), see §1.3a (resolve at fire time in receiver).
---
## 2. Consuming app (crowd-funder-for-time-pwa)
### 2.1 Ensure plugin is linked and built (fix UNIMPLEMENTED)
- **Verify** the app's iOS project is using the plugin from this repo (or a release that includes the iOS implementation):
- `package.json` dependency points to the right plugin (path, git, or npm).
- Run `npx cap sync ios` and confirm `DailyNotificationPlugin` (and its Swift files) are in the app's `ios/App/` or Pods.
- Clean build and run on device/simulator; confirm in Xcode that the plugin's `scheduleDualNotification` is registered and that a breakpoint in the Swift handler is hit when turning on New Activity.
- If the app is on **Capacitor 6**, follow any documented steps for plugin registration so native methods are not reported as unimplemented.
- No change to `buildDualScheduleConfig` or call order is needed; the config shape and sequence (configureNativeFetcher → updateStarredPlans → scheduleDualNotification) already match the plugin's expectations.
### 2.2 Error handling (optional but useful)
- **Current:** `AccountViewView.vue` treats `code === "UNIMPLEMENTED"` with a "not yet available on this device" message and any other error as "Could not schedule… try again."
- **Improvement:** Once the plugin implements and registers `cancelDualSchedule` on iOS, the app can:
- Keep handling `UNIMPLEMENTED` for older builds or platforms where the method is still missing.
- Optionally surface more specific errors (e.g. `code === "SCHEDULING_FAILED"` or message strings from the plugin) so the user gets clearer feedback when scheduling fails for a reason other than "not implemented."
- No change is strictly required for completion; the current flow is valid.
### 2.3 Turn-off flow
- The app already calls `DailyNotification.cancelDualSchedule()` when the user turns off New Activity (with a guard for `DailyNotification?.cancelDualSchedule`). Once the plugin implements and registers `cancelDualSchedule` on iOS (and optionally on Android), this will work without any app code change.
### 2.4 Edit time flow (New Activity)
- **Current:** When the user taps **Edit** and picks a new time, the app calls `scheduleNewActivityDualNotification(timeText)` (i.e. `scheduleDualNotification({ config })` again). See `editNewActivityNotification()` in `AccountViewView.vue` (~15041520).
- **Recommended:** Once the plugin implements `updateDualScheduleConfig` (see 1.5), the app should call **`updateDualScheduleConfig({ config })`** when the user saves a new time from the Edit dialog, with `config = buildDualScheduleConfig({ notifyTime: timeText })`. That makes the intent explicit ("update existing schedule") and avoids relying on replace semantics inside `scheduleDualNotification`. The app can keep a fallback to `scheduleDualNotification` when `updateDualScheduleConfig` is not available (e.g. older plugin version).
---
## 3. Summary table
| Where | What | Status / action |
|-------|------|------------------|
| **Plugin iOS** | `scheduleDualNotification` handler + registration | Done; fix bridge/build in app if still UNIMPLEMENTED. |
| **Plugin iOS** | Cron parsing in `calculateNextRunTime(from:)` | Done; real cron parsing (match Android semantics). |
| **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` | **Done;** persist dual config; in handleBackgroundFetch replace pending notification with resolved content. |
| **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Done; cancel BG task + dual user notification only; stable identifier. |
| **Plugin iOS** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
| **Plugin Android** | `cancelDualSchedule()` | Done; cancel dual schedules + WorkManager WORK_NAME_DUAL only. |
| **Plugin Android** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
| **Plugin Android** | Use `relationship` (contentTimeout / fallbackBehavior) | **Done;** persist dual config; in Worker at fire time (dual_notify_*) resolve config + cache and show resolved title/body. |
| **Plugin both** | Replace semantics for dual schedule | Done; stable dual identifier, replace before add. |
| **Plugin both** | Isolation of Daily Reminder vs New Activity | Done; cancelDualSchedule does not touch reminder_*. |
| **Consuming app** | Plugin linked and built for iOS | Verify dependency, `cap sync`, and build so native `scheduleDualNotification` is called. |
| **Consuming app** | Edit time: use `updateDualScheduleConfig` | In `editNewActivityNotification()`, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time; fallback to `scheduleDualNotification` if unavailable. |
| **Consuming app** | Error handling / UX | Optional: refine messages once plugin returns specific error codes. |
---
## 4. References
- **Plugin:** `ios/Plugin/DailyNotificationPlugin.swift` (scheduleDualNotification ~350379, scheduleBackgroundFetch/scheduleUserNotification ~731770, calculateNextRunTime ~767771, method list ~21952199), `ios/Plugin/DailyNotificationScheduleHelper.swift` (~98106), `src/definitions.ts` (DualScheduleConfiguration, cancelDualSchedule).
- **Android reference:** `android/.../DailyNotificationPlugin.kt` (scheduleDualNotification ~13691420, calculateNextRunTime ~23362378).
- **Consuming app:** `doc/plugin-feedback-ios-scheduleDualNotification.md`, `src/views/AccountViewView.vue` (~12371245, ~12591300, ~15011548 `editNewActivityNotification`), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/reminderIds.ts` (Daily Reminder vs New Activity IDs), `src/services/notifications/NativeNotificationService.ts` (Daily Reminder uses `scheduleDailyReminder` / `cancelDailyReminder`).

View File

@@ -0,0 +1,78 @@
# Consuming app handoff: iOS native fetcher + chained dual schedule
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
- **iOS** `NativeNotificationContentFetcher`style registration (`DailyNotificationPlugin.registerNativeFetcher`)
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; this file adds **app-side** steps not spelled out there.
---
## 1. iOS — register native fetcher before `configureNativeFetcher`
The plugin now **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
```swift
import CapacitorDailyNotification // actual product module name may match the Pod (e.g. CapacitorDailyNotification)
// After: import DailyNotificationPlugin if your target uses a different module name use the same module that exposes DailyNotificationPlugin.
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
```
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
- Conforms to `NativeNotificationContentFetcher`
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java` (`POST …/api/v2/report/plansLastUpdatedBetween`, starred plan IDs, JWT pool selection, aggregation copy, pagination / `last_acked_jwt_id` as in Java)
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript (optional; TS still persists `native_fetcher_config` UserDefaults key)
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS). Format matches Android: JSON array of strings.
---
## 2. iOS — `UNUserNotificationCenterDelegate` / rollover
Chained dual notifications set:
- `notification_id` = `org.timesafari.dailynotification.dual` (same stable identifier as before)
- `scheduled_time` = `NSNumber` (fire time in ms)
Ensure your existing `DailyNotificationDelivered` bridge still forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`** (not only from a custom payload). Foreground presentation handlers should read `notification.request.content.userInfo`.
---
## 3. Android — no API change for `setNativeFetcher`
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` and `configureNativeFetcher` from JS keep that flow.
**Behavior change:** the dual **notify** alarm is no longer scheduled at the initial `scheduleDualNotification` call; it is scheduled when **dual prefetch work finishes** (success or hard failure path), at `max(nextNotifyAt, now)` so late prefetch delays the notification.
---
## 4. Bump and sync
1. Bump **`@timesafari/daily-notification-plugin`** in the app `package.json`.
2. `npm install`
3. `npx cap sync ios && npx cap sync android`
4. iOS: `cd ios/App && pod install` (adjust path if your app uses a different `ios` layout)
5. Clean build in Xcode / Android Studio
---
## 5. QA focus
- **iOS:** Register fetcher **before** any `configureNativeFetcher` from the web layer; confirm `updateStarredPlans` is no longer `UNIMPLEMENTED`.
- **Both:** New Activity dual path: first notification should appear **after** prefetch for that cycle, not at a fixed time with stale API text.
- **Android:** Regression-test `cancelDualSchedule` and Daily Reminder (should remain independent).
---
## 6. Assumptions
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
- Module import name for the Capacitor iOS plugin follows your Pod (`CapacitorDailyNotification` in `CapacitorDailyNotification.podspec`).

View File

@@ -38,7 +38,7 @@ pnpm add @timesafari/daily-notification-plugin
```xml
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.timesafari.dailynotification.fetch</string>
<string>org.timesafari.dailynotification.fetch</string>
</array>
```
@@ -49,7 +49,7 @@ import BackgroundTasks
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.timesafari.dailynotification.fetch",
BGTaskScheduler.shared.register(forTaskWithIdentifier: "org.timesafari.dailynotification.fetch",
using: nil) { task in
// Handle background fetch task
}
@@ -64,9 +64,14 @@ func application(_ application: UIApplication,
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
```
> **Note on `USE_EXACT_ALARM`:** The `USE_EXACT_ALARM` permission is restricted
> by Google on Android. Apps that declare it must be primarily dedicated to alarm
> or calendar functionality. Google will reject apps from the Play Store that use
> this permission for other purposes. This plugin uses `SCHEDULE_EXACT_ALARM`
> instead, which is sufficient for scheduling daily notifications.
2. **Register WorkManager in `Application.kt`:**
```kotlin

View File

@@ -0,0 +1,280 @@
# Documentation Consolidation Source Map
**Date:** 2025-12-16
**Purpose:** Complete audit trail of all markdown file destinations during consolidation
**Total Files Mapped:** 139
This document guarantees no information loss by tracking every file's destination.
---
## Legend
- **Canonical**: File kept in active documentation, possibly edited/merged
- **Merged**: Content incorporated into canonical document, original archived
- **Archived**: File preserved verbatim in archive, referenced from index
---
## Root Canonical Files (Keep As-Is)
**Note (2025-03):** The following were moved after consolidation: `TODO.md``doc/progress/TODO.md`; `TODAY_SUMMARY.md``doc/progress/TODAY_SUMMARY.md`; `SESSION_RECONSTITUTION.md``doc/progress/SESSION_RECONSTITUTION.md`; `BATCH_A_COMPLETION_SUMMARY.md``doc/progress/BATCH_A_COMPLETION_SUMMARY.md`; `PR_DESCRIPTION.md``doc/_archive/PR_DESCRIPTION.md`; `MERGE_READY_SUMMARY.md``doc/_archive/MERGE_READY_SUMMARY.md`.
| Original Path | Status | Notes |
|--------------|--------|-------|
| `README.md` | Canonical | Main entry point, will link to doc/00-INDEX.md |
| `ARCHITECTURE.md` | Canonical | Foundational architecture document |
| `BUILDING.md` | Canonical | Build instructions |
| `CHANGELOG.md` | Canonical | Version history |
| `CONTRIBUTING.md` | Canonical | Contribution guidelines |
| `SECURITY.md` | Canonical | Security documentation |
| `API.md` | Canonical | API reference |
| `USAGE.md` | Canonical | Usage guide |
| `TODO.md` | Moved | → `doc/progress/TODO.md` |
| `PR_DESCRIPTION.md` | Moved | → `doc/_archive/PR_DESCRIPTION.md` |
| `MERGE_READY_SUMMARY.md` | Moved | → `doc/_archive/MERGE_READY_SUMMARY.md` |
---
## Integration Documentation (Consolidate to `doc/integration/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `INTEGRATION_GUIDE.md` | `doc/integration/INTEGRATION_GUIDE.md` | Canonical | Primary integration guide |
| `QUICK_INTEGRATION.md` | `doc/integration/QUICK_START.md` | Canonical | Quick start guide |
| `AI_INTEGRATION_GUIDE.md` | `doc/ai/AI_INTEGRATION_GUIDE.md` | Canonical | AI-specific integration |
| `doc/INTEGRATION_CHECKLIST.md` | `doc/integration/CHECKLIST.md` | Merged | Merge into INTEGRATION_GUIDE.md |
| `doc/INTEGRATION_REFACTOR_CONTEXT.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge context into refactor notes |
| `doc/INTEGRATION_REFACTOR_QUICK_START.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge into refactor notes |
| `doc/aar-integration-troubleshooting.md` | `doc/integration/TROUBLESHOOTING.md` | Merged | Merge into troubleshooting guide |
| `doc/integration-point-refactor-analysis.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge into refactor notes |
---
## Legacy Documentation (Archive to `doc/archive/2025-legacy-doc/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `doc/BACKGROUND_DATA_FETCHING_PLAN.md` | `doc/archive/2025-legacy-doc/BACKGROUND_DATA_FETCHING_PLAN.md` | Archived | Historical planning doc |
| `doc/BUILD_FIXES_SUMMARY.md` | `doc/archive/2025-legacy-doc/BUILD_FIXES_SUMMARY.md` | Archived | Historical build fixes |
| `doc/BUILD_SCRIPT_IMPROVEMENTS.md` | `doc/archive/2025-legacy-doc/BUILD_SCRIPT_IMPROVEMENTS.md` | Archived | Historical build improvements |
| `doc/directives/0001-Daily-Notification-Plugin-Implementation-Directive.md` | `doc/archive/2025-legacy-doc/directives/0001-Daily-Notification-Plugin-Implementation-Directive.md` | Archived | Historical directive |
| `doc/directives/0002-Daily-Notification-Plugin-Recommendations.md` | `doc/archive/2025-legacy-doc/directives/0002-Daily-Notification-Plugin-Recommendations.md` | Archived | Historical recommendations |
| `doc/directives/0003-iOS-Android-Parity-Directive.md` | `doc/archive/2025-legacy-doc/directives/0003-iOS-Android-Parity-Directive.md` | Archived | Historical directive |
| `doc/implementation-roadmap.md` | `doc/archive/2025-legacy-doc/implementation-roadmap.md` | Archived | Historical roadmap |
| `doc/IOS_ANDROID_ERROR_CODE_MAPPING.md` | `doc/archive/2025-legacy-doc/IOS_ANDROID_ERROR_CODE_MAPPING.md` | Archived | Historical mapping |
| `doc/IOS_PHASE1_FINAL_SUMMARY.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_FINAL_SUMMARY.md` | Archived | Historical summary |
| `doc/IOS_PHASE1_GAPS_ANALYSIS.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_GAPS_ANALYSIS.md` | Archived | Historical analysis |
| `doc/IOS_PHASE1_IMPLEMENTATION_CHECKLIST.md` | `doc/platform/ios/IMPLEMENTATION_CHECKLIST.md` | Merged | Promote to canonical iOS docs |
| `doc/IOS_PHASE1_QUICK_REFERENCE.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_QUICK_REFERENCE.md` | Archived | Historical quick reference |
| `doc/IOS_PHASE1_READY_FOR_TESTING.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_READY_FOR_TESTING.md` | Archived | Historical testing status |
| `doc/IOS_PHASE1_TESTING_GUIDE.md` | `doc/testing/IOS_PHASE1_TESTING_GUIDE.md` | Merged | Promote to testing docs |
| `doc/IOS_TEST_APP_SETUP_GUIDE.md` | `doc/testing/IOS_TEST_APP_SETUP.md` | Merged | Promote to testing docs |
| `doc/migration-guide.md` | `doc/platform/ios/MIGRATION_GUIDE.md` | Merged | Promote to canonical iOS docs |
| `doc/notification-system.md` | `doc/archive/2025-legacy-doc/notification-system.md` | Archived | Historical system doc |
| `doc/PHASE1_COMPLETION_SUMMARY.md` | `doc/archive/2025-legacy-doc/PHASE1_COMPLETION_SUMMARY.md` | Archived | Historical summary |
| `doc/RESEARCH_COMPLETE.md` | `doc/archive/2025-legacy-doc/RESEARCH_COMPLETE.md` | Archived | Historical research doc |
| `doc/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md` | `doc/design/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md` | Canonical | Promote to design docs (large, relevant) |
| `doc/test-app-ios/ENHANCEMENTS_APPLIED.md` | `doc/archive/2025-legacy-doc/test-app-ios/ENHANCEMENTS_APPLIED.md` | Archived | Historical enhancements |
| `doc/test-app-ios/IOS_LOGGING_GUIDE.md` | `doc/testing/IOS_LOGGING_GUIDE.md` | Merged | Promote to testing docs |
| `doc/test-app-ios/IOS_PREFETCH_GLOSSARY.md` | `doc/platform/ios/PREFETCH_GLOSSARY.md` | Merged | Promote to iOS docs |
| `doc/test-app-ios/IOS_PREFETCH_TESTING.md` | `doc/testing/IOS_PREFETCH_TESTING.md` | Merged | Promote to testing docs |
| `doc/test-app-ios/IOS_TEST_APP_REQUIREMENTS.md` | `doc/testing/IOS_TEST_APP_REQUIREMENTS.md` | Merged | Promote to testing docs |
| `doc/UI_REQUIREMENTS.md` | `doc/archive/2025-legacy-doc/UI_REQUIREMENTS.md` | Archived | Historical requirements |
---
## Platform Documentation - iOS (Consolidate to `doc/platform/ios/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `doc/IOS_IMPLEMENTATION_CHECKLIST.md` | `doc/platform/ios/IMPLEMENTATION_CHECKLIST.md` | Canonical | Primary iOS checklist |
| `doc/ios-implementation-directive.md` | `doc/platform/ios/IMPLEMENTATION_DIRECTIVE.md` | Canonical | iOS implementation directive |
| `doc/IOS_IMPLEMENTATION_DOCUMENTATION_REVIEW.md` | `doc/platform/ios/DOCUMENTATION_REVIEW.md` | Canonical | Documentation review |
| `doc/ios-core-data-migration.md` | `doc/platform/ios/CORE_DATA_MIGRATION.md` | Canonical | Core Data migration guide |
| `doc/ios-recovery-scenario-mapping.md` | `doc/platform/ios/RECOVERY_SCENARIO_MAPPING.md` | Canonical | Recovery scenario mapping |
| `doc/ios-rollover-edge-case-plan.md` | `doc/platform/ios/ROLLOVER_EDGE_CASES.md` | Canonical | Rollover edge cases |
| `doc/ios-rollover-implementation-review.md` | `doc/platform/ios/ROLLOVER_IMPLEMENTATION_REVIEW.md` | Canonical | Rollover implementation review |
| `doc/ios-rollover-open-questions-answers.md` | `doc/platform/ios/ROLLOVER_QA.md` | Canonical | Rollover Q&A |
| `doc/ios-troubleshooting-guide.md` | `doc/platform/ios/TROUBLESHOOTING.md` | Canonical | iOS troubleshooting |
---
## Platform Documentation - Android (Consolidate to `doc/platform/android/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `doc/android-implementation-directive.md` | `doc/platform/android/IMPLEMENTATION_DIRECTIVE.md` | Canonical | Primary Android directive |
| `doc/android-implementation-directive-phase1.md` | `doc/platform/android/PHASE1_DIRECTIVE.md` | Canonical | Phase 1 directive |
| `doc/android-implementation-directive-phase2.md` | `doc/platform/android/PHASE2_DIRECTIVE.md` | Canonical | Phase 2 directive |
| `doc/android-implementation-directive-phase3.md` | `doc/platform/android/PHASE3_DIRECTIVE.md` | Canonical | Phase 3 directive |
| `doc/android-alarm-persistence-directive.md` | `doc/platform/android/ALARM_PERSISTENCE_DIRECTIVE.md` | Canonical | Alarm persistence directive |
| `doc/android-app-analysis.md` | `doc/platform/android/APP_ANALYSIS.md` | Canonical | App analysis |
| `doc/android-app-improvement-plan.md` | `doc/platform/android/APP_IMPROVEMENT_PLAN.md` | Canonical | App improvement plan |
| `android/BUILDING.md` | `doc/platform/android/BUILDING.md` | Canonical | Android build guide |
| `android/DATABASE_CONSOLIDATION_PLAN.md` | `doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md` | Canonical | Database consolidation plan |
---
## Testing Documentation (Consolidate to `doc/testing/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `doc/comprehensive-testing-guide-v2.md` | `doc/testing/COMPREHENSIVE_GUIDE.md` | Canonical | Primary testing guide |
| `doc/testing-quick-reference.md` | `doc/testing/QUICK_REFERENCE.md` | Canonical | Quick reference |
| `doc/testing-quick-reference-v2.md` | `doc/testing/QUICK_REFERENCE.md` | Merged | Merge into QUICK_REFERENCE.md |
| `doc/manual_smoke_test.md` | `doc/testing/MANUAL_SMOKE_TEST.md` | Canonical | Manual smoke test |
| `doc/notification-testing-procedures.md` | `doc/testing/NOTIFICATION_PROCEDURES.md` | Canonical | Notification testing |
| `doc/reboot-testing-procedure.md` | `doc/testing/REBOOT_PROCEDURE.md` | Canonical | Reboot testing |
| `doc/reboot-testing-steps.md` | `doc/testing/REBOOT_PROCEDURE.md` | Merged | Merge into REBOOT_PROCEDURE.md |
| `doc/boot-receiver-testing-guide.md` | `doc/testing/BOOT_RECEIVER_GUIDE.md` | Canonical | Boot receiver testing |
| `doc/standalone-emulator-guide.md` | `doc/testing/EMULATOR_GUIDE.md` | Canonical | Emulator guide |
| `doc/localhost-testing-guide.md` | `doc/testing/LOCALHOST_GUIDE.md` | Canonical | Localhost testing |
---
## Alarm System Documentation (Keep in `doc/alarms/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `doc/alarms/000-UNIFIED-ALARM-DIRECTIVE.md` | `doc/alarms/000-UNIFIED-ALARM-DIRECTIVE.md` | Canonical | Keep as-is |
| `doc/alarms/01-platform-capability-reference.md` | `doc/alarms/01-platform-capability-reference.md` | Canonical | Keep as-is |
| `doc/alarms/02-plugin-behavior-exploration.md` | `doc/alarms/02-plugin-behavior-exploration.md` | Canonical | Keep as-is |
| `doc/alarms/03-plugin-requirements.md` | `doc/alarms/03-plugin-requirements.md` | Canonical | Keep as-is |
| `doc/alarms/ACTIVATION-GUIDE.md` | `doc/alarms/ACTIVATION-GUIDE.md` | Canonical | Keep as-is |
| `doc/alarms/PHASE1-EMULATOR-TESTING.md` | `doc/alarms/PHASE1-EMULATOR-TESTING.md` | Canonical | Keep as-is |
| `doc/alarms/PHASE1-VERIFICATION.md` | `doc/alarms/PHASE1-VERIFICATION.md` | Canonical | Keep as-is |
| `doc/alarms/PHASE2-EMULATOR-TESTING.md` | `doc/alarms/PHASE2-EMULATOR-TESTING.md` | Canonical | Keep as-is |
| `doc/alarms/PHASE2-VERIFICATION.md` | `doc/alarms/PHASE2-VERIFICATION.md` | Canonical | Keep as-is |
| `doc/alarms/PHASE3-EMULATOR-TESTING.md` | `doc/alarms/PHASE3-EMULATOR-TESTING.md` | Canonical | Keep as-is |
| `doc/alarms/PHASE3-VERIFICATION.md` | `doc/alarms/PHASE3-VERIFICATION.md` | Canonical | Keep as-is |
---
## AI / ChatGPT Documentation (Consolidate to `doc/ai/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `chatgpt-assessment-package.md` | `doc/ai/chatgpt-assessment-package.md` | Canonical | AI artifacts |
| `chatgpt-files-overview.md` | `doc/ai/chatgpt-files-overview.md` | Canonical | AI artifacts |
| `chatgpt-improvement-directives-template.md` | `doc/ai/chatgpt-improvement-directives-template.md` | Canonical | AI artifacts |
| `code-summary-for-chatgpt.md` | `doc/ai/code-summary-for-chatgpt.md` | Canonical | AI artifacts |
| `key-code-snippets-for-chatgpt.md` | `doc/ai/key-code-snippets-for-chatgpt.md` | Canonical | AI artifacts |
| `doc/chatgpt-analysis-guide.md` | `doc/ai/chatgpt-analysis-guide.md` | Canonical | AI artifacts |
---
## Design & Research Documentation (Consolidate to `doc/design/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `doc/exploration-findings-initial.md` | `doc/design/exploration-findings-initial.md` | Canonical | Design research |
| `doc/explore-alarm-behavior-directive.md` | `doc/design/explore-alarm-behavior-directive.md` | Canonical | Design research |
| `doc/improve-alarm-directives.md` | `doc/design/improve-alarm-directives.md` | Canonical | Design research |
| `doc/plugin-behavior-exploration-template.md` | `doc/design/plugin-behavior-exploration-template.md` | Canonical | Design template |
---
## Deployment Documentation (Keep in `doc/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `DEPLOYMENT_CHECKLIST.md` | `doc/DEPLOYMENT_CHECKLIST.md` | Canonical | Move to doc/ |
| `DEPLOYMENT_SUMMARY.md` | `doc/DEPLOYMENT_SUMMARY.md` | Canonical | Move to doc/ |
| `doc/deployment-guide.md` | `doc/DEPLOYMENT_GUIDE.md` | Canonical | Primary deployment guide |
---
## Feature-Specific Documentation (Keep in `doc/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `doc/CROSS_PLATFORM_STORAGE_PATTERN.md` | `doc/CROSS_PLATFORM_STORAGE_PATTERN.md` | Canonical | Keep as-is |
| `doc/DATABASE_INTERFACES.md` | `doc/DATABASE_INTERFACES.md` | Canonical | Keep as-is |
| `doc/DATABASE_INTERFACES_IMPLEMENTATION.md` | `doc/DATABASE_INTERFACES_IMPLEMENTATION.md` | Canonical | Keep as-is |
| `doc/NATIVE_FETCHER_CONFIGURATION.md` | `doc/NATIVE_FETCHER_CONFIGURATION.md` | Canonical | Keep as-is |
| `doc/platform-capability-reference.md` | `doc/platform-capability-reference.md` | Canonical | Keep as-is |
| `doc/plugin-requirements-implementation.md` | `doc/plugin-requirements-implementation.md` | Canonical | Keep as-is |
| `doc/prefetch-scheduling-diagnosis.md` | `doc/prefetch-scheduling-diagnosis.md` | Canonical | Keep as-is |
| `doc/prefetch-scheduling-trace.md` | `doc/prefetch-scheduling-trace.md` | Canonical | Keep as-is |
| `doc/app-startup-recovery-solution.md` | `doc/app-startup-recovery-solution.md` | Canonical | Keep as-is |
| `doc/getting-valid-plan-ids.md` | `doc/getting-valid-plan-ids.md` | Canonical | Keep as-is |
| `doc/host-request-configuration.md` | `doc/host-request-configuration.md` | Canonical | Keep as-is |
| `doc/hydrate-plan-implementation-guide.md` | `doc/hydrate-plan-implementation-guide.md` | Canonical | Keep as-is |
| `doc/user-zero-stars-implementation.md` | `doc/user-zero-stars-implementation.md` | Canonical | Keep as-is |
| `doc/accessibility-localization.md` | `doc/accessibility-localization.md` | Canonical | Keep as-is |
| `doc/legal-store-compliance.md` | `doc/legal-store-compliance.md` | Canonical | Keep as-is |
| `doc/observability-dashboards.md` | `doc/observability-dashboards.md` | Canonical | Keep as-is |
| `doc/file-organization-summary.md` | `doc/file-organization-summary.md` | Canonical | Keep as-is |
| `doc/capacitor-platform-service-clean-changes.md` | `doc/capacitor-platform-service-clean-changes.md` | Canonical | Keep as-is |
---
## Test App Documentation (Keep with Test Apps, Index in `doc/testing/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `test-apps/BUILD_PROCESS.md` | `test-apps/BUILD_PROCESS.md` | Canonical | Keep with test apps |
| `test-apps/android-test-app/doc/PHASE1_TEST0_GOLDEN.md` | `test-apps/android-test-app/doc/PHASE1_TEST0_GOLDEN.md` | Canonical | Keep with test apps |
| `test-apps/android-test-app/doc/PHASE1_TEST1_GOLDEN.md` | `test-apps/android-test-app/doc/PHASE1_TEST1_GOLDEN.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/doc/BUILD_QUICK_REFERENCE.md` | `test-apps/daily-notification-test/doc/BUILD_QUICK_REFERENCE.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/doc/NOTIFICATION_STACK_IMPROVEMENT_PLAN.md` | `test-apps/daily-notification-test/doc/NOTIFICATION_STACK_IMPROVEMENT_PLAN.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/doc/PLUGIN_DETECTION_GUIDE.md` | `test-apps/daily-notification-test/doc/PLUGIN_DETECTION_GUIDE.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/doc/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md` | `test-apps/daily-notification-test/doc/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md` | `test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md` | `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md` | `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/README.md` | `test-apps/daily-notification-test/README.md` | Canonical | Keep with test apps |
| `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` | `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/BUILD_NOTES.md` | `test-apps/ios-test-app/BUILD_NOTES.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/BUILD_SUCCESS.md` | `test-apps/ios-test-app/BUILD_SUCCESS.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/COMPILATION_FIXES.md` | `test-apps/ios-test-app/COMPILATION_FIXES.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/COMPILATION_STATUS.md` | `test-apps/ios-test-app/COMPILATION_STATUS.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/COMPILATION_SUMMARY.md` | `test-apps/ios-test-app/COMPILATION_SUMMARY.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/README.md` | `test-apps/ios-test-app/README.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/SETUP_COMPLETE.md` | `test-apps/ios-test-app/SETUP_COMPLETE.md` | Canonical | Keep with test apps |
| `test-apps/ios-test-app/SETUP_STATUS.md` | `test-apps/ios-test-app/SETUP_STATUS.md` | Canonical | Keep with test apps |
---
## Plugin-Specific Documentation (Keep in `ios/Plugin/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `ios/Plugin/README.md` | `ios/Plugin/README.md` | Canonical | Keep with plugin code |
---
## Cursor Rules Documentation (Keep in `.cursor/rules/`)
| Original Path | New Path | Status | Notes |
|--------------|----------|--------|-------|
| `.cursor/rules/README.md` | `.cursor/rules/README.md` | Canonical | Keep with cursor rules |
| `.cursor/rules/architecture/README.md` | `.cursor/rules/architecture/README.md` | Canonical | Keep with cursor rules |
| `.cursor/rules/meta_rule_architecture.md` | `.cursor/rules/meta_rule_architecture.md` | Canonical | Keep with cursor rules |
---
## Summary Statistics
- **Total Files:** 139
- **Canonical (Active):** ~95 files
- **Merged:** ~15 files
- **Archived:** ~29 files
---
## Verification Checklist
- [ ] All 139 files have a destination
- [ ] No file is marked for deletion
- [ ] All merged content is traceable
- [ ] Archive structure preserves original paths
- [ ] Index references all canonical files
- [ ] README.md links to doc/00-INDEX.md
---
**Last Updated:** 2025-12-16
**Status:** Complete - Ready for Implementation

View File

@@ -50,7 +50,7 @@ fi
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"
# Launch app
xcrun simctl launch "$SIMULATOR_ID" com.timesafari.dailynotification.test
xcrun simctl launch "$SIMULATOR_ID" org.timesafari.dailynotification.test
```
**Result:** ✅ Simulator now boots and app launches automatically

View File

@@ -94,7 +94,7 @@ po UNUserNotificationCenter.current().pendingNotificationRequests()
po await UNUserNotificationCenter.current().notificationSettings()
// Manually trigger BGTask (Simulator only)
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.timesafari.dailynotification.fetch"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"org.timesafari.dailynotification.fetch"]
```
---

View File

@@ -196,7 +196,7 @@ DailyNotificationScheduler: Scheduling notification: [id]
**Solution:** Use simulator-only LLDB command:
```swift
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.timesafari.dailynotification.fetch"]
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"org.timesafari.dailynotification.fetch"]
```
### Notifications Not Delivering

Some files were not shown because too many files have changed in this diff Show More