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/),
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
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",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides structured logging, event codes, and health monitoring
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.1
|
||||
* @version 1.1.2
|
||||
*/
|
||||
|
||||
import {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user