fix(android): second daily notification not firing after reschedule
Cancel-then-schedule was skipped because the idempotence check still found the cancelled PendingIntent in Android's cache. Skip PendingIntent idempotence on the cancel-then-schedule path so the new schedule is always set. - NotifyReceiver.scheduleExactNotification: add skipPendingIntentIdempotence (used only from scheduleDailyNotification) - ScheduleHelper: pass skipPendingIntentIdempotence=true after cancelNotification(scheduleId) - Version 1.1.2: package.json, CHANGELOG, README, TS/Android refs - docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md: optional app cleanup to use one stable id on both platforms
This commit is contained in:
@@ -5,6 +5,12 @@ All notable changes to the Daily Notification Plugin will be documented in this
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-02-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Android**: Second daily notification not firing after reschedule. After cancel-then-schedule, the idempotence check could still see the cancelled PendingIntent in Android's cache and skip the new schedule. The cancel-then-schedule path now skips PendingIntent-based idempotence so the new alarm is always registered.
|
||||||
|
|
||||||
## [1.1.1] - 2026-02-05
|
## [1.1.1] - 2026-02-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Daily Notification Plugin
|
# Daily Notification Plugin
|
||||||
|
|
||||||
**Author**: Matthew Raymer
|
**Author**: Matthew Raymer
|
||||||
**Version**: 1.1.1 (see `package.json` for source of truth)
|
**Version**: 1.1.2 (see `package.json` for source of truth)
|
||||||
**Created**: 2025-09-22 09:22:32 UTC
|
**Created**: 2025-09-22 09:22:32 UTC
|
||||||
**Last Updated**: 2025-12-23 UTC
|
**Last Updated**: 2025-12-23 UTC
|
||||||
|
|
||||||
|
|||||||
@@ -2642,15 +2642,17 @@ object ScheduleHelper {
|
|||||||
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
|
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
|
||||||
|
|
||||||
// Schedule AlarmManager notification as static reminder
|
// Schedule AlarmManager notification as static reminder
|
||||||
// (doesn't require cached content)
|
// (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
|
||||||
|
// this scheduleId and Android may still return the cancelled PendingIntent from cache.
|
||||||
NotifyReceiver.scheduleExactNotification(
|
NotifyReceiver.scheduleExactNotification(
|
||||||
context,
|
context,
|
||||||
nextRunTime,
|
nextRunTime,
|
||||||
config,
|
config,
|
||||||
isStaticReminder = true,
|
isStaticReminder = true,
|
||||||
reminderId = scheduleId,
|
reminderId = scheduleId,
|
||||||
scheduleId = scheduleId,
|
scheduleId = scheduleId,
|
||||||
source = ScheduleSource.INITIAL_SETUP
|
source = ScheduleSource.INITIAL_SETUP,
|
||||||
|
skipPendingIntentIdempotence = true
|
||||||
)
|
)
|
||||||
|
|
||||||
// Always schedule prefetch 2 minutes before notification
|
// Always schedule prefetch 2 minutes before notification
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import org.json.JSONObject
|
|||||||
* Implements exponential backoff and network constraints
|
* Implements exponential backoff and network constraints
|
||||||
*
|
*
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
* @version 1.1.1
|
* @version 1.1.2
|
||||||
*/
|
*/
|
||||||
class FetchWorker(
|
class FetchWorker(
|
||||||
appContext: Context,
|
appContext: Context,
|
||||||
@@ -205,7 +205,7 @@ class FetchWorker(
|
|||||||
|
|
||||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||||
notificationId,
|
notificationId,
|
||||||
"1.1.1", // Plugin version
|
"1.1.2", // Plugin version
|
||||||
null, // timesafariDid - can be set if available
|
null, // timesafariDid - can be set if available
|
||||||
"daily",
|
"daily",
|
||||||
title,
|
title,
|
||||||
@@ -301,7 +301,7 @@ class FetchWorker(
|
|||||||
"timestamp": ${System.currentTimeMillis()},
|
"timestamp": ${System.currentTimeMillis()},
|
||||||
"content": "Daily notification content",
|
"content": "Daily notification content",
|
||||||
"source": "mock_generator",
|
"source": "mock_generator",
|
||||||
"version": "1.1.1"
|
"version": "1.1.2"
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
return mockData.toByteArray()
|
return mockData.toByteArray()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
* Implements TTL-at-fire logic and notification delivery
|
* Implements TTL-at-fire logic and notification delivery
|
||||||
*
|
*
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
* @version 1.1.1
|
* @version 1.1.2
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Source of schedule request - tracks which code path triggered scheduling
|
* Source of schedule request - tracks which code path triggered scheduling
|
||||||
@@ -122,83 +122,87 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
* @param reminderId Optional reminder ID for tracking (used as scheduleId if provided)
|
* @param reminderId Optional reminder ID for tracking (used as scheduleId if provided)
|
||||||
* @param scheduleId Stable identifier for the schedule (used for requestCode stability)
|
* @param scheduleId Stable identifier for the schedule (used for requestCode stability)
|
||||||
* @param source Source of the scheduling request (for debugging duplicate alarms)
|
* @param source Source of the scheduling request (for debugging duplicate alarms)
|
||||||
|
* @param skipPendingIntentIdempotence If true, skip PendingIntent-based idempotence checks.
|
||||||
|
* Use when the caller has just cancelled this scheduleId (cancel-then-schedule path).
|
||||||
|
* Android may still return the cancelled PendingIntent from cache briefly, which would
|
||||||
|
* incorrectly cause the new schedule to be skipped.
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun scheduleExactNotification(
|
fun scheduleExactNotification(
|
||||||
context: Context,
|
context: Context,
|
||||||
triggerAtMillis: Long,
|
triggerAtMillis: Long,
|
||||||
config: UserNotificationConfig,
|
config: UserNotificationConfig,
|
||||||
isStaticReminder: Boolean = false,
|
isStaticReminder: Boolean = false,
|
||||||
reminderId: String? = null,
|
reminderId: String? = null,
|
||||||
scheduleId: String? = null,
|
scheduleId: String? = null,
|
||||||
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE
|
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE,
|
||||||
|
skipPendingIntentIdempotence: Boolean = false
|
||||||
) {
|
) {
|
||||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
|
||||||
// Generate stable scheduleId - prefer provided scheduleId, then reminderId, then generate from time
|
// Generate stable scheduleId - prefer provided scheduleId, then reminderId, then generate from time
|
||||||
// This ensures same schedule always uses same ID for idempotence checks
|
// This ensures same schedule always uses same ID for idempotence checks
|
||||||
val stableScheduleId = scheduleId ?: reminderId ?: "daily_${triggerAtMillis}"
|
val stableScheduleId = scheduleId ?: reminderId ?: "daily_${triggerAtMillis}"
|
||||||
|
|
||||||
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
|
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
|
||||||
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
|
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
|
||||||
|
|
||||||
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling
|
|
||||||
// This prevents duplicate alarms when multiple scheduling paths race
|
|
||||||
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
|
|
||||||
val requestCode = getRequestCode(stableScheduleId)
|
val requestCode = getRequestCode(stableScheduleId)
|
||||||
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
action = "com.timesafari.daily.NOTIFICATION"
|
action = "com.timesafari.daily.NOTIFICATION"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling.
|
||||||
var existingPendingIntent = PendingIntent.getBroadcast(
|
// Skip PendingIntent checks when caller just cancelled this schedule (Android may still
|
||||||
context,
|
// return the cancelled PendingIntent from cache and cause the new schedule to be skipped).
|
||||||
requestCode,
|
if (!skipPendingIntentIdempotence) {
|
||||||
checkIntent,
|
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
||||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
var existingPendingIntent = PendingIntent.getBroadcast(
|
||||||
)
|
|
||||||
|
|
||||||
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
|
|
||||||
// This catches cases where different scheduleIds are used for the same time
|
|
||||||
// Try a range of request codes around the trigger time
|
|
||||||
if (existingPendingIntent == null) {
|
|
||||||
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
|
|
||||||
existingPendingIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
context,
|
||||||
timeBasedRequestCode,
|
requestCode,
|
||||||
checkIntent,
|
checkIntent,
|
||||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
|
||||||
// Check 3: Also check if AlarmManager already has an alarm for this exact time
|
if (existingPendingIntent == null) {
|
||||||
// This is a fallback for when PendingIntent checks fail but alarm still exists
|
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
|
||||||
// We check the next alarm clock time (Android 5.0+)
|
existingPendingIntent = PendingIntent.getBroadcast(
|
||||||
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
context,
|
||||||
val nextAlarm = alarmManager.nextAlarmClock
|
timeBasedRequestCode,
|
||||||
if (nextAlarm != null) {
|
checkIntent,
|
||||||
val nextAlarmTime = nextAlarm.triggerTime
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||||
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
|
)
|
||||||
// If there's an alarm within 1 minute of our target time, consider it a duplicate
|
}
|
||||||
if (timeDiff < 60000) {
|
|
||||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
// Check 3: AlarmManager next alarm (Android 5.0+)
|
||||||
.format(java.util.Date(triggerAtMillis))
|
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
val nextAlarm = alarmManager.nextAlarmClock
|
||||||
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
|
if (nextAlarm != null) {
|
||||||
return
|
val nextAlarmTime = nextAlarm.triggerTime
|
||||||
|
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
|
||||||
|
if (timeDiff < 60000) {
|
||||||
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||||
|
.format(java.util.Date(triggerAtMillis))
|
||||||
|
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||||
|
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingPendingIntent != null) {
|
||||||
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||||
|
.format(java.util.Date(triggerAtMillis))
|
||||||
|
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||||
|
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existingPendingIntent != null) {
|
|
||||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
||||||
.format(java.util.Date(triggerAtMillis))
|
|
||||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
|
||||||
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
||||||
// This prevents logical duplicates before even hitting AlarmManager
|
// This prevents logical duplicates before even hitting AlarmManager
|
||||||
try {
|
try {
|
||||||
@@ -242,7 +246,7 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||||
notificationId,
|
notificationId,
|
||||||
"1.1.1", // Plugin version
|
"1.1.2", // Plugin version
|
||||||
null, // timesafariDid - can be set if available
|
null, // timesafariDid - can be set if available
|
||||||
"daily",
|
"daily",
|
||||||
config.title,
|
config.title,
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class ReactivationManager(private val context: Context) {
|
|||||||
// Create new notification content entry for missed alarm
|
// Create new notification content entry for missed alarm
|
||||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||||
notificationId,
|
notificationId,
|
||||||
"1.1.1", // Plugin version
|
"1.1.2", // Plugin version
|
||||||
null, // timesafariDid
|
null, // timesafariDid
|
||||||
"daily", // notificationType
|
"daily", // notificationType
|
||||||
"Daily Notification",
|
"Daily Notification",
|
||||||
@@ -1014,7 +1014,7 @@ class ReactivationManager(private val context: Context) {
|
|||||||
// Create new notification content entry for missed alarm
|
// Create new notification content entry for missed alarm
|
||||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||||
notificationId,
|
notificationId,
|
||||||
"1.1.1", // Plugin version
|
"1.1.2", // Plugin version
|
||||||
null, // timesafariDid
|
null, // timesafariDid
|
||||||
"daily", // notificationType
|
"daily", // notificationType
|
||||||
"Daily Notification",
|
"Daily Notification",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
|
|||||||
private final ExecutorService executorService;
|
private final ExecutorService executorService;
|
||||||
|
|
||||||
// Plugin version for migration tracking
|
// Plugin version for migration tracking
|
||||||
private static final String PLUGIN_VERSION = "1.1.1";
|
private static final String PLUGIN_VERSION = "1.1.2";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
|
|||||||
136
docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md
Normal file
136
docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Optional: Use a Single Stable Schedule ID on iOS and Android
|
||||||
|
|
||||||
|
**Audience:** Consuming apps (e.g. TimeSafari / crowd-funder-for-time-pwa) that use `@timesafari/daily-notification-plugin`.
|
||||||
|
**Purpose:** Describe an optional app-side cleanup now that the plugin’s Android second-schedule bug is fixed (plugin v1.1.2+).
|
||||||
|
**Use:** Feed this doc into Cursor (or any editor) in the consuming app repo when implementing the cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Plugin fix (v1.1.2):** After cancel-then-schedule on Android, the plugin no longer skips the new schedule due to PendingIntent cache. Rescheduling works reliably whether or not the app passes an explicit `id` to `scheduleDailyNotification`.
|
||||||
|
- **Previous workaround:** Some apps avoided passing `id` on Android and used the plugin default `"daily_notification"` so that the (now-fixed) second-schedule bug would not trigger. On iOS they passed a stable id (e.g. `"daily_timesafari_reminder"`) for getStatus/cancel and verification.
|
||||||
|
- **Optional cleanup:** You can use the **same** stable schedule id on both iOS and Android. That simplifies code (one id everywhere), makes getStatus/cancel and verification consistent across platforms, and is safe with plugin v1.1.2+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Depend on **`@timesafari/daily-notification-plugin@1.1.2`** (or `^1.1.2`) so the Android fix is in effect.
|
||||||
|
- No other code changes are required for the bug fix; this doc is only for the optional id cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to Change in the Consuming App
|
||||||
|
|
||||||
|
### 1. Single stable reminder ID (both platforms)
|
||||||
|
|
||||||
|
Use one reminder id for schedule, cancel, and getStatus on both iOS and Android.
|
||||||
|
|
||||||
|
**Example (current pattern):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before: different id per platform
|
||||||
|
private get reminderId(): string {
|
||||||
|
return Capacitor.getPlatform() === "ios"
|
||||||
|
? "daily_timesafari_reminder"
|
||||||
|
: "daily_notification";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (optional cleanup):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// After: same stable id on both platforms (requires plugin >= 1.1.2)
|
||||||
|
private readonly reminderId = "daily_timesafari_reminder";
|
||||||
|
```
|
||||||
|
|
||||||
|
Or keep a getter if you prefer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private get reminderId(): string {
|
||||||
|
return "daily_timesafari_reminder";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use whatever stable string your app already uses on iOS (e.g. `"daily_timesafari_reminder"`); no need to change the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Pass `id` when scheduling on Android
|
||||||
|
|
||||||
|
Today you may only add `scheduleOptions.id` on iOS. Add it for Android too so the plugin stores and returns this id (getStatus, getScheduledReminders, cancel all use it).
|
||||||
|
|
||||||
|
**Example (current pattern):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const scheduleOptions = {
|
||||||
|
time: options.time,
|
||||||
|
title: options.title,
|
||||||
|
body: options.body,
|
||||||
|
sound: true,
|
||||||
|
priority: (options.priority || "normal") as "low" | "default" | "high",
|
||||||
|
};
|
||||||
|
if (Capacitor.getPlatform() === "ios") {
|
||||||
|
scheduleOptions.id = this.reminderId;
|
||||||
|
}
|
||||||
|
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (optional cleanup):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const scheduleOptions = {
|
||||||
|
time: options.time,
|
||||||
|
title: options.title,
|
||||||
|
body: options.body,
|
||||||
|
sound: true,
|
||||||
|
priority: (options.priority || "normal") as "low" | "default" | "high",
|
||||||
|
id: this.reminderId, // same id on iOS and Android (plugin >= 1.1.2)
|
||||||
|
};
|
||||||
|
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
||||||
|
```
|
||||||
|
|
||||||
|
So: always pass `id: this.reminderId` (or your chosen constant) for both platforms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Update comments
|
||||||
|
|
||||||
|
Remove or update comments that say Android must not receive an `id` to avoid the second-schedule bug, and that the plugin uses `"daily_notification"` on Android. Replace with a short note that a single stable id is used on both platforms and requires plugin v1.1.2+.
|
||||||
|
|
||||||
|
**Example comment to add/update:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Stable schedule/reminder ID used for schedule, cancel, and getStatus.
|
||||||
|
* Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id).
|
||||||
|
*/
|
||||||
|
private readonly reminderId = "daily_timesafari_reminder";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Touch (typical)
|
||||||
|
|
||||||
|
- **Native notification service** (e.g. `src/services/notifications/NativeNotificationService.ts`):
|
||||||
|
- `reminderId`: use single value for both platforms.
|
||||||
|
- `scheduleDailyNotification`: always pass `id` in `scheduleOptions` (include Android).
|
||||||
|
- Adjust comments as above.
|
||||||
|
|
||||||
|
No changes are required to cancel or getStatus if they already use `this.reminderId`; they will now resolve the same schedule on Android as on iOS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Android:** Schedule a daily notification, then change time and save again (reschedule). The second scheduled time should fire; no need to reinstall.
|
||||||
|
2. **getStatus:** After scheduling on Android, getStatus should return the scheduled reminder with the same id you pass (e.g. `daily_timesafari_reminder`).
|
||||||
|
3. **Cancel:** Cancelling by that id on Android should clear the scheduled notification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Plugin CHANGELOG: `[1.1.2] - 2026-02-13` — Android second daily notification not firing after reschedule.
|
||||||
|
- Issue context (if present in consuming app): `doc/android-daily-notification-second-schedule-issue.md`.
|
||||||
462
docs/TIMESAFARI_ANDROID_COMPARISON.md
Normal file
462
docs/TIMESAFARI_ANDROID_COMPARISON.md
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
# Android Notification Implementation Comparison
|
||||||
|
|
||||||
|
**Test App (Working)** vs **TimeSafari (Not Working)**
|
||||||
|
|
||||||
|
This document identifies the critical differences between the test app where notifications work correctly and the TimeSafari app where notifications don't work at all. Use this as a checklist to fix TimeSafari.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Issues (Must Fix)
|
||||||
|
|
||||||
|
### 1. Missing Custom Application Class
|
||||||
|
|
||||||
|
**This is likely the primary cause of failure.**
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```xml
|
||||||
|
<!-- AndroidManifest.xml -->
|
||||||
|
<application
|
||||||
|
android:name=".TestApplication"
|
||||||
|
...>
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
// TestApplication.java
|
||||||
|
public class TestApplication extends Application {
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
Context context = getApplicationContext();
|
||||||
|
NativeNotificationContentFetcher testFetcher =
|
||||||
|
new com.timesafari.dailynotification.test.TestNativeFetcher(context);
|
||||||
|
DailyNotificationPlugin.setNativeFetcher(testFetcher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```xml
|
||||||
|
<!-- AndroidManifest.xml - NO android:name attribute -->
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
...>
|
||||||
|
```
|
||||||
|
- No custom Application class exists
|
||||||
|
- No native fetcher is registered
|
||||||
|
- Plugin cannot fetch notification content
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
1. Create `TimeSafariApplication.java` in `android/app/src/main/java/app/timesafari/`
|
||||||
|
2. Implement `NativeNotificationContentFetcher` specific to TimeSafari
|
||||||
|
3. Add `android:name=".TimeSafariApplication"` to AndroidManifest.xml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Missing Capacitor Plugin Configuration
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```typescript
|
||||||
|
// capacitor.config.ts
|
||||||
|
plugins: {
|
||||||
|
DailyNotification: {
|
||||||
|
debugMode: true,
|
||||||
|
enableNotifications: true,
|
||||||
|
timesafariConfig: {
|
||||||
|
activeDid: "did:ethr:0x...",
|
||||||
|
endpoints: {
|
||||||
|
projectsLastUpdated: "http://..."
|
||||||
|
},
|
||||||
|
starredProjectsConfig: {
|
||||||
|
enabled: true,
|
||||||
|
starredPlanHandleIds: [...],
|
||||||
|
fetchInterval: '0 8 * * *'
|
||||||
|
},
|
||||||
|
credentialConfig: {
|
||||||
|
jwtSecret: '...',
|
||||||
|
tokenExpirationMinutes: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
networkConfig: {
|
||||||
|
timeout: 30000,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 1000
|
||||||
|
},
|
||||||
|
contentFetch: {
|
||||||
|
enabled: true,
|
||||||
|
schedule: '0 00 * * *',
|
||||||
|
fetchLeadTimeMinutes: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```typescript
|
||||||
|
// capacitor.config.ts - NO DailyNotification configuration at all
|
||||||
|
plugins: {
|
||||||
|
App: { ... },
|
||||||
|
SplashScreen: { ... },
|
||||||
|
CapSQLite: { ... }
|
||||||
|
// DailyNotification is MISSING
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
Add `DailyNotification` configuration to `capacitor.config.ts` with appropriate values for TimeSafari.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Missing Permissions in AndroidManifest.xml
|
||||||
|
|
||||||
|
**Test App has these permissions that TimeSafari is missing:**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Add to TimeSafari's AndroidManifest.xml -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current TimeSafari permissions (incomplete):**
|
||||||
|
- ✅ `INTERNET`
|
||||||
|
- ✅ `POST_NOTIFICATIONS`
|
||||||
|
- ✅ `SCHEDULE_EXACT_ALARM`
|
||||||
|
- ✅ `USE_EXACT_ALARM`
|
||||||
|
- ✅ `RECEIVE_BOOT_COMPLETED`
|
||||||
|
- ✅ `WAKE_LOCK`
|
||||||
|
- ❌ `ACCESS_NETWORK_STATE` - **MISSING**
|
||||||
|
- ❌ `FOREGROUND_SERVICE` - **MISSING**
|
||||||
|
- ❌ `SYSTEM_ALERT_WINDOW` - **MISSING**
|
||||||
|
- ❌ `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` - **MISSING**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Missing Gradle Dependencies
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```gradle
|
||||||
|
// android/app/build.gradle
|
||||||
|
dependencies {
|
||||||
|
// Capacitor annotation processor for automatic plugin discovery
|
||||||
|
annotationProcessor project(':capacitor-android')
|
||||||
|
|
||||||
|
// Required dependencies for the plugin
|
||||||
|
implementation 'androidx.work:work-runtime:2.9.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```gradle
|
||||||
|
dependencies {
|
||||||
|
// Missing: annotationProcessor project(':capacitor-android')
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.9.0" // Using Kotlin version
|
||||||
|
// Missing: androidx.lifecycle:lifecycle-service
|
||||||
|
// Missing: com.google.code.gson:gson
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
Add to TimeSafari's `android/app/build.gradle`:
|
||||||
|
```gradle
|
||||||
|
annotationProcessor project(':capacitor-android')
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secondary Issues (Should Fix)
|
||||||
|
|
||||||
|
### 5. DailyNotificationReceiver Export Status
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```xml
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"> <!-- Note: false -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```xml
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"> <!-- Note: true - potential security issue -->
|
||||||
|
```
|
||||||
|
|
||||||
|
The test app uses `exported="false"` because the plugin creates PendingIntents with explicit component targeting. Using `exported="true"` is unnecessary and a potential security concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Missing Network Security Config
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```xml
|
||||||
|
<application
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
...>
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```xml
|
||||||
|
<application>
|
||||||
|
<!-- No networkSecurityConfig -->
|
||||||
|
```
|
||||||
|
|
||||||
|
This may affect HTTP (non-HTTPS) requests during development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Missing Java Compile Options
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```gradle
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
No explicit compile options set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Fix Checklist
|
||||||
|
|
||||||
|
### Step 1: Create Custom Application Class
|
||||||
|
|
||||||
|
Create file: `android/app/src/main/java/app/timesafari/TimeSafariApplication.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.timesafari.dailynotification.DailyNotificationPlugin;
|
||||||
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
|
||||||
|
public class TimeSafariApplication extends Application {
|
||||||
|
|
||||||
|
private static final String TAG = "TimeSafariApplication";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
Log.i(TAG, "Initializing TimeSafari notifications");
|
||||||
|
|
||||||
|
// Register native fetcher with application context
|
||||||
|
Context context = getApplicationContext();
|
||||||
|
NativeNotificationContentFetcher fetcher =
|
||||||
|
new TimeSafariNativeFetcher(context);
|
||||||
|
DailyNotificationPlugin.setNativeFetcher(fetcher);
|
||||||
|
|
||||||
|
Log.i(TAG, "Native fetcher registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Native Fetcher Implementation
|
||||||
|
|
||||||
|
Create file: `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
import com.timesafari.dailynotification.NotificationContent;
|
||||||
|
|
||||||
|
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
public TimeSafariNativeFetcher(Context context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public NotificationContent fetchContent(String scheduleId) {
|
||||||
|
// TODO: Implement actual content fetching for TimeSafari
|
||||||
|
// This should query the TimeSafari API for notification content
|
||||||
|
return new NotificationContent(
|
||||||
|
"timesafari_" + System.currentTimeMillis(),
|
||||||
|
"TimeSafari Update",
|
||||||
|
"Check your starred projects for updates!",
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
null,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Update AndroidManifest.xml
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:name=".TimeSafariApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<!-- ... existing content ... -->
|
||||||
|
|
||||||
|
<!-- Fix: Change exported to false -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.timesafari.daily.NOTIFICATION" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- ... rest of receivers ... -->
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Existing permissions -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- ADD these missing permissions -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
</manifest>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Update build.gradle
|
||||||
|
|
||||||
|
Add to `android/app/build.gradle`:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
android {
|
||||||
|
// ... existing config ...
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// ... existing dependencies ...
|
||||||
|
|
||||||
|
// ADD these for notification plugin
|
||||||
|
annotationProcessor project(':capacitor-android')
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Update capacitor.config.ts
|
||||||
|
|
||||||
|
Add DailyNotification configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
plugins: {
|
||||||
|
// ... existing plugins ...
|
||||||
|
|
||||||
|
DailyNotification: {
|
||||||
|
debugMode: true,
|
||||||
|
enableNotifications: true,
|
||||||
|
timesafariConfig: {
|
||||||
|
activeDid: '', // Will be set dynamically from user's DID
|
||||||
|
endpoints: {
|
||||||
|
projectsLastUpdated: 'https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween'
|
||||||
|
},
|
||||||
|
starredProjectsConfig: {
|
||||||
|
enabled: true,
|
||||||
|
starredPlanHandleIds: [],
|
||||||
|
fetchInterval: '0 8 * * *'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
networkConfig: {
|
||||||
|
timeout: 30000,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 1000
|
||||||
|
},
|
||||||
|
contentFetch: {
|
||||||
|
enabled: true,
|
||||||
|
schedule: '0 8 * * *',
|
||||||
|
fetchLeadTimeMinutes: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Rebuild
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx cap sync android
|
||||||
|
cd android && ./gradlew clean
|
||||||
|
cd .. && npx cap build android
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementing fixes, verify:
|
||||||
|
|
||||||
|
1. **Check logs for Application initialization:**
|
||||||
|
```bash
|
||||||
|
adb logcat | grep -E "TimeSafariApplication|Native fetcher"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check alarm scheduling:**
|
||||||
|
```bash
|
||||||
|
adb shell dumpsys alarm | grep -i timesafari
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test receiver manually:**
|
||||||
|
```bash
|
||||||
|
adb shell am broadcast -a com.timesafari.daily.NOTIFICATION \
|
||||||
|
--es id "test_notification" \
|
||||||
|
-n app.timesafari.app/com.timesafari.dailynotification.DailyNotificationReceiver
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check notification permissions:**
|
||||||
|
```bash
|
||||||
|
adb shell dumpsys package app.timesafari.app | grep -A 5 "granted=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Critical Differences
|
||||||
|
|
||||||
|
| Component | Test App (Working) | TimeSafari (Broken) |
|
||||||
|
|-----------|-------------------|---------------------|
|
||||||
|
| Custom Application class | ✅ TestApplication.java | ❌ None |
|
||||||
|
| Native fetcher registration | ✅ In Application.onCreate() | ❌ Not registered |
|
||||||
|
| DailyNotification config | ✅ Full config in capacitor.config.ts | ❌ Not configured |
|
||||||
|
| ACCESS_NETWORK_STATE | ✅ Present | ❌ Missing |
|
||||||
|
| FOREGROUND_SERVICE | ✅ Present | ❌ Missing |
|
||||||
|
| REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | ✅ Present | ❌ Missing |
|
||||||
|
| Gson dependency | ✅ Present | ❌ Missing |
|
||||||
|
| lifecycle-service dependency | ✅ Present | ❌ Missing |
|
||||||
|
| Capacitor annotation processor | ✅ Present | ❌ Missing |
|
||||||
|
|
||||||
|
**The most critical missing piece is the custom Application class with native fetcher registration.** Without this, the plugin has no way to fetch notification content when the alarm fires.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@timesafari/daily-notification-plugin",
|
"name": "@timesafari/daily-notification-plugin",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
|
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
|
||||||
"main": "dist/plugin.js",
|
"main": "dist/plugin.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Aligned with Android implementation and test requirements
|
* Aligned with Android implementation and test requirements
|
||||||
*
|
*
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
* @version 1.1.1 (see package.json for source of truth)
|
* @version 1.1.2 (see package.json for source of truth)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Import SPI types from content-fetcher.ts
|
// Import SPI types from content-fetcher.ts
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Provides structured logging, event codes, and health monitoring
|
* Provides structured logging, event codes, and health monitoring
|
||||||
*
|
*
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
* @version 1.1.1
|
* @version 1.1.2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* This implementation provides clear error messages for all methods.
|
* This implementation provides clear error messages for all methods.
|
||||||
*
|
*
|
||||||
* @author Matthew Raymer
|
* @author Matthew Raymer
|
||||||
* @version 1.1.1
|
* @version 1.1.2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
Reference in New Issue
Block a user