Add doc/new-activity-notifications-ios-android-parity.md covering dual-schedule and Endorser API parity, plugin vs app work, Android dual-path notes, prefetch vs notify ordering on iOS (§3.3), and clarified Phase B JWT pool status on both platforms. Link the guide from doc/notification-from-api-call.md under the iOS checklist.
12 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 (development): The Capacitor package is @timesafari/daily-notification-plugin (declared in package.json). A local clone used for plugin work may live at
/Users/aardimus/Sites/trentlarson/daily-notification-plugin_test/daily-notification-plugin
— implement plugin changes there, publish or link the package, then bump/sync in this app.
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 gaps (Android): Even with the above, the dual-schedule path in the plugin has had issues (native fetcher not used for dual prefetch, fetch time not aligned to
contentFetch.schedule). Seedoc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md. Full “Android parity” with the product intent may require plugin fixes as well as the host fetcher.
3. Current state: iOS (this app + bundled plugin)
3.1 This repository
- No iOS
TimeSafariNativeFetcher. There is no Swift/Objective-C equivalent registered with the plugin (unlike Android’ssetNativeFetcher). - 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)
Verified from the Swift sources shipped with the package (version pinned in ios/App/Podfile.lock):
scheduleDualNotification/cancelDualScheduleare implemented and exposed inpluginMethods(not inherentlyUNIMPLEMENTEDif Pods/binary are fresh). If you still seeUNIMPLEMENTED, followdoc/plugin-feedback-ios-scheduleDualNotification.md(clean sync,pod install, PluginHeaders check).configureNativeFetcherpersists JWT/API/DID toUserDefaults, but the in-file comment still states the iOS native fetcher interface is not fully aligned with Android—configuration is stored for use by the plugin’s own HTTP path.fetchContentFromAPI(in-plugin) callsGET /api/v2/report/offers, notplansLastUpdatedBetween. Response handling is a minimal JSON→NotificationContentmapping. This does not matchTimeSafariNativeFetcheror the help copy for “starred project updates.”updateStarredPlansis not implemented on iOS: no Swift symbol and noCAPPluginMethodentry. The app’ssyncStarredPlansToNativePluginintentionally toleratesUNIMPLEMENTED; starred plans therefore do not drive iOS prefetch today.- Prefetch execution runs in
BGAppRefreshTask(handleBackgroundFetch). Apple does not guarantee execution at a specific minute; the dual flow is best-effort compared to Android’s exact alarms.
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) — required for real parity
-
updateStarredPlanson iOS- Add method to
DailyNotificationPlugin.swift, register inpluginMethods, persistplanIds(e.g. UserDefaults or existing storage), and read them in the prefetch path.
- Add method to
-
Align HTTP/API with Android /
TimeSafariNativeFetcher- Replace or supplement
fetchContentFromAPIso prefetch usesPOST /api/v2/report/plansLastUpdatedBetween(or shared helper) with the same payload and response rules as the Java fetcher (empty data → no spurious “update” content; aggregated titles; pagination/last_acked_jwt_idif required). - Alternatively, introduce an iOS host registration pattern mirroring
setNativeFetcherso this app can ship one implementation in Swift and the plugin only orchestrates scheduling + cache (matches Android architecture and avoids duplicating business logic in the plugin).
- Replace or supplement
-
Dual schedule timing vs iOS constraints
- Document and implement the best possible mapping from
contentFetch.schedule/userNotification.scheduleto BGTaskScheduler + UNNotification triggers. Prefer chaining (arm or replace the user notification after prefetch for that cycle—see §3.3) where product requires “never show API-dependent copy before fetch,” rather than only two independent timers. Where exact T−5 cannot be guaranteed, document behavior and any fallback (e.g. refresh when app becomes active) so product expectations are clear.
- Document and implement the best possible mapping from
-
Android dual path (same repo)
- For cross-platform parity, apply fixes described in
doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.mdso Android dual prefetch actually invokes the native fetcher at the fetch cron.
- For cross-platform parity, apply fixes described in
-
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-plugin, runnpx cap sync ios,cd ios/App && pod install, clean build (seedoc/plugin-feedback-ios-scheduleDualNotification.md). - Re-test
syncStarredPlansToNativePlugin: once iOS exposesupdateStarredPlans, remove reliance on “optional UNIMPLEMENTED” for correctness tests (the helper can stay defensive). - If the plugin adds
setNativeFetcher(or similar) on iOS, add a SwiftTimeSafariNativeFetcherimplementation (port of the Java class) and register it at launch (mirrorMainActivity). - 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).
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 |
|---|---|
| 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. 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).