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:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
@@ -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,15 +685,54 @@ 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,
|
||||
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,
|
||||
@@ -628,106 +741,91 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
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)
|
||||
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 UserDefaults.standard.string(forKey: self.dualScheduleConfigKey) == nil {
|
||||
if let scheduler = self.scheduler {
|
||||
let nextScheduledTime = await scheduler.getNextNotificationTime()
|
||||
if let nextTime = nextScheduledTime {
|
||||
@@ -739,6 +837,7 @@ public class DailyNotificationPlugin: CAPPlugin {
|
||||
} else {
|
||||
print("DNP-FETCH: Scheduler not available, skipping next task schedule")
|
||||
}
|
||||
}
|
||||
|
||||
guard !taskCompleted else { return }
|
||||
task.setTaskCompleted(success: true)
|
||||
@@ -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 app’s `NativeNotificationContentFetcher` (call before `configureNativeFetcher`, typically from `AppDelegate`).
|
||||
public static func registerNativeFetcher(_ fetcher: NativeNotificationContentFetcher?) {
|
||||
NativeNotificationFetcherRegistry.shared.set(fetcher)
|
||||
}
|
||||
}
|
||||
83
ios/Plugin/NativeNotificationContentFetcher.swift
Normal file
83
ios/Plugin/NativeNotificationContentFetcher.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
4
package-lock.json
generated
@@ -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/*"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
// 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)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user