feat(dual): complete scheduleDualNotification; add relationship (contentTimeout/fallbackBehavior)

Plugin (iOS):
- Real cron parsing in calculateNextRunTime(from:); stable dual id + replace semantics; UNCalendarNotificationTrigger for daily
- cancelDualSchedule() and updateDualScheduleConfig(); persist/clear dual config for relationship

Plugin (Android):
- cancelDualSchedule() and updateDualScheduleConfig(); FetchWorker.scheduleFetchForDual; ScheduleHelper.cancelDualSchedule; dual_notify_* id
- Persist dual config; DualScheduleHelper + Worker dual branch for relationship at fire time

Relationship:
- iOS: replace pending dual notification when fetch completes (contentTimeout/fallbackBehavior)
- Android: resolve config + content cache in Worker for dual_notify_*; show resolved title/body

Doc: COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md (two types, Edit/updateDualScheduleConfig, §1.3a, status)
This commit is contained in:
Jose Olarte III
2026-03-18 21:10:49 +08:00
parent 7a1e58a4b6
commit 7b41ca9e0b
7 changed files with 464 additions and 44 deletions

View File

@@ -141,6 +141,22 @@ object DailyNotificationConstants {
*/
const val DEFAULT_SCHEDULE_ID = "daily_notification"
/**
* SharedPreferences name for dual (New Activity) schedule config.
* Used by plugin to persist config and by Worker to resolve relationship (contentTimeout/fallbackBehavior).
*/
const val DUAL_SCHEDULE_PREFS = "daily_notification_dual"
/**
* Key for persisted dual schedule config JSON (userNotification + relationship).
*/
const val DUAL_SCHEDULE_CONFIG_KEY = "dual_schedule_config"
/**
* Prefix for dual notify schedule IDs. Receiver uses this to apply relationship at fire time.
*/
const val DUAL_NOTIFY_SCHEDULE_ID_PREFIX = "dual_notify_"
// ============================================================
// Request Code Versioning
// ============================================================

View File

@@ -1395,17 +1395,18 @@ open class DailyNotificationPlugin : Plugin() {
CoroutineScope(Dispatchers.IO).launch {
try {
// Delegate to ScheduleHelper
// Use FetchWorker.scheduleFetchForDual so cancelDualSchedule can cancel only dual fetch
val success = ScheduleHelper.scheduleDualNotification(
context,
getDatabase(),
contentFetchConfig,
userNotificationConfig,
FetchWorker::scheduleFetch,
FetchWorker::scheduleFetchForDual,
::calculateNextRunTime
)
if (success) {
saveDualScheduleConfig(context!!, configJson)
call.resolve()
} else {
call.reject("Dual notification scheduling failed")
@@ -1421,6 +1422,27 @@ open class DailyNotificationPlugin : Plugin() {
}
}
private fun saveDualScheduleConfig(context: Context, configJson: JSObject) {
try {
val str = configJson.toString()
if (str.isNotEmpty()) {
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, str)
.apply()
}
} catch (e: Exception) {
Log.w(TAG, "saveDualScheduleConfig failed", e)
}
}
private fun clearDualScheduleConfig(context: Context) {
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
.edit()
.remove(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY)
.apply()
}
@PluginMethod
fun getDualScheduleStatus(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
@@ -1447,6 +1469,74 @@ open class DailyNotificationPlugin : Plugin() {
}
}
@PluginMethod
fun cancelDualSchedule(call: PluginCall) {
CoroutineScope(Dispatchers.IO).launch {
try {
if (context == null) {
call.reject("Context not available")
return@launch
}
val ctx = context!!
val db = getDatabase()
ScheduleHelper.cancelDualSchedule(ctx, db)
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
clearDualScheduleConfig(ctx)
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "cancelDualSchedule failed", e)
call.reject("Cancel dual schedule failed: ${e.message}")
}
}
}
@PluginMethod
fun updateDualScheduleConfig(call: PluginCall) {
val configJson = call.getObject("config") ?: run {
call.reject("Config is required")
return
}
val contentFetchObj = configJson.getJSObject("contentFetch") ?: run {
call.reject("contentFetch config is required")
return
}
val userNotificationObj = configJson.getJSObject("userNotification") ?: run {
call.reject("userNotification config is required")
return
}
CoroutineScope(Dispatchers.IO).launch {
try {
if (context == null) {
call.reject("Context not available")
return@launch
}
val ctx = context!!
val db = getDatabase()
ScheduleHelper.cancelDualSchedule(ctx, db)
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
val contentFetchConfig = parseContentFetchConfig(contentFetchObj)
val userNotificationConfig = parseUserNotificationConfig(userNotificationObj)
val success = ScheduleHelper.scheduleDualNotification(
ctx,
db,
contentFetchConfig,
userNotificationConfig,
FetchWorker::scheduleFetchForDual,
::calculateNextRunTime
)
if (success) {
saveDualScheduleConfig(ctx, configJson)
call.resolve()
} else {
call.reject("Update dual schedule failed")
}
} catch (e: Exception) {
Log.e(TAG, "updateDualScheduleConfig failed", e)
call.reject("Update dual schedule failed: ${e.message}")
}
}
}
@PluginMethod
fun registerCallback(call: PluginCall) {
try {
@@ -2787,9 +2877,9 @@ object ScheduleHelper {
// Schedule fetch
scheduleFetch(context, contentFetchConfig)
// Schedule notification
// Schedule notification (use dual_notify_* so receiver can recognize dual and apply relationship)
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
val scheduleId = "notify_${System.currentTimeMillis()}"
val scheduleId = "dual_notify_${System.currentTimeMillis()}"
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
@@ -2807,7 +2897,7 @@ object ScheduleHelper {
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
)
val notifySchedule = Schedule(
id = "dual_notify_${System.currentTimeMillis()}",
id = scheduleId,
kind = "notify",
cron = userNotificationConfig.schedule,
enabled = userNotificationConfig.enabled,
@@ -2873,6 +2963,33 @@ object ScheduleHelper {
}
}
/**
* Cancel only the dual (New Activity) schedule: alarms for dual_fetch_* / dual_notify_* and DB rows.
* Does not cancel Daily Reminder or other schedules. Caller must also cancel WorkManager unique work
* FetchWorker.WORK_NAME_DUAL.
*
* @param context Application context
* @param database Database instance
* @return Number of dual schedules removed
*/
suspend fun cancelDualSchedule(context: Context, database: DailyNotificationDatabase): Int {
return try {
val all = database.scheduleDao().getAll()
val dualSchedules = all.filter { it.id.startsWith("dual_fetch_") || it.id.startsWith("dual_notify_") }
if (dualSchedules.isEmpty()) {
Log.d("ScheduleHelper", "cancelDualSchedule: no dual schedules found")
return 0
}
cancelAlarmsForSchedules(context, dualSchedules)
dualSchedules.forEach { database.scheduleDao().deleteById(it.id) }
Log.i("ScheduleHelper", "cancelDualSchedule: cancelled and removed ${dualSchedules.size} dual schedule(s)")
dualSchedules.size
} catch (e: Exception) {
Log.e("ScheduleHelper", "cancelDualSchedule failed", e)
0
}
}
/**
* Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
* prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.

View File

@@ -127,8 +127,24 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
// Check if this is a static reminder (title/body in input data, not storage)
Data inputData = getInputData();
String scheduleId = inputData.getString("schedule_id");
// Dual (New Activity): resolve title/body from persisted config + content cache (relationship: contentTimeout, fallbackBehavior)
if (scheduleId != null && scheduleId.startsWith(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX)) {
NotificationContent content = DualScheduleHelper.resolveDualContentBlocking(getApplicationContext(), notificationId);
if (content != null) {
boolean displayed = displayNotification(content);
if (displayed) {
Log.i(TAG, "DN|DISPLAY_OK dual id=" + notificationId);
return Result.success();
}
}
Log.w(TAG, "DN|DISPLAY_SKIP dual_no_content id=" + notificationId);
return Result.success();
}
// Check if this is a static reminder (title/body in input data, not storage)
boolean isStaticReminder = inputData.getBoolean("is_static_reminder", false);
NotificationContent content;

View File

@@ -0,0 +1,68 @@
package org.timesafari.dailynotification
import android.content.Context
import android.util.Log
import kotlinx.coroutines.runBlocking
import org.json.JSONObject
/**
* Helper for resolving dual (New Activity) notification content at fire time.
* Applies relationship (contentTimeout, fallbackBehavior) using persisted config and content cache.
*/
object DualScheduleHelper {
private const val TAG = "DNP-DUAL"
/**
* Resolve title/body for a dual schedule: use cached content if within contentTimeout, else default from config.
* Call from Worker when schedule_id starts with DUAL_NOTIFY_SCHEDULE_ID_PREFIX.
*
* @param context Application context
* @param notificationId Notification run id for the display
* @return NotificationContent with resolved title/body, or null if no config or skip
*/
@JvmStatic
fun resolveDualContentBlocking(context: Context, notificationId: String): NotificationContent? {
return try {
val prefs = context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null) ?: return null
val config = JSONObject(configStr)
val userNotification = config.optJSONObject("userNotification") ?: return null
val relationship = config.optJSONObject("relationship")
val contentTimeoutMs = relationship?.optLong("contentTimeout", 300_000L) ?: 300_000L
val fallbackBehavior = relationship?.optString("fallbackBehavior", "show_default") ?: "show_default"
val defaultTitle = userNotification.optString("title", "Daily Notification")
val defaultBody = userNotification.optString("body", "Your daily update is ready")
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = runBlocking { db.contentCacheDao().getLatest() }
val nowMs = System.currentTimeMillis()
val (title: String, body: String) = if (latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs) {
val payloadStr = String(latestCache.payload, Charsets.UTF_8)
try {
val payload = JSONObject(payloadStr)
Pair(
payload.optString("title", defaultTitle),
payload.optString("body", payload.optString("content", defaultBody))
)
} catch (_: Exception) {
Pair(defaultTitle, defaultBody)
}
} else {
if (fallbackBehavior != "show_default") return null
Pair(defaultTitle, defaultBody)
}
val content = NotificationContent(title, body, nowMs)
content.setId(notificationId)
content.setSound(userNotification.optBoolean("sound", true))
content.setPriority(userNotification.optString("priority", "normal"))
Log.d(TAG, "Resolved dual content: useCache=${latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs}")
content
} catch (e: Exception) {
Log.w(TAG, "resolveDualContentBlocking failed", e)
null
}
}
}

View File

@@ -27,12 +27,24 @@ class FetchWorker(
companion object {
private const val TAG = "DNP-FETCH"
private const val WORK_NAME = "fetch_content"
/** Unique work name for dual (New Activity) schedule fetch; cancel via cancelDualSchedule only. */
const val WORK_NAME_DUAL = "fetch_dual"
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
enqueueFetch(context, config, WORK_NAME)
}
/**
* Schedule fetch for dual (New Activity) flow. Uses distinct work name so cancelDualSchedule can cancel only this.
*/
fun scheduleFetchForDual(context: Context, config: ContentFetchConfig) {
enqueueFetch(context, config, WORK_NAME_DUAL)
}
private fun enqueueFetch(context: Context, config: ContentFetchConfig, workName: String) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
@@ -49,13 +61,8 @@ class FetchWorker(
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.REPLACE,
workRequest
)
.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
}
/**

View File

@@ -2,6 +2,8 @@
**Purpose:** Checklist of what needs to be done to complete the dual-schedule (New Activity) implementation in the plugin and in the consuming app. For review before making changes.
**Status:** Cron parsing, stable dual ID, cancelDualSchedule, and updateDualScheduleConfig are implemented on iOS and Android. The **relationship** (contentTimeout / fallbackBehavior) is implemented: dual config is persisted; on iOS the pending dual notification is updated when the fetch completes; on Android the Worker resolves config + cache at fire time and shows the resolved title/body.
**Related:** Consuming app feedback doc `plugin-feedback-ios-scheduleDualNotification.md`; plugin `src/definitions.ts` (`DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`).
---
@@ -42,17 +44,50 @@ The completion plan below assumes this separation. Any new plugin code (e.g. iOS
- `UNCalendarNotificationTrigger` (or equivalent) for the user notification so it fires at the correct local time daily.
- Right now the implementation ignores the cron and always uses 86400 seconds, so schedules are wrong.
### 1.3 Use `relationship` (contentTimeout + fallbackBehavior)
### 1.3 Use `relationship` (contentTimeout + fallbackBehavior) — implemented
- **Content fetch:** Already runs in `handleBackgroundFetch` and can use the native fetcher; it stores content (e.g. via state actor / storage). No change needed for "run fetch at contentFetch.schedule."
- **User notification at userNotification.schedule:** Currently `scheduleUserNotification(config:)` (lines 741764) builds a notification from **config only** (title/body) and does not:
- Read cached content from the fetch,
- Apply `relationship.contentTimeout` (wait up to N ms for content),
- Apply `relationship.fallbackBehavior` (`show_default` vs skip vs retry).
- **Required:** When the user-notification time fires (or when the notification is prepared), resolve title/body by:
- Preferring cached content from the content fetch if it exists and is within `contentTimeout`;
- Else using `userNotification.title` / `userNotification.body` when `fallbackBehavior === "show_default"`.
- **Parsing:** Pass `relationship` from `scheduleDualNotification` into the code path that schedules and/or shows the user notification so timeout and fallback behavior are respected.
- **Intent:** When the user notification fires at `userNotification.schedule`, show **API-derived content** if the fetch completed and is within `relationship.contentTimeout`; otherwise show `userNotification.title` / `userNotification.body` (per `fallbackBehavior: "show_default"`).
- **Implemented:** Dual config (userNotification + relationship) is persisted when scheduling/updating. On **iOS**, after the content fetch completes in `handleBackgroundFetch`, the plugin replaces the pending dual notification with resolved title/body (from cache if within contentTimeout, else default). On **Android**, when the Worker runs for a `dual_notify_*` schedule, it loads the persisted config and content cache and resolves title/body at fire time, then displays one notification with that content. See **§1.3a** for implementation details (retained for reference).
### 1.3a Implementation plan: relationship (contentTimeout / fallbackBehavior)
Implement when ready so the New Activity notification can show API content when the fetch succeeds in time, or default text otherwise.
**Prerequisite: persist dual config (both platforms)**
When `scheduleDualNotification` or `updateDualScheduleConfig` runs, persist enough of the config for later use:
- **userNotification:** `schedule` (cron), `title`, `body` (and any other fields needed to build the notification).
- **relationship:** `contentTimeout`, `fallbackBehavior`.
So when we later resolve content (after fetch or at fire time), we have the default text and the rules. No new API surface; store what we already receive.
- **iOS:** e.g. a single key in UserDefaults (or alongside `native_fetcher_config`), e.g. `dual_schedule_config`, with this structure (e.g. JSON).
- **Android:** e.g. SharedPreferences or a keyed config; the code that runs at notification time (or after fetch) must be able to read it.
**iOS: update the pending notification when the fetch completes**
- When the content fetch runs (e.g. in `handleBackgroundFetch`), we already store the result. After a successful fetch:
1. **Read the persisted dual config.** If none (no dual schedule or legacy flow), skip.
2. **Resolve content:** Load the content just stored (or latest from cache) and its timestamp. If content exists and `(now - contentTimestamp) <= relationship.contentTimeout`, use that title/body; else use `userNotification.title` / `userNotification.body`.
3. **Replace the pending dual notification:** Remove the pending request with identifier `dualNotificationRequestIdentifier`, then add a new `UNNotificationRequest` with the same identifier, the same trigger (recompute from `userNotification.schedule` in stored config), and the resolved title/body.
- **Edge cases:** If the fetch completes after the notification time (next trigger already in the past), do not replace. If the fetch fails, leave the existing pending notification as-is (it already has default title/body).
**Android: resolve content when the notification is about to fire**
- On Android the “notification” is an alarm that fires at notify time and then runs code (e.g. `NotifyReceiver` / `DailyNotificationReceiver`) to display the notification. We cannot change the alarms “content” after the fact the same way as on iOS; we decide what to show when the alarm fires.
- **Persist dual config** when scheduling (same as above), keyed so the receiver can find it (e.g. by schedule id or a single “current dual config” key).
- **When the receiver runs** for a dual schedule (e.g. for `dual_notify_*` or the known dual schedule id): load the persisted dual config, load the latest content from the content cache and its timestamp, apply relationship (use cache if within `contentTimeout`, else default), then show one notification with that resolved title/body.
- The receiver must be dual-aware: for dual schedules it resolves title/body from config + cache + relationship instead of using fixed payload from the alarm.
**Summary**
| Step | iOS | Android |
|------|-----|---------|
| 1. Persist dual config | Store `userNotification` + `relationship` when scheduling/updating dual (e.g. UserDefaults). | Same; store when scheduling dual (e.g. SharedPreferences), keyed for the receiver. |
| 2. Where relationship is applied | In **handleBackgroundFetch** after storing content: resolve cache vs default, then **replace** the pending dual notification (same id, same trigger, new title/body). | In the **receiver** at notify time: load config + cache, resolve cache vs default, then **show** the notification with that title/body. |
| 3. Edge cases | Do not replace if next trigger is in the past; if fetch fails, leave existing default notification. | Receiver runs at fire time; “too old” handled by contentTimeout; if no config, fall back to alarm payload. |
### 1.4 Implement and register `cancelDualSchedule()` on iOS
@@ -73,9 +108,9 @@ The completion plan below assumes this separation. Any new plugin code (e.g. iOS
- **Replace semantics:** Whether or not the app uses `updateDualScheduleConfig`, the plugin must ensure that when a dual schedule already exists and the app calls `scheduleDualNotification` again (e.g. on Edit), the result is **replace** not **add** — no duplicate content-fetch tasks or user notifications. Using a stable dual notification identifier (see 1.4) and replacing the existing request when scheduling achieves this.
- **Other optional methods:** `pauseDualSchedule` and `resumeDualSchedule` remain optional; they are in `definitions.ts` but not required for the current app flow.
### 1.6 Android parity check
### 1.6 Android parity
- **cancelDualSchedule:** Android does not expose a dedicated `cancelDualSchedule`; it has `cancelAllNotifications` and WorkManager tag cancellation. For parity with the app's "turn off New Activity" flow, consider adding a `cancelDualSchedule` plugin method on Android that cancels **only** the dual-schedule work (e.g. the same tags used by `scheduleDualNotification`) and **not** the Daily Reminder schedule. That way turning off New Activity does not affect the user's Daily Reminder. Otherwise the app's call to `cancelDualSchedule()` may get `UNIMPLEMENTED` on Android too if the TS layer forwards it to native.
- **cancelDualSchedule / updateDualScheduleConfig:** Implemented; Android now exposes both methods and uses `FetchWorker.WORK_NAME_DUAL` so only dual fetch work is cancelled. For **relationship** (contentTimeout / fallbackBehavior), see §1.3a (resolve at fire time in receiver).
---
@@ -114,14 +149,15 @@ The completion plan below assumes this separation. Any new plugin code (e.g. iOS
| Where | What | Status / action |
|-------|------|------------------|
| **Plugin iOS** | `scheduleDualNotification` handler + registration | Done; fix bridge/build in app if still UNIMPLEMENTED. |
| **Plugin iOS** | Cron parsing in `calculateNextRunTime(from:)` | Replace stub with real parsing (match Android semantics). |
| **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` when showing user notification | Implement: prefer cached content, else default title/body. |
| **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Add handler and method registration; cancel BG task + dual user notifications only (use dedicated dual notification identifier; do not affect Daily Reminder). |
| **Plugin iOS** | `updateDualScheduleConfig(config)` | Implement for Edit-time use case; update existing dual schedule (e.g. cancel then schedule with new config). Use stable dual identifier so no duplicates. |
| **Plugin Android** | `cancelDualSchedule()` (if app calls it) | Add if not present; cancel only dual-schedule work, not Daily Reminder. |
| **Plugin Android** | `updateDualScheduleConfig(config)` | Implement for Edit-time use case; same semantics as iOS. |
| **Plugin both** | Replace semantics for dual schedule | When a dual schedule exists, calling `scheduleDualNotification` again or `updateDualScheduleConfig` must replace it, not add a second (no duplicate notifications). |
| **Plugin both** | Isolation of Daily Reminder vs New Activity | cancelDualSchedule must not touch reminder_* / Daily Reminder; cancelDailyReminder must not touch dual schedule. |
| **Plugin iOS** | Cron parsing in `calculateNextRunTime(from:)` | Done; real cron parsing (match Android semantics). |
| **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` | **Done;** persist dual config; in handleBackgroundFetch replace pending notification with resolved content. |
| **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Done; cancel BG task + dual user notification only; stable identifier. |
| **Plugin iOS** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
| **Plugin Android** | `cancelDualSchedule()` | Done; cancel dual schedules + WorkManager WORK_NAME_DUAL only. |
| **Plugin Android** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
| **Plugin Android** | Use `relationship` (contentTimeout / fallbackBehavior) | **Done;** persist dual config; in Worker at fire time (dual_notify_*) resolve config + cache and show resolved title/body. |
| **Plugin both** | Replace semantics for dual schedule | Done; stable dual identifier, replace before add. |
| **Plugin both** | Isolation of Daily Reminder vs New Activity | Done; cancelDualSchedule does not touch reminder_*. |
| **Consuming app** | Plugin linked and built for iOS | Verify dependency, `cap sync`, and build so native `scheduleDualNotification` is called. |
| **Consuming app** | Edit time: use `updateDualScheduleConfig` | In `editNewActivityNotification()`, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time; fallback to `scheduleDualNotification` if unavailable. |
| **Consuming app** | Error handling / UX | Optional: refine messages once plugin returns specific error codes. |

View File

@@ -32,6 +32,10 @@ public class DailyNotificationPlugin: CAPPlugin {
// Background task identifiers
private let fetchTaskIdentifier = "org.timesafari.dailynotification.fetch"
private let notifyTaskIdentifier = "org.timesafari.dailynotification.notify"
/// Stable identifier for the dual (New Activity) user notification. Used for replace and cancelDualSchedule; must not conflict with Daily Reminder (`reminder_<id>`).
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"
// Phase 1: Storage and Scheduler components
var storage: DailyNotificationStorage?
@@ -373,6 +377,7 @@ public class DailyNotificationPlugin: CAPPlugin {
try strongSelf.scheduleUserNotification(config: config)
}
)
saveDualScheduleConfig(config)
call.resolve()
} catch {
call.reject("Dual notification scheduling failed: \(error.localizedDescription)")
@@ -396,6 +401,123 @@ public class DailyNotificationPlugin: CAPPlugin {
}
}
/// Cancel the dual (New Activity) schedule only. Does not affect Daily Reminder (reminder_*).
@objc func cancelDualSchedule(_ call: CAPPluginCall) {
do {
performCancelDualSchedule()
call.resolve()
} catch {
call.reject("Cancel dual schedule failed: \(error.localizedDescription)")
}
}
/// Cancel only the dual content-fetch task and dual user notification. Used by cancelDualSchedule() and updateDualScheduleConfig().
private func performCancelDualSchedule() {
backgroundTaskScheduler.cancel(taskRequestWithIdentifier: fetchTaskIdentifier)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
UserDefaults.standard.removeObject(forKey: dualScheduleConfigKey)
print("DNP-PLUGIN: Canceled dual schedule (fetch task + user notification)")
}
@objc func updateDualScheduleConfig(_ call: CAPPluginCall) {
guard let config = call.getObject("config"),
let contentFetchConfig = config["contentFetch"] as? [String: Any],
let userNotificationConfig = config["userNotification"] as? [String: Any] else {
call.reject("Dual notification config required")
return
}
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)
}
)
saveDualScheduleConfig(config)
call.resolve()
} catch {
call.reject("Update dual schedule failed: \(error.localizedDescription)")
}
}
/// Persist dual schedule config (userNotification + relationship) for relationship resolution when fetch completes.
private func saveDualScheduleConfig(_ config: [String: Any]) {
guard config["userNotification"] != nil,
let jsonData = try? JSONSerialization.data(withJSONObject: config),
let jsonString = String(data: jsonData, encoding: .utf8) else { return }
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) {
guard let configJson = UserDefaults.standard.string(forKey: dualScheduleConfigKey),
let configData = configJson.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let userNotification = config["userNotification"] as? [String: Any] else {
return
}
let relationship = config["relationship"] as? [String: Any]
let contentTimeoutMs = (relationship?["contentTimeout"] as? NSNumber)?.intValue ?? 300_000
let fallbackBehavior = relationship?["fallbackBehavior"] as? String ?? "show_default"
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let useFetched = (nowMs - fetchedContent.fetchedAt) <= contentTimeoutMs
let title: String
let body: String
if useFetched {
title = fetchedContent.title
body = fetchedContent.body
} else if fallbackBehavior == "show_default" {
title = userNotification["title"] as? String ?? "Daily Notification"
body = userNotification["body"] as? String ?? "Your daily update is ready"
} else {
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)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
let request = UNNotificationRequest(identifier: dualNotificationRequestIdentifier, content: content, trigger: trigger)
notificationCenter.add(request) { [weak self] err in
if let e = err {
print("DNP-FETCH: Failed to update dual notification: \(e.localizedDescription)")
} else {
print("DNP-FETCH: Updated dual notification with \(useFetched ? "fetched" : "default") content")
}
}
}
/**
* Get health status for dual scheduling system
*
@@ -537,6 +659,9 @@ public class DailyNotificationPlugin: CAPPlugin {
self.storage?.saveLastSuccessfulRun(timestamp: currentTime)
}
// Relationship: update pending dual notification with resolved content (fetched if within contentTimeout, else default)
self.updateDualNotificationWithResolvedContent(fetchedContent: content)
// Phase 3.3: Recovery logic - verify scheduled notifications
// Check if notifications are still scheduled after fetch
if let reactivationManager = self.reactivationManager {
@@ -745,12 +870,29 @@ public class DailyNotificationPlugin: CAPPlugin {
content.body = config["body"] as? String ?? "Your daily update is ready"
content.sound = (config["sound"] as? Bool ?? true) ? .default : nil
// Create trigger (simplified - would use proper cron parsing in production)
let nextRunTime = calculateNextRunTime(from: config["schedule"] as? String ?? "0 9 * * *")
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: nextRunTime, repeats: false)
// Parse cron "minute hour * * *" for daily at local time (replace semantics: one dual notification)
let scheduleStr = config["schedule"] as? String ?? "0 9 * * *"
let parts = scheduleStr.trimmingCharacters(in: .whitespaces).split(separator: " ").map(String.init)
let hour: Int
let minute: Int
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
} else {
minute = 0
hour = 9
}
var dateComp = DateComponents()
dateComp.hour = hour
dateComp.minute = minute
dateComp.second = 0
let trigger = UNCalendarNotificationTrigger(dateMatching: dateComp, repeats: true)
// Replace any existing dual notification (stable id for cancelDualSchedule and updateDualScheduleConfig)
notificationCenter.removePendingNotificationRequests(withIdentifiers: [dualNotificationRequestIdentifier])
let request = UNNotificationRequest(
identifier: "daily-notification-\(Date().timeIntervalSince1970)",
identifier: dualNotificationRequestIdentifier,
content: content,
trigger: trigger
)
@@ -759,15 +901,31 @@ public class DailyNotificationPlugin: CAPPlugin {
if let error = error {
print("DNP-NOTIFY-SCHEDULE: Failed to schedule notification: \(error)")
} else {
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully")
print("DNP-NOTIFY-SCHEDULE: Notification scheduled successfully (id: \(self.dualNotificationRequestIdentifier))")
}
}
}
/// Parse cron "minute hour * * *" (daily) and return seconds from now until next occurrence (device local time).
/// Matches Android calculateNextRunTime semantics for parity.
private func calculateNextRunTime(from schedule: String) -> TimeInterval {
// Simplified implementation - would use proper cron parsing in production
// For now, return next day at 9 AM
return 86400 // 24 hours
let parts = schedule.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 {
print("DNP-SCHEDULE: Invalid cron format: \(schedule), defaulting to 24h from now")
return 24 * 60 * 60
}
var comp = DateComponents()
comp.hour = hour
comp.minute = minute
comp.second = 0
let cal = Calendar.current
guard let next = cal.nextDate(after: Date(), matching: comp, matchingPolicy: .nextTime) else {
return 24 * 60 * 60
}
let interval = next.timeIntervalSinceNow
return interval > 0 ? interval : (24 * 60 * 60)
}
// MARK: - Static Daily Reminder Methods
@@ -2197,6 +2355,8 @@ public class DailyNotificationPlugin: CAPPlugin {
methods.append(CAPPluginMethod(name: "scheduleUserNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleDualNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getDualScheduleStatus", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "updateDualScheduleConfig", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise))
return methods
}