From 7b41ca9e0bbb4ae3654fc84a5109446749906430 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Wed, 18 Mar 2026 21:10:49 +0800 Subject: [PATCH] feat(dual): complete scheduleDualNotification; add relationship (contentTimeout/fallbackBehavior) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../DailyNotificationConstants.kt | 16 ++ .../DailyNotificationPlugin.kt | 131 ++++++++++++- .../DailyNotificationWorker.java | 18 +- .../dailynotification/DualScheduleHelper.kt | 68 +++++++ .../dailynotification/FetchWorker.kt | 23 ++- ...PLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md | 76 ++++++-- ios/Plugin/DailyNotificationPlugin.swift | 176 +++++++++++++++++- 7 files changed, 464 insertions(+), 44 deletions(-) create mode 100644 android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt diff --git a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationConstants.kt b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationConstants.kt index 772adbf..6f4ef1d 100644 --- a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationConstants.kt +++ b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationConstants.kt @@ -140,6 +140,22 @@ object DailyNotificationConstants { * Used when user doesn't provide a custom ID */ 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 diff --git a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt index 04be8f3..8d2fc93 100644 --- a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt +++ b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationPlugin.kt @@ -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 { @@ -1446,6 +1468,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) { @@ -2787,12 +2877,12 @@ 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, + context, + nextRunTime, userNotificationConfig, scheduleId = scheduleId, source = ScheduleSource.INITIAL_SETUP @@ -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. diff --git a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java index de01ba5..6e8cca3 100644 --- a/android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java +++ b/android/src/main/java/org/timesafari/dailynotification/DailyNotificationWorker.java @@ -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; diff --git a/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt b/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt new file mode 100644 index 0000000..0e59986 --- /dev/null +++ b/android/src/main/java/org/timesafari/dailynotification/DualScheduleHelper.kt @@ -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 + } + } +} diff --git a/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt b/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt index 9251ca4..23eff66 100644 --- a/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt +++ b/android/src/main/java/org/timesafari/dailynotification/FetchWorker.kt @@ -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() .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) } /** diff --git a/doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md b/doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md index 06376b5..48bd71d 100644 --- a/doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md +++ b/doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md @@ -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 741–764) 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 alarm’s “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. | diff --git a/ios/Plugin/DailyNotificationPlugin.swift b/ios/Plugin/DailyNotificationPlugin.swift index 1c6cb8b..b57ca60 100644 --- a/ios/Plugin/DailyNotificationPlugin.swift +++ b/ios/Plugin/DailyNotificationPlugin.swift @@ -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_`). + 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 }