Files
crowd-funder-for-time-pwa/doc/new-activity-notifications-ios-android-parity.md
Jose Olarte III 7d87a746f9 feat(ios): register Swift TimeSafariNativeFetcher for New Activity notifications
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.
2026-04-02 19:02:48 +08:00

14 KiB
Raw Blame History

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 T5 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 plugins 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 plugins 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).
  • configureNativeFetcherrequires 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 T5 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 plugins 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 fetcherDone: 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).
  • 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 rows 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).