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)
This commit is contained in:
Jose Olarte III
2026-03-18 21:10:49 +08:00
parent 7a1e58a4b6
commit 7b41ca9e0b
7 changed files with 464 additions and 44 deletions

View File

@@ -2,6 +2,8 @@
**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`).
---
@@ -42,17 +44,50 @@ The completion plan below assumes this separation. Any new plugin code (e.g. iOS
- `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)
### 1.3 Use `relationship` (contentTimeout + fallbackBehavior) — implemented
- **Content fetch:** Already runs in `handleBackgroundFetch` and can use the native fetcher; it stores content (e.g. via state actor / storage). No change needed for "run fetch at contentFetch.schedule."
- **User notification at userNotification.schedule:** Currently `scheduleUserNotification(config:)` (lines 741764) builds a notification from **config only** (title/body) and does not:
- Read cached content from the fetch,
- Apply `relationship.contentTimeout` (wait up to N ms for content),
- Apply `relationship.fallbackBehavior` (`show_default` vs skip vs retry).
- **Required:** When the user-notification time fires (or when the notification is prepared), resolve title/body by:
- Preferring cached content from the content fetch if it exists and is within `contentTimeout`;
- Else using `userNotification.title` / `userNotification.body` when `fallbackBehavior === "show_default"`.
- **Parsing:** Pass `relationship` from `scheduleDualNotification` into the code path that schedules and/or shows the user notification so timeout and fallback behavior are respected.
- **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
@@ -73,9 +108,9 @@ The completion plan below assumes this separation. Any new plugin code (e.g. iOS
- **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 check
### 1.6 Android parity
- **cancelDualSchedule:** Android does not expose a dedicated `cancelDualSchedule`; it has `cancelAllNotifications` and WorkManager tag cancellation. For parity with the app's "turn off New Activity" flow, consider adding a `cancelDualSchedule` plugin method on Android that cancels **only** the dual-schedule work (e.g. the same tags used by `scheduleDualNotification`) and **not** the Daily Reminder schedule. That way turning off New Activity does not affect the user's Daily Reminder. Otherwise the app's call to `cancelDualSchedule()` may get `UNIMPLEMENTED` on Android too if the TS layer forwards it to native.
- **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).
---
@@ -114,14 +149,15 @@ The completion plan below assumes this separation. Any new plugin code (e.g. iOS
| 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:)` | Replace stub with real parsing (match Android semantics). |
| **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` when showing user notification | Implement: prefer cached content, else default title/body. |
| **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Add handler and method registration; cancel BG task + dual user notifications only (use dedicated dual notification identifier; do not affect Daily Reminder). |
| **Plugin iOS** | `updateDualScheduleConfig(config)` | Implement for Edit-time use case; update existing dual schedule (e.g. cancel then schedule with new config). Use stable dual identifier so no duplicates. |
| **Plugin Android** | `cancelDualSchedule()` (if app calls it) | Add if not present; cancel only dual-schedule work, not Daily Reminder. |
| **Plugin Android** | `updateDualScheduleConfig(config)` | Implement for Edit-time use case; same semantics as iOS. |
| **Plugin both** | Replace semantics for dual schedule | When a dual schedule exists, calling `scheduleDualNotification` again or `updateDualScheduleConfig` must replace it, not add a second (no duplicate notifications). |
| **Plugin both** | Isolation of Daily Reminder vs New Activity | cancelDualSchedule must not touch reminder_* / Daily Reminder; cancelDailyReminder must not touch dual schedule. |
| **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. |