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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user