chore(release): v3.0.0 — iOS native fetcher, starred plans, chained dual (iOS + Android)

BREAKING CHANGE (iOS): configureNativeFetcher now requires
DailyNotificationPlugin.registerNativeFetcher(_) first, aligned with Android.

iOS:
- Add NativeNotificationContentFetcher SPI, registry, FetchContext, timeout helper
- Add updateStarredPlans / getStarredPlans; persist daily_notification_timesafari.starredPlanIds
- Chained dual: prefetch only on scheduleDualNotification; arm one-shot UN after fetch
- configureNativeFetcher invokes fetcher.configure; BG fetch prefers registered fetcher
- Public NotificationContent for host implementations

Android:
- Dual notify alarm scheduled after dual FetchWorker completes (DualScheduleNotifyScheduler)
- Persist dual_notify_schedule_id; remove upfront NotifyReceiver for dual setup

Docs: CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md; CHANGELOG 3.0.0
Made-with: Cursor
This commit is contained in:
Jose Olarte III
2026-04-02 16:48:06 +08:00
parent 9121b1e0f7
commit fbb5a94071
12 changed files with 544 additions and 146 deletions

View File

@@ -5,6 +5,26 @@ All notable changes to the Daily Notification Plugin will be documented in this
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.0] - 2026-04-02
### Added
- **iOS**: `NativeNotificationContentFetcher` SPI, `FetchContext`, `NativeNotificationFetcherRegistry`, and `DailyNotificationPlugin.registerNativeFetcher(_:)` for host-provided fetch (parity with Android `setNativeFetcher`).
- **iOS**: `updateStarredPlans` / `getStarredPlans` plugin methods; starred IDs stored under UserDefaults key `daily_notification_timesafari.starredPlanIds` (JSON array string).
- **Android**: `DualScheduleNotifyScheduler` and `DUAL_NOTIFY_SCHEDULE_ID_KEY` to arm the dual user notification **after** prefetch completes.
- **Docs**: `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` for consuming apps.
### Changed
- **iOS**: `configureNativeFetcher` requires a registered native fetcher (matches Android); calls `configure` on the fetcher; background fetch prefers registered fetcher with timeout, then legacy in-plugin HTTP when no fetcher + config exists.
- **iOS**: Dual (`scheduleDualNotification`) uses **chained** scheduling: prefetch BG task only, then one-shot user notification after fetch (`armChainedDualNotificationAfterPrefetch`), with max slip before fallback copy.
- **iOS**: `NotificationContent` is `public` for host fetcher implementations.
- **Android**: Dual notify exact alarm is no longer scheduled in `ScheduleHelper.scheduleDualNotification`; it is scheduled when `FetchWorker` completes (`max(nextNotifyAt, now)`), with recovery enqueue unchanged.
### Breaking
- **iOS**: `configureNativeFetcher` rejects if `registerNativeFetcher` was not called first.
## [2.1.5] - 2026-03-25
### Changed

View File

@@ -158,6 +158,12 @@ object DailyNotificationConstants {
*/
const val DUAL_SCHEDULE_CONFIG_KEY = "dual_schedule_config"
/**
* Stable dual notify [Schedule.id] persisted when [ScheduleHelper.scheduleDualNotification] runs.
* The user-visible alarm is scheduled after prefetch completes ([DualScheduleNotifyScheduler]).
*/
const val DUAL_NOTIFY_SCHEDULE_ID_KEY = "dual_notify_schedule_id"
/**
* Prefix for dual notify schedule IDs. Receiver uses this to apply relationship at fire time.
*/

View File

@@ -1482,6 +1482,7 @@ open class DailyNotificationPlugin : Plugin() {
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.remove(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY)
.remove(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY)
.apply()
}
@@ -2900,16 +2901,13 @@ object ScheduleHelper {
nextNotifyAt
)
// Schedule notification (use dual_notify_* so receiver can recognize dual and apply relationship)
// Chained dual: user notification is armed from FetchWorker after prefetch (see DualScheduleNotifyScheduler).
val nextRunTime = nextNotifyAt
val scheduleId = "dual_notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
userNotificationConfig,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
)
val scheduleId = "${DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX}${System.currentTimeMillis()}"
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, scheduleId)
.apply()
// Store both schedules
val fetchSchedule = Schedule(

View File

@@ -0,0 +1,76 @@
package org.timesafari.dailynotification
import android.content.Context
import android.util.Log
import org.json.JSONObject
/**
* Arms the dual (New Activity) user notification **after** prefetch completes (chained schedule).
* Fires at [max]([nextNotifyAtMillis], now) so a late fetch delays delivery instead of showing stale API copy first.
*/
object DualScheduleNotifyScheduler {
private const val TAG = "DNP-DUAL-NOTIFY"
/**
* Schedule exact alarm for dual notify using persisted [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY]
* and [DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY].
*/
@JvmStatic
fun scheduleChainedNotifyAlarm(context: Context, nextNotifyAtMillis: Long) {
try {
val prefs = context.getSharedPreferences(
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
Context.MODE_PRIVATE
)
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
?: run {
Log.w(TAG, "No dual_schedule_config; skip chained notify")
return
}
val scheduleId = prefs.getString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, null)
?: run {
Log.w(TAG, "No dual_notify_schedule_id; skip chained notify")
return
}
val root = JSONObject(configStr)
val userObj = root.optJSONObject("userNotification") ?: run {
Log.w(TAG, "dual config missing userNotification")
return
}
val config = parseUserNotificationConfig(userObj)
val now = System.currentTimeMillis()
val triggerAt = maxOf(nextNotifyAtMillis, now + 500L)
Log.i(TAG, "Chained dual notify: scheduleId=$scheduleId triggerAt=$triggerAt (nextNotify=$nextNotifyAtMillis)")
NotifyReceiver.scheduleExactNotification(
context,
triggerAt,
config,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP,
skipPendingIntentIdempotence = true
)
} catch (e: Exception) {
Log.e(TAG, "scheduleChainedNotifyAlarm failed", e)
}
}
private fun parseUserNotificationConfig(configJson: JSONObject): UserNotificationConfig {
return UserNotificationConfig(
enabled = configJson.optBoolean("enabled", true),
schedule = configJson.optString("schedule", "0 9 * * *"),
title = configJson.optString("title").takeIf { it.isNotEmpty() },
body = configJson.optString("body").takeIf { it.isNotEmpty() },
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) {
configJson.optBoolean("sound")
} else {
null
},
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) {
configJson.optBoolean("vibration")
} else {
null
},
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
)
}
}

View File

@@ -303,7 +303,8 @@ class FetchWorker(
)
Log.i(TAG, "Content fetch completed successfully")
if (isDual) {
if (isDual && nextNotifyAt > 0L) {
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
}
Result.success()
@@ -314,6 +315,9 @@ class FetchWorker(
} catch (e: Exception) {
Log.e(TAG, "Unexpected error during fetch", e)
recordFailure("unexpected_error", start, e)
if (isDual && nextNotifyAt > 0L) {
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
}
Result.failure()
}
}

View File

@@ -0,0 +1,78 @@
# Consuming app handoff: iOS native fetcher + chained dual schedule
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
- **iOS** `NativeNotificationContentFetcher`style registration (`DailyNotificationPlugin.registerNativeFetcher`)
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; this file adds **app-side** steps not spelled out there.
---
## 1. iOS — register native fetcher before `configureNativeFetcher`
The plugin now **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
```swift
import CapacitorDailyNotification // actual product module name may match the Pod (e.g. CapacitorDailyNotification)
// After: import DailyNotificationPlugin if your target uses a different module name use the same module that exposes DailyNotificationPlugin.
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
```
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
- Conforms to `NativeNotificationContentFetcher`
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java` (`POST …/api/v2/report/plansLastUpdatedBetween`, starred plan IDs, JWT pool selection, aggregation copy, pagination / `last_acked_jwt_id` as in Java)
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript (optional; TS still persists `native_fetcher_config` UserDefaults key)
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS). Format matches Android: JSON array of strings.
---
## 2. iOS — `UNUserNotificationCenterDelegate` / rollover
Chained dual notifications set:
- `notification_id` = `org.timesafari.dailynotification.dual` (same stable identifier as before)
- `scheduled_time` = `NSNumber` (fire time in ms)
Ensure your existing `DailyNotificationDelivered` bridge still forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`** (not only from a custom payload). Foreground presentation handlers should read `notification.request.content.userInfo`.
---
## 3. Android — no API change for `setNativeFetcher`
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` and `configureNativeFetcher` from JS keep that flow.
**Behavior change:** the dual **notify** alarm is no longer scheduled at the initial `scheduleDualNotification` call; it is scheduled when **dual prefetch work finishes** (success or hard failure path), at `max(nextNotifyAt, now)` so late prefetch delays the notification.
---
## 4. Bump and sync
1. Bump **`@timesafari/daily-notification-plugin`** in the app `package.json`.
2. `npm install`
3. `npx cap sync ios && npx cap sync android`
4. iOS: `cd ios/App && pod install` (adjust path if your app uses a different `ios` layout)
5. Clean build in Xcode / Android Studio
---
## 5. QA focus
- **iOS:** Register fetcher **before** any `configureNativeFetcher` from the web layer; confirm `updateStarredPlans` is no longer `UNIMPLEMENTED`.
- **Both:** New Activity dual path: first notification should appear **after** prefetch for that cycle, not at a fixed time with stale API text.
- **Android:** Regression-test `cancelDualSchedule` and Daily Reminder (should remain independent).
---
## 6. Assumptions
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
- Module import name for the Capacitor iOS plugin follows your Pod (`CapacitorDailyNotification` in `CapacitorDailyNotification.podspec`).

View File

@@ -36,6 +36,12 @@ public class DailyNotificationPlugin: CAPPlugin {
private let dualNotificationRequestIdentifier = "org.timesafari.dailynotification.dual"
/// UserDefaults key for persisted dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
private let dualScheduleConfigKey = "dual_schedule_config"
/// Matches Android [DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY] stable id for chained dual notify.
private let dualNotifyScheduleIdStorageKey = "dual_notify_schedule_id"
/// Max slip after nominal notify time before showing fallback (parity with product discussion).
private let dualChainedMaxSlipSeconds: TimeInterval = 15 * 60
/// Parity with Android SharedPreferences `daily_notification_timesafari` + `starredPlanIds`.
private let starredPlanIdsStorageKey = "daily_notification_timesafari.starredPlanIds"
// Phase 1: Storage and Scheduler components
var storage: DailyNotificationStorage?
@@ -232,6 +238,11 @@ public class DailyNotificationPlugin: CAPPlugin {
return
}
guard NativeNotificationFetcherRegistry.shared.fetcher != nil else {
call.reject("No native fetcher registered. Host app must call DailyNotificationPlugin.registerNativeFetcher(_:) before configureNativeFetcher.")
return
}
guard let apiBaseUrl = options["apiBaseUrl"] as? String else {
call.reject("apiBaseUrl is required")
return
@@ -277,9 +288,13 @@ public class DailyNotificationPlugin: CAPPlugin {
print("DNP-PLUGIN: jwtTokenPool size=\(pool.count)")
}
// Store configuration in database for persistence across app restarts
// Note: iOS native fetcher interface not yet implemented, but we store config for future use
let configId = "native_fetcher_config"
NativeNotificationFetcherRegistry.shared.fetcher?.configure(
apiBaseUrl: apiBaseUrl,
activeDid: activeDid,
jwtToken: jwtToken,
jwtTokenPool: jwtTokenPool
)
var configValue: [String: Any] = [
"apiBaseUrl": apiBaseUrl,
"activeDid": activeDid,
@@ -289,16 +304,12 @@ public class DailyNotificationPlugin: CAPPlugin {
configValue["jwtTokenPool"] = pool
}
// Convert to JSON string for storage
guard let jsonData = try? JSONSerialization.data(withJSONObject: configValue, options: []),
let jsonString = String(data: jsonData, encoding: .utf8) else {
call.reject("Failed to serialize configuration")
return
}
// Store configuration in UserDefaults for now
// This matches Android's approach of storing in database, but uses UserDefaults for simplicity
// Can be enhanced later to use CoreData when native fetcher interface is implemented
let configKey = "native_fetcher_config"
UserDefaults.standard.set(jsonString, forKey: configKey)
print("DNP-PLUGIN: Native fetcher configuration stored successfully")
@@ -389,24 +400,11 @@ public class DailyNotificationPlugin: CAPPlugin {
}
do {
// Delegate to ScheduleHelper for dual scheduling orchestration
try DailyNotificationScheduleHelper.scheduleDualNotification(
contentFetchConfig: contentFetchConfig,
userNotificationConfig: userNotificationConfig,
scheduleBackgroundFetch: { [weak self] config in
guard let strongSelf = self else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
}
try strongSelf.scheduleBackgroundFetch(config: config)
},
scheduleUserNotification: { [weak self] config in
guard let strongSelf = self else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
}
try strongSelf.scheduleUserNotification(config: config)
}
)
// Chained dual: prefetch BG task only; user notification is armed after prefetch completes.
try scheduleBackgroundFetch(config: contentFetchConfig)
saveDualScheduleConfig(config)
let sid = "dual_notify_\(Int64(Date().timeIntervalSince1970 * 1000))"
UserDefaults.standard.set(sid, forKey: dualNotifyScheduleIdStorageKey)
call.resolve()
} catch {
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
@@ -445,6 +443,7 @@ public class DailyNotificationPlugin: CAPPlugin {
backgroundTaskScheduler.cancel(taskRequestWithIdentifier: fetchTaskIdentifier)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
UserDefaults.standard.removeObject(forKey: dualScheduleConfigKey)
UserDefaults.standard.removeObject(forKey: dualNotifyScheduleIdStorageKey)
print("DNP-PLUGIN: Canceled dual schedule (fetch task + user notification)")
}
@@ -457,29 +456,86 @@ public class DailyNotificationPlugin: CAPPlugin {
}
do {
performCancelDualSchedule()
try DailyNotificationScheduleHelper.scheduleDualNotification(
contentFetchConfig: contentFetchConfig,
userNotificationConfig: userNotificationConfig,
scheduleBackgroundFetch: { [weak self] config in
guard let strongSelf = self else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
}
try strongSelf.scheduleBackgroundFetch(config: config)
},
scheduleUserNotification: { [weak self] config in
guard let strongSelf = self else {
throw NSError(domain: "DailyNotificationPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Plugin deallocated"])
}
try strongSelf.scheduleUserNotification(config: config)
}
)
try scheduleBackgroundFetch(config: contentFetchConfig)
saveDualScheduleConfig(config)
let sid = "dual_notify_\(Int64(Date().timeIntervalSince1970 * 1000))"
UserDefaults.standard.set(sid, forKey: dualNotifyScheduleIdStorageKey)
call.resolve()
} catch {
call.reject("Update dual schedule failed: \(error.localizedDescription)")
}
}
@objc func updateStarredPlans(_ call: CAPPluginCall) {
guard let raw = call.options?["planIds"] else {
call.reject("planIds is required and must be a string array")
return
}
var planIds: [String] = []
if let a = raw as? [String] {
planIds = a
} else if let anyArr = raw as? [Any] {
for (index, item) in anyArr.enumerated() {
guard let s = item as? String else {
call.reject("planIds must be an array of strings (non-string at index \(index))")
return
}
planIds.append(s)
}
} else {
call.reject("planIds must be a string array")
return
}
for (index, id) in planIds.enumerated() {
if id.trimmingCharacters(in: .whitespaces).isEmpty {
call.reject("planIds[\(index)] must be a non-empty string")
return
}
}
do {
let jsonData = try JSONSerialization.data(withJSONObject: planIds, options: [])
guard let jsonStr = String(data: jsonData, encoding: .utf8) else {
call.reject("Failed to serialize planIds")
return
}
UserDefaults.standard.set(jsonStr, forKey: starredPlanIdsStorageKey)
let updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
UserDefaults.standard.set(updatedAt, forKey: "\(starredPlanIdsStorageKey).updatedAt")
call.resolve([
"success": true,
"planIdsCount": planIds.count,
"updatedAt": updatedAt
])
} catch {
call.reject("Failed to update starred plans: \(error.localizedDescription)")
}
}
@objc func getStarredPlans(_ call: CAPPluginCall) {
let jsonStr = UserDefaults.standard.string(forKey: starredPlanIdsStorageKey) ?? "[]"
let planIds: [String]
if let data = jsonStr.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: data) as? [String] {
planIds = arr
} else {
planIds = []
}
let updatedKey = "\(starredPlanIdsStorageKey).updatedAt"
let updatedAt: Int64
if let n = UserDefaults.standard.object(forKey: updatedKey) as? Int64 {
updatedAt = n
} else if let num = UserDefaults.standard.object(forKey: updatedKey) as? NSNumber {
updatedAt = num.int64Value
} else {
updatedAt = 0
}
call.resolve([
"planIds": planIds,
"count": planIds.count,
"updatedAt": updatedAt
])
}
/// Persist dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
private func saveDualScheduleConfig(_ config: [String: Any]) {
guard config["userNotification"] != nil,
@@ -488,8 +544,8 @@ public class DailyNotificationPlugin: CAPPlugin {
UserDefaults.standard.set(jsonString, forKey: dualScheduleConfigKey)
}
/// Replace the pending dual user notification with resolved title/body (from fetched content if within contentTimeout, else default). Call after saving content in handleBackgroundFetch.
private func updateDualNotificationWithResolvedContent(fetchedContent: NotificationContent) {
/// Chained dual: arm a **one-shot** user notification at `max(T, prefetchCompletedAt)` (capped by slip), never before prefetch completes.
private func armChainedDualNotificationAfterPrefetch(fetchedContent: NotificationContent, prefetchCompletedAt: Date) {
guard let configJson = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
let configData = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
@@ -502,9 +558,23 @@ public class DailyNotificationPlugin: CAPPlugin {
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let useFetched = (nowMs - fetchedContent.fetchedAt) <= contentTimeoutMs
let scheduleStr = userNotification["schedule"] as? String ?? "0 9 * * *"
guard let nominalT = dualNextWallClockDate(scheduleStr: scheduleStr, after: prefetchCompletedAt.addingTimeInterval(-1)) else {
print("DNP-FETCH: Dual chained: invalid notify cron")
return
}
let slippedLate = prefetchCompletedAt.timeIntervalSince(nominalT) > dualChainedMaxSlipSeconds
var fireDate = max(nominalT, prefetchCompletedAt)
if slippedLate {
fireDate = Date().addingTimeInterval(2)
}
let title: String
let body: String
if useFetched {
if slippedLate && fallbackBehavior == "show_default" {
title = userNotification["title"] as? String ?? "Daily Notification"
body = userNotification["body"] as? String ?? "Your daily update is ready"
} else if useFetched {
title = fetchedContent.title ?? "Daily Notification"
body = fetchedContent.body ?? "Your daily update is ready"
} else if fallbackBehavior == "show_default" {
@@ -514,39 +584,43 @@ public class DailyNotificationPlugin: CAPPlugin {
return
}
let scheduleStr = userNotification["schedule"] as? String ?? "0 9 * * *"
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
var hour = 9, minute = 0
if parts.count >= 2, let m = Int(parts[0]), let h = Int(parts[1]), m >= 0, m <= 59, h >= 0, h <= 23 {
minute = m
hour = h
}
var dateComp = DateComponents()
dateComp.hour = hour
dateComp.minute = minute
dateComp.second = 0
let cal = Calendar.current
guard let nextDate = cal.nextDate(after: Date(), matching: dateComp, matchingPolicy: .nextTime), nextDate.timeIntervalSinceNow > 0 else {
print("DNP-FETCH: Dual notify time already passed, skipping notification update")
return
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = (userNotification["sound"] as? Bool ?? true) ? .default : nil
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: true)
let scheduledMs = Int64(fireDate.timeIntervalSince1970 * 1000)
content.userInfo = [
"notification_id": dualNotificationRequestIdentifier,
"scheduled_time": NSNumber(value: scheduledMs)
]
let cal = Calendar.current
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute, .second], from: fireDate)
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
let request = UNNotificationRequest(identifier: dualNotificationRequestIdentifier, content: content, trigger: trigger)
notificationCenter.add(request) { [weak self] err in
notificationCenter.add(request) { err in
if let e = err {
print("DNP-FETCH: Failed to update dual notification: \(e.localizedDescription)")
print("DNP-FETCH: Failed to arm chained dual notification: \(e.localizedDescription)")
} else {
print("DNP-FETCH: Updated dual notification with \(useFetched ? "fetched" : "default") content")
print("DNP-FETCH: Armed chained dual notification at \(fireDate) (useFetched=\(useFetched))")
}
}
}
private func dualNextWallClockDate(scheduleStr: String, after date: Date) -> Date? {
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
guard parts.count >= 2,
let minute = Int(parts[0]), minute >= 0, minute <= 59,
let hour = Int(parts[1]), hour >= 0, hour <= 23 else {
return nil
}
var comp = DateComponents()
comp.hour = hour
comp.minute = minute
comp.second = 0
return Calendar.current.nextDate(after: date, matching: comp, matchingPolicy: .nextTime)
}
/**
* Get health status for dual scheduling system
*
@@ -611,133 +685,158 @@ public class DailyNotificationPlugin: CAPPlugin {
taskCompleted = true
}
// Phase 3: Check for JWT-signed fetcher configuration
// If native fetcher is configured, use it; otherwise fall back to dummy content
let nativeFetcherConfig = UserDefaults.standard.string(forKey: "native_fetcher_config")
// Save content to storage via state actor (thread-safe)
Task {
let prefetchCompletedAt = Date()
let fetchTimeMs = Int64(prefetchCompletedAt.timeIntervalSince1970 * 1000)
let content: NotificationContent
if let configJson = nativeFetcherConfig,
let configData = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let apiBaseUrl = config["apiBaseUrl"] as? String,
let activeDid = config["activeDid"] as? String {
if let reg = NativeNotificationFetcherRegistry.shared.fetcher {
let ctx = FetchContext(
trigger: "prefetch",
scheduledTimeMillis: nil,
fetchTimeMillis: fetchTimeMs,
metadata: [:]
)
do {
let list = try await withTimeout(milliseconds: 30_000) {
try await reg.fetchContent(context: ctx)
}
if let first = list.first {
content = first
print("DNP-FETCH: Native fetcher returned content id=\(first.id)")
} else {
print("DNP-FETCH: Native fetcher returned empty list; using placeholder")
content = NotificationContent(
id: "empty_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: ["empty": true],
etag: nil
)
}
} catch {
print("DNP-FETCH: Native fetcher failed (\(error.localizedDescription)), using fallback content")
content = NotificationContent(
id: "fallback_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: ["fetchError": error.localizedDescription],
etag: nil
)
}
} else if let configJson = nativeFetcherConfig,
let configData = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let apiBaseUrl = config["apiBaseUrl"] as? String,
let activeDid = config["activeDid"] as? String {
let jwtFromPrimary = (config["jwtToken"] as? String).flatMap { $0.isEmpty ? nil : $0 }
let jwtFromPool = (config["jwtTokenPool"] as? [String])?.first { !$0.isEmpty }
let bearerToken = jwtFromPrimary ?? jwtFromPool
if let jwtToken = bearerToken {
// Phase 3: JWT-signed fetcher is configured - attempt HTTP fetch
print("DNP-FETCH: Using JWT-signed fetcher (apiBaseUrl=\(apiBaseUrl), activeDid=\(activeDid.prefix(30))...)")
// Attempt to fetch content from TimeSafari API
// Note: This is a minimal implementation - can be extended with full API client
print("DNP-FETCH: Legacy in-plugin HTTP (no registered native fetcher)")
do {
let fetchedContent = try await fetchContentFromAPI(
content = try await fetchContentFromAPI(
apiBaseUrl: apiBaseUrl,
activeDid: activeDid,
jwtToken: jwtToken
)
content = fetchedContent
print("DNP-FETCH: Successfully fetched content from API")
} catch {
// Fallback to dummy content on fetch failure
print("DNP-FETCH: API fetch failed (\(error.localizedDescription)), using fallback content")
content = NotificationContent(
id: "fallback_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: ["fetchError": error.localizedDescription],
etag: nil
)
}
} else {
// Config present but no bearer (empty jwtToken and pool)
print("DNP-FETCH: Using dummy content (no bearer token)")
content = NotificationContent(
id: "dummy_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: nil,
etag: nil
)
}
} else {
// Fallback: Dummy content fetch (no network)
print("DNP-FETCH: Using dummy content (native fetcher not configured)")
print("DNP-FETCH: Using dummy content (no native fetcher, no config)")
content = NotificationContent(
id: "dummy_\(Date().timeIntervalSince1970)",
title: "Daily Update",
body: "Your daily notification is ready",
scheduledTime: Int64(Date().timeIntervalSince1970 * 1000) + (5 * 60 * 1000),
fetchedAt: Int64(Date().timeIntervalSince1970 * 1000),
scheduledTime: fetchTimeMs + (5 * 60 * 1000),
fetchedAt: fetchTimeMs,
url: nil,
payload: nil,
etag: nil
)
}
do {
// Use the content (either from JWT fetcher or dummy)
if #available(iOS 13.0, *) {
if let stateActor = await self.stateActor {
await stateActor.saveNotificationContent(content)
// Mark successful run
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
await stateActor.saveLastSuccessfulRun(timestamp: currentTime)
} else {
// Fallback to direct storage access
self.storage?.saveNotificationContent(content)
} else {
self.storage?.saveNotificationContent(content)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
} else {
// Fallback for iOS < 13
self.storage?.saveNotificationContent(content)
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
// Relationship: update pending dual notification with resolved content (fetched if within contentTimeout, else default)
self.updateDualNotificationWithResolvedContent(fetchedContent: content)
if UserDefaults.standard.string(forKey: self.dualScheduleConfigKey) != nil {
self.armChainedDualNotificationAfterPrefetch(fetchedContent: content, prefetchCompletedAt: prefetchCompletedAt)
do {
try self.scheduleNextDualPrefetchFromPersistedConfig()
} catch {
print("DNP-FETCH: Next dual prefetch schedule failed: \(error.localizedDescription)")
}
}
// Phase 3.3: Recovery logic - verify scheduled notifications
// Check if notifications are still scheduled after fetch
if let reactivationManager = self.reactivationManager {
// Perform lightweight verification (non-blocking)
Task {
do {
let verificationResult = try await reactivationManager.verifyFutureNotifications()
if verificationResult.notificationsMissing > 0 {
print("DNP-FETCH: Recovery - found \(verificationResult.notificationsMissing) missing notifications, will reschedule on next app launch")
// Note: Full recovery happens on app launch, not in background task
}
} catch {
// Non-fatal: Log but don't fail task
print("DNP-FETCH: Recovery verification failed (non-fatal): \(error.localizedDescription)")
}
}
}
// Phase 3.3: Schedule next background task
// Calculate next fetch time based on notification schedule
if let scheduler = self.scheduler {
let nextScheduledTime = await scheduler.getNextNotificationTime()
if let nextTime = nextScheduledTime {
self.scheduleBackgroundFetch(scheduledTime: nextTime)
print("DNP-FETCH: Next background fetch scheduled")
if UserDefaults.standard.string(forKey: self.dualScheduleConfigKey) == nil {
if let scheduler = self.scheduler {
let nextScheduledTime = await scheduler.getNextNotificationTime()
if let nextTime = nextScheduledTime {
self.scheduleBackgroundFetch(scheduledTime: nextTime)
print("DNP-FETCH: Next background fetch scheduled")
} else {
print("DNP-FETCH: No future notifications found, skipping next task schedule")
}
} else {
print("DNP-FETCH: No future notifications found, skipping next task schedule")
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
}
} else {
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
}
guard !taskCompleted else { return }
@@ -754,6 +853,16 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
private func scheduleNextDualPrefetchFromPersistedConfig() throws {
guard let json = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
let data = json.data(using: .utf8),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let cf = root["contentFetch"] as? [String: Any] else {
return
}
try scheduleBackgroundFetch(config: cf)
}
/**
* Handle background notification task
*
@@ -2403,7 +2512,16 @@ public class DailyNotificationPlugin: CAPPlugin {
methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "updateDualScheduleConfig", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "updateStarredPlans", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getStarredPlans", returnType: CAPPluginReturnPromise))
return methods
}
}
extension DailyNotificationPlugin {
/// Register the host apps `NativeNotificationContentFetcher` (call before `configureNativeFetcher`, typically from `AppDelegate`).
public static func registerNativeFetcher(_ fetcher: NativeNotificationContentFetcher?) {
NativeNotificationFetcherRegistry.shared.set(fetcher)
}
}

View File

@@ -0,0 +1,83 @@
//
// NativeNotificationContentFetcher.swift
// DailyNotificationPlugin
//
// Swift SPI mirroring org.timesafari.dailynotification.NativeNotificationContentFetcher (Android).
//
import Foundation
/// Context for a native fetch (trigger, scheduled user notification time, metadata).
public struct FetchContext {
public let trigger: String
public let scheduledTimeMillis: Int64?
public let fetchTimeMillis: Int64
public let metadata: [String: Any]
public init(
trigger: String,
scheduledTimeMillis: Int64?,
fetchTimeMillis: Int64,
metadata: [String: Any] = [:]
) {
self.trigger = trigger
self.scheduledTimeMillis = scheduledTimeMillis
self.fetchTimeMillis = fetchTimeMillis
self.metadata = metadata
}
}
/// Host app implements this and registers via `DailyNotificationPlugin.registerNativeFetcher(_:)`.
public protocol NativeNotificationContentFetcher: AnyObject {
/// Called when TypeScript invokes `configureNativeFetcher` (mirrors Android `NativeNotificationContentFetcher.configure`).
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?)
/// Background-safe fetch; return empty array when there is nothing to show (not an error).
func fetchContent(context: FetchContext) async throws -> [NotificationContent]
}
public extension NativeNotificationContentFetcher {
func configure(apiBaseUrl: String, activeDid: String, jwtToken: String, jwtTokenPool: [String]?) {
// Default: no-op
}
}
/// Holds the registered host fetcher (thread-safe).
public final class NativeNotificationFetcherRegistry {
public static let shared = NativeNotificationFetcherRegistry()
private let lock = NSLock()
private weak var weakFetcher: NativeNotificationContentFetcher?
private init() {}
public var fetcher: NativeNotificationContentFetcher? {
lock.lock()
defer { lock.unlock() }
return weakFetcher
}
public func set(_ fetcher: NativeNotificationContentFetcher?) {
lock.lock()
weakFetcher = fetcher
lock.unlock()
}
}
enum NativeFetcherTimeoutError: Error {
case timedOut(ms: Int)
}
func withTimeout<T>(milliseconds: Int, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000)
throw NativeFetcherTimeoutError.timedOut(ms: milliseconds)
}
let first = try await group.next()!
group.cancelAll()
return first
}
}

View File

@@ -15,18 +15,18 @@ import Foundation
* This class encapsulates all the information needed for a notification
* including scheduling, content, and metadata.
*/
class NotificationContent: Codable {
public class NotificationContent: Codable {
// MARK: - Properties
let id: String
let title: String?
let body: String?
let scheduledTime: Int64 // milliseconds since epoch (matches Android long)
let fetchedAt: Int64 // milliseconds since epoch (matches Android long)
let url: String?
let payload: [String: Any]?
let etag: String?
public let id: String
public let title: String?
public let body: String?
public let scheduledTime: Int64 // milliseconds since epoch (matches Android long)
public let fetchedAt: Int64 // milliseconds since epoch (matches Android long)
public let url: String?
public let payload: [String: Any]?
public let etag: String?
/** When > 0, next occurrence is this many minutes after trigger (dev/testing). Nil/0 = 24h. Persisted for rollover and recovery. */
var rolloverIntervalMinutes: Int?
@@ -50,7 +50,7 @@ class NotificationContent: Codable {
case lastDeliveryAttempt
}
required init(from decoder: Decoder) throws {
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
@@ -72,7 +72,7 @@ class NotificationContent: Codable {
lastDeliveryAttempt = try container.decodeIfPresent(Int64.self, forKey: .lastDeliveryAttempt)
}
func encode(to encoder: Encoder) throws {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(title, forKey: .title)
@@ -109,7 +109,7 @@ class NotificationContent: Codable {
* @param deliveryStatus Delivery status (optional, Phase 2)
* @param lastDeliveryAttempt Last delivery attempt timestamp (optional, Phase 2)
*/
init(id: String,
public init(id: String,
title: String?,
body: String?,
scheduledTime: Int64,

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@timesafari/daily-notification-plugin",
"version": "2.1.4",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@timesafari/daily-notification-plugin",
"version": "2.1.4",
"version": "3.0.0",
"license": "MIT",
"workspaces": [
"packages/*"

View File

@@ -1,6 +1,6 @@
{
"name": "@timesafari/daily-notification-plugin",
"version": "2.2.0",
"version": "3.0.0",
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
"main": "dist/plugin.js",
"module": "dist/esm/index.js",

View File

@@ -381,7 +381,8 @@
fetcherStatus.innerHTML = '❌ Error';
}
} else {
console.log('[Config Check] ❌ Native fetcher config not found');
console.log('[Config Check] ❌ Native fetcher config not found in database');
console.log('[Config Check] This may be normal after app uninstall/reinstall (database wiped)');
fetcherStatus.innerHTML = '❌ Not configured';
}
@@ -395,8 +396,15 @@
})
.catch(error => {
console.error('[Config Check] Failed to check configuration:', error);
configStatus.innerHTML = '❌ Error';
fetcherStatus.innerHTML = '❌ Error';
// Don't show error if database might not be ready yet (recovery in progress)
if (error.message && error.message.includes('database')) {
console.log('[Config Check] Database may not be ready yet, will retry...');
fetcherStatus.innerHTML = '⏳ Checking...';
configStatus.innerHTML = '⏳ Checking...';
} else {
configStatus.innerHTML = '❌ Error';
fetcherStatus.innerHTML = '❌ Error';
}
});
}
@@ -650,8 +658,15 @@
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
console.log('[Visibility] App became visible, refreshing UI status...');
// Small delay to allow recovery to complete
// Longer delay to allow recovery to complete (force-stop recovery can take a few seconds)
// Also refresh immediately, then again after delay to catch any late recovery
loadPluginStatus();
loadPermissionStatus();
loadChannelStatus();
loadConfigurationStatus();
setTimeout(() => {
console.log('[Visibility] Delayed refresh after recovery period...');
loadPluginStatus();
loadPermissionStatus();
loadChannelStatus();
@@ -692,7 +707,7 @@
console.error('[Visibility] Failed to get notification status:', error);
});
}
}, 1000); // Wait 1 second for recovery to complete
}, 3000); // Wait 3 seconds for recovery to complete (force-stop recovery can take time)
}
});