Add TimeSafariNativeFetcher (plansLastUpdatedBetween parity with Android) and call DailyNotificationPlugin.registerNativeFetcher from AppDelegate before JS configureNativeFetcher; broaden DailyNotificationDelivered scheduled_time types in willPresent. Wire the new file into the App target; normalize PBX object IDs to 24-char hex. Document plugin ≥3 handoff (consuming-app-handoff-ios-native-fetcher-chained-dual), refresh iOS/Android parity and notification-from-api-call file tables.
14 KiB
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.javaimplements the plugin’sNativeNotificationContentFetcherand callsPOST …/api/v2/report/plansLastUpdatedBetweenusing starred plan IDs (via plugin storage fromupdateStarredPlans). - Registration:
MainActivitycallsDailyNotificationPlugin.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.swiftimplementsNativeNotificationContentFetcher(EndorserplansLastUpdatedBetween, same prefs keys as Java).AppDelegatecallsDailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)at launch before anyconfigureNativeFetcherfrom JS (see plugindoc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.mdanddoc/consuming-app-handoff-ios-native-fetcher-chained-dual.md). - JS/TS is already shared:
nativeFetcherConfig.ts,dualScheduleConfig.ts,syncStarredPlansToNativePlugin.ts, andAccountViewView.vuecall the same APIs on both platforms. - Info.plist already lists
UIBackgroundModes(fetch, processing) andBGTaskSchedulerPermittedIdentifiersfor the plugin’s task IDs. Xcode Signing & Capabilities should still enable Background fetch and Background processing (seedoc/daily-notification-plugin-integration.md). - AppDelegate posts
DailyNotificationDeliveredfor 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 installif you seeUNIMPLEMENTED(doc/plugin-feedback-ios-scheduleDualNotification.md).configureNativeFetcher— requiresDailyNotificationPlugin.registerNativeFetcherfirst; the host Swift fetcher performsplansLastUpdatedBetween(plugin does not use in-pluginoffersGET when a fetcher is registered—mirrors Android).updateStarredPlans— implemented on iOS in current plugin; persistsdaily_notification_timesafari.starredPlanIdsfor 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
UNNotificationRequestfor 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.
-
updateStarredPlanson iOS — shipped in current plugin. -
iOS
plansLastUpdatedBetween/ host fetcher — shipped: host registersTimeSafariNativeFetcher(Swift); plugin does not duplicate Endorser logic when a fetcher is registered. -
Dual schedule / chaining — shipped (notify after prefetch; see plugin release notes and §3.3).
-
Android dual path — chained dual + native fetcher alignment in current plugin (see
doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.mdfor historical context). -
JWT pool / expiry (Phase B)
- App: Phase B is already implemented:
configureNativeFetcherIfReady()passesjwtTokensfrommintBackgroundJwtTokenPoolon both iOS and Android (src/services/notifications/nativeFetcherConfig.ts). - Android:
TimeSafariNativeFetcherselects a bearer from the pool for background requests (doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md). - iOS: The bundled plugin’s
configureNativeFetcheralready accepts and persistsjwtTokens/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 (offersGET vsplansLastUpdatedBetween+ starred plans)—that falls under §4A.2, not “waiting for Phase B on iOS.” - Expiry: Re-calling
configureNativeFetcherIfReadyon foreground / Account (seenotification-from-api-call.md) remains relevant on both platforms.
- App: Phase B is already implemented:
4B. This app (crowd-funder-for-time-pwa) — after or alongside plugin changes
- Bump
@timesafari/daily-notification-pluginto ≥ 3.0.0 via the git dependency inpackage.json, runnpm 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). - iOS native fetcher — Done:
TimeSafariNativeFetcher.swift+registerNativeFetcherinAppDelegate(see handoff doc). - Re-test
syncStarredPlansToNativePluginon iOS; the helper may still catchUNIMPLEMENTEDfor older plugin binaries. - Xcode: Confirm Background Modes capabilities match
Info.plist. - 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.vuevs New Activity: Enabling New Activity can still schedule the single daily reminder by mistake; turning New Activity off may not cancel that reminder. Seedoc/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 onapiBaseUrl). - Headers:
Content-Type: application/json,Authorization: Bearer {token}(token fromjwtTokenor JWT pool selection—see JavaselectBearerTokenForRequest: 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+ keystarredPlanIds. iOS (plugin + host):UserDefaults.standardkeydaily_notification_timesafari.starredPlanIds(JSON array string). - Pagination: After a successful response with non-empty
data, updatelast_acked_jwt_idfrom the last row’sjwtId(item or nestedplan.jwtId)—see JavaupdateLastAckedJwtIdFromResponse. iOS host (TimeSafariNativeFetcher.swift) persistsdaily_notification_timesafari.last_acked_jwt_idinUserDefaults.standard. - Empty
data: Return no notification items (empty list); do not synthesize a “no updates” push from an empty result—Java returns emptycontentswhendatais absent or empty. - Non-empty
data: One aggregatedNotificationContent: 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 JavaparseApiResponse).
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)
- Tag / publish
@timesafari/daily-notification-plugin. - 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
offersGET. - Starred plan IDs from settings change what is queried (
updateStarredPlansworks 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).cancelDualScheduleclears 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).