# New Activity Notifications: iOS Parity with Android **Purpose:** Describe what is required for **iOS** to match **Android** for the daily-notification-plugin **API-driven “New Activity”** flow (`scheduleDualNotification` / `cancelDualSchedule`, with prefetch and Endorser-backed content). The canonical product behavior is documented in `doc/notification-from-api-call.md` and `doc/notification-new-activity-lay-of-the-land.md`. **Plugin source of truth:** The Capacitor package is `@timesafari/daily-notification-plugin`, pulled from the official remote in `package.json` (`git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git`). Plugin development happens in that repository; this app bumps the dependency and runs `npm install` / `npx cap sync` after releases. --- ## 1. What “parity” means here | Concern | Intended behavior | |--------|---------------------| | **Scheduling** | Dual schedule: prefetch job **before** notify time (app uses cron T−5 minutes), then user-visible notification at the chosen time. | | **API content** | Prefetch calls the **same Endorser semantics** as the Android host: **`plansLastUpdatedBetween`** (POST) with **starred plan IDs**, JWT auth, aggregated titles/bodies consistent with `TimeSafariNativeFetcher`. | | **Starred plans** | `updateStarredPlans({ planIds })` from the app must affect what the native prefetch queries. | | **Configure** | `configureNativeFetcher({ apiBaseUrl, activeDid, jwtToken, … })` supplies credentials the native layer uses for prefetch. | | **Lifecycle** | `cancelDualSchedule()` removes the dual prefetch + notify schedule without breaking the separate Daily Reminder. | Platform differences (iOS **BGTaskScheduler** is opportunistic; Android **alarms/WorkManager** can be more exact) mean **timing** may never be identical, but **API behavior and user-visible copy** should align. --- ## 2. Current state: Android (this app) - **Host native fetcher:** `android/.../TimeSafariNativeFetcher.java` implements the plugin’s `NativeNotificationContentFetcher` and calls **`POST …/api/v2/report/plansLastUpdatedBetween`** using starred plan IDs (via plugin storage from `updateStarredPlans`). - **Registration:** `MainActivity` calls `DailyNotificationPlugin.setNativeFetcher(new TimeSafariNativeFetcher(this))`. - **Plugin (Android) — older notes:** Prior dual-schedule issues (native fetcher / fetch cron) are addressed in **plugin ≥ 3.0.0** (chained dual: notify after prefetch). Historical analysis: `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md`. --- ## 3. Current state: iOS (this app + bundled plugin) ### 3.1 This repository - **iOS native fetcher:** `ios/App/App/TimeSafariNativeFetcher.swift` implements `NativeNotificationContentFetcher` (Endorser `plansLastUpdatedBetween`, same prefs keys as Java). **`AppDelegate`** calls `DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)` at launch **before** any `configureNativeFetcher` from JS (see plugin `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` and **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**). - **JS/TS is already shared:** `nativeFetcherConfig.ts`, `dualScheduleConfig.ts`, `syncStarredPlansToNativePlugin.ts`, and `AccountViewView.vue` call the same APIs on both platforms. - **Info.plist** already lists `UIBackgroundModes` (fetch, processing) and `BGTaskSchedulerPermittedIdentifiers` for the plugin’s task IDs. Xcode **Signing & Capabilities** should still enable **Background fetch** and **Background processing** (see `doc/daily-notification-plugin-integration.md`). - **AppDelegate** posts `DailyNotificationDelivered` for foreground presentation—aligned with plugin rollover behavior. ### 3.2 Bundled plugin (`node_modules/@timesafari/daily-notification-plugin`, iOS) Requires **plugin ≥ 3.0.0** (register native fetcher, chained dual, iOS `updateStarredPlans`). Version pinned in `ios/App/Podfile.lock` after `pod install`. - **`scheduleDualNotification` / `cancelDualSchedule`** — see plugin release notes; clean sync + `pod install` if you see `UNIMPLEMENTED` (`doc/plugin-feedback-ios-scheduleDualNotification.md`). - **`configureNativeFetcher`** — **requires** `DailyNotificationPlugin.registerNativeFetcher` first; the host Swift fetcher performs **`plansLastUpdatedBetween`** (plugin does not use in-plugin `offers` GET when a fetcher is registered—mirrors Android). - **`updateStarredPlans`** — implemented on iOS in current plugin; persists **`daily_notification_timesafari.starredPlanIds`** for the host fetcher. - **Chained dual** — user notification is armed **after** prefetch for that cycle (plugin); iOS remains subject to BG scheduling limits; see **§3.3**. ### 3.3 Prefetch before notify (ordering, not cron) iOS has no system cron; the app/plugin may still **parse** cron to compute “next run” times. The hard part is **ordering**: if **prefetch** is driven by **`BGTaskScheduler`** (opportunistic) and **notify** by **`UNUserNotificationCenter`** at a fixed time **T**, those are **independent**. The OS can deliver the local notification at **T** while prefetch runs **after** **T** or not at all—so the awkward case (notify first, prefetch later, stale or fallback content) **can** happen. Two peer timers do **not** imply “fetch always completes before **T**.” To **enforce** prefetch-before-notify as a rule, use **chaining**, not two unrelated schedules: - After prefetch for that cycle **finishes** (success or explicit timeout policy), **then** schedule or **replace** the pending `UNNotificationRequest` for time **T** with the resolved title/body (or fallback). Until then, do not arm a user-visible notification that claims fresh API content. - **Tradeoffs:** If prefetch is late, the notification may be **late**; if prefetch never runs before a deadline, use **fallback** copy at **T** or skip—product choice. - **Parsing cron** remains useful to compute **T** and to decide when to **submit** BG work; **ordering** is a **pipeline** decision (fetch → cache → arm notify), not “BG at T−5 and UN at **T** both scheduled up front.” Plugin work item **§4A.3** should reflect this: document the chosen strategy (chained arm vs best-effort dual timer) and how it interacts with `relationship.contentTimeout` / fallback. --- ## 4. Work breakdown ### 4A. Plugin (`daily-notification-plugin`) — status (v3.x) Items below were the original gap list; **plugin ≥ 3.0.0** ships **iOS** `updateStarredPlans`, **`registerNativeFetcher`**, **chained dual** on iOS and Android, and Android dual-path fixes. Remaining work is **release coordination** (bump, sync, QA), not greenfield plugin implementation. 1. **`updateStarredPlans` on iOS** — shipped in current plugin. 2. **iOS `plansLastUpdatedBetween` / host fetcher** — shipped: host registers **`TimeSafariNativeFetcher`** (Swift); plugin does not duplicate Endorser logic when a fetcher is registered. 3. **Dual schedule / chaining** — shipped (notify after prefetch; see plugin release notes and **§3.3**). 4. **Android dual path** — chained dual + native fetcher alignment in current plugin (see `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` for historical context). 5. **JWT pool / expiry (Phase B)** - **App:** Phase B is already implemented: `configureNativeFetcherIfReady()` passes `jwtTokens` from `mintBackgroundJwtTokenPool` on **both** iOS and Android (`src/services/notifications/nativeFetcherConfig.ts`). - **Android:** `TimeSafariNativeFetcher` selects a bearer from the pool for background requests (`doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md`). - **iOS:** The bundled plugin’s `configureNativeFetcher` **already accepts and persists** `jwtTokens` / `jwtTokenPoolJson`, and the in-plugin fetch path uses a bearer from the primary token or pool. What is **not** yet at parity with Android is **which API** that token is used for (`offers` GET vs `plansLastUpdatedBetween` + starred plans)—that falls under **§4A.2**, not “waiting for Phase B on iOS.” - **Expiry:** Re-calling `configureNativeFetcherIfReady` on foreground / Account (see `notification-from-api-call.md`) remains relevant on both platforms. ### 4B. This app (crowd-funder-for-time-pwa) — after or alongside plugin changes 1. **Bump `@timesafari/daily-notification-plugin`** to **≥ 3.0.0** via the git dependency in `package.json`, run `npm install`, `npx cap sync ios`, `cd ios/App && pod install`, clean build (`doc/plugin-feedback-ios-scheduleDualNotification.md`, **`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`**). 2. **iOS native fetcher** — **Done:** `TimeSafariNativeFetcher.swift` + `registerNativeFetcher` in `AppDelegate` (see handoff doc). 3. **Re-test** `syncStarredPlansToNativePlugin` on iOS; the helper may still catch `UNIMPLEMENTED` for older plugin binaries. 4. **Xcode:** Confirm Background Modes capabilities match `Info.plist`. 5. **QA:** Full matrix in `doc/notification-from-api-call.md` (enable/disable, empty starred list, JWT expiry, foreground/background); chained dual timing (notify after prefetch). ### 4C. Related product bug (both platforms) - **`PushNotificationPermission.vue` vs New Activity:** Enabling New Activity can still schedule the **single** daily reminder by mistake; turning New Activity off may not cancel that reminder. See `doc/notification-new-activity-lay-of-the-land.md`. Fixing this is orthogonal to iOS/Android API parity but affects perceived “notifications behavior.” --- ## 5. Reference map (this repo) | Topic | Document | |-------|-----------| | Plugin post-bump handoff (iOS fetcher + chained dual) | `doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md` | | Feature plan & file list | `doc/notification-from-api-call.md` | | Dual vs Daily Reminder confusion | `doc/notification-new-activity-lay-of-the-land.md` | | iOS `UNIMPLEMENTED` / PluginHeaders | `doc/plugin-feedback-ios-scheduleDualNotification.md` | | Android dual schedule + native fetcher | `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` | | Integration & Xcode | `doc/daily-notification-plugin-integration.md` | | Android host fetcher | `android/.../TimeSafariNativeFetcher.java`, `MainActivity.java` | --- ## 6. Handoff to plugin repo (Cursor / isolated workspace) Use this section when **daily-notification-plugin** is open **without** the TimeSafari app tree, so implementers do not depend on paths that only exist in crowd-funder-for-time-pwa. ### 6.1 Bring reference material into scope | Source (this app repo) | Why | |------------------------|-----| | `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java` | **Canonical Endorser behavior** for New Activity: POST body, pagination, aggregation copy, prefs keys for starred IDs and `last_acked_jwt_id`. Copy or open alongside the plugin when implementing iOS fetch or `setNativeFetcher`. | | `src/services/notifications/dualScheduleConfig.ts` | Shape the app sends to `scheduleDualNotification` (`buildDualScheduleConfig`). | | `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` | Android plugin: dual path must call native fetcher at fetch cron. | | `doc/plugin-feedback-ios-scheduleDualNotification.md` | iOS `UNIMPLEMENTED` / PluginHeaders troubleshooting. | In the plugin repo itself, align with **`src/definitions.ts`** (`DualScheduleConfiguration`, `configureNativeFetcher`, `updateStarredPlans`) and **INTEGRATION_GUIDE** if present. ### 6.2 HTTP / storage contract (match `TimeSafariNativeFetcher`) Implementations on **iOS** (in-plugin Swift or host `NativeNotificationContentFetcher`) should match this **unless** product explicitly changes: - **Method & path:** `POST` `{apiBaseUrl}/api/v2/report/plansLastUpdatedBetween` (no trailing slash mismatch on `apiBaseUrl`). - **Headers:** `Content-Type: application/json`, `Authorization: Bearer {token}` (token from `jwtToken` or **JWT pool** selection—see Java `selectBearerTokenForRequest`: UTC day mod pool size). - **JSON body:** `planIds` (array of strings, possibly empty), `afterId` (string; use `"0"` if none stored). - **Starred plans:** Android: SharedPreferences **`daily_notification_timesafari`** + key **`starredPlanIds`**. iOS (plugin + host): `UserDefaults.standard` key **`daily_notification_timesafari.starredPlanIds`** (JSON array string). - **Pagination:** After a successful response with non-empty `data`, update **`last_acked_jwt_id`** from the last row’s `jwtId` (item or nested `plan.jwtId`)—see Java `updateLastAckedJwtIdFromResponse`. iOS host (`TimeSafariNativeFetcher.swift`) persists **`daily_notification_timesafari.last_acked_jwt_id`** in `UserDefaults.standard`. - **Empty `data`:** Return **no** notification items (empty list); do not synthesize a “no updates” push from an empty result—Java returns empty `contents` when `data` is absent or empty. - **Non-empty `data`:** One aggregated `NotificationContent`: titles **Starred Project Update** / **Starred Project Updates**, bodies use typographic quotes around first project name and **has been updated.** / **+ N more have been updated.** (see Java `parseApiResponse`). ### 6.3 Likely plugin touchpoints (maintenance / debugging) - **iOS:** `ios/Plugin/DailyNotificationPlugin.swift`, `DailyNotificationScheduleHelper.swift`, native fetcher registry, BG / UN paths. - **Android:** `DailyNotificationPlugin.kt`, fetch workers / `ScheduleHelper`—see dual-schedule feedback doc for history. ### 6.4 Suggested order (plugin shipped ≥ 3.0.0) 1. Tag / publish **`@timesafari/daily-notification-plugin`**. 2. **Consuming app:** bump, `npm install`, `npx cap sync`, `pod install`, QA (`doc/consuming-app-handoff-ios-native-fetcher-chained-dual.md`). --- ## 7. Acceptance checklist (iOS vs Android product intent) - [ ] Prefetch uses **plansLastUpdatedBetween** (or host fetcher with identical behavior), not only `offers` GET. - [ ] **Starred plan IDs** from settings change what is queried (`updateStarredPlans` works on iOS). - [ ] Notification title/body match the **same rules** as Android for “starred project updates” (including empty updates). - [ ] `configureNativeFetcher` + JWT refresh story documented; re-config on foreground if needed (`notification-from-api-call.md`). - [ ] `cancelDualSchedule` clears dual prefetch/notify without leaving orphan schedules. - [ ] Understand and document **iOS timing** limitations vs Android for support/Help copy. - [ ] **Prefetch vs notify ordering** on iOS: chosen strategy (chained arm vs independent BG + UN) documented; avoids claiming fresh API content when prefetch has not run yet (**§3.3**).