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:
Jose Olarte III
2026-02-13 19:26:09 +08:00
parent 602eafc892
commit 7702bd3b81
13 changed files with 677 additions and 67 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -2642,7 +2642,8 @@ 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,
@@ -2650,7 +2651,8 @@ object ScheduleHelper {
isStaticReminder = true,
reminderId = scheduleId,
scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP
source = ScheduleSource.INITIAL_SETUP,
skipPendingIntentIdempotence = true
)
// Always schedule prefetch 2 minutes before notification

View File

@@ -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()

View File

@@ -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,6 +122,10 @@ 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(
@@ -131,7 +135,8 @@ class NotifyReceiver : BroadcastReceiver() {
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
@@ -142,61 +147,60 @@ class NotifyReceiver : BroadcastReceiver() {
// 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
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)")
}
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
@@ -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,

View File

@@ -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",

View File

@@ -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

View 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 plugins 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`.

View 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.

View File

@@ -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",

View File

@@ -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

View File

@@ -3,7 +3,7 @@
* Provides structured logging, event codes, and health monitoring
*
* @author Matthew Raymer
* @version 1.1.1
* @version 1.1.2
*/
import {

View File

@@ -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 {