diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ffd69..7986848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Fixed diff --git a/README.md b/README.md index 271fee0..e776e2d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Daily Notification Plugin **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 **Last Updated**: 2025-12-23 UTC diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt index 22afec3..00e9e8d 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt @@ -2642,15 +2642,17 @@ object ScheduleHelper { Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime") // 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( - context, - nextRunTime, + context, + nextRunTime, config, isStaticReminder = true, reminderId = scheduleId, scheduleId = scheduleId, - source = ScheduleSource.INITIAL_SETUP + source = ScheduleSource.INITIAL_SETUP, + skipPendingIntentIdempotence = true ) // Always schedule prefetch 2 minutes before notification diff --git a/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt b/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt index aadcdc2..9180ee9 100644 --- a/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt +++ b/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt @@ -17,7 +17,7 @@ import org.json.JSONObject * Implements exponential backoff and network constraints * * @author Matthew Raymer - * @version 1.1.1 + * @version 1.1.2 */ class FetchWorker( appContext: Context, @@ -205,7 +205,7 @@ class FetchWorker( val entity = com.timesafari.dailynotification.entities.NotificationContentEntity( notificationId, - "1.1.1", // Plugin version + "1.1.2", // Plugin version null, // timesafariDid - can be set if available "daily", title, @@ -301,7 +301,7 @@ class FetchWorker( "timestamp": ${System.currentTimeMillis()}, "content": "Daily notification content", "source": "mock_generator", - "version": "1.1.1" + "version": "1.1.2" } """.trimIndent() return mockData.toByteArray() diff --git a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt index 70b6e25..bf7d00d 100644 --- a/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt +++ b/android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking * Implements TTL-at-fire logic and notification delivery * * @author Matthew Raymer - * @version 1.1.1 + * @version 1.1.2 */ /** * 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 scheduleId Stable identifier for the schedule (used for requestCode stability) * @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 fun scheduleExactNotification( - context: Context, + context: Context, triggerAtMillis: Long, config: UserNotificationConfig, isStaticReminder: Boolean = false, reminderId: 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 - + // Generate stable scheduleId - prefer provided scheduleId, then reminderId, then generate from time // This ensures same schedule always uses same ID for idempotence checks val stableScheduleId = scheduleId ?: reminderId ?: "daily_${triggerAtMillis}" - + // Generate notification ID (use reminderId if provided, otherwise generate from trigger time) 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 checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { setPackage(context.packageName) action = "com.timesafari.daily.NOTIFICATION" } - - // Check 1: Same scheduleId (stable requestCode) - most reliable - var existingPendingIntent = PendingIntent.getBroadcast( - context, - requestCode, - checkIntent, - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE - ) - - // 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( + + // IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling. + // Skip PendingIntent checks when caller just cancelled this schedule (Android may still + // return the cancelled PendingIntent from cache and cause the new schedule to be skipped). + if (!skipPendingIntentIdempotence) { + // Check 1: Same scheduleId (stable requestCode) - most reliable + var existingPendingIntent = PendingIntent.getBroadcast( context, - timeBasedRequestCode, + requestCode, checkIntent, PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE ) - } - - // Check 3: Also check if AlarmManager already has an alarm for this exact time - // This is a fallback for when PendingIntent checks fail but alarm still exists - // We check the next alarm clock time (Android 5.0+) - if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - val nextAlarm = alarmManager.nextAlarmClock - if (nextAlarm != null) { - val nextAlarmTime = nextAlarm.triggerTime - 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) - .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 + + // Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance) + if (existingPendingIntent == null) { + val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis) + existingPendingIntent = PendingIntent.getBroadcast( + context, + timeBasedRequestCode, + checkIntent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) + } + + // Check 3: AlarmManager next alarm (Android 5.0+) + if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val nextAlarm = alarmManager.nextAlarmClock + if (nextAlarm != null) { + 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 // This prevents logical duplicates before even hitting AlarmManager try { @@ -242,7 +246,7 @@ class NotifyReceiver : BroadcastReceiver() { val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context) val entity = com.timesafari.dailynotification.entities.NotificationContentEntity( notificationId, - "1.1.1", // Plugin version + "1.1.2", // Plugin version null, // timesafariDid - can be set if available "daily", config.title, diff --git a/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt index b3e198e..366112e 100644 --- a/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt +++ b/android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt @@ -247,7 +247,7 @@ class ReactivationManager(private val context: Context) { // Create new notification content entry for missed alarm val notification = com.timesafari.dailynotification.entities.NotificationContentEntity( notificationId, - "1.1.1", // Plugin version + "1.1.2", // Plugin version null, // timesafariDid "daily", // notificationType "Daily Notification", @@ -1014,7 +1014,7 @@ class ReactivationManager(private val context: Context) { // Create new notification content entry for missed alarm val notification = com.timesafari.dailynotification.entities.NotificationContentEntity( notificationId, - "1.1.1", // Plugin version + "1.1.2", // Plugin version null, // timesafariDid "daily", // notificationType "Daily Notification", diff --git a/android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java b/android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java index 35e4d7c..f64b4a8 100644 --- a/android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java +++ b/android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java @@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom { private final ExecutorService executorService; // Plugin version for migration tracking - private static final String PLUGIN_VERSION = "1.1.1"; + private static final String PLUGIN_VERSION = "1.1.2"; /** * Constructor diff --git a/docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md b/docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md new file mode 100644 index 0000000..a4eb1f9 --- /dev/null +++ b/docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md @@ -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`. diff --git a/docs/TIMESAFARI_ANDROID_COMPARISON.md b/docs/TIMESAFARI_ANDROID_COMPARISON.md new file mode 100644 index 0000000..d602074 --- /dev/null +++ b/docs/TIMESAFARI_ANDROID_COMPARISON.md @@ -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 + + +``` + +```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 + + +``` +- 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 + + + + + +``` + +**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 + +``` + +**TimeSafari (Broken):** +```xml + +``` + +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 + +``` + +**TimeSafari (Broken):** +```xml + + +``` + +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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 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. diff --git a/package.json b/package.json index d590613..07eebe5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "main": "dist/plugin.js", "module": "dist/esm/index.js", diff --git a/src/definitions.ts b/src/definitions.ts index 447dcd2..3cda12f 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -5,7 +5,7 @@ * Aligned with Android implementation and test requirements * * @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 diff --git a/src/observability.ts b/src/observability.ts index 924019d..c3a0ca7 100644 --- a/src/observability.ts +++ b/src/observability.ts @@ -3,7 +3,7 @@ * Provides structured logging, event codes, and health monitoring * * @author Matthew Raymer - * @version 1.1.1 + * @version 1.1.2 */ import { diff --git a/src/web.ts b/src/web.ts index 4497093..68fd804 100644 --- a/src/web.ts +++ b/src/web.ts @@ -7,7 +7,7 @@ * This implementation provides clear error messages for all methods. * * @author Matthew Raymer - * @version 1.1.1 + * @version 1.1.2 */ import type {