Compare commits
72 Commits
android-fi
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a5395082f6 | |||
|
|
b6f663121d | ||
|
|
5756178c23 | ||
|
|
fbb5a94071 | ||
|
|
9121b1e0f7 | ||
|
|
469167a55f | ||
|
|
a5c5a7e74e | ||
|
|
fc1cebd720 | ||
|
|
5f12b69d2a | ||
|
|
4dd1aea002 | ||
|
|
33010ad7cf | ||
|
|
ba1186c057 | ||
|
|
757263c073 | ||
|
|
539b011fa8 | ||
|
|
d3ade1f27a | ||
|
|
21ab05d63b | ||
|
|
87d24ca506 | ||
|
|
7b41ca9e0b | ||
|
|
7a1e58a4b6 | ||
| 4a1d476528 | |||
| 11561991bd | |||
|
|
ca6a75ded8 | ||
|
|
d8a0eaf413 | ||
|
|
b8d9b6247d | ||
|
|
6df1d4a7c6 | ||
|
|
daaf7aa62a | ||
| 1dc0052b39 | |||
|
|
6ad7ff5fe1 | ||
|
|
f58eeda8a7 | ||
|
|
36356e0aca | ||
|
|
6f4d946662 | ||
|
|
c38f235647 | ||
|
|
2714480070 | ||
|
|
e873a46bbd | ||
|
|
aa0eaa5389 | ||
|
|
c36781e440 | ||
|
|
cff7b659dc | ||
|
|
d3df4d9115 | ||
|
|
bc3bf484cc | ||
|
|
25f83cf1fa | ||
|
|
7188d32ae6 | ||
|
|
1157a0f1ef | ||
|
|
c2b1a60804 | ||
|
|
fa8028a698 | ||
|
|
9feaf60c84 | ||
|
|
aaeb71d31d | ||
|
|
531ce9f709 | ||
|
|
0b61d33f21 | ||
|
|
02a44a3e7b | ||
|
|
cb3cb5a78e | ||
|
|
a62f54b8a8 | ||
|
|
7702bd3b81 | ||
|
|
602eafc892 | ||
|
|
a77f08052f | ||
|
|
442b826401 | ||
|
|
bf90f158ac | ||
|
|
5dbe0d1455 | ||
|
|
7f79c5990b | ||
|
|
bef88ad844 | ||
|
|
839e167c98 | ||
|
|
f40562b68a | ||
|
|
f1830e5f6f | ||
|
|
f38b06abed | ||
|
|
ea4bc88808 | ||
|
|
63e5b4535e | ||
|
|
d913f03e23 | ||
|
|
4c1281754e | ||
|
|
9655fa10f8 | ||
|
|
6ac7b35566 | ||
|
|
62559cd546 | ||
|
|
7b1f1200bc | ||
|
|
39eed856f5 |
@@ -1239,10 +1239,10 @@ dependencies {
|
||||
-keep @androidx.room.Dao class *
|
||||
|
||||
# Plugin classes
|
||||
-keep class com.timesafari.dailynotification.** { *; }
|
||||
-keep class org.timesafari.dailynotification.** { *; }
|
||||
|
||||
# Capacitor plugin
|
||||
-keep class com.timesafari.dailynotification.DailyNotificationPlugin { *; }
|
||||
-keep class org.timesafari.dailynotification.DailyNotificationPlugin { *; }
|
||||
|
||||
# Encryption
|
||||
-keep class javax.crypto.** { *; }
|
||||
|
||||
12
BUILDING.md
12
BUILDING.md
@@ -653,7 +653,7 @@ public class MainActivity extends BridgeActivity {
|
||||
{
|
||||
"plugins": {
|
||||
"DailyNotification": {
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -728,7 +728,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
|
||||
{
|
||||
"id": "DailyNotification",
|
||||
"name": "DailyNotification",
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -899,7 +899,7 @@ npm install @timesafari/daily-notification-plugin
|
||||
npm install /path/to/daily-notification-plugin
|
||||
|
||||
# Install from git repository
|
||||
npm install git+https://github.com/timesafari/daily-notification-plugin.git
|
||||
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
|
||||
```
|
||||
|
||||
#### 3. Integration in Host Applications
|
||||
@@ -1128,7 +1128,7 @@ npx cap sync android
|
||||
#### AAR Duplicate Class Issues
|
||||
```bash
|
||||
# Problem: Duplicate class errors when integrating plugin AAR
|
||||
# Error: "Duplicate class com.timesafari.dailynotification.BootReceiver found in modules"
|
||||
# Error: "Duplicate class org.timesafari.dailynotification.BootReceiver found in modules"
|
||||
# Root Cause: Plugin being included both as project reference and as AAR file
|
||||
|
||||
# Solution 1: Use Project Reference Approach (Recommended)
|
||||
@@ -1215,7 +1215,7 @@ daily-notification-plugin/
|
||||
├── scripts/ # Build scripts and automation
|
||||
├── test-apps/ # Test applications
|
||||
│ └── daily-notification-test/ # Vue 3 test app
|
||||
├── docs/ # Documentation
|
||||
├── doc/ # Documentation
|
||||
├── examples/ # Usage examples
|
||||
├── tests/ # Test files
|
||||
├── package.json # Node.js dependencies
|
||||
@@ -1310,7 +1310,7 @@ scripts/
|
||||
|
||||
### Getting Help
|
||||
- Check the [troubleshooting section](#troubleshooting)
|
||||
- Review [GitHub issues](https://github.com/timesafari/daily-notification-plugin/issues)
|
||||
- Review [GitHub issues](https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin/issues)
|
||||
- Consult [Capacitor documentation](https://capacitorjs.com/docs)
|
||||
- Ask in [Capacitor community](https://github.com/ionic-team/capacitor/discussions)
|
||||
|
||||
|
||||
102
CHANGELOG.md
102
CHANGELOG.md
@@ -5,6 +5,108 @@ 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).
|
||||
|
||||
## [3.0.1] - 2026-04-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Dual native prefetch with an empty `NotificationContent` list no longer maps to placeholder title/body or arms the chained notify alarm for that cycle. The cache stores `skipNotification`, `DualScheduleHelper` skips display for fresh payloads (and for stale cache when `relationship.fallbackBehavior` is `skip`), and `DualScheduleFetchRecovery` still schedules the next prefetch.
|
||||
|
||||
### Added
|
||||
|
||||
- **Android**: Unit tests (`DualScheduleHelperTest`) for dual empty-cache resolution and skip payload detection.
|
||||
|
||||
## [3.0.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- **iOS**: `NativeNotificationContentFetcher` SPI, `FetchContext`, `NativeNotificationFetcherRegistry`, and `DailyNotificationPlugin.registerNativeFetcher(_:)` for host-provided fetch (parity with Android `setNativeFetcher`).
|
||||
- **iOS**: `updateStarredPlans` / `getStarredPlans` plugin methods; starred IDs stored under UserDefaults key `daily_notification_timesafari.starredPlanIds` (JSON array string).
|
||||
- **Android**: `DualScheduleNotifyScheduler` and `DUAL_NOTIFY_SCHEDULE_ID_KEY` to arm the dual user notification **after** prefetch completes.
|
||||
- **Docs**: `doc/CONSUMING_APP_HANDOFF_IOS_NATIVE_FETCHER_AND_CHAINED_DUAL.md` for consuming apps.
|
||||
|
||||
### Changed
|
||||
|
||||
- **iOS**: `configureNativeFetcher` requires a registered native fetcher (matches Android); calls `configure` on the fetcher; background fetch prefers registered fetcher with timeout, then legacy in-plugin HTTP when no fetcher + config exists.
|
||||
- **iOS**: Dual (`scheduleDualNotification`) uses **chained** scheduling: prefetch BG task only, then one-shot user notification after fetch (`armChainedDualNotificationAfterPrefetch`), with max slip before fallback copy.
|
||||
- **iOS**: `NotificationContent` is `public` for host fetcher implementations.
|
||||
- **Android**: Dual notify exact alarm is no longer scheduled in `ScheduleHelper.scheduleDualNotification`; it is scheduled when `FetchWorker` completes (`max(nextNotifyAt, now)`), with recovery enqueue unchanged.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **iOS**: `configureNativeFetcher` rejects if `registerNativeFetcher` was not called first.
|
||||
|
||||
## [2.1.5] - 2026-03-25
|
||||
|
||||
### Changed
|
||||
|
||||
- **Android**: Dual (`scheduleDualNotification`) content prefetch uses **WorkManager** with **`initialDelay`** to the next `contentFetch.schedule` occurrence (not an immediate fetch at setup). After each successful dual fetch, the next prefetch is re-enqueued from persisted dual config.
|
||||
- **Android**: Dual prefetch with no `contentFetch.url` invokes the registered **`NativeNotificationContentFetcher`** when present (same SPI as `DailyNotificationFetchWorker`); otherwise mock JSON is used for development.
|
||||
- **Android**: `content_cache` rows include **`cacheScope`** (`dual` | `daily` | `legacy`). Dual notify resolution reads only **`dual`**; daily reminder fetches write **`daily`**, avoiding cross-feature overwrites. Database version **4** with migration from v3.
|
||||
|
||||
### Documentation
|
||||
|
||||
- **Android**: `doc/platform/android/ANDROID_DUAL_SCHEDULE_NATIVE_FETCH_AND_CACHE_SCOPE.md` (implementation plan; see repo for details).
|
||||
|
||||
## [2.1.4] - 2026-03-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: `scheduleDualNotification` / `parseContentFetchConfig` no longer requires `timeout`, `retryAttempts`, and `retryDelay` in `contentFetch` (optional fields per TypeScript). Omitted values defer to `FetchWorker` defaults.
|
||||
- **Android**: `parseUserNotificationConfig` no longer uses strict `getBoolean` / `getString` for optional `userNotification` fields (`title`, `body`, `sound`, `vibration`, `priority`). Omitted keys no longer throw `JSONException`; native scheduling applies existing defaults (`NotifyReceiver` / `DualScheduleHelper`).
|
||||
|
||||
### Documentation
|
||||
|
||||
- **README**: Notes for omitted `contentFetch` and optional `userNotification` fields on Android.
|
||||
|
||||
## [1.1.6] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Alarm set after edit/reschedule now fires. Removed `existingPendingIntent.cancel()` in the "cancel existing alarm before rescheduling" path so the PendingIntent passed to `setAlarmClock` is not cancelled (only `alarmManager.cancel()` is used), fixing no-fire on some devices.
|
||||
|
||||
## [1.1.5] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Rollover work using a `daily_rollover_*` schedule id no longer overwrites the app's schedule row in the DB. `NotifyReceiver` post-schedule update skips the "first enabled notify" fallback when `stableScheduleId` starts with `daily_rollover_`, so the app's reminder (e.g. `daily_timesafari_reminder`) keeps the correct `nextRunAt` after a notification fires.
|
||||
|
||||
### Added
|
||||
|
||||
- **Docs**: `doc/platform/android/CONSUMING_APP_ANDROID_NOTES.md` — notes for consuming apps on debouncing double `scheduleDailyNotification` calls and debugging alarms that are scheduled but do not fire (logcat with `DailyNotificationReceiver`).
|
||||
|
||||
## [1.1.4] - 2026-02-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android**: Re-setting a daily notification (edit/save same time) no longer cancels the alarm and then skips re-scheduling. DB idempotence in `NotifyReceiver.scheduleExactNotification()` now runs only when `!skipPendingIntentIdempotence`, so the app reset flow can re-register the alarm.
|
||||
- **Android**: Static reminder title/body no longer revert to fallback after the first fire. `DailyNotificationWorker.scheduleNextNotification()` now preserves `is_static_reminder` and stable `scheduleId` on rollover so the next occurrence keeps custom text.
|
||||
|
||||
### Added
|
||||
|
||||
- **Android**: `cancelDailyReminder(call)` in `DailyNotificationPlugin.kt` for parity with iOS. Accepts `reminderId` (or `id`, `reminder_id`, `scheduleId`), cancels the AlarmManager alarm for that id, and performs best-effort DB cleanup (`setEnabled` false, `updateRunTimes` null).
|
||||
|
||||
## [1.1.3] - 2026-02-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android (Java)**: Java call sites for `NotifyReceiver.scheduleExactNotification()` now pass the 8th parameter `skipPendingIntentIdempotence`, fixing "actual and formal argument lists differ in length" when building consuming apps. Updated `DailyNotificationReceiver.java` and `DailyNotificationWorker.java`.
|
||||
|
||||
## [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
|
||||
|
||||
- **Android**: Target alarm broadcast to app package so receiver is triggered correctly
|
||||
|
||||
### Documentation
|
||||
|
||||
- EMULATOR_GUIDE: prerequisites, API 35, Apple Silicon; build.sh Android-only sync
|
||||
|
||||
## [2.1.0] - 2025-01-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
docs(building): update BUILDING.md with iOS prerequisites and clean-build script
|
||||
|
||||
Updates BUILDING.md to reflect recent changes in build-native.sh, especially
|
||||
the Xcode Command Line Tools prerequisite check and the clean-build script.
|
||||
|
||||
Problem:
|
||||
- BUILDING.md didn't mention Xcode Command Line Tools prerequisite
|
||||
(recently added to build-native.sh)
|
||||
- clean-build.sh script exists but wasn't documented
|
||||
- iOS build troubleshooting lacked Command Line Tools guidance
|
||||
|
||||
Changes:
|
||||
- Add Xcode Command Line Tools to Prerequisites section
|
||||
- Document installation command (xcode-select --install)
|
||||
- Include verification steps (xcode-select -p, xcodebuild -version)
|
||||
- Note that build script automatically checks for these tools
|
||||
- Explain that sqlite3 is part of Command Line Tools
|
||||
|
||||
- Document clean-build.sh script in Build Scripts section
|
||||
- Basic usage: ./scripts/clean-build.sh
|
||||
- All options: --all, --clean-gradle-cache, --clean-derived-data,
|
||||
--reinstall-node
|
||||
- Explain when to use clean builds
|
||||
|
||||
- Enhance iOS Native Build Process section
|
||||
- Add prerequisite note about Command Line Tools
|
||||
- Include troubleshooting commands for pod install issues
|
||||
- Reference prerequisites section for details
|
||||
|
||||
- Add comprehensive troubleshooting sections
|
||||
- Clean Build section at start of Troubleshooting
|
||||
- Recommends clean-build as first step for many issues
|
||||
- Lists when to use clean builds
|
||||
- iOS Build Issues section
|
||||
- Command Line Tools configuration errors
|
||||
- SQLite/linker issues and pkgx conflicts
|
||||
- CocoaPods installation problems
|
||||
- All with clear solutions and commands
|
||||
|
||||
The documentation now accurately reflects:
|
||||
- Xcode Command Line Tools as required iOS prerequisite
|
||||
- clean-build.sh as available build tool
|
||||
- Complete iOS troubleshooting workflow
|
||||
|
||||
Files modified:
|
||||
- BUILDING.md
|
||||
130
README.md
130
README.md
@@ -1,24 +1,35 @@
|
||||
# Daily Notification Plugin
|
||||
|
||||
**Author**: Matthew Raymer
|
||||
**Version**: 1.0.11 (see `package.json` for source of truth)
|
||||
**Created**: 2025-09-22 09:22:32 UTC
|
||||
**Last Updated**: 2025-12-23 UTC
|
||||
|
||||
## Overview
|
||||
|
||||
The Daily Notification Plugin is a comprehensive Capacitor plugin that provides enterprise-grade daily notification functionality across Android, iOS, and Electron platforms. It features dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability.
|
||||
The Daily Notification Plugin is a Capacitor plugin that provides daily notification functionality following local-first principles across Android, iOS, and Electron platforms.
|
||||
|
||||
This is to support apps that allow users to own their data. This approach is in contrast to standard server-managed notifications; they have the advantage of trustworthy delivery, but they have the following downsides:
|
||||
|
||||
* Users must store their search terms and notification preferences on the server.
|
||||
|
||||
* Users are not able to move their notifications elsewhere, and cannot take control of their notifications with their own apps.
|
||||
|
||||
* Peer-to-peer network scenarios are not supported.
|
||||
|
||||
There are two types of notifications supported:
|
||||
|
||||
* Periodic static reminder messages
|
||||
|
||||
* Periodic API requests, then notifying the user if there is new content
|
||||
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
**New to the plugin?** Start here:
|
||||
|
||||
1. **[Installation & Setup](./docs/GETTING_STARTED.md)** — Installation, platform setup, and basic usage
|
||||
2. **[Quick Start Guide](./docs/examples/QUICK_START.md)** — Minimal working example
|
||||
3. **[Common Patterns](./docs/examples/COMMON_PATTERNS.md)** — Common integration patterns
|
||||
4. **[Troubleshooting](./docs/TROUBLESHOOTING.md)** — Common issues and solutions
|
||||
1. **[Installation & Setup](./doc/GETTING_STARTED.md)** — Installation, platform setup, and basic usage
|
||||
2. **[Quick Start Guide](./doc/examples/QUICK_START.md)** — Minimal working example
|
||||
3. **[Common Patterns](./doc/examples/COMMON_PATTERNS.md)** — Common integration patterns
|
||||
4. **[Troubleshooting](./doc/TROUBLESHOOTING.md)** — Common issues and solutions
|
||||
|
||||
For complete documentation, see the [Documentation Index](./docs/00-INDEX.md).
|
||||
For complete documentation, see the [Documentation Index](./doc/00-INDEX.md).
|
||||
|
||||
### 🎯 **Native-First Architecture**
|
||||
|
||||
@@ -27,7 +38,7 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
**Platform Support:**
|
||||
- ✅ **Android**: WorkManager + AlarmManager + SQLite
|
||||
- ✅ **iOS**: BGTaskScheduler + UNUserNotificationCenter + Core Data
|
||||
- ✅ **Electron**: Desktop notifications + SQLite/LocalStorage
|
||||
- ⏳ **Electron**: Desktop notifications + SQLite/LocalStorage (someday)
|
||||
- ❌ **Web (PWA)**: Removed for native-first focus
|
||||
|
||||
**Key Benefits:**
|
||||
@@ -40,12 +51,8 @@ The plugin has been optimized for **native-first deployment** with the following
|
||||
|
||||
### **Overview**
|
||||
|
||||
Dec 17
|
||||
- test-apps
|
||||
- android has been seen to work
|
||||
- ios is being developed (Jose)
|
||||
- after ios, will work on daily-notification-test (that includes Vue)
|
||||
- need to test with real data in the API
|
||||
Stand-alone tests are found in the test-apps directory.
|
||||
- The daily-notification-test (that includes Vue) has worked but is not tested extensively.
|
||||
|
||||
### ✅ **Phase 2 Complete - Production Ready**
|
||||
|
||||
@@ -66,7 +73,6 @@ Dec 17
|
||||
|
||||
The plugin guarantees the following behaviors:
|
||||
|
||||
- **Monotonic Watermark**: Watermark values are strictly monotonic (never decrease)
|
||||
- **Idempotency**: Operations with the same idempotency key are safe to retry
|
||||
- **TTL Semantics**: Content with expired TTL is not delivered
|
||||
- **Schedule Persistence**: Schedules persist across app restarts
|
||||
@@ -80,7 +86,7 @@ The following behaviors are best-effort and may vary by platform:
|
||||
- **Background Fetch Timing**: Exact timing depends on OS scheduling
|
||||
- **Battery Optimization**: May be affected by device battery optimization settings
|
||||
|
||||
### 🧪 **Testing & Quality**
|
||||
### **Testing & Quality**
|
||||
|
||||
- **Test Coverage**: 58 tests across 4 test suites ✅
|
||||
- **Build Status**: TypeScript compilation and Rollup bundling ✅
|
||||
@@ -89,7 +95,7 @@ The following behaviors are best-effort and may vary by platform:
|
||||
|
||||
## Features
|
||||
|
||||
### 🚀 **Core Features**
|
||||
### **Core Features**
|
||||
|
||||
- **Dual Scheduling**: Separate content fetch and user notification scheduling
|
||||
- **TTL-at-Fire Logic**: Content validity checking at notification time
|
||||
@@ -98,25 +104,19 @@ The following behaviors are best-effort and may vary by platform:
|
||||
- **Static Daily Reminders**: Simple daily notifications without network content
|
||||
- **Cross-Platform**: Android, iOS, and Electron implementations
|
||||
|
||||
### 📱 **Platform Support**
|
||||
|
||||
- **Android**: WorkManager + AlarmManager + SQLite (Room)
|
||||
- **iOS**: BGTaskScheduler + UNUserNotificationCenter + Core Data
|
||||
- **Web**: ❌ Removed (native-first architecture)
|
||||
|
||||
### 🔧 **Enterprise Features**
|
||||
### **Enterprise Features**
|
||||
|
||||
- **Observability**: Structured logging with event codes
|
||||
- **Health Monitoring**: Comprehensive status and performance metrics
|
||||
- **Error Handling**: Exponential backoff and retry logic
|
||||
- **Security**: Encrypted storage and secure callback handling
|
||||
- **Database Access**: Full TypeScript interfaces for plugin database access
|
||||
- See [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) for complete API reference
|
||||
- See [docs/00-INDEX.md](docs/00-INDEX.md) for complete documentation index
|
||||
- See [`doc/architecture/DATABASE_INTERFACES.md`](doc/architecture/DATABASE_INTERFACES.md) for complete API reference
|
||||
- See [doc/00-INDEX.md](doc/00-INDEX.md) for complete documentation index
|
||||
- Plugin owns its SQLite database - access via Capacitor interfaces
|
||||
- Supports schedules, content cache, callbacks, history, and configuration
|
||||
|
||||
### ⏰ **Static Daily Reminders**
|
||||
### **Static Daily Reminders**
|
||||
|
||||
- **No Network Required**: Completely offline reminder notifications
|
||||
- **Simple Scheduling**: Easy daily reminder setup with HH:mm time format
|
||||
@@ -134,18 +134,14 @@ npm install @timesafari/daily-notification-plugin
|
||||
Or install from Git repository:
|
||||
|
||||
```bash
|
||||
npm install git+https://github.com/timesafari/daily-notification-plugin.git
|
||||
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
|
||||
```
|
||||
|
||||
The plugin follows the standard Capacitor Android structure - no additional path configuration needed!
|
||||
|
||||
## Documentation
|
||||
|
||||
**📚 Complete Documentation Index**: See [docs/00-INDEX.md](./docs/00-INDEX.md) for organized access to all documentation.
|
||||
|
||||
## Quick Integration
|
||||
|
||||
**New to the plugin?** Start with the [Quick Integration Guide](./docs/integration/QUICK_START.md) for step-by-step setup instructions.
|
||||
**New to the plugin?** Start with the [Quick Integration Guide](./doc/integration/QUICK_START.md) for step-by-step setup instructions.
|
||||
|
||||
The quick guide covers:
|
||||
- Installation and setup
|
||||
@@ -154,7 +150,7 @@ The quick guide covers:
|
||||
- Basic usage examples
|
||||
- Troubleshooting common issues
|
||||
|
||||
**For AI Agents**: See [AI Integration Guide](./docs/ai/AI_INTEGRATION_GUIDE.md) for explicit, machine-readable instructions with verification steps, error handling, and decision trees.
|
||||
**For AI Agents**: See [AI Integration Guide](./doc/ai/AI_INTEGRATION_GUIDE.md) for explicit, machine-readable instructions with verification steps, error handling, and decision trees.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -320,6 +316,12 @@ await DailyNotification.scheduleDualNotification({
|
||||
});
|
||||
```
|
||||
|
||||
If `contentFetch` omits `timeout`, `retryAttempts`, or `retryDelay`, Android applies defaults when scheduling fetch work (currently 30000 ms, 3 attempts, 1000 ms between attempts; see `FetchWorker`).
|
||||
|
||||
If `userNotification` omits optional fields (`title`, `body`, `sound`, `vibration`, `priority`), Android parses them as omitted; scheduling uses the same defaults as `NotifyReceiver` / `DualScheduleHelper` (e.g. sound and vibration default to on, priority to `normal` where applicable).
|
||||
|
||||
**Android (dual prefetch timing & cache):** Prefetch work is scheduled with a delay to the next `contentFetch.schedule` instant (best-effort under Doze/OEM). Fetched content is stored in a **scoped** cache row (`dual`) so it is not overwritten by the daily reminder fetch (`daily`). With no `contentFetch.url`, the host app’s **`NativeNotificationContentFetcher`** is used when registered.
|
||||
|
||||
### Callback Methods
|
||||
|
||||
#### `registerCallback(name, config)`
|
||||
@@ -410,13 +412,13 @@ console.log(`Will fire at: ${new Date(result.triggerAtMillis).toLocaleString()}`
|
||||
|
||||
For immediate validation of plugin functionality:
|
||||
|
||||
- **Android**: [Manual Smoke Test - Android](./docs/testing/MANUAL_SMOKE_TEST.md#android-platform-testing)
|
||||
- **iOS**: [Manual Smoke Test - iOS](./docs/testing/MANUAL_SMOKE_TEST.md#ios-platform-testing)
|
||||
- **Electron**: [Manual Smoke Test - Electron](./docs/testing/MANUAL_SMOKE_TEST.md#electron-platform-testing)
|
||||
- **Android**: [Manual Smoke Test - Android](./doc/testing/MANUAL_SMOKE_TEST.md#android-platform-testing)
|
||||
- **iOS**: [Manual Smoke Test - iOS](./doc/testing/MANUAL_SMOKE_TEST.md#ios-platform-testing)
|
||||
- **Electron**: [Manual Smoke Test - Electron](./doc/testing/MANUAL_SMOKE_TEST.md#electron-platform-testing)
|
||||
|
||||
### Manual Smoke Test Documentation
|
||||
|
||||
Complete testing procedures: [docs/testing/MANUAL_SMOKE_TEST.md](./docs/testing/MANUAL_SMOKE_TEST.md)
|
||||
Complete testing procedures: [doc/testing/MANUAL_SMOKE_TEST.md](./doc/testing/MANUAL_SMOKE_TEST.md)
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
@@ -559,18 +561,17 @@ await DailyNotification.updateDailyReminder('morning_checkin', {
|
||||
```xml
|
||||
<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" />
|
||||
|
||||
<!-- NotifyReceiver for AlarmManager-based notifications -->
|
||||
<!-- REQUIRED: Without this, alarms fire but notifications won't display -->
|
||||
<receiver android:name="com.timesafari.dailynotification.NotifyReceiver"
|
||||
<receiver android:name="org.timesafari.dailynotification.NotifyReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
</receiver>
|
||||
|
||||
<receiver android:name="com.timesafari.dailynotification.BootReceiver"
|
||||
<receiver android:name="org.timesafari.dailynotification.BootReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
@@ -605,8 +606,8 @@ dependencies {
|
||||
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.timesafari.dailynotification.content-fetch</string>
|
||||
<string>com.timesafari.dailynotification.notification-delivery</string>
|
||||
<string>org.timesafari.dailynotification.content-fetch</string>
|
||||
<string>org.timesafari.dailynotification.notification-delivery</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
@@ -795,7 +796,7 @@ console.log('Callbacks:', callbacks);
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/timesafari/daily-notification-plugin.git
|
||||
git clone https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
|
||||
cd daily-notification-plugin
|
||||
npm install
|
||||
npm run build
|
||||
@@ -818,29 +819,25 @@ npm test
|
||||
5. Ensure all tests pass
|
||||
6. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Support
|
||||
|
||||
### Documentation
|
||||
|
||||
**📚 [Complete Documentation Index](./docs/00-INDEX.md)** - Central hub for all project documentation
|
||||
**[Complete Documentation Index](./doc/00-INDEX.md)** - Central hub for all project documentation
|
||||
|
||||
**Key Documentation:**
|
||||
- **Integration**: [Integration Guide](./docs/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
|
||||
- **Integration**: [Integration Guide](./doc/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
|
||||
- **Platform Guides**:
|
||||
- [iOS Platform Docs](./docs/platform/ios/) - iOS implementation, migration, and troubleshooting
|
||||
- [Android Platform Docs](./docs/platform/android/) - Android implementation and directives
|
||||
- **Testing**: [Testing Documentation](./docs/testing/) - Comprehensive testing guides and procedures
|
||||
- **Alarms**: [Alarm System Docs](./docs/alarms/) - Alarm system documentation
|
||||
- **Database Interfaces**: [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) - Complete guide to accessing plugin database from TypeScript/webview
|
||||
- **Database Implementation**: [`docs/DATABASE_INTERFACES_IMPLEMENTATION.md`](docs/DATABASE_INTERFACES_IMPLEMENTATION.md) - Implementation summary and status
|
||||
- **Database Consolidation Plan**: [`docs/platform/android/DATABASE_CONSOLIDATION_PLAN.md`](docs/platform/android/DATABASE_CONSOLIDATION_PLAN.md) - Database schema consolidation roadmap
|
||||
- [iOS Platform Docs](./doc/platform/ios/) - iOS implementation, migration, and troubleshooting
|
||||
- [Android Platform Docs](./doc/platform/android/) - Android implementation and directives
|
||||
- **Testing**: [Testing Documentation](./doc/testing/) - Comprehensive testing guides and procedures
|
||||
- **Alarms**: [Alarm System Docs](./doc/alarms/) - Alarm system documentation
|
||||
- **Database Interfaces**: [`doc/architecture/DATABASE_INTERFACES.md`](doc/architecture/DATABASE_INTERFACES.md) - Complete guide to accessing plugin database from TypeScript/webview
|
||||
- **Database Implementation**: [`doc/DATABASE_INTERFACES_IMPLEMENTATION.md`](doc/DATABASE_INTERFACES_IMPLEMENTATION.md) - Implementation summary and status
|
||||
- **Database Consolidation Plan**: [`doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md`](doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md) - Database schema consolidation roadmap
|
||||
- **Building Guide**: [BUILDING.md](BUILDING.md) - Comprehensive build instructions and troubleshooting
|
||||
- **Design & Research**: [Design Documentation](./docs/design/) - Design research and implementation guides
|
||||
- **Archive**: [Legacy Documentation](./docs/archive/2025-legacy-doc/) - Historical documentation preserved for reference
|
||||
- **Design & Research**: [Design Documentation](./doc/design/) - Design research and implementation guides
|
||||
- **Archive**: [Legacy Documentation](./doc/archive/2025-legacy-doc/) - Historical documentation preserved for reference
|
||||
|
||||
### Community
|
||||
|
||||
@@ -853,10 +850,3 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
- **Custom Implementations**: Tailored solutions for enterprise needs
|
||||
- **Integration Support**: Help with complex integrations
|
||||
- **Performance Optimization**: Custom performance tuning
|
||||
|
||||
---
|
||||
|
||||
**Version**: 2.0.0
|
||||
**Last Updated**: 2025-09-22 09:22:32 UTC
|
||||
**Status**: Phase 2 Complete - Production Ready
|
||||
**Author**: Matthew Raymer
|
||||
|
||||
19
android/app/capacitor.build.gradle
Normal file
19
android/app/capacitor.build.gradle
Normal file
@@ -0,0 +1,19 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
16
android/app/src/main/assets/capacitor.config.json
Normal file
16
android/app/src/main/assets/capacitor.config.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"appId": "org.timesafari.dailynotification",
|
||||
"appName": "DailyNotification Test App",
|
||||
"webDir": "www",
|
||||
"server": {
|
||||
"androidScheme": "https"
|
||||
},
|
||||
"plugins": {
|
||||
"DailyNotification": {
|
||||
"fetchUrl": "https://api.example.com/daily-content",
|
||||
"scheduleTime": "09:00",
|
||||
"enableNotifications": true,
|
||||
"debugMode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
1
android/app/src/main/assets/capacitor.plugins.json
Normal file
1
android/app/src/main/assets/capacitor.plugins.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
0
android/app/src/main/assets/public/cordova.js
vendored
Normal file
0
android/app/src/main/assets/public/cordova.js
vendored
Normal file
0
android/app/src/main/assets/public/cordova_plugins.js
vendored
Normal file
0
android/app/src/main/assets/public/cordova_plugins.js
vendored
Normal file
475
android/app/src/main/assets/public/index.html
Normal file
475
android/app/src/main/assets/public/index.html
Normal file
@@ -0,0 +1,475 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>DailyNotification Plugin Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
margin: 10px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.status {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="statusCard" class="status" style="margin-bottom: 20px; font-size: 14px;">
|
||||
<strong>Plugin Status</strong><br>
|
||||
<div style="margin-top: 10px;">
|
||||
⚙️ Plugin Settings: <span id="configStatus">Not configured</span><br>
|
||||
🔌 Native Fetcher: <span id="fetcherStatus">Not configured</span><br>
|
||||
🔔 Notifications: <span id="notificationPermStatus">Checking...</span><br>
|
||||
⏰ Exact Alarms: <span id="exactAlarmPermStatus">Checking...</span><br>
|
||||
📢 Channel: <span id="channelStatus">Checking...</span><br>
|
||||
<div id="pluginStatusContent" style="margin-top: 8px;">
|
||||
Loading plugin status...
|
||||
</div>
|
||||
<div id="notificationReceivedIndicator" style="margin-top: 8px; padding: 8px; background: rgba(0, 255, 0, 0.2); border-radius: 5px; display: none;">
|
||||
<strong>🔔 Notification Received!</strong><br>
|
||||
<span id="notificationReceivedTime"></span><br>
|
||||
<small>Check the top of your screen for the notification banner</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="button" onclick="configurePlugin()">Configure Plugin</button>
|
||||
<button class="button" onclick="requestPermissions()">Request Permissions</button>
|
||||
<button class="button" onclick="testNotification()">Test Notification</button>
|
||||
<button class="button" onclick="checkComprehensiveStatus()">Full System Status</button>
|
||||
|
||||
<div id="status" class="status">
|
||||
Ready to test...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
console.log('Script loading...');
|
||||
console.log('JavaScript is working!');
|
||||
|
||||
// Use real DailyNotification plugin
|
||||
console.log('Using real DailyNotification plugin...');
|
||||
window.DailyNotification = window.Capacitor.Plugins.DailyNotification;
|
||||
|
||||
// Define functions immediately and attach to window
|
||||
|
||||
function configurePlugin() {
|
||||
console.log('configurePlugin called');
|
||||
const status = document.getElementById('status');
|
||||
const configStatus = document.getElementById('configStatus');
|
||||
const fetcherStatus = document.getElementById('fetcherStatus');
|
||||
|
||||
status.innerHTML = 'Configuring plugin...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
// Update top status to show configuring
|
||||
configStatus.innerHTML = '⏳ Configuring...';
|
||||
fetcherStatus.innerHTML = '⏳ Waiting...';
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
configStatus.innerHTML = '❌ Plugin unavailable';
|
||||
fetcherStatus.innerHTML = '❌ Plugin unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
// Configure plugin settings
|
||||
window.DailyNotification.configure({
|
||||
storage: 'tiered',
|
||||
ttlSeconds: 86400,
|
||||
prefetchLeadMinutes: 60,
|
||||
maxNotificationsPerDay: 3,
|
||||
retentionDays: 7
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Plugin settings configured, now configuring native fetcher...');
|
||||
// Update top status
|
||||
configStatus.innerHTML = '✅ Configured';
|
||||
|
||||
// Configure native fetcher with demo credentials
|
||||
// Note: DemoNativeFetcher uses hardcoded mock data, so this is optional
|
||||
// but demonstrates the API. In production, this would be real credentials.
|
||||
return window.DailyNotification.configureNativeFetcher({
|
||||
apiBaseUrl: 'http://10.0.2.2:3000', // Android emulator → host localhost
|
||||
activeDid: 'did:ethr:0xDEMO1234567890', // Demo DID
|
||||
jwtSecret: 'demo-jwt-secret-for-development-testing'
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
// Update top status
|
||||
fetcherStatus.innerHTML = '✅ Configured';
|
||||
|
||||
// Update bottom status for user feedback
|
||||
status.innerHTML = 'Plugin configured successfully!';
|
||||
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
|
||||
})
|
||||
.catch(error => {
|
||||
// Update top status with error
|
||||
if (configStatus.innerHTML.includes('Configuring')) {
|
||||
configStatus.innerHTML = '❌ Failed';
|
||||
}
|
||||
if (fetcherStatus.innerHTML.includes('Waiting') || fetcherStatus.innerHTML.includes('Configuring')) {
|
||||
fetcherStatus.innerHTML = '❌ Failed';
|
||||
}
|
||||
|
||||
status.innerHTML = `Configuration failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
configStatus.innerHTML = '❌ Error';
|
||||
fetcherStatus.innerHTML = '❌ Error';
|
||||
status.innerHTML = `Configuration failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
function loadPluginStatus() {
|
||||
console.log('loadPluginStatus called');
|
||||
const pluginStatusContent = document.getElementById('pluginStatusContent');
|
||||
const statusCard = document.getElementById('statusCard');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
pluginStatusContent.innerHTML = '❌ DailyNotification plugin not available';
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled';
|
||||
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
||||
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
||||
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
||||
📅 Next Notification: ${nextTime}<br>
|
||||
⏳ Pending: ${result.pending || 0}`;
|
||||
statusCard.style.background = hasSchedules ?
|
||||
'rgba(0, 255, 0, 0.15)' : 'rgba(255, 255, 255, 0.1)'; // Green if active, light gray if none
|
||||
})
|
||||
.catch(error => {
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
pluginStatusContent.innerHTML = `⚠️ Status check failed: ${error.message}`;
|
||||
statusCard.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
// Notification test functions
|
||||
function testNotification() {
|
||||
console.log('testNotification called');
|
||||
|
||||
// Quick sanity check - test plugin availability
|
||||
if (window.Capacitor && window.Capacitor.isPluginAvailable) {
|
||||
const isAvailable = window.Capacitor.isPluginAvailable('DailyNotification');
|
||||
console.log('is plugin available?', isAvailable);
|
||||
}
|
||||
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = 'Testing plugin connection...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the notification method directly
|
||||
console.log('Testing notification scheduling...');
|
||||
const now = new Date();
|
||||
const notificationTime = new Date(now.getTime() + 240000); // 4 minutes from now
|
||||
const prefetchTime = new Date(now.getTime() + 120000); // 2 minutes from now (2 min before notification)
|
||||
const notificationTimeString = notificationTime.getHours().toString().padStart(2, '0') + ':' +
|
||||
notificationTime.getMinutes().toString().padStart(2, '0');
|
||||
const prefetchTimeString = prefetchTime.getHours().toString().padStart(2, '0') + ':' +
|
||||
prefetchTime.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
window.DailyNotification.scheduleDailyNotification({
|
||||
time: notificationTimeString,
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification from the DailyNotification plugin!',
|
||||
sound: true,
|
||||
priority: 'high'
|
||||
})
|
||||
.then(() => {
|
||||
const prefetchTimeReadable = prefetchTime.toLocaleTimeString();
|
||||
const notificationTimeReadable = notificationTime.toLocaleTimeString();
|
||||
status.innerHTML = '✅ Notification scheduled!<br>' +
|
||||
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' +
|
||||
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')<br><br>' +
|
||||
'<small>💡 When the notification fires, look for a banner at the <strong>top of your screen</strong>.</small>';
|
||||
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
|
||||
// Refresh plugin status display
|
||||
setTimeout(() => loadPluginStatus(), 500);
|
||||
})
|
||||
.catch(error => {
|
||||
// Check if this is an exact alarm permission error
|
||||
if (error.code === 'EXACT_ALARM_PERMISSION_REQUIRED' ||
|
||||
error.message.includes('Exact alarm permission') ||
|
||||
error.message.includes('Alarms & reminders')) {
|
||||
status.innerHTML = '⚠️ Exact Alarm Permission Required<br><br>' +
|
||||
'Settings opened automatically.<br>' +
|
||||
'Please enable "Allow exact alarms" and return to try again.';
|
||||
status.style.background = 'rgba(255, 165, 0, 0.3)'; // Orange background
|
||||
} else {
|
||||
status.innerHTML = `❌ Notification failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
status.innerHTML = `Notification test failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Permission management functions
|
||||
function requestPermissions() {
|
||||
console.log('requestPermissions called');
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = 'Requesting permissions...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.requestNotificationPermissions()
|
||||
.then(() => {
|
||||
status.innerHTML = 'Permission request completed! Check your device settings if needed.';
|
||||
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
|
||||
|
||||
// Refresh permission and channel status display after request
|
||||
setTimeout(() => {
|
||||
loadPermissionStatus();
|
||||
loadChannelStatus();
|
||||
}, 1000);
|
||||
})
|
||||
.catch(error => {
|
||||
status.innerHTML = `Permission request failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
status.innerHTML = `Permission request failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
function loadChannelStatus() {
|
||||
const channelStatus = document.getElementById('channelStatus');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
channelStatus.innerHTML = '❌ Plugin unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.isChannelEnabled()
|
||||
.then(result => {
|
||||
const importanceText = getImportanceText(result.importance);
|
||||
if (result.enabled) {
|
||||
channelStatus.innerHTML = `✅ Enabled (${importanceText})`;
|
||||
} else {
|
||||
channelStatus.innerHTML = `❌ Disabled (${importanceText})`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
channelStatus.innerHTML = '⚠️ Error';
|
||||
});
|
||||
} catch (error) {
|
||||
channelStatus.innerHTML = '⚠️ Error';
|
||||
}
|
||||
}
|
||||
|
||||
function checkComprehensiveStatus() {
|
||||
const status = document.getElementById('status');
|
||||
status.innerHTML = 'Checking comprehensive status...';
|
||||
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
status.innerHTML = 'DailyNotification plugin not available';
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.checkStatus()
|
||||
.then(result => {
|
||||
const canSchedule = result.canScheduleNow;
|
||||
const issues = [];
|
||||
|
||||
if (!result.postNotificationsGranted) {
|
||||
issues.push('POST_NOTIFICATIONS permission');
|
||||
}
|
||||
if (!result.channelEnabled) {
|
||||
issues.push('notification channel disabled');
|
||||
}
|
||||
if (!result.exactAlarmsGranted) {
|
||||
issues.push('exact alarm permission');
|
||||
}
|
||||
|
||||
let statusText = `Status: ${canSchedule ? 'Ready to schedule' : 'Issues found'}`;
|
||||
if (issues.length > 0) {
|
||||
statusText += `\nIssues: ${issues.join(', ')}`;
|
||||
}
|
||||
|
||||
statusText += `\nChannel: ${getImportanceText(result.channelImportance)}`;
|
||||
statusText += `\nChannel ID: ${result.channelId}`;
|
||||
|
||||
status.innerHTML = statusText;
|
||||
status.style.background = canSchedule ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)';
|
||||
})
|
||||
.catch(error => {
|
||||
status.innerHTML = `Status check failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
});
|
||||
} catch (error) {
|
||||
status.innerHTML = `Status check failed: ${error.message}`;
|
||||
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
|
||||
}
|
||||
}
|
||||
|
||||
function getImportanceText(importance) {
|
||||
switch (importance) {
|
||||
case 0: return 'None (blocked)';
|
||||
case 1: return 'Min';
|
||||
case 2: return 'Low';
|
||||
case 3: return 'Default';
|
||||
case 4: return 'High';
|
||||
case 5: return 'Max';
|
||||
default: return `Unknown (${importance})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach to window object
|
||||
window.configurePlugin = configurePlugin;
|
||||
window.testNotification = testNotification;
|
||||
window.requestPermissions = requestPermissions;
|
||||
window.checkComprehensiveStatus = checkComprehensiveStatus;
|
||||
|
||||
function loadPermissionStatus() {
|
||||
const notificationPermStatus = document.getElementById('notificationPermStatus');
|
||||
const exactAlarmPermStatus = document.getElementById('exactAlarmPermStatus');
|
||||
|
||||
try {
|
||||
if (!window.DailyNotification) {
|
||||
notificationPermStatus.innerHTML = '❌ Plugin unavailable';
|
||||
exactAlarmPermStatus.innerHTML = '❌ Plugin unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
window.DailyNotification.checkPermissionStatus()
|
||||
.then(result => {
|
||||
notificationPermStatus.innerHTML = result.notificationsEnabled ? '✅ Granted' : '❌ Not granted';
|
||||
exactAlarmPermStatus.innerHTML = result.exactAlarmEnabled ? '✅ Granted' : '❌ Not granted';
|
||||
})
|
||||
.catch(error => {
|
||||
notificationPermStatus.innerHTML = '⚠️ Error';
|
||||
exactAlarmPermStatus.innerHTML = '⚠️ Error';
|
||||
});
|
||||
} catch (error) {
|
||||
notificationPermStatus.innerHTML = '⚠️ Error';
|
||||
exactAlarmPermStatus.innerHTML = '⚠️ Error';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for notification delivery periodically
|
||||
function checkNotificationDelivery() {
|
||||
if (!window.DailyNotification) return;
|
||||
|
||||
window.DailyNotification.getNotificationStatus()
|
||||
.then(result => {
|
||||
if (result.lastNotificationTime) {
|
||||
const lastTime = new Date(result.lastNotificationTime);
|
||||
const now = new Date();
|
||||
const timeDiff = now - lastTime;
|
||||
|
||||
// If notification was received in the last 2 minutes, show indicator
|
||||
if (timeDiff > 0 && timeDiff < 120000) {
|
||||
const indicator = document.getElementById('notificationReceivedIndicator');
|
||||
const timeSpan = document.getElementById('notificationReceivedTime');
|
||||
|
||||
if (indicator && timeSpan) {
|
||||
indicator.style.display = 'block';
|
||||
timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString()}`;
|
||||
|
||||
// Hide after 30 seconds
|
||||
setTimeout(() => {
|
||||
indicator.style.display = 'none';
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Silently fail - this is just for visual feedback
|
||||
});
|
||||
}
|
||||
|
||||
// Load plugin status automatically on page load
|
||||
window.addEventListener('load', () => {
|
||||
console.log('Page loaded, loading plugin status...');
|
||||
// Small delay to ensure Capacitor is ready
|
||||
setTimeout(() => {
|
||||
loadPluginStatus();
|
||||
loadPermissionStatus();
|
||||
loadChannelStatus();
|
||||
|
||||
// Check for notification delivery every 5 seconds
|
||||
setInterval(checkNotificationDelivery, 5000);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
|
||||
|
||||
console.log('Functions attached to window:', {
|
||||
configurePlugin: typeof window.configurePlugin,
|
||||
testNotification: typeof window.testNotification
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
6
android/app/src/main/res/xml/config.xml
Normal file
6
android/app/src/main/res/xml/config.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<access origin="*" />
|
||||
|
||||
|
||||
</widget>
|
||||
@@ -14,7 +14,7 @@ apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
android {
|
||||
namespace "com.timesafari.dailynotification.plugin"
|
||||
namespace "org.timesafari.dailynotification.plugin"
|
||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
|
||||
|
||||
defaultConfig {
|
||||
|
||||
59
android/capacitor-cordova-android-plugins/build.gradle
Normal file
59
android/capacitor-cordova-android-plugins/build.gradle
Normal file
@@ -0,0 +1,59 @@
|
||||
ext {
|
||||
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
|
||||
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
namespace "capacitor.cordova.android.plugins"
|
||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
|
||||
defaultConfig {
|
||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
|
||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
flatDir{
|
||||
dirs 'src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'src/main/libs', include: ['*.jar'])
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "org.apache.cordova:framework:$cordovaAndroidVersion"
|
||||
// SUB-PROJECT DEPENDENCIES START
|
||||
|
||||
// SUB-PROJECT DEPENDENCIES END
|
||||
}
|
||||
|
||||
// PLUGIN GRADLE EXTENSIONS START
|
||||
apply from: "cordova.variables.gradle"
|
||||
// PLUGIN GRADLE EXTENSIONS END
|
||||
|
||||
for (def func : cdvPluginPostBuildExtras) {
|
||||
func()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
ext {
|
||||
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
|
||||
// Plugin gradle extensions can append to this to have code run at the end.
|
||||
cdvPluginPostBuildExtras = []
|
||||
cordovaConfig = [:]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:amazon="http://schemas.amazon.com/apk/res/android">
|
||||
<application >
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
3
android/capacitor.settings.gradle
Normal file
3
android/capacitor.settings.gradle
Normal file
@@ -0,0 +1,3 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
@@ -2,7 +2,7 @@
|
||||
# These rules are applied to consuming apps when they use this plugin
|
||||
|
||||
# Keep plugin classes
|
||||
-keep class com.timesafari.dailynotification.** { *; }
|
||||
-keep class org.timesafari.dailynotification.** { *; }
|
||||
|
||||
# Keep Capacitor plugin interface
|
||||
-keep class com.getcapacitor.Plugin { *; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.timesafari.dailynotification.plugin">
|
||||
package="org.timesafari.dailynotification.plugin">
|
||||
|
||||
<!-- Plugin receivers are declared in consuming app's manifest -->
|
||||
<!-- This manifest is optional and mainly for library metadata -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"pkg": "@timesafari/daily-notification-plugin",
|
||||
"name": "DailyNotification",
|
||||
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -76,21 +76,24 @@ class BootReceiver : BroadcastReceiver() {
|
||||
// Reschedule AlarmManager notification
|
||||
val nextRunTime = calculateNextRunTime(schedule)
|
||||
if (nextRunTime > System.currentTimeMillis()) {
|
||||
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
)
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextRunTime,
|
||||
context,
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
@@ -43,7 +43,7 @@ public class ChannelManager {
|
||||
Log.d(TAG, "Ensuring notification channel exists");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
|
||||
if (channel == null) {
|
||||
Log.d(TAG, "Creating notification channel");
|
||||
@@ -72,7 +72,7 @@ public class ChannelManager {
|
||||
public boolean isChannelEnabled() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
if (channel == null) {
|
||||
Log.w(TAG, "Channel does not exist");
|
||||
return false;
|
||||
@@ -99,7 +99,7 @@ public class ChannelManager {
|
||||
public int getChannelImportance() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
return channel.getImportance();
|
||||
}
|
||||
@@ -117,7 +117,7 @@ public class ChannelManager {
|
||||
* @return true if settings intent was launched, false otherwise
|
||||
*/
|
||||
public boolean openChannelSettings() {
|
||||
return openChannelSettings(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
return openChannelSettings(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +142,7 @@ public class ChannelManager {
|
||||
try {
|
||||
Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName())
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId != null ? channelId : com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID)
|
||||
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId != null ? channelId : org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
context.startActivity(intent);
|
||||
@@ -180,11 +180,11 @@ public class ChannelManager {
|
||||
private void createDefaultChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID,
|
||||
com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_NAME,
|
||||
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID,
|
||||
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
);
|
||||
channel.setDescription(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_DESCRIPTION);
|
||||
channel.setDescription(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_DESCRIPTION);
|
||||
channel.enableLights(true);
|
||||
channel.enableVibration(true);
|
||||
channel.setShowBadge(true);
|
||||
@@ -200,7 +200,7 @@ public class ChannelManager {
|
||||
* @return the default channel ID
|
||||
*/
|
||||
public String getDefaultChannelId() {
|
||||
return com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID;
|
||||
return org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +209,7 @@ public class ChannelManager {
|
||||
public void logChannelStatus() {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
NotificationChannel channel = notificationManager.getNotificationChannel(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
|
||||
if (channel != null) {
|
||||
Log.i(TAG, "Channel Status - ID: " + channel.getId() +
|
||||
", Importance: " + channel.getImportance() +
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
/**
|
||||
* [org.timesafari.dailynotification.ContentCache] row discriminator so dual-schedule
|
||||
* prefetch does not overwrite daily-reminder cache (and vice versa).
|
||||
*/
|
||||
object ContentCacheScope {
|
||||
const val DUAL = "dual"
|
||||
const val DAILY = "daily"
|
||||
const val LEGACY = "legacy"
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
/**
|
||||
* Centralized constants for Daily Notification Plugin
|
||||
@@ -22,6 +22,12 @@ object DailyNotificationConstants {
|
||||
// Permission Request Codes
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Maximum number of distinct JWT strings allowed in [configureNativeFetcher] `jwtTokens` / pool.
|
||||
* Host apps (e.g. TimeSafari) use a pool for background prefetch; cap avoids oversized bridge payloads.
|
||||
*/
|
||||
const val JWT_TOKEN_POOL_MAX = 128
|
||||
|
||||
/**
|
||||
* Request code for notification permission requests
|
||||
* Used by ActivityCompat.requestPermissions()
|
||||
@@ -56,7 +62,7 @@ object DailyNotificationConstants {
|
||||
* Action string for notification broadcast intents
|
||||
* Used by AlarmManager PendingIntents
|
||||
*/
|
||||
const val ACTION_NOTIFICATION = "com.timesafari.daily.NOTIFICATION"
|
||||
const val ACTION_NOTIFICATION = "org.timesafari.daily.NOTIFICATION"
|
||||
|
||||
// ============================================================
|
||||
// Intent Extras Keys
|
||||
@@ -140,6 +146,28 @@ object DailyNotificationConstants {
|
||||
* Used when user doesn't provide a custom ID
|
||||
*/
|
||||
const val DEFAULT_SCHEDULE_ID = "daily_notification"
|
||||
|
||||
/**
|
||||
* SharedPreferences name for dual (New Activity) schedule config.
|
||||
* Used by plugin to persist config and by Worker to resolve relationship (contentTimeout/fallbackBehavior).
|
||||
*/
|
||||
const val DUAL_SCHEDULE_PREFS = "daily_notification_dual"
|
||||
|
||||
/**
|
||||
* Key for persisted dual schedule config JSON (userNotification + relationship).
|
||||
*/
|
||||
const val DUAL_SCHEDULE_CONFIG_KEY = "dual_schedule_config"
|
||||
|
||||
/**
|
||||
* Stable dual notify [Schedule.id] persisted when [ScheduleHelper.scheduleDualNotification] runs.
|
||||
* The user-visible alarm is scheduled after prefetch completes ([DualScheduleNotifyScheduler]).
|
||||
*/
|
||||
const val DUAL_NOTIFY_SCHEDULE_ID_KEY = "dual_notify_schedule_id"
|
||||
|
||||
/**
|
||||
* Prefix for dual notify schedule IDs. Receiver uses this to apply relationship at fire time.
|
||||
*/
|
||||
const val DUAL_NOTIFY_SCHEDULE_ID_PREFIX = "dual_notify_"
|
||||
|
||||
// ============================================================
|
||||
// Request Code Versioning
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -42,7 +42,7 @@ public class DailyNotificationFetcher {
|
||||
|
||||
private final Context context;
|
||||
private final DailyNotificationStorage storage; // Deprecated path (kept for transitional read paths)
|
||||
private final com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
|
||||
private final org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
|
||||
private final WorkManager workManager;
|
||||
|
||||
// ETag manager for efficient fetching
|
||||
@@ -60,7 +60,7 @@ public class DailyNotificationFetcher {
|
||||
|
||||
public DailyNotificationFetcher(Context context,
|
||||
DailyNotificationStorage storage,
|
||||
com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
|
||||
org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
|
||||
this.context = context;
|
||||
this.storage = storage;
|
||||
this.roomStorage = roomStorage;
|
||||
@@ -220,8 +220,8 @@ public class DailyNotificationFetcher {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
com.timesafari.dailynotification.entities.NotificationContentEntity entity =
|
||||
new com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
org.timesafari.dailynotification.entities.NotificationContentEntity entity =
|
||||
new org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
|
||||
"1.0.0",
|
||||
null,
|
||||
@@ -9,7 +9,7 @@
|
||||
* @created 2025-10-03 06:53:30 UTC
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Debug;
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
@@ -18,7 +18,7 @@ import androidx.work.WorkManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.Data
|
||||
import java.util.concurrent.TimeUnit
|
||||
import com.timesafari.dailynotification.DailyNotificationFetchWorker
|
||||
import org.timesafari.dailynotification.DailyNotificationFetchWorker
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
@@ -35,7 +35,7 @@ import org.json.JSONObject
|
||||
* Bridges Capacitor calls to native Android functionality
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
@CapacitorPlugin(name = "DailyNotification")
|
||||
open class DailyNotificationPlugin : Plugin() {
|
||||
@@ -519,12 +519,22 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required")
|
||||
val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required")
|
||||
|
||||
val jwtTokenPool: List<String>? = try {
|
||||
parseJwtTokenPool(options)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return call.reject(e.message ?: "Invalid jwt token pool")
|
||||
}
|
||||
|
||||
val nativeFetcher = getNativeFetcherStatic()
|
||||
if (nativeFetcher == null) {
|
||||
return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.")
|
||||
}
|
||||
|
||||
Log.i(TAG, "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid" +
|
||||
if (jwtTokenPool != null) ", jwtTokenPoolSize=${jwtTokenPool.size}" else ""
|
||||
)
|
||||
|
||||
// Call the native fetcher's configure method FIRST
|
||||
// This configures the fetcher instance with API credentials for background operations
|
||||
@@ -533,7 +543,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
|
||||
try {
|
||||
Log.d(TAG, "FETCHER|CONFIGURE_START apiBaseUrl=$apiBaseUrl, activeDid=${activeDid.take(30)}...")
|
||||
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken)
|
||||
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken, jwtTokenPool)
|
||||
configureSuccess = true
|
||||
Log.i(TAG, "FETCHER|CONFIGURE_COMPLETE success=true")
|
||||
} catch (e: Exception) {
|
||||
@@ -566,6 +576,12 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
// Only store JWT token if explicitly opted-in
|
||||
if (persistToken) {
|
||||
put("jwtToken", jwtToken)
|
||||
if (jwtTokenPool != null && jwtTokenPool.isNotEmpty()) {
|
||||
put("jwtTokenPool", JSONArray(jwtTokenPool))
|
||||
Log.w(TAG, "JWT token pool stored (size=${jwtTokenPool.size}, persistToken=true).")
|
||||
} else {
|
||||
put("jwtTokenPool", JSONArray())
|
||||
}
|
||||
Log.w(TAG, "JWT token stored in database (persistToken=true). " +
|
||||
"Database is NOT encrypted - token is stored in plain text.")
|
||||
} else {
|
||||
@@ -585,7 +601,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val config = com.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
val config = org.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
configId, null, "native_fetcher", "config", configValue, "json"
|
||||
)
|
||||
getDatabase().notificationConfigDao().insertConfig(config)
|
||||
@@ -606,6 +622,34 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
call.reject("Configuration error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional JWT pool from `jwtTokens` (bridge array) or `jwtTokenPoolJson` (JSON array string).
|
||||
* Prefer `jwtTokens` when both are present. Empty array → null (same as omitting).
|
||||
*/
|
||||
private fun parseJwtTokenPool(options: JSObject): List<String>? {
|
||||
val arr: JSONArray = when {
|
||||
options.has("jwtTokens") -> options.optJSONArray("jwtTokens")
|
||||
!options.optString("jwtTokenPoolJson", "").isNullOrBlank() ->
|
||||
JSONArray(options.getString("jwtTokenPoolJson"))
|
||||
else -> null
|
||||
} ?: return null
|
||||
if (arr.length() == 0) return null
|
||||
if (arr.length() > DailyNotificationConstants.JWT_TOKEN_POOL_MAX) {
|
||||
throw IllegalArgumentException(
|
||||
"jwtTokens must have at most ${DailyNotificationConstants.JWT_TOKEN_POOL_MAX} entries"
|
||||
)
|
||||
}
|
||||
val out = ArrayList<String>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
if (arr.isNull(i)) continue
|
||||
val s = arr.optString(i)
|
||||
if (s.isNotEmpty()) {
|
||||
out.add(s)
|
||||
}
|
||||
}
|
||||
return if (out.isEmpty()) null else out
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getNotificationStatus(call: PluginCall) {
|
||||
@@ -706,6 +750,34 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
scheduleDailyNotification(call)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelDailyReminder(call: PluginCall) {
|
||||
try {
|
||||
val reminderId = call.getString("reminderId")
|
||||
?: call.getString("id")
|
||||
?: call.getString("reminder_id")
|
||||
?: call.getString("scheduleId")
|
||||
if (reminderId.isNullOrBlank()) {
|
||||
call.reject("cancelDailyReminder: missing reminderId")
|
||||
return
|
||||
}
|
||||
NotifyReceiver.cancelNotification(context, scheduleId = reminderId)
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val db = getDatabase()
|
||||
db.scheduleDao().setEnabled(reminderId, false)
|
||||
db.scheduleDao().updateRunTimes(reminderId, null, null)
|
||||
}
|
||||
} catch (dbErr: Exception) {
|
||||
Log.w(TAG, "cancelDailyReminder: failed DB update for $reminderId", dbErr)
|
||||
}
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "cancelDailyReminder failed", e)
|
||||
call.reject("cancelDailyReminder failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if exact alarms can be scheduled
|
||||
* Helper method for internal use
|
||||
@@ -1027,51 +1099,14 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
return call.reject("Context not available")
|
||||
}
|
||||
|
||||
// Check if exact alarms can be scheduled
|
||||
// Do not open Settings or reject when exact alarms are not granted.
|
||||
// Proceed with scheduling; underlying layer uses inexact/windowed alarms when exact is unavailable.
|
||||
// Apps that want to prompt for exact alarm can use openExactAlarmSettings() or requestExactAlarmPermission().
|
||||
if (!canScheduleExactAlarms(context)) {
|
||||
// Permission not granted - request it
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
|
||||
// Open Settings to let user grant permission
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
||||
data = android.net.Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
|
||||
call.reject(
|
||||
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
|
||||
"EXACT_ALARM_PERMISSION_REQUIRED"
|
||||
)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open exact alarm settings", e)
|
||||
call.reject("Failed to open exact alarm settings: ${e.message}")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Permission permanently denied - direct to app settings
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = android.net.Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
|
||||
call.reject(
|
||||
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
|
||||
"PERMISSION_DENIED"
|
||||
)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open app settings", e)
|
||||
call.reject("Failed to open app settings: ${e.message}")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "Exact alarm permission not granted; scheduling will use inexact/windowed fallback.")
|
||||
}
|
||||
|
||||
// Permission granted - proceed with exact alarm scheduling
|
||||
// Proceed with scheduling (exact when granted, otherwise inexact/windowed)
|
||||
// Capacitor passes the object directly via call.data
|
||||
val options = call.data ?: return call.reject("Options are required")
|
||||
|
||||
@@ -1081,8 +1116,13 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val sound = options.getBoolean("sound") ?: true
|
||||
val priority = options.getString("priority") ?: "default"
|
||||
val url = options.getString("url") // Optional URL for prefetch (not used in helper yet)
|
||||
val rolloverIntervalMinutes = try {
|
||||
(options.getInt("rolloverIntervalMinutes") ?: 0).takeIf { it > 0 }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title")
|
||||
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title, rolloverIntervalMinutes=$rolloverIntervalMinutes")
|
||||
|
||||
// Convert HH:mm time to cron expression (daily at specified time)
|
||||
val cronExpression = convertTimeToCron(time)
|
||||
@@ -1109,6 +1149,14 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
|
||||
}
|
||||
|
||||
// Cancel only fetch-related WorkManager jobs so they cannot create a second (UUID) alarm
|
||||
// with fallback or placeholder text. Does not cancel display/dismiss; future fetched-content
|
||||
// flows should use distinct tags so they are not affected.
|
||||
val workCancelled = ScheduleHelper.cancelFetchRelatedWorkManagerJobs(context)
|
||||
if (workCancelled) {
|
||||
Log.i(TAG, "scheduleDailyNotification: Cancelled pending prefetch/fetch WorkManager jobs")
|
||||
}
|
||||
|
||||
val config = UserNotificationConfig(
|
||||
enabled = true,
|
||||
schedule = cronExpression,
|
||||
@@ -1126,7 +1174,8 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
scheduleId,
|
||||
config,
|
||||
time,
|
||||
::calculateNextRunTime
|
||||
::calculateNextRunTime,
|
||||
rolloverIntervalMinutes
|
||||
)
|
||||
|
||||
if (success) {
|
||||
@@ -1390,17 +1439,16 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Delegate to ScheduleHelper
|
||||
val success = ScheduleHelper.scheduleDualNotification(
|
||||
context,
|
||||
getDatabase(),
|
||||
contentFetchConfig,
|
||||
userNotificationConfig,
|
||||
FetchWorker::scheduleFetch,
|
||||
::calculateNextRunTime
|
||||
)
|
||||
|
||||
if (success) {
|
||||
saveDualScheduleConfig(context!!, configJson)
|
||||
call.resolve()
|
||||
} else {
|
||||
call.reject("Dual notification scheduling failed")
|
||||
@@ -1416,12 +1464,34 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveDualScheduleConfig(context: Context, configJson: JSObject) {
|
||||
try {
|
||||
val str = configJson.toString()
|
||||
if (str.isNotEmpty()) {
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, str)
|
||||
.apply()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "saveDualScheduleConfig failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearDualScheduleConfig(context: Context) {
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY)
|
||||
.remove(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDualScheduleStatus(call: PluginCall) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val enabledSchedules = getDatabase().scheduleDao().getEnabled()
|
||||
val latestCache = getDatabase().contentCacheDao().getLatest()
|
||||
val latestCache = getDatabase().contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
|
||||
val recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
|
||||
|
||||
val status = JSObject().apply {
|
||||
@@ -1441,6 +1511,73 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelDualSchedule(call: PluginCall) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (context == null) {
|
||||
call.reject("Context not available")
|
||||
return@launch
|
||||
}
|
||||
val ctx = context!!
|
||||
val db = getDatabase()
|
||||
ScheduleHelper.cancelDualSchedule(ctx, db)
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
|
||||
clearDualScheduleConfig(ctx)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "cancelDualSchedule failed", e)
|
||||
call.reject("Cancel dual schedule failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateDualScheduleConfig(call: PluginCall) {
|
||||
val configJson = call.getObject("config") ?: run {
|
||||
call.reject("Config is required")
|
||||
return
|
||||
}
|
||||
val contentFetchObj = configJson.getJSObject("contentFetch") ?: run {
|
||||
call.reject("contentFetch config is required")
|
||||
return
|
||||
}
|
||||
val userNotificationObj = configJson.getJSObject("userNotification") ?: run {
|
||||
call.reject("userNotification config is required")
|
||||
return
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (context == null) {
|
||||
call.reject("Context not available")
|
||||
return@launch
|
||||
}
|
||||
val ctx = context!!
|
||||
val db = getDatabase()
|
||||
ScheduleHelper.cancelDualSchedule(ctx, db)
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
|
||||
val contentFetchConfig = parseContentFetchConfig(contentFetchObj)
|
||||
val userNotificationConfig = parseUserNotificationConfig(userNotificationObj)
|
||||
val success = ScheduleHelper.scheduleDualNotification(
|
||||
ctx,
|
||||
db,
|
||||
contentFetchConfig,
|
||||
userNotificationConfig,
|
||||
::calculateNextRunTime
|
||||
)
|
||||
if (success) {
|
||||
saveDualScheduleConfig(ctx, configJson)
|
||||
call.resolve()
|
||||
} else {
|
||||
call.reject("Update dual schedule failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "updateDualScheduleConfig failed", e)
|
||||
call.reject("Update dual schedule failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun registerCallback(call: PluginCall) {
|
||||
@@ -1802,12 +1939,15 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val ttlSeconds = contentJson.getInt("ttlSeconds")
|
||||
?: return@launch call.reject("TTL seconds is required")
|
||||
|
||||
val scope = contentJson.getString("cacheScope")?.takeIf { it.isNotEmpty() }
|
||||
?: ContentCacheScope.LEGACY
|
||||
val cache = ContentCache(
|
||||
id = id,
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
ttlSeconds = ttlSeconds,
|
||||
payload = payload.toByteArray(),
|
||||
meta = contentJson.getString("meta")
|
||||
meta = contentJson.getString("meta"),
|
||||
cacheScope = scope
|
||||
)
|
||||
|
||||
getDatabase().contentCacheDao().upsert(cache)
|
||||
@@ -2072,6 +2212,8 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val options = call.getObject("options")
|
||||
val timesafariDid = options?.getString("timesafariDid")
|
||||
|
||||
Log.d(TAG, "DNP-CONFIG: Loading config from database: key=$key, timesafariDid=${timesafariDid?.take(20)}...")
|
||||
|
||||
val entity = if (timesafariDid != null) {
|
||||
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
|
||||
} else {
|
||||
@@ -2079,8 +2221,10 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
|
||||
if (entity != null) {
|
||||
Log.i(TAG, "DNP-CONFIG: Configuration restored from database: key=$key, configType=${entity.configType}, hasValue=${entity.configValue.isNotEmpty()}")
|
||||
call.resolve(configToJson(entity))
|
||||
} else {
|
||||
Log.d(TAG, "DNP-CONFIG: Configuration not found in database: key=$key")
|
||||
call.resolve(JSObject().apply { put("config", null) })
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -2144,7 +2288,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
?: return@launch call.reject("Config value is required")
|
||||
val configDataType = configJson.getString("configDataType", "string")
|
||||
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
id, timesafariDid, configType, configKey, configValue, configDataType
|
||||
)
|
||||
|
||||
@@ -2250,6 +2394,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
put("ttlSeconds", cache.ttlSeconds)
|
||||
put("payload", String(cache.payload))
|
||||
put("meta", cache.meta)
|
||||
put("cacheScope", cache.cacheScope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2276,7 +2421,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun configToJson(config: com.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
|
||||
private fun configToJson(config: org.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
|
||||
return JSObject().apply {
|
||||
put("id", config.id)
|
||||
put("timesafariDid", config.timesafariDid)
|
||||
@@ -2295,15 +2440,30 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
/**
|
||||
* Optional int from JSON: absent or JSON-null → null (aligns with TS `ContentFetchConfig` optional fields).
|
||||
* [FetchWorker] applies defaults (e.g. 30000 / 3 / 1000) when null.
|
||||
*/
|
||||
private fun JSObject.optIntOrNull(key: String): Int? =
|
||||
if (has(key) && !isNull(key)) optInt(key) else null
|
||||
|
||||
/** Optional boolean: absent or JSON-null → null (aligns with TS optional fields). */
|
||||
private fun JSObject.optBooleanOrNull(key: String): Boolean? =
|
||||
if (has(key) && !isNull(key)) optBoolean(key) else null
|
||||
|
||||
/** Optional string: absent or JSON-null → null. Present empty string is preserved. */
|
||||
private fun JSObject.optStringOrNull(key: String): String? =
|
||||
if (has(key) && !isNull(key)) optString(key) else null
|
||||
|
||||
private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
|
||||
val callbacksObj = configJson.getJSObject("callbacks")
|
||||
return ContentFetchConfig(
|
||||
enabled = configJson.getBoolean("enabled") ?: true,
|
||||
schedule = configJson.getString("schedule") ?: "0 9 * * *",
|
||||
url = configJson.getString("url"),
|
||||
timeout = configJson.getInt("timeout"),
|
||||
retryAttempts = configJson.getInt("retryAttempts"),
|
||||
retryDelay = configJson.getInt("retryDelay"),
|
||||
timeout = configJson.optIntOrNull("timeout"),
|
||||
retryAttempts = configJson.optIntOrNull("retryAttempts"),
|
||||
retryDelay = configJson.optIntOrNull("retryDelay"),
|
||||
callbacks = CallbackConfig(
|
||||
apiService = callbacksObj?.getString("apiService"),
|
||||
database = callbacksObj?.getString("database"),
|
||||
@@ -2316,57 +2476,16 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
return UserNotificationConfig(
|
||||
enabled = configJson.getBoolean("enabled") ?: true,
|
||||
schedule = configJson.getString("schedule") ?: "0 9 * * *",
|
||||
title = configJson.getString("title"),
|
||||
body = configJson.getString("body"),
|
||||
sound = configJson.getBoolean("sound"),
|
||||
vibration = configJson.getBoolean("vibration"),
|
||||
priority = configJson.getString("priority")
|
||||
title = configJson.optStringOrNull("title"),
|
||||
body = configJson.optStringOrNull("body"),
|
||||
sound = configJson.optBooleanOrNull("sound"),
|
||||
vibration = configJson.optBooleanOrNull("vibration"),
|
||||
priority = configJson.optStringOrNull("priority")
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateNextRunTime(schedule: String): Long {
|
||||
// Parse cron expression: "minute hour * * *" (daily schedule)
|
||||
// Example: "9 7 * * *" = 07:09 daily
|
||||
try {
|
||||
val parts = schedule.trim().split("\\s+".toRegex())
|
||||
if (parts.size < 2) {
|
||||
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val minute = parts[0].toIntOrNull() ?: 0
|
||||
val hour = parts[1].toIntOrNull() ?: 9
|
||||
|
||||
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
|
||||
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
// Calculate next occurrence of this time
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
val now = calendar.timeInMillis
|
||||
|
||||
// Set to today at the specified time
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
|
||||
calendar.set(java.util.Calendar.MINUTE, minute)
|
||||
calendar.set(java.util.Calendar.SECOND, 0)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
||||
|
||||
var nextRun = calendar.timeInMillis
|
||||
|
||||
// If the time has already passed today, schedule for tomorrow
|
||||
if (nextRun <= now) {
|
||||
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
nextRun = calendar.timeInMillis
|
||||
}
|
||||
|
||||
Log.d(TAG, "Calculated next run time: cron=$schedule, nextRun=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(nextRun))}")
|
||||
return nextRun
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calculating next run time for schedule: $schedule", e)
|
||||
// Fallback: 24 hours from now
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
return ScheduleCronUtils.calculateNextRunTimeMillis(schedule)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2465,7 +2584,7 @@ object TestDataHelper {
|
||||
suspend fun injectInvalidNotificationData(database: DailyNotificationDatabase): Boolean {
|
||||
return try {
|
||||
val invalidNotification =
|
||||
com.timesafari.dailynotification.entities.NotificationContentEntity()
|
||||
org.timesafari.dailynotification.entities.NotificationContentEntity()
|
||||
invalidNotification.id = "" // Empty ID - should be skipped by recovery
|
||||
invalidNotification.title = "Test Invalid Notification"
|
||||
invalidNotification.body = "This has an empty ID"
|
||||
@@ -2618,6 +2737,7 @@ object ScheduleHelper {
|
||||
* @param config User notification configuration
|
||||
* @param clockTime Original HH:mm time string
|
||||
* @param calculateNextRunTime Function to calculate next run time from cron expression
|
||||
* @param rolloverIntervalMinutes When > 0, next occurrence is this many minutes after trigger (dev/testing). Null/0 = 24h.
|
||||
* @return true if successful, false otherwise
|
||||
*/
|
||||
suspend fun scheduleDailyNotification(
|
||||
@@ -2626,82 +2746,131 @@ object ScheduleHelper {
|
||||
scheduleId: String,
|
||||
config: UserNotificationConfig,
|
||||
clockTime: String,
|
||||
calculateNextRunTime: (String) -> Long
|
||||
calculateNextRunTime: (String) -> Long,
|
||||
rolloverIntervalMinutes: Int? = null
|
||||
): Boolean {
|
||||
return try {
|
||||
val nextRunTime = calculateNextRunTime(config.schedule)
|
||||
|
||||
// CRITICAL: Cancel any existing alarm for this scheduleId BEFORE scheduling new one
|
||||
// This ensures "one per day" semantics - when updating schedule time, old alarm is canceled
|
||||
// The cleanupExistingNotificationSchedules() above only cancels OTHER schedules, not the current one
|
||||
NotifyReceiver.cancelNotification(context, scheduleId)
|
||||
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
|
||||
// (URL is optional - native fetcher will be used if registered)
|
||||
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before
|
||||
val delayMs = fetchTime - System.currentTimeMillis()
|
||||
// Do not enqueue prefetch for static reminders: display is already in the NotifyReceiver
|
||||
// alarm. Prefetch is for "fetch content then show"; for static reminders there is nothing
|
||||
// to fetch. Enqueueing prefetch would cause the worker to use fallback content and
|
||||
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
|
||||
// notifications at fire time.
|
||||
|
||||
if (delayMs > 0) {
|
||||
// Schedule delayed prefetch
|
||||
val inputData = Data.Builder()
|
||||
.putLong("scheduled_time", nextRunTime)
|
||||
.putLong("fetch_time", fetchTime)
|
||||
.putInt("retry_count", 0)
|
||||
.putBoolean("immediate", false)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
|
||||
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("prefetch")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(workRequest)
|
||||
|
||||
Log.i("ScheduleHelper", "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs")
|
||||
} else {
|
||||
// Fetch time is in the past, schedule immediate fetch
|
||||
val inputData = Data.Builder()
|
||||
.putLong("scheduled_time", nextRunTime)
|
||||
.putLong("fetch_time", System.currentTimeMillis())
|
||||
.putInt("retry_count", 0)
|
||||
.putBoolean("immediate", true)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
|
||||
.setInputData(inputData)
|
||||
.addTag("prefetch")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(workRequest)
|
||||
|
||||
Log.i("ScheduleHelper", "Immediate prefetch scheduled: notificationTime=$nextRunTime")
|
||||
}
|
||||
|
||||
// Store schedule in database
|
||||
// Store schedule in database (include rollover interval for dev/testing; survives reboot)
|
||||
val schedule = Schedule(
|
||||
id = scheduleId,
|
||||
kind = "notify",
|
||||
cron = config.schedule,
|
||||
clockTime = clockTime,
|
||||
enabled = true,
|
||||
nextRunAt = nextRunTime
|
||||
nextRunAt = nextRunTime,
|
||||
rolloverIntervalMinutes = rolloverIntervalMinutes
|
||||
)
|
||||
database.scheduleDao().upsert(schedule)
|
||||
|
||||
// Persist title/body for this scheduleId so rollover and post-reboot resolve user content
|
||||
// (see plugin-feedback-android-rollover-double-fire-and-user-content)
|
||||
try {
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
scheduleId,
|
||||
"1.3.1",
|
||||
null,
|
||||
"daily",
|
||||
config.title ?: "Daily Notification",
|
||||
config.body ?: "",
|
||||
nextRunTime,
|
||||
java.time.ZoneId.systemDefault().id
|
||||
)
|
||||
entity.soundEnabled = config.sound ?: true
|
||||
entity.vibrationEnabled = config.vibration ?: true
|
||||
entity.priority = when (config.priority) {
|
||||
"high", "max" -> 2
|
||||
"low", "min" -> -1
|
||||
else -> 0
|
||||
}
|
||||
entity.createdAt = System.currentTimeMillis()
|
||||
entity.updatedAt = System.currentTimeMillis()
|
||||
database.notificationContentDao().insertNotification(entity)
|
||||
Log.d("ScheduleHelper", "Persisted title/body for scheduleId=$scheduleId (rollover/post-reboot)")
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
|
||||
}
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking load of a schedule by id (for use from Java Worker / rollover path).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getScheduleBlocking(context: Context, scheduleId: String): Schedule? {
|
||||
return kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
DailyNotificationDatabase.getDatabase(context).scheduleDao().getById(scheduleId)
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "getScheduleBlocking failed: $scheduleId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking: first enabled notify schedule with rolloverIntervalMinutes > 0 (canonical for rollover chain).
|
||||
* Used when the firing run has schedule_id = daily_rollover_* so we can still apply the interval.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getCanonicalRolloverScheduleBlocking(context: Context): Schedule? {
|
||||
return kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
DailyNotificationDatabase.getDatabase(context).scheduleDao()
|
||||
.getByKindAndEnabled("notify", true)
|
||||
.firstOrNull { it.rolloverIntervalMinutes != null && it.rolloverIntervalMinutes > 0 }
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "getCanonicalRolloverScheduleBlocking failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking update of schedule next run time (for use from Java Worker after rollover).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun updateScheduleNextRunTimeBlocking(context: Context, scheduleId: String, lastRunAt: Long?, nextRunAt: Long) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
DailyNotificationDatabase.getDatabase(context).scheduleDao().updateRunTimes(scheduleId, lastRunAt, nextRunAt)
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "updateScheduleNextRunTimeBlocking failed: $scheduleId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule dual notification (fetch + notify)
|
||||
@@ -2712,7 +2881,6 @@ object ScheduleHelper {
|
||||
* @param database Database instance
|
||||
* @param contentFetchConfig Content fetch configuration
|
||||
* @param userNotificationConfig User notification configuration
|
||||
* @param scheduleFetch Function to schedule fetch (FetchWorker.scheduleFetch)
|
||||
* @param calculateNextRunTime Function to calculate next run time from cron expression
|
||||
* @return true if successful, false otherwise
|
||||
*/
|
||||
@@ -2721,23 +2889,25 @@ object ScheduleHelper {
|
||||
database: DailyNotificationDatabase,
|
||||
contentFetchConfig: ContentFetchConfig,
|
||||
userNotificationConfig: UserNotificationConfig,
|
||||
scheduleFetch: (Context, ContentFetchConfig) -> Unit,
|
||||
calculateNextRunTime: (String) -> Long
|
||||
): Boolean {
|
||||
return try {
|
||||
// Schedule fetch
|
||||
scheduleFetch(context, contentFetchConfig)
|
||||
|
||||
// Schedule notification
|
||||
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
|
||||
val scheduleId = "notify_${System.currentTimeMillis()}"
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextRunTime,
|
||||
userNotificationConfig,
|
||||
scheduleId = scheduleId,
|
||||
source = ScheduleSource.INITIAL_SETUP
|
||||
val nextFetchAt = calculateNextRunTime(contentFetchConfig.schedule)
|
||||
val nextNotifyAt = calculateNextRunTime(userNotificationConfig.schedule)
|
||||
FetchWorker.enqueueDualFetch(
|
||||
context,
|
||||
contentFetchConfig,
|
||||
nextFetchAt,
|
||||
nextNotifyAt
|
||||
)
|
||||
|
||||
// Chained dual: user notification is armed from FetchWorker after prefetch (see DualScheduleNotifyScheduler).
|
||||
val nextRunTime = nextNotifyAt
|
||||
val scheduleId = "${DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX}${System.currentTimeMillis()}"
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, scheduleId)
|
||||
.apply()
|
||||
|
||||
// Store both schedules
|
||||
val fetchSchedule = Schedule(
|
||||
@@ -2745,10 +2915,10 @@ object ScheduleHelper {
|
||||
kind = "fetch",
|
||||
cron = contentFetchConfig.schedule,
|
||||
enabled = contentFetchConfig.enabled,
|
||||
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
|
||||
nextRunAt = nextFetchAt
|
||||
)
|
||||
val notifySchedule = Schedule(
|
||||
id = "dual_notify_${System.currentTimeMillis()}",
|
||||
id = scheduleId,
|
||||
kind = "notify",
|
||||
cron = userNotificationConfig.schedule,
|
||||
enabled = userNotificationConfig.enabled,
|
||||
@@ -2814,9 +2984,57 @@ object ScheduleHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel only the dual (New Activity) schedule: alarms for dual_fetch_* / dual_notify_* and DB rows.
|
||||
* Does not cancel Daily Reminder or other schedules. Caller must also cancel WorkManager unique work
|
||||
* FetchWorker.WORK_NAME_DUAL.
|
||||
*
|
||||
* @param context Application context
|
||||
* @param database Database instance
|
||||
* @return Number of dual schedules removed
|
||||
*/
|
||||
suspend fun cancelDualSchedule(context: Context, database: DailyNotificationDatabase): Int {
|
||||
return try {
|
||||
val all = database.scheduleDao().getAll()
|
||||
val dualSchedules = all.filter { it.id.startsWith("dual_fetch_") || it.id.startsWith("dual_notify_") }
|
||||
if (dualSchedules.isEmpty()) {
|
||||
Log.d("ScheduleHelper", "cancelDualSchedule: no dual schedules found")
|
||||
return 0
|
||||
}
|
||||
cancelAlarmsForSchedules(context, dualSchedules)
|
||||
dualSchedules.forEach { database.scheduleDao().deleteById(it.id) }
|
||||
Log.i("ScheduleHelper", "cancelDualSchedule: cancelled and removed ${dualSchedules.size} dual schedule(s)")
|
||||
dualSchedules.size
|
||||
} catch (e: Exception) {
|
||||
Log.e("ScheduleHelper", "cancelDualSchedule failed", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
|
||||
* prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.
|
||||
* Use this when handling scheduleDailyNotification so pending prefetch does not run and create
|
||||
* a duplicate alarm; future fetched-content flows should use distinct tags so they are not affected.
|
||||
*
|
||||
* @param context Application context
|
||||
* @return true if cancellation was successful
|
||||
*/
|
||||
suspend fun cancelFetchRelatedWorkManagerJobs(context: Context): Boolean {
|
||||
return try {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelAllWorkByTag("prefetch")
|
||||
workManager.cancelAllWorkByTag("daily_notification_fetch")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "Failed to cancel fetch-related WorkManager jobs", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all WorkManager jobs by tags
|
||||
*
|
||||
*
|
||||
* @param context Application context
|
||||
* @return true if cancellation was successful
|
||||
*/
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -59,7 +59,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("com.timesafari.daily.NOTIFICATION".equals(action)) {
|
||||
if ("org.timesafari.daily.NOTIFICATION".equals(action)) {
|
||||
// Parse intent and enqueue work - keep receiver ultra-light
|
||||
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
||||
if (notificationId == null) {
|
||||
@@ -72,7 +72,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
enqueueNotificationWork(context, notificationId, intent);
|
||||
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
|
||||
|
||||
} else if ("com.timesafari.daily.DISMISS".equals(action)) {
|
||||
} else if ("org.timesafari.daily.DISMISS".equals(action)) {
|
||||
// Handle dismissal - also lightweight
|
||||
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
||||
if (notificationId != null) {
|
||||
@@ -107,9 +107,11 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
// Create unique work name based on notification ID to prevent duplicates
|
||||
// WorkManager will automatically skip if work with this name already exists
|
||||
String workName = "display_" + notificationId;
|
||||
|
||||
|
||||
// Extract static reminder extras from intent if present
|
||||
// Static reminders have title/body in Intent extras, not in storage
|
||||
// Static reminders have title/body in Intent extras, not in storage.
|
||||
// Do NOT access DB on main thread here (Room disallows it); Worker will resolve
|
||||
// missing title/body by schedule_id on a background thread (see plugin-feedback-android-rollover-double-fire).
|
||||
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
|
||||
String title = intent.getStringExtra("title");
|
||||
String body = intent.getStringExtra("body");
|
||||
@@ -119,13 +121,17 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
if (priority == null) {
|
||||
priority = "normal";
|
||||
}
|
||||
|
||||
String scheduleId = intent.getStringExtra("schedule_id");
|
||||
|
||||
Data.Builder dataBuilder = new Data.Builder()
|
||||
.putString("notification_id", notificationId)
|
||||
.putString("action", "display")
|
||||
.putBoolean("is_static_reminder", isStaticReminder);
|
||||
|
||||
// Add static reminder data if present
|
||||
if (scheduleId != null && !scheduleId.isEmpty()) {
|
||||
dataBuilder.putString("schedule_id", scheduleId);
|
||||
}
|
||||
|
||||
// Add static reminder data when present (from Intent; Worker resolves from DB by schedule_id if missing)
|
||||
if (isStaticReminder && title != null && body != null) {
|
||||
dataBuilder.putString("title", title)
|
||||
.putString("body", body)
|
||||
@@ -134,7 +140,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
.putString("priority", priority);
|
||||
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder id=" + notificationId);
|
||||
}
|
||||
|
||||
|
||||
Data inputData = dataBuilder.build();
|
||||
|
||||
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
|
||||
@@ -195,7 +201,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle notification intent
|
||||
*
|
||||
@@ -356,7 +362,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
|
||||
// Add dismiss action
|
||||
Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class);
|
||||
dismissIntent.setAction("com.timesafari.daily.DISMISS");
|
||||
dismissIntent.setAction("org.timesafari.daily.DISMISS");
|
||||
dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
|
||||
|
||||
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
|
||||
@@ -426,8 +432,8 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
}
|
||||
|
||||
// Create config for next notification
|
||||
com.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new com.timesafari.dailynotification.UserNotificationConfig(
|
||||
org.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new org.timesafari.dailynotification.UserNotificationConfig(
|
||||
true, // enabled
|
||||
cronExpression,
|
||||
content.getTitle() != null ? content.getTitle() : "Daily Notification",
|
||||
@@ -438,14 +444,15 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
);
|
||||
|
||||
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
|
||||
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
scheduleId,
|
||||
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||
);
|
||||
|
||||
Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId);
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -157,8 +157,8 @@ public class DailyNotificationScheduler {
|
||||
// Create intent for the notification; setPackage ensures AlarmManager delivery on all OEMs
|
||||
Intent intent = new Intent(context, DailyNotificationReceiver.class);
|
||||
intent.setPackage(context.getPackageName());
|
||||
intent.setAction(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
|
||||
intent.putExtra(com.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
|
||||
intent.setAction(org.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
|
||||
intent.putExtra(org.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
|
||||
|
||||
// Check if this is a static reminder
|
||||
if (content.getId().startsWith("reminder_") || content.getId().contains("_reminder")) {
|
||||
@@ -481,7 +481,7 @@ public class DailyNotificationScheduler {
|
||||
try {
|
||||
Log.d(TAG, "Scheduling test alarm in " + secondsFromNow + " seconds");
|
||||
// Delegate to NotifyReceiver.testAlarm()
|
||||
com.timesafari.dailynotification.NotifyReceiver.Companion.testAlarm(context, secondsFromNow);
|
||||
org.timesafari.dailynotification.NotifyReceiver.Companion.testAlarm(context, secondsFromNow);
|
||||
Log.i(TAG, "Test alarm scheduled successfully");
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error scheduling test alarm", e);
|
||||
@@ -591,7 +591,7 @@ public class DailyNotificationScheduler {
|
||||
// Note: NotifyReceiver.isAlarmScheduled is a Kotlin companion object function with default parameters
|
||||
// From Java, we need to use Companion and provide explicit values (null is acceptable for optional params)
|
||||
// Kotlin Long? maps to java.lang.Long in Java
|
||||
return com.timesafari.dailynotification.NotifyReceiver.Companion.isAlarmScheduled(
|
||||
return org.timesafari.dailynotification.NotifyReceiver.Companion.isAlarmScheduled(
|
||||
context,
|
||||
scheduleId,
|
||||
triggerAtMillis
|
||||
@@ -624,7 +624,7 @@ public class DailyNotificationScheduler {
|
||||
// Delegate to NotifyReceiver which checks actual AlarmManager state
|
||||
// Note: NotifyReceiver.getNextAlarmTime is a Kotlin companion object function
|
||||
// Kotlin Long? maps to java.lang.Long in Java
|
||||
return com.timesafari.dailynotification.NotifyReceiver.Companion.getNextAlarmTime(context);
|
||||
return org.timesafari.dailynotification.NotifyReceiver.Companion.getNextAlarmTime(context);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error getting next alarm time", e);
|
||||
return null;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -30,9 +30,9 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import com.timesafari.dailynotification.DailyNotificationFetcher;
|
||||
import org.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import org.timesafari.dailynotification.DailyNotificationFetcher;
|
||||
|
||||
/**
|
||||
* WorkManager worker for processing daily notifications
|
||||
@@ -127,13 +127,29 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
|
||||
|
||||
// Check if this is a static reminder (title/body in input data, not storage)
|
||||
Data inputData = getInputData();
|
||||
String scheduleId = inputData.getString("schedule_id");
|
||||
|
||||
// Dual (New Activity): resolve title/body from persisted config + content cache (relationship: contentTimeout, fallbackBehavior)
|
||||
if (scheduleId != null && scheduleId.startsWith(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX)) {
|
||||
NotificationContent content = DualScheduleHelper.resolveDualContentBlocking(getApplicationContext(), notificationId);
|
||||
if (content != null) {
|
||||
boolean displayed = displayNotification(content);
|
||||
if (displayed) {
|
||||
Log.i(TAG, "DN|DISPLAY_OK dual id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP dual_no_content id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// Check if this is a static reminder (title/body in input data, not storage)
|
||||
boolean isStaticReminder = inputData.getBoolean("is_static_reminder", false);
|
||||
NotificationContent content;
|
||||
|
||||
if (isStaticReminder) {
|
||||
// Static reminder: create NotificationContent from input data
|
||||
// Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
|
||||
String title = inputData.getString("title");
|
||||
String body = inputData.getString("body");
|
||||
boolean sound = inputData.getBoolean("sound", true);
|
||||
@@ -142,7 +158,17 @@ public class DailyNotificationWorker extends Worker {
|
||||
if (priority == null) {
|
||||
priority = "normal";
|
||||
}
|
||||
|
||||
// Post-reboot/rollover: Intent may lack title/body; resolve from DB by canonical schedule_id
|
||||
if ((title == null || title.isEmpty() || body == null || body.isEmpty()) && scheduleId != null) {
|
||||
NotificationContent canonical = getContentByScheduleId(scheduleId);
|
||||
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
|
||||
title = canonical.getTitle();
|
||||
body = canonical.getBody();
|
||||
sound = canonical.isSound();
|
||||
priority = canonical.getPriority() != null ? canonical.getPriority() : "normal";
|
||||
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER_FROM_DB id=" + notificationId + " schedule_id=" + scheduleId);
|
||||
}
|
||||
}
|
||||
if (title == null || body == null) {
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP static_reminder_missing_data id=" + notificationId);
|
||||
return Result.success();
|
||||
@@ -160,25 +186,34 @@ public class DailyNotificationWorker extends Worker {
|
||||
|
||||
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title);
|
||||
} else {
|
||||
// Regular notification: load from storage
|
||||
// Prefer Room storage; fallback to legacy SharedPreferences storage
|
||||
// Regular notification: load from storage (by notification_id, then by schedule_id for rollover/user content)
|
||||
content = getContentFromRoomOrLegacy(notificationId);
|
||||
|
||||
if (content == null) {
|
||||
// Content not found - likely removed during deduplication or cleanup
|
||||
// Return success instead of failure to prevent retries for intentionally removed notifications
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
||||
return Result.success(); // Success prevents retry loops for removed notifications
|
||||
}
|
||||
|
||||
// Check if notification is ready to display
|
||||
if (!content.isReadyToDisplay()) {
|
||||
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
// JIT Freshness Re-check (Soft TTL) - skip for static reminders
|
||||
content = performJITFreshnessCheck(content);
|
||||
// Rollover/notify_* runs: prefer canonical reminder content by schedule_id so user text is shown
|
||||
if (scheduleId != null && (content == null || content.getTitle() == null || content.getTitle().isEmpty()
|
||||
|| content.getBody() == null || content.getBody().isEmpty())) {
|
||||
NotificationContent canonical = getContentByScheduleId(scheduleId);
|
||||
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
|
||||
content = canonical;
|
||||
content.setId(notificationId); // keep run id for display/dismiss
|
||||
Log.d(TAG, "DN|DISPLAY_USE_CANONICAL id=" + notificationId + " schedule_id=" + scheduleId);
|
||||
}
|
||||
}
|
||||
if (content == null) {
|
||||
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
||||
return Result.success();
|
||||
}
|
||||
if (!content.isReadyToDisplay()) {
|
||||
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
|
||||
return Result.success();
|
||||
}
|
||||
// JIT Freshness Re-check (Soft TTL) - skip when content has title/body from Room
|
||||
boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
|
||||
&& content.getBody() != null && !content.getBody().isEmpty();
|
||||
if (!hasTitleBody) {
|
||||
content = performJITFreshnessCheck(content);
|
||||
} else {
|
||||
Log.d(TAG, "DN|DISPLAY_USE_ROOM_CONTENT id=" + notificationId + " (skip JIT)");
|
||||
}
|
||||
}
|
||||
|
||||
// Display the notification
|
||||
@@ -361,7 +396,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
|
||||
// Create one-time work request
|
||||
androidx.work.OneTimeWorkRequest softRefetchWork = new androidx.work.OneTimeWorkRequest.Builder(
|
||||
com.timesafari.dailynotification.SoftRefetchWorker.class)
|
||||
org.timesafari.dailynotification.SoftRefetchWorker.class)
|
||||
.setConstraints(constraints)
|
||||
.setInputData(inputData)
|
||||
.setInitialDelay(softRefetchTime - System.currentTimeMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
@@ -448,7 +483,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
// Add action buttons
|
||||
// 1. Dismiss action
|
||||
Intent dismissIntent = new Intent(getApplicationContext(), DailyNotificationReceiver.class);
|
||||
dismissIntent.setAction("com.timesafari.daily.DISMISS");
|
||||
dismissIntent.setAction("org.timesafari.daily.DISMISS");
|
||||
dismissIntent.putExtra("notification_id", content.getId());
|
||||
|
||||
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
|
||||
@@ -514,8 +549,41 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
|
||||
|
||||
// Calculate next occurrence using DST-safe ZonedDateTime
|
||||
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
||||
// Resolve schedule_id first so we can load rollover interval from DB
|
||||
Data inputDataForSchedule = getInputData();
|
||||
boolean preserveStaticReminder = inputDataForSchedule.getBoolean("is_static_reminder", false);
|
||||
String scheduleIdForRollover = inputDataForSchedule.getString("schedule_id");
|
||||
if (scheduleIdForRollover == null || scheduleIdForRollover.isEmpty()) {
|
||||
String notificationId = content.getId();
|
||||
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
||||
scheduleIdForRollover = notificationId;
|
||||
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleIdForRollover = notificationId;
|
||||
}
|
||||
}
|
||||
// When firing run used daily_rollover_* id, resolve canonical schedule so we still apply rolloverIntervalMinutes
|
||||
String logicalScheduleIdForRollover = scheduleIdForRollover;
|
||||
if (scheduleIdForRollover != null && scheduleIdForRollover.startsWith("daily_rollover_")) {
|
||||
org.timesafari.dailynotification.Schedule canonical = org.timesafari.dailynotification.ScheduleHelper.getCanonicalRolloverScheduleBlocking(getApplicationContext());
|
||||
if (canonical != null) {
|
||||
logicalScheduleIdForRollover = canonical.getId();
|
||||
}
|
||||
}
|
||||
Integer rolloverMinutes = null;
|
||||
if (logicalScheduleIdForRollover != null && !logicalScheduleIdForRollover.isEmpty()) {
|
||||
org.timesafari.dailynotification.Schedule s = org.timesafari.dailynotification.ScheduleHelper.getScheduleBlocking(getApplicationContext(), logicalScheduleIdForRollover);
|
||||
if (s != null && s.getRolloverIntervalMinutes() != null && s.getRolloverIntervalMinutes() > 0) {
|
||||
rolloverMinutes = s.getRolloverIntervalMinutes();
|
||||
Log.d(TAG, "DN|ROLLOVER_INTERVAL scheduleId=" + logicalScheduleIdForRollover + " minutes=" + rolloverMinutes);
|
||||
}
|
||||
}
|
||||
long nextScheduledTime;
|
||||
if (rolloverMinutes != null && rolloverMinutes > 0) {
|
||||
nextScheduledTime = addMinutesToTime(content.getScheduledTime(), rolloverMinutes);
|
||||
Log.d(TAG, "DN|ROLLOVER_NEXT using_interval_minutes=" + rolloverMinutes + " next=" + nextScheduledTime);
|
||||
} else {
|
||||
nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
||||
}
|
||||
|
||||
// Check for existing notification at the same time to prevent duplicates
|
||||
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
|
||||
@@ -540,37 +608,35 @@ public class DailyNotificationWorker extends Worker {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract scheduleId from notificationId pattern or use fallback
|
||||
// Notification IDs are often "daily_${scheduleId}"
|
||||
String scheduleId = null;
|
||||
String cronExpression = null;
|
||||
|
||||
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
|
||||
String notificationId = content.getId();
|
||||
if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleId = notificationId; // Use notificationId as scheduleId
|
||||
} else {
|
||||
String scheduleId = scheduleIdForRollover;
|
||||
if (scheduleId == null || scheduleId.isEmpty()) {
|
||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Calculate cron from current scheduled time (extract hour:minute)
|
||||
try {
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
cal.setTimeInMillis(content.getScheduledTime());
|
||||
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
|
||||
int minute = cal.get(java.util.Calendar.MINUTE);
|
||||
cronExpression = String.format("%d %d * * *", minute, hour);
|
||||
|
||||
// Recalculate next run time from cron (tomorrow at same time)
|
||||
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
|
||||
cronExpression = "0 9 * * *"; // Default to 9 AM
|
||||
// When using rollover interval, next time already set; otherwise compute from cron (tomorrow same time)
|
||||
if (rolloverMinutes == null || rolloverMinutes <= 0) {
|
||||
try {
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
cal.setTimeInMillis(content.getScheduledTime());
|
||||
int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
|
||||
int minute = cal.get(java.util.Calendar.MINUTE);
|
||||
cronExpression = String.format("%d %d * * *", minute, hour);
|
||||
nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
|
||||
cronExpression = "0 9 * * *";
|
||||
}
|
||||
} else {
|
||||
cronExpression = String.format("%d %d * * *",
|
||||
java.util.Calendar.getInstance().get(java.util.Calendar.MINUTE),
|
||||
java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY));
|
||||
}
|
||||
|
||||
// Create config for next notification
|
||||
com.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new com.timesafari.dailynotification.UserNotificationConfig(
|
||||
org.timesafari.dailynotification.UserNotificationConfig config =
|
||||
new org.timesafari.dailynotification.UserNotificationConfig(
|
||||
true, // enabled
|
||||
cronExpression,
|
||||
content.getTitle() != null ? content.getTitle() : "Daily Notification",
|
||||
@@ -581,48 +647,50 @@ public class DailyNotificationWorker extends Worker {
|
||||
);
|
||||
|
||||
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
|
||||
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
Log.d(TAG, "DN|ROLLOVER next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
getApplicationContext(),
|
||||
nextScheduledTime,
|
||||
config,
|
||||
false, // isStaticReminder
|
||||
null, // reminderId
|
||||
preserveStaticReminder, // isStaticReminder – preserve so next run keeps title/body
|
||||
preserveStaticReminder ? scheduleId : null, // reminderId
|
||||
scheduleId,
|
||||
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
|
||||
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
|
||||
false // skipPendingIntentIdempotence – rollover path does not skip
|
||||
);
|
||||
|
||||
if (scheduleId != null && !scheduleId.startsWith("daily_rollover_")) {
|
||||
org.timesafari.dailynotification.ScheduleHelper.updateScheduleNextRunTimeBlocking(
|
||||
getApplicationContext(), scheduleId, content.getScheduledTime(), nextScheduledTime);
|
||||
}
|
||||
// Log next scheduled time in readable format
|
||||
String nextTimeStr = formatScheduledTime(nextScheduledTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
|
||||
|
||||
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
||||
try {
|
||||
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
|
||||
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
|
||||
getApplicationContext(),
|
||||
storageForFetcher,
|
||||
roomStorageForFetcher
|
||||
);
|
||||
|
||||
// Calculate fetch time (5 minutes before notification)
|
||||
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
|
||||
if (fetchTime > currentTime) {
|
||||
fetcher.scheduleFetch(fetchTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() +
|
||||
" next_fetch=" + fetchTime +
|
||||
" next_notification=" + nextScheduledTime);
|
||||
} else {
|
||||
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
|
||||
" fetch_time=" + fetchTime +
|
||||
" current=" + currentTime);
|
||||
fetcher.scheduleImmediateFetch();
|
||||
// Do not schedule prefetch for static reminders (single NotifyReceiver alarm is enough; avoids second alarm)
|
||||
if (preserveStaticReminder) {
|
||||
Log.d(TAG, "DN|RESCHEDULE_SKIP_PREFETCH static_reminder scheduleId=" + scheduleId);
|
||||
} else {
|
||||
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
||||
try {
|
||||
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
|
||||
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
|
||||
getApplicationContext(),
|
||||
storageForFetcher,
|
||||
roomStorageForFetcher
|
||||
);
|
||||
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
|
||||
long currentTime = System.currentTimeMillis();
|
||||
if (fetchTime > currentTime) {
|
||||
fetcher.scheduleFetch(fetchTime);
|
||||
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() + " next_fetch=" + fetchTime + " next_notification=" + nextScheduledTime);
|
||||
} else {
|
||||
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + " fetch_time=" + fetchTime + " current=" + currentTime);
|
||||
fetcher.scheduleImmediateFetch();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + " error scheduling prefetch", e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
|
||||
" error scheduling prefetch", e);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -632,6 +700,28 @@ public class DailyNotificationWorker extends Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load notification content by canonical schedule id (for static reminder / rollover user text).
|
||||
* Tries id then "daily_" + id to match getTitleBodyForSchedule / BootReceiver.
|
||||
*/
|
||||
private NotificationContent getContentByScheduleId(String scheduleId) {
|
||||
if (scheduleId == null || scheduleId.isEmpty()) return null;
|
||||
try {
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(scheduleId);
|
||||
if (entity == null) {
|
||||
entity = db.notificationContentDao().getNotificationById("daily_" + scheduleId);
|
||||
}
|
||||
if (entity != null) {
|
||||
return mapEntityToContent(entity);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "DN|CANONICAL_READ_FAIL schedule_id=" + scheduleId + " err=" + t.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load content from Room; fallback to legacy storage
|
||||
*/
|
||||
@@ -640,8 +730,8 @@ public class DailyNotificationWorker extends Worker {
|
||||
try {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
// Use unified database (Kotlin schema with Java entities)
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase db =
|
||||
org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
|
||||
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
|
||||
if (entity != null) {
|
||||
return mapEntityToContent(entity);
|
||||
@@ -688,7 +778,7 @@ public class DailyNotificationWorker extends Worker {
|
||||
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
|
||||
NotificationContentEntity entity = new NotificationContentEntity(
|
||||
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
|
||||
"1.0.0",
|
||||
"1.2.1",
|
||||
null,
|
||||
"daily",
|
||||
content.getTitle(),
|
||||
@@ -725,6 +815,21 @@ public class DailyNotificationWorker extends Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add minutes to a timestamp (DST-safe via Calendar).
|
||||
* Used for rollover interval (e.g. 10 minutes for testing).
|
||||
*/
|
||||
private long addMinutesToTime(long timeMillis, int minutes) {
|
||||
try {
|
||||
java.util.Calendar cal = java.util.Calendar.getInstance();
|
||||
cal.setTimeInMillis(timeMillis);
|
||||
cal.add(java.util.Calendar.MINUTE, minutes);
|
||||
return cal.getTimeInMillis();
|
||||
} catch (Exception e) {
|
||||
return timeMillis + (minutes * 60 * 1000L);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next scheduled time with DST-safe handling
|
||||
*
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
/**
|
||||
* Information about a scheduled daily reminder
|
||||
@@ -9,7 +9,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity
|
||||
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity
|
||||
import com.timesafari.dailynotification.entities.NotificationConfigEntity
|
||||
import com.timesafari.dailynotification.dao.NotificationContentDao
|
||||
import com.timesafari.dailynotification.dao.NotificationDeliveryDao
|
||||
import com.timesafari.dailynotification.dao.NotificationConfigDao
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity
|
||||
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity
|
||||
import org.timesafari.dailynotification.entities.NotificationConfigEntity
|
||||
import org.timesafari.dailynotification.dao.NotificationContentDao
|
||||
import org.timesafari.dailynotification.dao.NotificationDeliveryDao
|
||||
import org.timesafari.dailynotification.dao.NotificationConfigDao
|
||||
|
||||
/**
|
||||
* Unified SQLite schema for Daily Notification Plugin
|
||||
@@ -33,7 +33,9 @@ data class ContentCache(
|
||||
val fetchedAt: Long, // epoch ms
|
||||
val ttlSeconds: Int,
|
||||
val payload: ByteArray, // BLOB
|
||||
val meta: String? = null
|
||||
val meta: String? = null,
|
||||
/** dual | daily | legacy — see [ContentCacheScope] */
|
||||
val cacheScope: String = ContentCacheScope.LEGACY
|
||||
)
|
||||
|
||||
@Entity(tableName = "schedules")
|
||||
@@ -47,7 +49,9 @@ data class Schedule(
|
||||
val nextRunAt: Long? = null,
|
||||
val jitterMs: Int = 0,
|
||||
val backoffPolicy: String = "exp",
|
||||
val stateJson: String? = null
|
||||
val stateJson: String? = null,
|
||||
/** When > 0, next occurrence is this many minutes after current trigger (dev/testing). Null or 0 = 24h. */
|
||||
val rolloverIntervalMinutes: Int? = null
|
||||
)
|
||||
|
||||
@Entity(tableName = "callbacks")
|
||||
@@ -83,7 +87,7 @@ data class History(
|
||||
NotificationDeliveryEntity::class,
|
||||
NotificationConfigEntity::class
|
||||
],
|
||||
version = 2, // Incremented for unified schema
|
||||
version = 4, // 4: content_cache.cacheScope
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -118,7 +122,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
||||
DailyNotificationDatabase::class.java,
|
||||
DATABASE_NAME
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addCallback(roomCallback)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
@@ -266,6 +270,24 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
|
||||
""".trimIndent())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration from version 2 to 3: add rollover_interval_minutes to schedules
|
||||
*/
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL("ALTER TABLE schedules ADD COLUMN rollover_interval_minutes INTEGER")
|
||||
}
|
||||
}
|
||||
|
||||
/** Add cacheScope to content_cache for dual vs daily isolation */
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE content_cache ADD COLUMN cacheScope TEXT NOT NULL DEFAULT 'legacy'"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +298,9 @@ interface ContentCacheDao {
|
||||
|
||||
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
|
||||
suspend fun getLatest(): ContentCache?
|
||||
|
||||
@Query("SELECT * FROM content_cache WHERE cacheScope = :scope ORDER BY fetchedAt DESC LIMIT 1")
|
||||
suspend fun getLatestByScope(scope: String): ContentCache?
|
||||
|
||||
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT :limit")
|
||||
suspend fun getHistory(limit: Int): List<ContentCache>
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.content.Context;
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Re-enqueues dual (New Activity) prefetch from persisted SharedPreferences config
|
||||
* (boot recovery, after a successful dual fetch rollover).
|
||||
*/
|
||||
object DualScheduleFetchRecovery {
|
||||
private const val TAG = "DNP-DUAL-RECOVER"
|
||||
|
||||
/**
|
||||
* Parses [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY] and enqueues the next delayed dual fetch.
|
||||
* @return true if a job was scheduled
|
||||
*/
|
||||
@JvmStatic
|
||||
fun enqueueFromPersistedConfig(context: Context): Boolean {
|
||||
return try {
|
||||
val prefs = context.getSharedPreferences(
|
||||
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val jsonStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
|
||||
?: return false
|
||||
val root = JSONObject(jsonStr)
|
||||
val contentFetchObj = root.optJSONObject("contentFetch") ?: return false
|
||||
val userNotificationObj = root.optJSONObject("userNotification") ?: return false
|
||||
val contentFetchConfig = parseContentFetchConfigJson(contentFetchObj)
|
||||
val userNotificationConfig = parseUserNotificationConfigJson(userNotificationObj)
|
||||
if (!contentFetchConfig.enabled) {
|
||||
Log.d(TAG, "contentFetch disabled, skip dual fetch recovery")
|
||||
return false
|
||||
}
|
||||
val nextFetchAt = ScheduleCronUtils.calculateNextRunTimeMillis(contentFetchConfig.schedule)
|
||||
val nextNotifyAt = ScheduleCronUtils.calculateNextRunTimeMillis(userNotificationConfig.schedule)
|
||||
FetchWorker.enqueueDualFetch(
|
||||
context,
|
||||
contentFetchConfig,
|
||||
nextFetchAt,
|
||||
nextNotifyAt
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "enqueueFromPersistedConfig failed", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseContentFetchConfigJson(configJson: JSONObject): ContentFetchConfig {
|
||||
val callbacksObj = configJson.optJSONObject("callbacks")
|
||||
return ContentFetchConfig(
|
||||
enabled = configJson.optBoolean("enabled", true),
|
||||
schedule = configJson.optString("schedule", "0 9 * * *"),
|
||||
url = configJson.optString("url").takeIf { it.isNotEmpty() },
|
||||
timeout = configJson.takeUnless { !it.has("timeout") || JSONObject.NULL == it.get("timeout") }
|
||||
?.optInt("timeout"),
|
||||
retryAttempts = configJson.takeUnless { !it.has("retryAttempts") || JSONObject.NULL == it.get("retryAttempts") }
|
||||
?.optInt("retryAttempts"),
|
||||
retryDelay = configJson.takeUnless { !it.has("retryDelay") || JSONObject.NULL == it.get("retryDelay") }
|
||||
?.optInt("retryDelay"),
|
||||
callbacks = CallbackConfig(
|
||||
apiService = callbacksObj?.optString("apiService")?.takeIf { it.isNotEmpty() },
|
||||
database = callbacksObj?.optString("database")?.takeIf { it.isNotEmpty() },
|
||||
reporting = callbacksObj?.optString("reporting")?.takeIf { it.isNotEmpty() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseUserNotificationConfigJson(configJson: JSONObject): UserNotificationConfig {
|
||||
return UserNotificationConfig(
|
||||
enabled = configJson.optBoolean("enabled", true),
|
||||
schedule = configJson.optString("schedule", "0 9 * * *"),
|
||||
title = configJson.optString("title").takeIf { it.isNotEmpty() },
|
||||
body = configJson.optString("body").takeIf { it.isNotEmpty() },
|
||||
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) configJson.optBoolean("sound") else null,
|
||||
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) configJson.optBoolean("vibration") else null,
|
||||
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Helper for resolving dual (New Activity) notification content at fire time.
|
||||
* Applies relationship (contentTimeout, fallbackBehavior) using persisted config and content cache.
|
||||
*/
|
||||
object DualScheduleHelper {
|
||||
private const val TAG = "DNP-DUAL"
|
||||
|
||||
/**
|
||||
* Resolve title/body for a dual schedule: use cached content if within contentTimeout, else default from config.
|
||||
* Call from Worker when schedule_id starts with DUAL_NOTIFY_SCHEDULE_ID_PREFIX.
|
||||
*
|
||||
* @param context Application context
|
||||
* @param notificationId Notification run id for the display
|
||||
* @return NotificationContent with resolved title/body, or null if no config or skip
|
||||
*/
|
||||
@JvmStatic
|
||||
fun resolveDualContentBlocking(context: Context, notificationId: String): NotificationContent? {
|
||||
return try {
|
||||
val prefs = context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null) ?: return null
|
||||
val config = JSONObject(configStr)
|
||||
val userNotification = config.optJSONObject("userNotification") ?: return null
|
||||
val relationship = config.optJSONObject("relationship")
|
||||
val contentTimeoutMs = relationship?.optLong("contentTimeout", 300_000L) ?: 300_000L
|
||||
val fallbackBehavior = relationship?.optString("fallbackBehavior", "show_default") ?: "show_default"
|
||||
|
||||
val defaultTitle = userNotification.optString("title", "Daily Notification")
|
||||
val defaultBody = userNotification.optString("body", "Your daily update is ready")
|
||||
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val latestCache = runBlocking {
|
||||
db.contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
|
||||
}
|
||||
val nowMs = System.currentTimeMillis()
|
||||
|
||||
val (title: String, body: String) = if (latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs) {
|
||||
val payloadStr = String(latestCache.payload, Charsets.UTF_8)
|
||||
try {
|
||||
val payload = JSONObject(payloadStr)
|
||||
if (payload.optBoolean("skipNotification", false)) {
|
||||
return null
|
||||
}
|
||||
Pair(
|
||||
payload.optString("title", defaultTitle),
|
||||
payload.optString("body", payload.optString("content", defaultBody))
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
Pair(defaultTitle, defaultBody)
|
||||
}
|
||||
} else {
|
||||
val staleSkip = latestCache?.let { cache ->
|
||||
try {
|
||||
JSONObject(String(cache.payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
if (staleSkip && fallbackBehavior == "skip") {
|
||||
return null
|
||||
}
|
||||
if (fallbackBehavior != "show_default") return null
|
||||
Pair(defaultTitle, defaultBody)
|
||||
}
|
||||
|
||||
val content = NotificationContent(title, body, nowMs)
|
||||
content.setId(notificationId)
|
||||
content.setSound(userNotification.optBoolean("sound", true))
|
||||
content.setPriority(userNotification.optString("priority", "normal"))
|
||||
Log.d(TAG, "Resolved dual content: useCache=${latestCache != null && (nowMs - latestCache.fetchedAt) <= contentTimeoutMs}")
|
||||
content
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "resolveDualContentBlocking failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Arms the dual (New Activity) user notification **after** prefetch completes (chained schedule).
|
||||
* Fires at [max]([nextNotifyAtMillis], now) so a late fetch delays delivery instead of showing stale API copy first.
|
||||
*/
|
||||
object DualScheduleNotifyScheduler {
|
||||
private const val TAG = "DNP-DUAL-NOTIFY"
|
||||
|
||||
/**
|
||||
* Schedule exact alarm for dual notify using persisted [DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY]
|
||||
* and [DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun scheduleChainedNotifyAlarm(context: Context, nextNotifyAtMillis: Long) {
|
||||
try {
|
||||
val prefs = context.getSharedPreferences(
|
||||
DailyNotificationConstants.DUAL_SCHEDULE_PREFS,
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val configStr = prefs.getString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, null)
|
||||
?: run {
|
||||
Log.w(TAG, "No dual_schedule_config; skip chained notify")
|
||||
return
|
||||
}
|
||||
val scheduleId = prefs.getString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, null)
|
||||
?: run {
|
||||
Log.w(TAG, "No dual_notify_schedule_id; skip chained notify")
|
||||
return
|
||||
}
|
||||
val root = JSONObject(configStr)
|
||||
val userObj = root.optJSONObject("userNotification") ?: run {
|
||||
Log.w(TAG, "dual config missing userNotification")
|
||||
return
|
||||
}
|
||||
val config = parseUserNotificationConfig(userObj)
|
||||
val now = System.currentTimeMillis()
|
||||
val triggerAt = maxOf(nextNotifyAtMillis, now + 500L)
|
||||
Log.i(TAG, "Chained dual notify: scheduleId=$scheduleId triggerAt=$triggerAt (nextNotify=$nextNotifyAtMillis)")
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
triggerAt,
|
||||
config,
|
||||
scheduleId = scheduleId,
|
||||
source = ScheduleSource.INITIAL_SETUP,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "scheduleChainedNotifyAlarm failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseUserNotificationConfig(configJson: JSONObject): UserNotificationConfig {
|
||||
return UserNotificationConfig(
|
||||
enabled = configJson.optBoolean("enabled", true),
|
||||
schedule = configJson.optString("schedule", "0 9 * * *"),
|
||||
title = configJson.optString("title").takeIf { it.isNotEmpty() },
|
||||
body = configJson.optString("body").takeIf { it.isNotEmpty() },
|
||||
sound = if (configJson.has("sound") && JSONObject.NULL != configJson.get("sound")) {
|
||||
configJson.optBoolean("sound")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
vibration = if (configJson.has("vibration") && JSONObject.NULL != configJson.get("vibration")) {
|
||||
configJson.optBoolean("vibration")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
priority = configJson.optString("priority").takeIf { it.isNotEmpty() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* @created 2025-10-03 06:53:30 UTC
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -11,7 +11,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -1,23 +1,24 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.text.Charsets
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* WorkManager implementation for content fetching
|
||||
* Implements exponential backoff and network constraints
|
||||
*
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
class FetchWorker(
|
||||
appContext: Context,
|
||||
@@ -27,12 +28,85 @@ class FetchWorker(
|
||||
companion object {
|
||||
private const val TAG = "DNP-FETCH"
|
||||
private const val WORK_NAME = "fetch_content"
|
||||
|
||||
/** Unique work name for dual (New Activity) schedule fetch; cancel via cancelDualSchedule only. */
|
||||
const val WORK_NAME_DUAL = "fetch_dual"
|
||||
|
||||
private const val KEY_IS_DUAL = "is_dual"
|
||||
private const val KEY_CACHE_SCOPE = "cache_scope"
|
||||
private const val KEY_NEXT_NOTIFY_AT = "next_notify_at"
|
||||
|
||||
/**
|
||||
* Persisted for dual native fetch when the host returns no rows.
|
||||
* [DualScheduleHelper] must not display a notification; [doWork] skips chained notify.
|
||||
*/
|
||||
internal val dualEmptyNativeFetchPayload: ByteArray =
|
||||
"""{"skipNotification":true}""".toByteArray(Charsets.UTF_8)
|
||||
|
||||
/** True when [payload] is the dual-cache sentinel for “API / native had no content”. */
|
||||
@JvmStatic
|
||||
fun isDualSkipNotificationPayload(payload: ByteArray): Boolean {
|
||||
return try {
|
||||
JSONObject(String(payload, Charsets.UTF_8)).optBoolean("skipNotification", false)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
|
||||
enqueueFetch(context, config, WORK_NAME)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual (New Activity) prefetch: delayed to [nextFetchAtMillis], scoped cache, optional native fetcher.
|
||||
*/
|
||||
fun enqueueDualFetch(
|
||||
context: Context,
|
||||
contentFetchConfig: ContentFetchConfig,
|
||||
nextFetchAtMillis: Long,
|
||||
nextNotifyAtMillis: Long
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
val delayMs = (nextFetchAtMillis - now).coerceAtLeast(0L)
|
||||
val requiresNetwork = !contentFetchConfig.url.isNullOrBlank() ||
|
||||
DailyNotificationPlugin.getNativeFetcherStatic() != null
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (requiresNetwork) NetworkType.CONNECTED else NetworkType.NOT_REQUIRED
|
||||
)
|
||||
.build()
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
30,
|
||||
TimeUnit.SECONDS
|
||||
)
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putString("url", contentFetchConfig.url)
|
||||
.putInt("timeout", contentFetchConfig.timeout ?: 30000)
|
||||
.putInt("retryAttempts", contentFetchConfig.retryAttempts ?: 3)
|
||||
.putInt("retryDelay", contentFetchConfig.retryDelay ?: 1000)
|
||||
.putLong("notificationTime", 0L)
|
||||
.putBoolean(KEY_IS_DUAL, true)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DUAL)
|
||||
.putLong(KEY_NEXT_NOTIFY_AT, nextNotifyAtMillis)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(WORK_NAME_DUAL, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Dual fetch enqueued: delayMs=$delayMs, nextFetchAt=$nextFetchAtMillis, nextNotifyAt=$nextNotifyAtMillis"
|
||||
)
|
||||
}
|
||||
|
||||
private fun enqueueFetch(context: Context, config: ContentFetchConfig, workName: String) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
@@ -46,21 +120,18 @@ class FetchWorker(
|
||||
.putInt("timeout", config.timeout ?: 30000)
|
||||
.putInt("retryAttempts", config.retryAttempts ?: 3)
|
||||
.putInt("retryDelay", config.retryDelay ?: 1000)
|
||||
.putBoolean(KEY_IS_DUAL, false)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(
|
||||
WORK_NAME,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Schedule a delayed fetch for prefetch (5 minutes before notification)
|
||||
*
|
||||
*
|
||||
* @param context Application context
|
||||
* @param fetchTime When to fetch (in milliseconds since epoch)
|
||||
* @param notificationTime When the notification will be shown (in milliseconds since epoch)
|
||||
@@ -74,15 +145,15 @@ class FetchWorker(
|
||||
) {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val delayMs = fetchTime - currentTime
|
||||
|
||||
|
||||
Log.i(TAG, "Scheduling delayed prefetch: fetchTime=$fetchTime, notificationTime=$notificationTime, delayMs=$delayMs")
|
||||
|
||||
|
||||
if (delayMs <= 0) {
|
||||
Log.w(TAG, "Fetch time is in the past, scheduling immediate fetch")
|
||||
scheduleImmediateFetch(context, notificationTime, url)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Only require network if URL is provided (mock content doesn't need network)
|
||||
val constraints = Constraints.Builder()
|
||||
.apply {
|
||||
@@ -94,11 +165,11 @@ class FetchWorker(
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
|
||||
// Create unique work name based on notification time to prevent duplicate fetches
|
||||
val notificationTimeMinutes = notificationTime / (60 * 1000)
|
||||
val workName = "prefetch_${notificationTimeMinutes}"
|
||||
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
|
||||
@@ -115,21 +186,23 @@ class FetchWorker(
|
||||
.putInt("timeout", 30000)
|
||||
.putInt("retryAttempts", 3)
|
||||
.putInt("retryDelay", 1000)
|
||||
.putBoolean(KEY_IS_DUAL, false)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
|
||||
.build()
|
||||
)
|
||||
.addTag("prefetch")
|
||||
.build()
|
||||
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(
|
||||
workName,
|
||||
ExistingWorkPolicy.REPLACE,
|
||||
workRequest
|
||||
)
|
||||
|
||||
|
||||
Log.i(TAG, "Delayed prefetch scheduled: workName=$workName, delayMs=$delayMs")
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Schedule an immediate fetch (fallback when delay is in the past)
|
||||
*/
|
||||
@@ -149,7 +222,7 @@ class FetchWorker(
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(
|
||||
@@ -160,14 +233,16 @@ class FetchWorker(
|
||||
.putInt("retryAttempts", 3)
|
||||
.putInt("retryDelay", 1000)
|
||||
.putBoolean("immediate", true)
|
||||
.putBoolean(KEY_IS_DUAL, false)
|
||||
.putString(KEY_CACHE_SCOPE, ContentCacheScope.DAILY)
|
||||
.build()
|
||||
)
|
||||
.addTag("prefetch")
|
||||
.build()
|
||||
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueue(workRequest)
|
||||
|
||||
|
||||
Log.i(TAG, "Immediate prefetch scheduled")
|
||||
}
|
||||
}
|
||||
@@ -179,33 +254,39 @@ class FetchWorker(
|
||||
val retryAttempts = inputData.getInt("retryAttempts", 3)
|
||||
val retryDelay = inputData.getInt("retryDelay", 1000)
|
||||
val notificationTime = inputData.getLong("notificationTime", 0L)
|
||||
|
||||
val isDual = inputData.getBoolean(KEY_IS_DUAL, false)
|
||||
val cacheScope = inputData.getString(KEY_CACHE_SCOPE) ?: ContentCacheScope.LEGACY
|
||||
val nextNotifyAt = inputData.getLong(KEY_NEXT_NOTIFY_AT, 0L)
|
||||
|
||||
try {
|
||||
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime")
|
||||
|
||||
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
|
||||
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime, isDual=$isDual, scope=$cacheScope")
|
||||
|
||||
val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
|
||||
val skipDualChainedNotify =
|
||||
isDual && nextNotifyAt > 0L && isDualSkipNotificationPayload(payload)
|
||||
val contentCache = ContentCache(
|
||||
id = generateId(),
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
ttlSeconds = 3600, // 1 hour default TTL
|
||||
payload = payload,
|
||||
meta = "fetched_by_workmanager"
|
||||
meta = "fetched_by_workmanager",
|
||||
cacheScope = cacheScope
|
||||
)
|
||||
|
||||
|
||||
// Store in database
|
||||
val db = DailyNotificationDatabase.getDatabase(applicationContext)
|
||||
db.contentCacheDao().upsert(contentCache)
|
||||
|
||||
|
||||
// If this is a prefetch for a specific notification, create NotificationContentEntity
|
||||
// so the notification worker can find it when the alarm fires
|
||||
if (notificationTime > 0) {
|
||||
try {
|
||||
val notificationId = "notify_$notificationTime"
|
||||
val (title, body) = parsePayload(payload)
|
||||
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
title,
|
||||
@@ -220,7 +301,7 @@ class FetchWorker(
|
||||
entity.createdAt = System.currentTimeMillis()
|
||||
entity.updatedAt = System.currentTimeMillis()
|
||||
entity.ttlSeconds = contentCache.ttlSeconds.toLong()
|
||||
|
||||
|
||||
// Save to Room database so notification worker can find it
|
||||
db.notificationContentDao().insertNotification(entity)
|
||||
Log.i(TAG, "Created NotificationContentEntity: id=$notificationId, scheduledTime=$notificationTime")
|
||||
@@ -229,7 +310,7 @@ class FetchWorker(
|
||||
// Continue - at least ContentCache was saved
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Record success in history
|
||||
db.historyDao().insert(
|
||||
History(
|
||||
@@ -240,22 +321,91 @@ class FetchWorker(
|
||||
outcome = "success"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Log.i(TAG, "Content fetch completed successfully")
|
||||
if (isDual && nextNotifyAt > 0L) {
|
||||
if (!skipDualChainedNotify) {
|
||||
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
|
||||
} else {
|
||||
Log.i(TAG, "Dual fetch: empty native content — skip chained notify")
|
||||
}
|
||||
DualScheduleFetchRecovery.enqueueFromPersistedConfig(applicationContext)
|
||||
}
|
||||
Result.success()
|
||||
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Network error during fetch", e)
|
||||
recordFailure("network_error", start, e)
|
||||
Result.retry()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unexpected error during fetch", e)
|
||||
recordFailure("unexpected_error", start, e)
|
||||
if (isDual && nextNotifyAt > 0L) {
|
||||
DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm(applicationContext, nextNotifyAt)
|
||||
}
|
||||
Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private suspend fun resolvePayload(
|
||||
url: String?,
|
||||
timeout: Int,
|
||||
retryAttempts: Int,
|
||||
retryDelay: Int,
|
||||
isDual: Boolean,
|
||||
nextNotifyAt: Long
|
||||
): ByteArray {
|
||||
if (isDual && url.isNullOrBlank()) {
|
||||
val native = DailyNotificationPlugin.getNativeFetcherStatic()
|
||||
return if (native != null) {
|
||||
fetchNativeDualPayload(native, timeout, nextNotifyAt)
|
||||
} else {
|
||||
Log.w(TAG, "Dual fetch with no URL and no native fetcher; using mock content")
|
||||
generateMockContent()
|
||||
}
|
||||
}
|
||||
return fetchContent(url, timeout, retryAttempts, retryDelay)
|
||||
}
|
||||
|
||||
private suspend fun fetchNativeDualPayload(
|
||||
native: NativeNotificationContentFetcher,
|
||||
timeoutMs: Int,
|
||||
nextNotifyAtMillis: Long
|
||||
): ByteArray = withContext(Dispatchers.IO) {
|
||||
val metadata = java.util.HashMap<String, Any>()
|
||||
metadata["retryCount"] = 0
|
||||
metadata["immediate"] = false
|
||||
val scheduledTime: Long? = if (nextNotifyAtMillis > 0L) nextNotifyAtMillis else null
|
||||
val ctx = FetchContext(
|
||||
"prefetch",
|
||||
scheduledTime,
|
||||
System.currentTimeMillis(),
|
||||
metadata
|
||||
)
|
||||
val future = native.fetchContent(ctx)
|
||||
try {
|
||||
val contents = future.get(timeoutMs.toLong(), TimeUnit.MILLISECONDS)
|
||||
val list = contents?.toList() ?: emptyList()
|
||||
notificationContentsToDualPayloadBytes(list)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Native dual fetch failed", e)
|
||||
throw IOException("native_fetch_failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationContentsToDualPayloadBytes(contents: List<NotificationContent>): ByteArray {
|
||||
if (contents.isEmpty()) {
|
||||
return dualEmptyNativeFetchPayload
|
||||
}
|
||||
val c = contents[0]
|
||||
val title = c.getTitle() ?: "Daily Notification"
|
||||
val body = c.getBody() ?: ""
|
||||
val json = JSONObject()
|
||||
json.put("title", title)
|
||||
json.put("body", body)
|
||||
json.put("content", body)
|
||||
return json.toString().toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private suspend fun fetchContent(
|
||||
url: String?,
|
||||
timeout: Int,
|
||||
@@ -266,23 +416,22 @@ class FetchWorker(
|
||||
// Generate mock content for testing
|
||||
return generateMockContent()
|
||||
}
|
||||
|
||||
|
||||
var lastException: Exception? = null
|
||||
|
||||
|
||||
repeat(retryAttempts) { attempt ->
|
||||
try {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = timeout
|
||||
connection.readTimeout = timeout
|
||||
connection.requestMethod = "GET"
|
||||
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode == HttpURLConnection.HTTP_OK) {
|
||||
return connection.inputStream.readBytes()
|
||||
} else {
|
||||
throw IOException("HTTP $responseCode: ${connection.responseMessage}")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
if (attempt < retryAttempts - 1) {
|
||||
@@ -291,22 +440,22 @@ class FetchWorker(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
throw lastException ?: IOException("All retry attempts failed")
|
||||
}
|
||||
|
||||
|
||||
private fun generateMockContent(): ByteArray {
|
||||
val mockData = """
|
||||
{
|
||||
"timestamp": ${System.currentTimeMillis()},
|
||||
"content": "Daily notification content",
|
||||
"source": "mock_generator",
|
||||
"version": "1.1.0"
|
||||
"version": "2.1.4"
|
||||
}
|
||||
""".trimIndent()
|
||||
return mockData.toByteArray()
|
||||
}
|
||||
|
||||
|
||||
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
|
||||
try {
|
||||
val db = DailyNotificationDatabase.getDatabase(applicationContext)
|
||||
@@ -324,22 +473,22 @@ class FetchWorker(
|
||||
Log.e(TAG, "Failed to record failure", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun generateId(): String {
|
||||
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse payload to extract title and body
|
||||
* Handles both JSON and plain text payloads
|
||||
*
|
||||
*
|
||||
* @param payload Raw payload bytes
|
||||
* @return Pair of (title, body)
|
||||
*/
|
||||
private fun parsePayload(payload: ByteArray): Pair<String, String> {
|
||||
return try {
|
||||
val payloadString = String(payload, Charsets.UTF_8)
|
||||
|
||||
|
||||
// Try to parse as JSON
|
||||
val json = JSONObject(payloadString)
|
||||
val title = json.optString("title", "Daily Notification")
|
||||
@@ -15,9 +15,10 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@@ -142,5 +143,21 @@ public interface NativeNotificationContentFetcher {
|
||||
// This allows fetchers that don't need TypeScript-provided configuration
|
||||
// to ignore this method without implementing an empty body.
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional overload: distinct JWT strings for background use (e.g. one per day slot).
|
||||
* Persisted when {@code persistToken} is true; host fetchers (e.g. TimeSafari) should choose
|
||||
* which entry to use per fetch (e.g. day index modulo pool size).
|
||||
* Default delegates to {@link #configure(String, String, String)} so existing fetchers stay compatible.
|
||||
*
|
||||
* @param jwtTokenPool validated list (max length 128), or null if the host omitted the pool or sent an empty array
|
||||
*/
|
||||
default void configure(
|
||||
String apiBaseUrl,
|
||||
String activeDid,
|
||||
String jwtToken,
|
||||
@Nullable List<String> jwtTokenPool) {
|
||||
configure(apiBaseUrl, activeDid, jwtToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
import java.util.UUID;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
@@ -518,13 +518,13 @@ public class NotificationStatusChecker {
|
||||
* @param database Database instance for querying schedules and history
|
||||
* @return JSObject containing notification status (schedules, last notification time, etc.)
|
||||
*/
|
||||
public JSObject getNotificationStatus(com.timesafari.dailynotification.DailyNotificationDatabase database) {
|
||||
public JSObject getNotificationStatus(org.timesafari.dailynotification.DailyNotificationDatabase database) {
|
||||
try {
|
||||
Log.d(TAG, "DN|NOTIFICATION_STATUS_START");
|
||||
|
||||
// Delegate to Kotlin helper function (uses runBlocking internally)
|
||||
// This is safe because status checks are quick operations
|
||||
return com.timesafari.dailynotification.NotificationStatusHelper.getNotificationStatusBlocking(database);
|
||||
return org.timesafari.dailynotification.NotificationStatusHelper.getNotificationStatusBlocking(database);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "DN|NOTIFICATION_STATUS_ERR err=" + e.getMessage(), e);
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.AlarmManager.AlarmClockInfo
|
||||
@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
|
||||
* Implements TTL-at-fire logic and notification delivery
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 1.1.0
|
||||
* @version 2.0.0
|
||||
*/
|
||||
/**
|
||||
* Source of schedule request - tracks which code path triggered scheduling
|
||||
@@ -122,104 +122,119 @@ 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 {
|
||||
val checkIntent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
action = "org.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
|
||||
|
||||
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
|
||||
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
|
||||
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
|
||||
|
||||
// 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 for id=$stableScheduleId at $triggerTimeStr from source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
|
||||
return@runBlocking
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
||||
|
||||
if (existingPendingIntent != null) {
|
||||
if (source == ScheduleSource.ROLLOVER_ON_FIRE) {
|
||||
// Rollover chain: same schedule id, new trigger time — treat as update: cancel then set
|
||||
Log.d(SCHEDULE_TAG, "ROLLOVER_ON_FIRE: cancelling existing alarm for id=$stableScheduleId to set new trigger")
|
||||
alarmManager.cancel(existingPendingIntent)
|
||||
} else {
|
||||
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
|
||||
// When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't
|
||||
// cancel the alarm and then skip re-scheduling, resulting in no alarm.
|
||||
if (!skipPendingIntentIdempotence) {
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
|
||||
|
||||
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
|
||||
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
|
||||
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
|
||||
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 for id=$stableScheduleId at $triggerTimeStr from source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
|
||||
return@runBlocking
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
||||
}
|
||||
} else {
|
||||
Log.d(SCHEDULE_TAG, "Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId")
|
||||
}
|
||||
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
@@ -235,14 +250,16 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val contentCache = db.contentCacheDao().getLatest()
|
||||
val contentCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
|
||||
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
|
||||
?: db.contentCacheDao().getLatest()
|
||||
|
||||
// Always create a notification content entity for recovery tracking
|
||||
// Phase 1: Recovery needs NotificationContentEntity to detect missed notifications
|
||||
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
val roomStorage = org.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid - can be set if available
|
||||
"daily",
|
||||
config.title,
|
||||
@@ -273,9 +290,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
|
||||
// FIX: Set action to match manifest registration; setPackage() ensures AlarmManager
|
||||
// delivery reaches this app on all OEMs (see daily-notification-plugin-android-receiver-issue)
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
|
||||
action = "org.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
|
||||
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
|
||||
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
|
||||
// Also preserve original extras for backward compatibility if needed
|
||||
@@ -312,7 +329,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
if (existingPendingIntent != null) {
|
||||
Log.w(SCHEDULE_TAG, "Cancelling existing alarm before rescheduling: requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source")
|
||||
alarmManager.cancel(existingPendingIntent)
|
||||
existingPendingIntent.cancel()
|
||||
// Do not call existingPendingIntent.cancel(): the cached PendingIntent may be the same
|
||||
// object we pass to setAlarmClock below; cancelling it can prevent the new alarm from firing.
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "Failed to cancel existing alarm before scheduling: $stableScheduleId", e)
|
||||
@@ -399,6 +417,63 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
||||
}
|
||||
|
||||
// Update database schedule with new nextRunAt so getNotificationStatus() returns correct value
|
||||
// This is critical for rollover scenarios where the UI needs to show the updated time
|
||||
// Strategy: Find existing enabled notify schedule and update it (there should only be one)
|
||||
// This ensures getNotificationStatus() finds the updated schedule, not a stale one
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
|
||||
// First, try to find schedule by the provided stableScheduleId
|
||||
var scheduleToUpdate = db.scheduleDao().getById(stableScheduleId)
|
||||
|
||||
// If not found by ID, only use "first enabled notify" fallback when this is NOT
|
||||
// a rollover id (daily_rollover_*). Rollover work may use a different notification_id
|
||||
// (e.g. from recovery); updating the app's schedule row here would overwrite
|
||||
// nextRunAt with the rollover time and can leave the app's alarm in a bad state.
|
||||
if (scheduleToUpdate == null && !stableScheduleId.startsWith("daily_rollover_")) {
|
||||
val allSchedules = db.scheduleDao().getAll()
|
||||
scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled }
|
||||
}
|
||||
|
||||
// Calculate cron expression from trigger time (HH:mm format)
|
||||
val calendar = java.util.Calendar.getInstance().apply {
|
||||
timeInMillis = triggerAtMillis
|
||||
}
|
||||
val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY)
|
||||
val minute = calendar.get(java.util.Calendar.MINUTE)
|
||||
val cronExpression = "${minute} ${hour} * * *"
|
||||
val clockTime = String.format("%02d:%02d", hour, minute)
|
||||
|
||||
if (scheduleToUpdate != null) {
|
||||
// Update existing schedule with new nextRunAt
|
||||
// Use the existing schedule's ID (not stableScheduleId) to ensure we update the right one
|
||||
db.scheduleDao().updateRunTimes(scheduleToUpdate.id, scheduleToUpdate.lastRunAt, triggerAtMillis)
|
||||
Log.d(SCHEDULE_TAG, "Updated schedule in database: id=${scheduleToUpdate.id}, nextRunAt=$triggerAtMillis (rollover)")
|
||||
} else {
|
||||
// No existing schedule found - create new one (shouldn't happen in normal flow)
|
||||
val newSchedule = Schedule(
|
||||
id = stableScheduleId,
|
||||
kind = "notify",
|
||||
cron = cronExpression,
|
||||
clockTime = clockTime,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = triggerAtMillis,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
db.scheduleDao().upsert(newSchedule)
|
||||
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Log but don't fail - alarm is already scheduled, DB update is best-effort
|
||||
Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,9 +486,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
action = "org.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
val requestCode = when {
|
||||
scheduleId != null -> getRequestCode(scheduleId)
|
||||
@@ -423,14 +498,38 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
return
|
||||
}
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
|
||||
// CRITICAL: Use FLAG_NO_CREATE to get existing PendingIntent, don't create new one
|
||||
// This matches the pattern used in scheduleExactNotification for proper cancellation
|
||||
val existingPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
alarmManager.cancel(pendingIntent)
|
||||
Log.i(TAG, "Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
||||
|
||||
if (existingPendingIntent != null) {
|
||||
// Cancel both the alarm in AlarmManager AND the PendingIntent itself
|
||||
// This matches the pattern in scheduleExactNotification (lines 311-312)
|
||||
alarmManager.cancel(existingPendingIntent)
|
||||
existingPendingIntent.cancel()
|
||||
Log.i(TAG, "DNP-CANCEL: Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
||||
|
||||
// Verify cancellation by checking if alarm still exists
|
||||
val verifyIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
if (verifyIntent == null) {
|
||||
Log.d(TAG, "DNP-CANCEL: ✅ Cancellation verified - no PendingIntent found for requestCode=$requestCode")
|
||||
} else {
|
||||
Log.w(TAG, "DNP-CANCEL: ⚠️ Cancellation may have failed - PendingIntent still exists for requestCode=$requestCode")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "DNP-CANCEL: No existing PendingIntent found to cancel: scheduleId=$scheduleId, requestCode=$requestCode")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,9 +542,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
*/
|
||||
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
|
||||
// FIX: Use DailyNotificationReceiver to match what was scheduled
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
action = "org.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
val requestCode = when {
|
||||
scheduleId != null -> getRequestCode(scheduleId)
|
||||
@@ -573,7 +672,9 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
|
||||
// Existing cached content logic for regular notifications
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
val latestCache = db.contentCacheDao().getLatest()
|
||||
val latestCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
|
||||
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
|
||||
?: db.contentCacheDao().getLatest()
|
||||
|
||||
if (latestCache == null) {
|
||||
Log.w(TAG, "No cached content available for notification")
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 2.0.0 - Modular Architecture
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
@@ -87,7 +87,7 @@ public class PermissionManager {
|
||||
androidx.core.app.ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
new String[]{android.Manifest.permission.POST_NOTIFICATIONS},
|
||||
com.timesafari.dailynotification.DailyNotificationConstants.PERMISSION_REQUEST_CODE // Centralized constant
|
||||
org.timesafari.dailynotification.DailyNotificationConstants.PERMISSION_REQUEST_CODE // Centralized constant
|
||||
);
|
||||
|
||||
Log.d(TAG, "Permission dialog shown, waiting for user response");
|
||||
@@ -125,7 +125,7 @@ public class PermissionManager {
|
||||
*
|
||||
* @return PermissionStatus with all permission states
|
||||
*/
|
||||
public com.timesafari.dailynotification.PermissionStatus getPermissionStatus() {
|
||||
public org.timesafari.dailynotification.PermissionStatus getPermissionStatus() {
|
||||
boolean postNotificationsGranted = false;
|
||||
boolean exactAlarmsGranted = false;
|
||||
boolean notificationsEnabledAtOsLevel = false;
|
||||
@@ -168,7 +168,7 @@ public class PermissionManager {
|
||||
batteryOptimizationsIgnored = true; // Pre-Android 6, no battery optimization restrictions
|
||||
}
|
||||
|
||||
return new com.timesafari.dailynotification.PermissionStatus(
|
||||
return new org.timesafari.dailynotification.PermissionStatus(
|
||||
postNotificationsGranted,
|
||||
exactAlarmsGranted,
|
||||
batteryOptimizationsIgnored,
|
||||
@@ -187,7 +187,7 @@ public class PermissionManager {
|
||||
try {
|
||||
Log.d(TAG, "Checking permission status");
|
||||
|
||||
com.timesafari.dailynotification.PermissionStatus status = getPermissionStatus();
|
||||
org.timesafari.dailynotification.PermissionStatus status = getPermissionStatus();
|
||||
|
||||
JSObject result = status.toJSObject();
|
||||
result.put("success", true);
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
/**
|
||||
* Comprehensive permission status model
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
@@ -17,9 +17,9 @@ import java.util.concurrent.TimeUnit
|
||||
* Phase 2: Force stop detection and recovery
|
||||
*
|
||||
* Implements:
|
||||
* - [Plugin Requirements §3.1.2 - App Cold Start](../docs/alarms/03-plugin-requirements.md#312-app-cold-start)
|
||||
* - [Plugin Requirements §3.1.4 - Force Stop Recovery](../docs/alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
|
||||
* Platform Reference: [Android §2.1.4](../docs/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
|
||||
* - [Plugin Requirements §3.1.2 - App Cold Start](../../../../../../../doc/alarms/03-plugin-requirements.md#312-app-cold-start)
|
||||
* - [Plugin Requirements §3.1.4 - Force Stop Recovery](../../../../../../../doc/alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
|
||||
* Platform Reference: [Android §2.1.4](../../../../../../../doc/alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart)
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 2.0.0 - Phase 2: Force stop detection
|
||||
@@ -41,13 +41,33 @@ class ReactivationManager(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "DNP-REACTIVATION"
|
||||
private const val RECOVERY_TIMEOUT_SECONDS = 2L
|
||||
|
||||
/**
|
||||
* Load persisted title/body for a schedule from NotificationContentEntity (post-reboot recovery).
|
||||
* Tries schedule.id then "daily_${schedule.id}" to match NotifyReceiver/ScheduleHelper id convention.
|
||||
* Internal so BootReceiver can use when rescheduling after boot.
|
||||
*/
|
||||
internal fun getTitleBodyForSchedule(db: DailyNotificationDatabase, schedule: Schedule): Pair<String, String>? {
|
||||
val entity = try {
|
||||
db.notificationContentDao().getNotificationById(schedule.id)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: try {
|
||||
db.notificationContentDao().getNotificationById("daily_${schedule.id}")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return null
|
||||
val t = entity.title?.takeIf { it.isNotBlank() } ?: return null
|
||||
val b = entity.body?.takeIf { it.isNotBlank() } ?: return null
|
||||
return Pair(t, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run boot-time recovery
|
||||
*
|
||||
* Phase 3: Boot recovery that restores alarms after device reboot
|
||||
*
|
||||
* Implements: [Plugin Requirements §3.1.1 - Boot Event](../docs/alarms/03-plugin-requirements.md#311-boot-event-android-only)
|
||||
* Implements: [Plugin Requirements §3.1.1 - Boot Event](../../../../../../../doc/alarms/03-plugin-requirements.md#311-boot-event-android-only)
|
||||
*
|
||||
* This method is called from BootReceiver when BOOT_COMPLETED is received.
|
||||
* It runs asynchronously with timeout protection to avoid blocking boot.
|
||||
@@ -105,8 +125,8 @@ class ReactivationManager(private val context: Context) {
|
||||
markMissedNotificationForSchedule(schedule, nextRunTime, db)
|
||||
missedCount++
|
||||
|
||||
// Schedule next occurrence if repeating
|
||||
val nextOccurrence = calculateNextOccurrence(currentTime)
|
||||
// Schedule next occurrence (use rollover interval if set, else 24h)
|
||||
val nextOccurrence = calculateNextOccurrenceForSchedule(schedule, nextRunTime, currentTime)
|
||||
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
|
||||
rescheduledCount++
|
||||
|
||||
@@ -134,6 +154,12 @@ class ReactivationManager(private val context: Context) {
|
||||
Log.e(TAG, "Failed to recover schedule ${schedule.id}", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (enabledSchedules.any { it.id.startsWith("dual_fetch_") }) {
|
||||
if (DualScheduleFetchRecovery.enqueueFromPersistedConfig(context)) {
|
||||
Log.i(TAG, "Dual prefetch WorkManager re-enqueued from persisted config")
|
||||
}
|
||||
}
|
||||
|
||||
// Record recovery in history
|
||||
val result = RecoveryResult(
|
||||
@@ -218,10 +244,25 @@ class ReactivationManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun calculateNextOccurrence(fromTime: Long): Long {
|
||||
// For daily schedules, add 24 hours
|
||||
// This is simplified - production should handle weekly/monthly patterns
|
||||
return fromTime + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
/**
|
||||
* Next occurrence from a given trigger time. Uses schedule.rolloverIntervalMinutes when set and > 0 (dev/testing), else 24h.
|
||||
* Advances until result > currentTime so we don't reschedule in the past.
|
||||
*/
|
||||
private fun calculateNextOccurrenceForSchedule(schedule: Schedule, fromTime: Long, currentTime: Long): Long {
|
||||
val intervalMs = when {
|
||||
schedule.rolloverIntervalMinutes != null && schedule.rolloverIntervalMinutes!! > 0 ->
|
||||
schedule.rolloverIntervalMinutes!! * 60 * 1000L
|
||||
else -> 24 * 60 * 60 * 1000L
|
||||
}
|
||||
var next = fromTime + intervalMs
|
||||
while (next < currentTime) {
|
||||
next += intervalMs
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
private suspend fun markMissedNotificationForSchedule(
|
||||
schedule: Schedule,
|
||||
@@ -245,9 +286,9 @@ class ReactivationManager(private val context: Context) {
|
||||
db.notificationContentDao().updateNotification(existing)
|
||||
} else {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
@@ -275,22 +316,25 @@ class ReactivationManager(private val context: Context) {
|
||||
db: DailyNotificationDatabase
|
||||
) {
|
||||
try {
|
||||
val (title, body) = getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
)
|
||||
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextRunTime,
|
||||
context,
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
|
||||
// Update schedule in database (best effort)
|
||||
@@ -441,9 +485,9 @@ class ReactivationManager(private val context: Context) {
|
||||
private fun alarmsExist(): Boolean {
|
||||
return try {
|
||||
// Check if any PendingIntent for our receiver exists (must match NotifyReceiver schedule path)
|
||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
||||
setPackage(context.packageName)
|
||||
action = "com.timesafari.daily.NOTIFICATION"
|
||||
action = "org.timesafari.daily.NOTIFICATION"
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
@@ -816,13 +860,13 @@ class ReactivationManager(private val context: Context) {
|
||||
db: DailyNotificationDatabase
|
||||
) {
|
||||
try {
|
||||
// Use existing BootReceiver logic for calculating next run time
|
||||
// For now, use schedule.nextRunAt directly
|
||||
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
@@ -1012,9 +1056,9 @@ class ReactivationManager(private val context: Context) {
|
||||
db.notificationContentDao().updateNotification(existing)
|
||||
} else {
|
||||
// Create new notification content entry for missed alarm
|
||||
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
notificationId,
|
||||
"1.0.2", // Plugin version
|
||||
"2.1.0", // Plugin version
|
||||
null, // timesafariDid
|
||||
"daily", // notificationType
|
||||
"Daily Notification",
|
||||
@@ -1045,22 +1089,25 @@ class ReactivationManager(private val context: Context) {
|
||||
db: DailyNotificationDatabase
|
||||
) {
|
||||
try {
|
||||
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
||||
?: Pair("Daily Notification", "Your daily update is ready")
|
||||
val config = UserNotificationConfig(
|
||||
enabled = schedule.enabled,
|
||||
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
|
||||
title = "Daily Notification",
|
||||
body = "Your daily update is ready",
|
||||
title = title,
|
||||
body = body,
|
||||
sound = true,
|
||||
vibration = true,
|
||||
priority = "normal"
|
||||
)
|
||||
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextRunTime,
|
||||
context,
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
)
|
||||
|
||||
// Update schedule in database (best effort)
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Shared cron → next wall-clock instant (daily "minute hour * * *" style).
|
||||
* Used by dual prefetch scheduling, rollover, and [DailyNotificationPlugin] scheduling.
|
||||
*/
|
||||
object ScheduleCronUtils {
|
||||
private const val TAG = "DNP-CRON"
|
||||
|
||||
/**
|
||||
* Next occurrence of the given daily cron after "now" (same logic as DailyNotificationPlugin.calculateNextRunTime).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun calculateNextRunTimeMillis(schedule: String): Long {
|
||||
try {
|
||||
val parts = schedule.trim().split("\\s+".toRegex())
|
||||
if (parts.size < 2) {
|
||||
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val minute = parts[0].toIntOrNull() ?: 0
|
||||
val hour = parts[1].toIntOrNull() ?: 9
|
||||
|
||||
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
|
||||
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
val now = calendar.timeInMillis
|
||||
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
|
||||
calendar.set(java.util.Calendar.MINUTE, minute)
|
||||
calendar.set(java.util.Calendar.SECOND, 0)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
||||
|
||||
var nextRun = calendar.timeInMillis
|
||||
if (nextRun <= now) {
|
||||
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
nextRun = calendar.timeInMillis
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Next run: cron=$schedule, nextRun=${
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(nextRun))
|
||||
}"
|
||||
)
|
||||
return nextRun
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calculating next run for schedule: $schedule", e)
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Trace;
|
||||
@@ -24,7 +24,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification;
|
||||
package org.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -9,10 +9,10 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.dao;
|
||||
package org.timesafari.dailynotification.dao;
|
||||
|
||||
import androidx.room.*;
|
||||
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.dao;
|
||||
package org.timesafari.dailynotification.dao;
|
||||
|
||||
import androidx.room.*;
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.dao;
|
||||
package org.timesafari.dailynotification.dao;
|
||||
|
||||
import androidx.room.*;
|
||||
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.entities;
|
||||
package org.timesafari.dailynotification.entities;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
@@ -9,7 +9,7 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.entities;
|
||||
package org.timesafari.dailynotification.entities;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
@@ -9,7 +9,7 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.entities;
|
||||
package org.timesafari.dailynotification.entities;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.ColumnInfo;
|
||||
@@ -9,18 +9,18 @@
|
||||
* @since 2025-10-20
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification.storage;
|
||||
package org.timesafari.dailynotification.storage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.timesafari.dailynotification.DailyNotificationDatabase;
|
||||
import com.timesafari.dailynotification.dao.NotificationContentDao;
|
||||
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
|
||||
import com.timesafari.dailynotification.dao.NotificationConfigDao;
|
||||
import com.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
import org.timesafari.dailynotification.DailyNotificationDatabase;
|
||||
import org.timesafari.dailynotification.dao.NotificationContentDao;
|
||||
import org.timesafari.dailynotification.dao.NotificationDeliveryDao;
|
||||
import org.timesafari.dailynotification.dao.NotificationConfigDao;
|
||||
import org.timesafari.dailynotification.entities.NotificationContentEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity;
|
||||
import org.timesafari.dailynotification.entities.NotificationConfigEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
|
||||
private final ExecutorService executorService;
|
||||
|
||||
// Plugin version for migration tracking
|
||||
private static final String PLUGIN_VERSION = "1.0.0";
|
||||
private static final String PLUGIN_VERSION = "1.2.1";
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
@@ -9,7 +9,7 @@
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
@@ -0,0 +1,143 @@
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.text.Charsets
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28])
|
||||
class DualScheduleHelperTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.clear()
|
||||
.commit()
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().deleteAll()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_skipNotification_returnsNull_evenWithShowDefault() {
|
||||
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":600000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_skipNotification_returnsNull_withSkipFallback() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCache_skipNotification_skipFallback_returnsNull() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":1000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis() - 60_000L
|
||||
)
|
||||
assertNull(DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCache_skipNotification_showDefault_usesUserDefaults() {
|
||||
putDualConfig("""{"fallbackBehavior":"show_default","contentTimeout":1000}""")
|
||||
insertDualCache(
|
||||
payload = FetchWorker.dualEmptyNativeFetchPayload,
|
||||
fetchedAt = System.currentTimeMillis() - 60_000L
|
||||
)
|
||||
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
|
||||
assertNotNull(content)
|
||||
assertTrue(content!!.title == "New Activity")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshCache_realPayload_returnsContent() {
|
||||
putDualConfig("""{"fallbackBehavior":"skip","contentTimeout":600000}""")
|
||||
val json = JSONObject()
|
||||
json.put("title", "API Title")
|
||||
json.put("body", "API Body")
|
||||
insertDualCache(
|
||||
payload = json.toString().toByteArray(Charsets.UTF_8),
|
||||
fetchedAt = System.currentTimeMillis()
|
||||
)
|
||||
val content = DualScheduleHelper.resolveDualContentBlocking(context, "dual_notify_test")
|
||||
assertNotNull(content)
|
||||
assertTrue(content!!.title == "API Title" && content.body == "API Body")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDualSkipNotificationPayload_emptyObject_false() {
|
||||
assertFalse(FetchWorker.isDualSkipNotificationPayload("{}".toByteArray(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isDualSkipNotificationPayload_sentinel_true() {
|
||||
assertTrue(FetchWorker.isDualSkipNotificationPayload(FetchWorker.dualEmptyNativeFetchPayload))
|
||||
}
|
||||
|
||||
private fun putDualConfig(relationshipJson: String) {
|
||||
val root = JSONObject()
|
||||
val user = JSONObject()
|
||||
user.put("enabled", true)
|
||||
user.put("schedule", "0 9 * * *")
|
||||
user.put("title", "New Activity")
|
||||
user.put("body", "Check your starred projects for updates")
|
||||
root.put("userNotification", user)
|
||||
root.put("relationship", JSONObject(relationshipJson))
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, root.toString())
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun insertDualCache(payload: ByteArray, fetchedAt: Long) {
|
||||
val cache = ContentCache(
|
||||
id = "test_dual_${System.nanoTime()}",
|
||||
fetchedAt = fetchedAt,
|
||||
ttlSeconds = 3600,
|
||||
payload = payload,
|
||||
meta = "test",
|
||||
cacheScope = ContentCacheScope.DUAL
|
||||
)
|
||||
runBlocking {
|
||||
DailyNotificationDatabase.getDatabase(context).contentCacheDao().upsert(cache)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
|
||||
package com.timesafari.dailynotification
|
||||
package org.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'com.timesafari.dailynotification',
|
||||
appId: 'org.timesafari.dailynotification',
|
||||
appName: 'DailyNotification Test App',
|
||||
webDir: 'www',
|
||||
server: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "DailyNotification",
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
**Owner:** Development Team
|
||||
**Last Updated:** 2025-12-23
|
||||
**Status:** active
|
||||
**Baseline:** See `docs/progress/00-STATUS.md` for current baseline tag
|
||||
**Baseline:** See `doc/progress/00-STATUS.md` for current baseline tag
|
||||
|
||||
This index provides organized access to all documentation in the repository. For a complete audit trail of file movements, see [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md).
|
||||
This index provides organized access to all documentation in the repository. For a complete audit trail of file movements, see [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -14,12 +14,12 @@ This index provides organized access to all documentation in the repository. For
|
||||
|
||||
These are **policy-as-code**. Any gate (push, release, publish) MUST call `./ci/run.sh`.
|
||||
|
||||
- **System Invariants:** `docs/SYSTEM_INVARIANTS.md` — Single authoritative document naming and explaining all enforced invariants
|
||||
- **System Invariants:** `doc/SYSTEM_INVARIANTS.md` — Single authoritative document naming and explaining all enforced invariants
|
||||
- **Local CI Contract:** `./ci/run.sh` — Single source of truth for CI/release gates
|
||||
- **Verification / Invariants:** `./scripts/verify.sh` — Encodes packaging, core-purity, and build invariants
|
||||
- **CI Usage & Setup:** `ci/README.md` — Local CI documentation
|
||||
- **Performance Characteristics:** `docs/PERFORMANCE.md` — Performance characteristics and benchmarks
|
||||
- **Troubleshooting Guide:** `docs/TROUBLESHOOTING.md` — Common issues and solutions
|
||||
- **Performance Characteristics:** `doc/PERFORMANCE.md` — Performance characteristics and benchmarks
|
||||
- **Troubleshooting Guide:** `doc/TROUBLESHOOTING.md` — Common issues and solutions
|
||||
|
||||
---
|
||||
|
||||
@@ -35,6 +35,10 @@ These files define the current truth about project state, decisions, and verific
|
||||
- **[05-CHATGPT-FEEDBACK-PACKAGE.md](./progress/05-CHATGPT-FEEDBACK-PACKAGE.md)** — AI collaboration package
|
||||
- **[P2-DESIGN.md](./progress/P2-DESIGN.md)** — P2 scope, invariants, and acceptance criteria (design-only)
|
||||
- **[P2.1-REFACTORING-COMPLETE.md](./progress/P2.1-REFACTORING-COMPLETE.md)** — P2.1 native plugin refactoring complete summary (Android + iOS)
|
||||
- **[TODO.md](./progress/TODO.md)** — Project TODO and improvement tracking
|
||||
- **[TODAY_SUMMARY.md](./progress/TODAY_SUMMARY.md)** — Dated work summaries
|
||||
- **[SESSION_RECONSTITUTION.md](./progress/SESSION_RECONSTITUTION.md)** — P2.1 Batch A session reconstitution
|
||||
- **[BATCH_A_COMPLETION_SUMMARY.md](./progress/BATCH_A_COMPLETION_SUMMARY.md)** — P2.1 Batch A completion summary
|
||||
|
||||
---
|
||||
|
||||
@@ -51,14 +55,15 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
## Archive & Reference-only
|
||||
|
||||
- **`docs/_archive/`** — Historical artifacts, preserved for audit trail (not part of active doc surface)
|
||||
- `docs/_archive/2025-legacy-doc/` — Legacy documentation from 2025
|
||||
- **`doc/_archive/`** — Historical artifacts, preserved for audit trail (not part of active doc surface)
|
||||
- `doc/_archive/PR_DESCRIPTION.md`, `MERGE_READY_SUMMARY.md` — One-off PR/merge artifacts (2025-10)
|
||||
- `doc/_archive/2025-legacy-doc/` — Legacy documentation from 2025
|
||||
- [IMPLEMENTATION_CHECKLIST_LEGACY.md](./_archive/2025-legacy-doc/IMPLEMENTATION_CHECKLIST_LEGACY.md) — iOS Phase 1 checklist (historical)
|
||||
- `docs/_archive/2025-12-16-consolidation/` — 2025-12-16 consolidation artifacts (audit trail)
|
||||
- `doc/_archive/2025-12-16-consolidation/` — 2025-12-16 consolidation artifacts (audit trail)
|
||||
- [CONSOLIDATION_COMPLETE.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_COMPLETE.md) — Consolidation completion summary
|
||||
- [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) — Complete file mapping (139 files)
|
||||
- **`docs/_reference/`** — Reference templates (not used by current workflow)
|
||||
- `docs/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
|
||||
- **`doc/_reference/`** — Reference templates (not used by current workflow)
|
||||
- `doc/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
|
||||
|
||||
---
|
||||
|
||||
@@ -68,7 +73,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
1. **[README.md](../README.md)** - Project overview and getting started
|
||||
2. **[ARCHITECTURE.md](../ARCHITECTURE.md)** - System architecture
|
||||
3. **[docs/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
|
||||
3. **[doc/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
|
||||
4. **[BUILDING.md](../BUILDING.md)** - Build instructions
|
||||
|
||||
---
|
||||
@@ -90,7 +95,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
## Integration Documentation
|
||||
|
||||
**Location:** `docs/integration/`
|
||||
**Location:** `doc/integration/`
|
||||
|
||||
- **[INTEGRATION_GUIDE.md](./integration/INTEGRATION_GUIDE.md)** - Complete integration guide
|
||||
- **[QUICK_START.md](./integration/QUICK_START.md)** - Quick integration path
|
||||
@@ -104,7 +109,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
### iOS
|
||||
|
||||
**Location:** `docs/platform/ios/`
|
||||
**Location:** `doc/platform/ios/`
|
||||
|
||||
- **[IOS_IMPLEMENTATION_CHECKLIST.md](./platform/ios/IOS_IMPLEMENTATION_CHECKLIST.md)** - iOS implementation checklist
|
||||
- **[IMPLEMENTATION_DIRECTIVE.md](./platform/ios/IMPLEMENTATION_DIRECTIVE.md)** - iOS implementation directive
|
||||
@@ -119,7 +124,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
### Android
|
||||
|
||||
**Location:** `docs/platform/android/`
|
||||
**Location:** `doc/platform/android/`
|
||||
|
||||
- **[IMPLEMENTATION_DIRECTIVE.md](./platform/android/IMPLEMENTATION_DIRECTIVE.md)** - Primary Android implementation directive
|
||||
- **[PHASE1_DIRECTIVE.md](./platform/android/PHASE1_DIRECTIVE.md)** - Phase 1 directive
|
||||
@@ -135,7 +140,7 @@ These files define the current truth about project state, decisions, and verific
|
||||
|
||||
## Testing Documentation
|
||||
|
||||
**Location:** `docs/testing/`
|
||||
**Location:** `doc/testing/`
|
||||
|
||||
### General Testing
|
||||
|
||||
@@ -178,7 +183,7 @@ Test app-specific documentation remains with the test apps but is indexed here:
|
||||
|
||||
## Alarm System Documentation
|
||||
|
||||
**Location:** `docs/alarms/`
|
||||
**Location:** `doc/alarms/`
|
||||
|
||||
The alarm system documentation is well-organized and kept in its current location:
|
||||
|
||||
@@ -198,7 +203,7 @@ The alarm system documentation is well-organized and kept in its current locatio
|
||||
|
||||
## Design & Research Documentation
|
||||
|
||||
**Location:** `docs/design/`
|
||||
**Location:** `doc/design/`
|
||||
|
||||
- **[STARRED_PROJECTS_POLLING_IMPLEMENTATION.md](./design/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md)** - Starred projects polling implementation
|
||||
- **[exploration-findings-initial.md](./design/exploration-findings-initial.md)** - Initial exploration findings
|
||||
@@ -208,63 +213,83 @@ The alarm system documentation is well-organized and kept in its current locatio
|
||||
|
||||
---
|
||||
|
||||
## Feature-Specific Documentation
|
||||
## Architecture (Storage & Core Tech)
|
||||
|
||||
**Location:** `docs/`
|
||||
**Location:** `doc/architecture/`
|
||||
|
||||
### Storage & Database
|
||||
- **[CROSS_PLATFORM_STORAGE_PATTERN.md](./architecture/CROSS_PLATFORM_STORAGE_PATTERN.md)** - Cross-platform storage pattern
|
||||
- **[DATABASE_INTERFACES.md](./architecture/DATABASE_INTERFACES.md)** - Database interfaces
|
||||
- **[DATABASE_INTERFACES_IMPLEMENTATION.md](./architecture/DATABASE_INTERFACES_IMPLEMENTATION.md)** - Database interfaces implementation
|
||||
- **[NATIVE_FETCHER_CONFIGURATION.md](./architecture/NATIVE_FETCHER_CONFIGURATION.md)** - Native fetcher configuration
|
||||
|
||||
- **[CROSS_PLATFORM_STORAGE_PATTERN.md](./CROSS_PLATFORM_STORAGE_PATTERN.md)** - Cross-platform storage pattern
|
||||
- **[DATABASE_INTERFACES.md](./DATABASE_INTERFACES.md)** - Database interfaces
|
||||
- **[DATABASE_INTERFACES_IMPLEMENTATION.md](./DATABASE_INTERFACES_IMPLEMENTATION.md)** - Database interfaces implementation
|
||||
---
|
||||
|
||||
### Native Fetcher
|
||||
## Deployment
|
||||
|
||||
- **[NATIVE_FETCHER_CONFIGURATION.md](./NATIVE_FETCHER_CONFIGURATION.md)** - Native fetcher configuration
|
||||
**Location:** `doc/deployment/`
|
||||
|
||||
### Prefetch & Scheduling
|
||||
- **[deployment-guide.md](./deployment/deployment-guide.md)** - Deployment guide (primary)
|
||||
- **[DEPLOYMENT_CHECKLIST.md](./deployment/DEPLOYMENT_CHECKLIST.md)** - Deployment checklist
|
||||
|
||||
- **[prefetch-scheduling-diagnosis.md](./prefetch-scheduling-diagnosis.md)** - Prefetch scheduling diagnosis
|
||||
- **[prefetch-scheduling-trace.md](./prefetch-scheduling-trace.md)** - Prefetch scheduling trace
|
||||
---
|
||||
|
||||
### Recovery & Startup
|
||||
## Compliance & Operations
|
||||
|
||||
- **[app-startup-recovery-solution.md](./app-startup-recovery-solution.md)** - App startup recovery solution
|
||||
**Location:** `doc/compliance/`
|
||||
|
||||
### Platform Capabilities
|
||||
- **[accessibility-localization.md](./compliance/accessibility-localization.md)** - Accessibility and localization
|
||||
- **[legal-store-compliance.md](./compliance/legal-store-compliance.md)** - Legal and store compliance
|
||||
- **[observability-dashboards.md](./compliance/observability-dashboards.md)** - Observability dashboards
|
||||
|
||||
- **[platform-capability-reference.md](./platform-capability-reference.md)** - Platform capability reference
|
||||
- **[plugin-requirements-implementation.md](./plugin-requirements-implementation.md)** - Plugin requirements implementation
|
||||
---
|
||||
|
||||
### Feature Implementation
|
||||
## Feature-Specific (Integration, Design, Progress)
|
||||
|
||||
- **[getting-valid-plan-ids.md](./getting-valid-plan-ids.md)** - Getting valid plan IDs
|
||||
- **[host-request-configuration.md](./host-request-configuration.md)** - Host request configuration
|
||||
- **[hydrate-plan-implementation-guide.md](./hydrate-plan-implementation-guide.md)** - Hydrate plan implementation guide
|
||||
- **[user-zero-stars-implementation.md](./user-zero-stars-implementation.md)** - User zero stars implementation
|
||||
### Integration (`doc/integration/`)
|
||||
|
||||
### Compliance & Operations
|
||||
- **[getting-valid-plan-ids.md](./integration/getting-valid-plan-ids.md)** - Getting valid plan IDs
|
||||
- **[host-request-configuration.md](./integration/host-request-configuration.md)** - Host request configuration
|
||||
- **[hydrate-plan-implementation-guide.md](./integration/hydrate-plan-implementation-guide.md)** - Hydrate plan implementation guide
|
||||
- **[user-zero-stars-implementation.md](./integration/user-zero-stars-implementation.md)** - User zero stars implementation
|
||||
- **[capacitor-platform-service-clean-changes.md](./integration/capacitor-platform-service-clean-changes.md)** - Capacitor platform service changes
|
||||
- **[ACTION_PLAN_INTEGRATION_FIXES.md](./integration/ACTION_PLAN_INTEGRATION_FIXES.md)** - Integration fixes action plan
|
||||
|
||||
- **[accessibility-localization.md](./accessibility-localization.md)** - Accessibility and localization
|
||||
- **[legal-store-compliance.md](./legal-store-compliance.md)** - Legal and store compliance
|
||||
- **[observability-dashboards.md](./observability-dashboards.md)** - Observability dashboards
|
||||
### Design (`doc/design/`) — plans, prefetch, recovery
|
||||
|
||||
### Deployment
|
||||
- **[P1.5-CONSOLIDATION-PLAN.md](./design/P1.5-CONSOLIDATION-PLAN.md)** - P1.5 consolidation plan
|
||||
- **[P1.5-STEP4-CLUSTERS.md](./design/P1.5-STEP4-CLUSTERS.md)** - P1.5 step 4 clusters
|
||||
- **[P1.5-STEP4-DECISIONS.md](./design/P1.5-STEP4-DECISIONS.md)** - P1.5 step 4 decisions
|
||||
- **[P2.1-NATIVE-REFACTORING-ANALYSIS.md](./design/P2.1-NATIVE-REFACTORING-ANALYSIS.md)** - P2.1 native refactoring analysis
|
||||
- **[FEEDBACK-RESPONSE-PLAN.md](./design/FEEDBACK-RESPONSE-PLAN.md)** - Feedback response plan
|
||||
- **[prefetch-scheduling-diagnosis.md](./design/prefetch-scheduling-diagnosis.md)** - Prefetch scheduling diagnosis
|
||||
- **[prefetch-scheduling-trace.md](./design/prefetch-scheduling-trace.md)** - Prefetch scheduling trace
|
||||
- **[app-startup-recovery-solution.md](./design/app-startup-recovery-solution.md)** - App startup recovery solution
|
||||
- **[plugin-requirements-implementation.md](./design/plugin-requirements-implementation.md)** - Plugin requirements implementation
|
||||
|
||||
- **[deployment-guide.md](./deployment-guide.md)** - Deployment guide (primary)
|
||||
- **[DEPLOYMENT_CHECKLIST.md](./DEPLOYMENT_CHECKLIST.md)** - Deployment checklist
|
||||
- **[DEPLOYMENT_SUMMARY.md](./DEPLOYMENT_SUMMARY.md)** - Deployment summary
|
||||
### Progress (`doc/progress/`)
|
||||
|
||||
### Utilities
|
||||
- **[DEPLOYMENT_SUMMARY.md](./progress/DEPLOYMENT_SUMMARY.md)** - Deployment summary
|
||||
- **[TODO-CLASSIFICATION.md](./progress/TODO-CLASSIFICATION.md)** - TODO classification
|
||||
|
||||
### Platform — Android (`doc/platform/android/`)
|
||||
|
||||
- **[CONSUMING_APP_ANDROID_NOTES.md](./platform/android/CONSUMING_APP_ANDROID_NOTES.md)** - Consuming app Android notes
|
||||
- **[CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md](./platform/android/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md)** - Optional Android ID cleanup
|
||||
- **[TIMESAFARI_ANDROID_COMPARISON.md](./platform/android/TIMESAFARI_ANDROID_COMPARISON.md)** - TimeSafari Android comparison
|
||||
|
||||
### Platform Capabilities (canonical)
|
||||
|
||||
- **[01-platform-capability-reference.md](./alarms/01-platform-capability-reference.md)** - Platform capability reference (canonical; deprecated root copy archived in `_archive/`)
|
||||
|
||||
### Utilities (meta)
|
||||
|
||||
- **[file-organization-summary.md](./file-organization-summary.md)** - File organization summary
|
||||
- **[capacitor-platform-service-clean-changes.md](./capacitor-platform-service-clean-changes.md)** - Capacitor platform service changes
|
||||
|
||||
---
|
||||
|
||||
## AI / Prompting / Automation Artifacts
|
||||
|
||||
**Location:** `docs/ai/`
|
||||
**Location:** `doc/ai/`
|
||||
|
||||
These are derived operational artifacts for AI-assisted development:
|
||||
|
||||
@@ -280,9 +305,9 @@ These are derived operational artifacts for AI-assisted development:
|
||||
|
||||
## Archive Documentation
|
||||
|
||||
**Location:** `docs/archive/2025-legacy-doc/`
|
||||
**Location:** `doc/archive/2025-legacy-doc/`
|
||||
|
||||
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
|
||||
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
|
||||
|
||||
**Notable archived content:**
|
||||
- Historical directives (`doc/directives/`)
|
||||
@@ -300,18 +325,20 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
|
||||
|
||||
| Category | Count | Location |
|
||||
|----------|-------|----------|
|
||||
| **Core Documentation** | 8 | Root + `docs/` |
|
||||
| **Integration** | 5 | `docs/integration/` |
|
||||
| **Platform (iOS)** | 10 | `docs/platform/ios/` |
|
||||
| **Platform (Android)** | 9 | `docs/platform/android/` |
|
||||
| **Testing** | 13 | `docs/testing/` |
|
||||
| **Alarms** | 11 | `docs/alarms/` |
|
||||
| **Design & Research** | 5 | `docs/design/` |
|
||||
| **Feature-Specific** | 18 | `docs/` |
|
||||
| **AI Artifacts** | 7 | `docs/ai/` |
|
||||
| **Deployment** | 3 | `docs/` |
|
||||
| **Core Documentation** | 8 | Root + `doc/` |
|
||||
| **Integration** | 5 | `doc/integration/` |
|
||||
| **Platform (iOS)** | 10 | `doc/platform/ios/` |
|
||||
| **Platform (Android)** | 9 | `doc/platform/android/` |
|
||||
| **Testing** | 13 | `doc/testing/` |
|
||||
| **Alarms** | 11 | `doc/alarms/` |
|
||||
| **Design & Research** | 5 | `doc/design/` |
|
||||
| **Architecture** | 4 | `doc/architecture/` |
|
||||
| **Deployment** | 2 | `doc/deployment/` |
|
||||
| **Compliance** | 3 | `doc/compliance/` |
|
||||
| **Feature-Specific (integration, design, progress, platform)** | 14 | `doc/integration/`, `doc/design/`, `doc/progress/`, `doc/platform/android/` |
|
||||
| **AI Artifacts** | 7 | `doc/ai/` |
|
||||
| **Test Apps** | 20+ | `test-apps/*/` |
|
||||
| **Archive** | 29 | `docs/archive/2025-legacy-doc/` |
|
||||
| **Archive** | 29 | `doc/archive/2025-legacy-doc/` |
|
||||
|
||||
### By Status
|
||||
|
||||
@@ -334,13 +361,13 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
|
||||
- **Test on Android** → See [Android Test App Docs](../test-apps/android-test-app/docs/)
|
||||
- **Understand alarms** → Browse [Alarms Documentation](./alarms/)
|
||||
- **Troubleshoot** → Check platform-specific troubleshooting guides
|
||||
- **Deploy** → See [Deployment Guide](./deployment-guide.md)
|
||||
- **Deploy** → See [Deployment Guide](./deployment/deployment-guide.md)
|
||||
|
||||
### By Platform
|
||||
|
||||
- **iOS** → `docs/platform/ios/`
|
||||
- **Android** → `docs/platform/android/`
|
||||
- **Cross-Platform** → `docs/alarms/`, `docs/integration/`
|
||||
- **iOS** → `doc/platform/ios/`
|
||||
- **Android** → `doc/platform/android/`
|
||||
- **Cross-Platform** → `doc/alarms/`, `doc/integration/`
|
||||
|
||||
### By Phase
|
||||
|
||||
@@ -354,19 +381,19 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
|
||||
|
||||
### Updating This Index
|
||||
|
||||
**Index-first rule:** New docs must be linked from `docs/00-INDEX.md` or explicitly placed under `_archive/` / `_reference/`.
|
||||
**Index-first rule:** New docs must be linked from `doc/00-INDEX.md` or explicitly placed under `_archive/` / `_reference/`.
|
||||
|
||||
When adding new documentation:
|
||||
|
||||
1. Place file in appropriate category directory
|
||||
2. Add entry to this index in the correct section
|
||||
3. Update the "Document Map by Category" table if needed
|
||||
4. Update [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) if consolidating
|
||||
4. Update [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) if consolidating
|
||||
|
||||
### Consolidation Reference
|
||||
|
||||
For complete consolidation audit trail, see:
|
||||
- **[CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
|
||||
- **[CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
|
||||
|
||||
---
|
||||
|
||||
173
doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md
Normal file
173
doc/COMPLETION-PLAN-SCHEDULE-DUAL-NOTIFICATION.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Completion Plan: scheduleDualNotification (Plugin + Consuming App)
|
||||
|
||||
**Purpose:** Checklist of what needs to be done to complete the dual-schedule (New Activity) implementation in the plugin and in the consuming app. For review before making changes.
|
||||
|
||||
**Status:** Cron parsing, stable dual ID, cancelDualSchedule, and updateDualScheduleConfig are implemented on iOS and Android. The **relationship** (contentTimeout / fallbackBehavior) is implemented: dual config is persisted; on iOS the pending dual notification is updated when the fetch completes; on Android the Worker resolves config + cache at fire time and shows the resolved title/body.
|
||||
|
||||
**Related:** Consuming app feedback doc `plugin-feedback-ios-scheduleDualNotification.md`; plugin `src/definitions.ts` (`DualScheduleConfiguration`, `scheduleDualNotification`, `cancelDualSchedule`).
|
||||
|
||||
**For app-side implementation (crowd-funder-for-time-pwa):** All plugin work below is **done**. Use this doc in the app repo to implement app changes. Require plugin **v2.1.0+** (or current local plugin with dual schedule + relationship). Focus on **§2** and the **Consuming app** rows in **§3**. Key tasks: (1) Verify plugin is linked and built so `scheduleDualNotification` is not UNIMPLEMENTED (§2.1); (2) In Edit-time flow, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time, with fallback to `scheduleDualNotification` (§2.4). App paths: `src/views/AccountViewView.vue` (e.g. `editNewActivityNotification()` ~1504–1520), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/NativeNotificationService.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context: Two notification types in the consuming app
|
||||
|
||||
The consuming app (crowd-funder-for-time-pwa) has **two independent notification types**, both using a user-set time from the app DB and firing once a day at that time:
|
||||
|
||||
| Type | Content source | Plugin API | Cancel API |
|
||||
|------|----------------|------------|------------|
|
||||
| **Daily Reminder** | User-set text from app DB | `scheduleDailyReminder(id, title, body, time, …)` | `cancelDailyReminder({ reminderId })` |
|
||||
| **New Activity** | API-fetched content (native fetcher) | `scheduleDualNotification({ config })` | `cancelDualSchedule()` |
|
||||
|
||||
- Both can be **on at the same time**; the app turns each on/off and sets its time independently.
|
||||
- **Isolation requirement:** Cancelling one must not affect the other. So:
|
||||
- `cancelDualSchedule()` must cancel **only** the dual schedule (content fetch + user notification for New Activity). It must **not** remove Daily Reminder notifications (iOS uses `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
|
||||
- `cancelDailyReminder({ reminderId })` must cancel **only** that reminder; it must **not** cancel the dual content-fetch task or New Activity user notification.
|
||||
|
||||
The completion plan below assumes this separation. Any new plugin code (e.g. iOS `cancelDualSchedule`, or dual user-notification identifiers) must preserve it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin (daily-notification-plugin) — iOS
|
||||
|
||||
### 1.1 Fix UNIMPLEMENTED (bridge / integration)
|
||||
|
||||
- **Ensure the native method is actually invoked.** Capacitor returns `UNIMPLEMENTED` when the bridge doesn't call the native handler. In the **consuming app**:
|
||||
- Confirm the app depends on this plugin (or an up-to-date fork) and that `npx cap sync ios` / build includes the plugin's native code.
|
||||
- If they use Capacitor 6, check the [Capacitor 6 plugin registration / UNIMPLEMENTED issues](https://github.com/ionic-team/capacitor-docs/issues/325) and apply any required registration or build fixes so `scheduleDualNotification` is exposed and called.
|
||||
- No code changes are required in the plugin for this; the handler and registration already exist.
|
||||
|
||||
### 1.2 Cron parsing (align with Android)
|
||||
|
||||
- **Replace the stub `calculateNextRunTime(from:)`** in `ios/Plugin/DailyNotificationPlugin.swift` (lines 767–771) with real cron parsing.
|
||||
- **Reference:** Android's `calculateNextRunTime(schedule: String)` in `DailyNotificationPlugin.kt` (lines 2336–2378): supports `"minute hour * * *"`, uses device timezone, returns next occurrence (today or tomorrow).
|
||||
- **Behavior:** For a given cron string (e.g. `"25 18 * * *"`), compute the next run as `Date`/`TimeInterval` and use that for:
|
||||
- `BGAppRefreshTaskRequest.earliestBeginDate`
|
||||
- `UNCalendarNotificationTrigger` (or equivalent) for the user notification so it fires at the correct local time daily.
|
||||
- Right now the implementation ignores the cron and always uses 86400 seconds, so schedules are wrong.
|
||||
|
||||
### 1.3 Use `relationship` (contentTimeout + fallbackBehavior) — implemented
|
||||
|
||||
- **Intent:** When the user notification fires at `userNotification.schedule`, show **API-derived content** if the fetch completed and is within `relationship.contentTimeout`; otherwise show `userNotification.title` / `userNotification.body` (per `fallbackBehavior: "show_default"`).
|
||||
- **Implemented:** Dual config (userNotification + relationship) is persisted when scheduling/updating. On **iOS**, after the content fetch completes in `handleBackgroundFetch`, the plugin replaces the pending dual notification with resolved title/body (from cache if within contentTimeout, else default). On **Android**, when the Worker runs for a `dual_notify_*` schedule, it loads the persisted config and content cache and resolves title/body at fire time, then displays one notification with that content. See **§1.3a** for implementation details (retained for reference).
|
||||
|
||||
### 1.3a Implementation plan: relationship (contentTimeout / fallbackBehavior)
|
||||
|
||||
Implement when ready so the New Activity notification can show API content when the fetch succeeds in time, or default text otherwise.
|
||||
|
||||
**Prerequisite: persist dual config (both platforms)**
|
||||
|
||||
When `scheduleDualNotification` or `updateDualScheduleConfig` runs, persist enough of the config for later use:
|
||||
|
||||
- **userNotification:** `schedule` (cron), `title`, `body` (and any other fields needed to build the notification).
|
||||
- **relationship:** `contentTimeout`, `fallbackBehavior`.
|
||||
|
||||
So when we later resolve content (after fetch or at fire time), we have the default text and the rules. No new API surface; store what we already receive.
|
||||
|
||||
- **iOS:** e.g. a single key in UserDefaults (or alongside `native_fetcher_config`), e.g. `dual_schedule_config`, with this structure (e.g. JSON).
|
||||
- **Android:** e.g. SharedPreferences or a keyed config; the code that runs at notification time (or after fetch) must be able to read it.
|
||||
|
||||
**iOS: update the pending notification when the fetch completes**
|
||||
|
||||
- When the content fetch runs (e.g. in `handleBackgroundFetch`), we already store the result. After a successful fetch:
|
||||
1. **Read the persisted dual config.** If none (no dual schedule or legacy flow), skip.
|
||||
2. **Resolve content:** Load the content just stored (or latest from cache) and its timestamp. If content exists and `(now - contentTimestamp) <= relationship.contentTimeout`, use that title/body; else use `userNotification.title` / `userNotification.body`.
|
||||
3. **Replace the pending dual notification:** Remove the pending request with identifier `dualNotificationRequestIdentifier`, then add a new `UNNotificationRequest` with the same identifier, the same trigger (recompute from `userNotification.schedule` in stored config), and the resolved title/body.
|
||||
|
||||
- **Edge cases:** If the fetch completes after the notification time (next trigger already in the past), do not replace. If the fetch fails, leave the existing pending notification as-is (it already has default title/body).
|
||||
|
||||
**Android: resolve content when the notification is about to fire**
|
||||
|
||||
- On Android the “notification” is an alarm that fires at notify time and then runs code (e.g. `NotifyReceiver` / `DailyNotificationReceiver`) to display the notification. We cannot change the alarm’s “content” after the fact the same way as on iOS; we decide what to show when the alarm fires.
|
||||
- **Persist dual config** when scheduling (same as above), keyed so the receiver can find it (e.g. by schedule id or a single “current dual config” key).
|
||||
- **When the receiver runs** for a dual schedule (e.g. for `dual_notify_*` or the known dual schedule id): load the persisted dual config, load the latest content from the content cache and its timestamp, apply relationship (use cache if within `contentTimeout`, else default), then show one notification with that resolved title/body.
|
||||
- The receiver must be dual-aware: for dual schedules it resolves title/body from config + cache + relationship instead of using fixed payload from the alarm.
|
||||
|
||||
**Summary**
|
||||
|
||||
| Step | iOS | Android |
|
||||
|------|-----|---------|
|
||||
| 1. Persist dual config | Store `userNotification` + `relationship` when scheduling/updating dual (e.g. UserDefaults). | Same; store when scheduling dual (e.g. SharedPreferences), keyed for the receiver. |
|
||||
| 2. Where relationship is applied | In **handleBackgroundFetch** after storing content: resolve cache vs default, then **replace** the pending dual notification (same id, same trigger, new title/body). | In the **receiver** at notify time: load config + cache, resolve cache vs default, then **show** the notification with that title/body. |
|
||||
| 3. Edge cases | Do not replace if next trigger is in the past; if fetch fails, leave existing default notification. | Receiver runs at fire time; “too old” handled by contentTimeout; if no config, fall back to alarm payload. |
|
||||
|
||||
### 1.4 Implement and register `cancelDualSchedule()` on iOS
|
||||
|
||||
- **Current state:** `cancelDualSchedule` is in `definitions.ts` and the web implementation, but there is **no** `@objc func cancelDualSchedule(_ call: CAPPluginCall)` in the iOS plugin and **no** `CAPPluginMethod(name: "cancelDualSchedule", ...)` in the plugin's method list (around 2195–2199).
|
||||
- **Required:**
|
||||
- Add `cancelDualSchedule(_ call: CAPPluginCall)` that:
|
||||
- Cancels the BGAppRefreshTaskRequest for the dual content fetch (e.g. cancel the task with `fetchTaskIdentifier` or the identifier used for the dual schedule).
|
||||
- Cancels **only** the pending user notification(s) created for the dual (New Activity) schedule — e.g. by a **dedicated request identifier** (see below). Must **not** remove Daily Reminder notifications (those use identifier `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
|
||||
- When implementing or refactoring the dual user notification in `scheduleUserNotification(config:)` (or the path used by `scheduleDualNotification`), use a **stable, dedicated identifier** for the dual notification (e.g. `"dual_daily_notification"` or `"new_activity"`) so `cancelDualSchedule` can remove only that request. Currently the code uses `"daily-notification-\(Date().timeIntervalSince1970)"`, which is unique per call and not suitable for targeted cancellation.
|
||||
- Append `CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise)` in the same method list.
|
||||
- **Result:** Turning off "New Activity" in the app and calling `DailyNotification.cancelDualSchedule()` will no longer get `UNIMPLEMENTED` and will clear only the dual schedule, leaving Daily Reminder untouched.
|
||||
|
||||
### 1.5 Implement `updateDualScheduleConfig` for Edit time (recommended)
|
||||
|
||||
- **Use case:** The consuming app has an **Edit** button for New Activity that lets the user **change the time** of the notification. That flow is exactly what `updateDualScheduleConfig(config: DualScheduleConfiguration)` is for: "update the existing dual schedule with new config" (same config shape as `scheduleDualNotification`).
|
||||
- **Current app behavior:** Edit is implemented by calling `scheduleNewActivityDualNotification(timeText)` again (i.e. `scheduleDualNotification({ config })` with the new time). That can create duplicate pending notifications if the plugin does not replace the existing dual schedule (e.g. iOS currently uses a unique identifier per call: `daily-notification-<timestamp>`).
|
||||
- **Recommendation:** Implement **`updateDualScheduleConfig`** on iOS (and Android) for clear semantics: "change time" → call `updateDualScheduleConfig(newConfig)`. Implementation can be cancel existing dual schedule then schedule with new config (same as cancel + scheduleDualNotification under the hood). The consuming app should then call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when the user saves a new time from the Edit dialog, instead of (or with fallback to) `scheduleDualNotification`.
|
||||
- **Replace semantics:** Whether or not the app uses `updateDualScheduleConfig`, the plugin must ensure that when a dual schedule already exists and the app calls `scheduleDualNotification` again (e.g. on Edit), the result is **replace** not **add** — no duplicate content-fetch tasks or user notifications. Using a stable dual notification identifier (see 1.4) and replacing the existing request when scheduling achieves this.
|
||||
- **Other optional methods:** `pauseDualSchedule` and `resumeDualSchedule` remain optional; they are in `definitions.ts` but not required for the current app flow.
|
||||
|
||||
### 1.6 Android parity
|
||||
|
||||
- **cancelDualSchedule / updateDualScheduleConfig:** Implemented; Android now exposes both methods and uses `FetchWorker.WORK_NAME_DUAL` so only dual fetch work is cancelled. For **relationship** (contentTimeout / fallbackBehavior), see §1.3a (resolve at fire time in receiver).
|
||||
|
||||
---
|
||||
|
||||
## 2. Consuming app (crowd-funder-for-time-pwa)
|
||||
|
||||
### 2.1 Ensure plugin is linked and built (fix UNIMPLEMENTED)
|
||||
|
||||
- **Verify** the app's iOS project is using the plugin from this repo (or a release that includes the iOS implementation):
|
||||
- `package.json` dependency points to the right plugin (path, git, or npm).
|
||||
- Run `npx cap sync ios` and confirm `DailyNotificationPlugin` (and its Swift files) are in the app's `ios/App/` or Pods.
|
||||
- Clean build and run on device/simulator; confirm in Xcode that the plugin's `scheduleDualNotification` is registered and that a breakpoint in the Swift handler is hit when turning on New Activity.
|
||||
- If the app is on **Capacitor 6**, follow any documented steps for plugin registration so native methods are not reported as unimplemented.
|
||||
- No change to `buildDualScheduleConfig` or call order is needed; the config shape and sequence (configureNativeFetcher → updateStarredPlans → scheduleDualNotification) already match the plugin's expectations.
|
||||
|
||||
### 2.2 Error handling (optional but useful)
|
||||
|
||||
- **Current:** `AccountViewView.vue` treats `code === "UNIMPLEMENTED"` with a "not yet available on this device" message and any other error as "Could not schedule… try again."
|
||||
- **Improvement:** Once the plugin implements and registers `cancelDualSchedule` on iOS, the app can:
|
||||
- Keep handling `UNIMPLEMENTED` for older builds or platforms where the method is still missing.
|
||||
- Optionally surface more specific errors (e.g. `code === "SCHEDULING_FAILED"` or message strings from the plugin) so the user gets clearer feedback when scheduling fails for a reason other than "not implemented."
|
||||
- No change is strictly required for completion; the current flow is valid.
|
||||
|
||||
### 2.3 Turn-off flow
|
||||
|
||||
- The app already calls `DailyNotification.cancelDualSchedule()` when the user turns off New Activity (with a guard for `DailyNotification?.cancelDualSchedule`). Once the plugin implements and registers `cancelDualSchedule` on iOS (and optionally on Android), this will work without any app code change.
|
||||
|
||||
### 2.4 Edit time flow (New Activity)
|
||||
|
||||
- **Current:** When the user taps **Edit** and picks a new time, the app calls `scheduleNewActivityDualNotification(timeText)` (i.e. `scheduleDualNotification({ config })` again). See `editNewActivityNotification()` in `AccountViewView.vue` (~1504–1520).
|
||||
- **Recommended:** Once the plugin implements `updateDualScheduleConfig` (see 1.5), the app should call **`updateDualScheduleConfig({ config })`** when the user saves a new time from the Edit dialog, with `config = buildDualScheduleConfig({ notifyTime: timeText })`. That makes the intent explicit ("update existing schedule") and avoids relying on replace semantics inside `scheduleDualNotification`. The app can keep a fallback to `scheduleDualNotification` when `updateDualScheduleConfig` is not available (e.g. older plugin version).
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary table
|
||||
|
||||
| Where | What | Status / action |
|
||||
|-------|------|------------------|
|
||||
| **Plugin iOS** | `scheduleDualNotification` handler + registration | Done; fix bridge/build in app if still UNIMPLEMENTED. |
|
||||
| **Plugin iOS** | Cron parsing in `calculateNextRunTime(from:)` | Done; real cron parsing (match Android semantics). |
|
||||
| **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` | **Done;** persist dual config; in handleBackgroundFetch replace pending notification with resolved content. |
|
||||
| **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Done; cancel BG task + dual user notification only; stable identifier. |
|
||||
| **Plugin iOS** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
|
||||
| **Plugin Android** | `cancelDualSchedule()` | Done; cancel dual schedules + WorkManager WORK_NAME_DUAL only. |
|
||||
| **Plugin Android** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
|
||||
| **Plugin Android** | Use `relationship` (contentTimeout / fallbackBehavior) | **Done;** persist dual config; in Worker at fire time (dual_notify_*) resolve config + cache and show resolved title/body. |
|
||||
| **Plugin both** | Replace semantics for dual schedule | Done; stable dual identifier, replace before add. |
|
||||
| **Plugin both** | Isolation of Daily Reminder vs New Activity | Done; cancelDualSchedule does not touch reminder_*. |
|
||||
| **Consuming app** | Plugin linked and built for iOS | Verify dependency, `cap sync`, and build so native `scheduleDualNotification` is called. |
|
||||
| **Consuming app** | Edit time: use `updateDualScheduleConfig` | In `editNewActivityNotification()`, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time; fallback to `scheduleDualNotification` if unavailable. |
|
||||
| **Consuming app** | Error handling / UX | Optional: refine messages once plugin returns specific error codes. |
|
||||
|
||||
---
|
||||
|
||||
## 4. References
|
||||
|
||||
- **Plugin:** `ios/Plugin/DailyNotificationPlugin.swift` (scheduleDualNotification ~350–379, scheduleBackgroundFetch/scheduleUserNotification ~731–770, calculateNextRunTime ~767–771, method list ~2195–2199), `ios/Plugin/DailyNotificationScheduleHelper.swift` (~98–106), `src/definitions.ts` (DualScheduleConfiguration, cancelDualSchedule).
|
||||
- **Android reference:** `android/.../DailyNotificationPlugin.kt` (scheduleDualNotification ~1369–1420, calculateNextRunTime ~2336–2378).
|
||||
- **Consuming app:** `doc/plugin-feedback-ios-scheduleDualNotification.md`, `src/views/AccountViewView.vue` (~1237–1245, ~1259–1300, ~1501–1548 `editNewActivityNotification`), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/reminderIds.ts` (Daily Reminder vs New Activity IDs), `src/services/notifications/NativeNotificationService.ts` (Daily Reminder uses `scheduleDailyReminder` / `cancelDailyReminder`).
|
||||
@@ -0,0 +1,78 @@
|
||||
# Consuming app handoff: iOS native fetcher + chained dual schedule
|
||||
|
||||
This document is for the **host app** repository (e.g. crowd-funder-for-time-pwa) after bumping `@timesafari/daily-notification-plugin` to a version that includes:
|
||||
|
||||
- **iOS** `NativeNotificationContentFetcher`–style registration (`DailyNotificationPlugin.registerNativeFetcher`)
|
||||
- **iOS** `updateStarredPlans` / `getStarredPlans` (parity with Android `daily_notification_timesafari` / `starredPlanIds` semantics)
|
||||
- **iOS** chained dual flow: user notification is **armed only after** prefetch completes (delay if fetch is late; max slip 15 minutes before fallback copy)
|
||||
- **Android** chained dual flow: exact **notify** alarm is scheduled **after** dual prefetch completes (no longer scheduled at initial `scheduleDualNotification` before fetch)
|
||||
|
||||
Material from `doc/new-activity-notifications-ios-android-parity.md` still applies; this file adds **app-side** steps not spelled out there.
|
||||
|
||||
---
|
||||
|
||||
## 1. iOS — register native fetcher before `configureNativeFetcher`
|
||||
|
||||
The plugin now **rejects** `configureNativeFetcher` if no fetcher is registered (aligned with Android).
|
||||
|
||||
**In `AppDelegate` (or earliest app startup before Capacitor calls into the plugin):**
|
||||
|
||||
```swift
|
||||
import CapacitorDailyNotification // actual product module name may match the Pod (e.g. CapacitorDailyNotification)
|
||||
|
||||
// After: import DailyNotificationPlugin if your target uses a different module name — use the same module that exposes DailyNotificationPlugin.
|
||||
|
||||
DailyNotificationPlugin.registerNativeFetcher(TimeSafariNativeFetcher.shared)
|
||||
```
|
||||
|
||||
Implement **`TimeSafariNativeFetcher`** as a Swift type that:
|
||||
|
||||
- Conforms to `NativeNotificationContentFetcher`
|
||||
- Implements `fetchContent(context: FetchContext) async throws -> [NotificationContent]` with the same **Endorser** behavior as `TimeSafariNativeFetcher.java` (`POST …/api/v2/report/plansLastUpdatedBetween`, starred plan IDs, JWT pool selection, aggregation copy, pagination / `last_acked_jwt_id` as in Java)
|
||||
- Implements `configure(apiBaseUrl:activeDid:jwtToken:jwtTokenPool:)` if the fetcher needs credentials pushed from TypeScript (optional; TS still persists `native_fetcher_config` UserDefaults key)
|
||||
|
||||
**Starred plan IDs for the fetcher:** Read JSON array string from UserDefaults key **`daily_notification_timesafari.starredPlanIds`** (written by `updateStarredPlans` from JS). Format matches Android: JSON array of strings.
|
||||
|
||||
---
|
||||
|
||||
## 2. iOS — `UNUserNotificationCenterDelegate` / rollover
|
||||
|
||||
Chained dual notifications set:
|
||||
|
||||
- `notification_id` = `org.timesafari.dailynotification.dual` (same stable identifier as before)
|
||||
- `scheduled_time` = `NSNumber` (fire time in ms)
|
||||
|
||||
Ensure your existing `DailyNotificationDelivered` bridge still forwards **`notification_id`** and **`scheduled_time`** from **notification content `userInfo`** (not only from a custom payload). Foreground presentation handlers should read `notification.request.content.userInfo`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Android — no API change for `setNativeFetcher`
|
||||
|
||||
Host apps that already call `DailyNotificationPlugin.setNativeFetcher(TimeSafariNativeFetcher(...))` and `configureNativeFetcher` from JS keep that flow.
|
||||
|
||||
**Behavior change:** the dual **notify** alarm is no longer scheduled at the initial `scheduleDualNotification` call; it is scheduled when **dual prefetch work finishes** (success or hard failure path), at `max(nextNotifyAt, now)` so late prefetch delays the notification.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bump and sync
|
||||
|
||||
1. Bump **`@timesafari/daily-notification-plugin`** in the app `package.json`.
|
||||
2. `npm install`
|
||||
3. `npx cap sync ios && npx cap sync android`
|
||||
4. iOS: `cd ios/App && pod install` (adjust path if your app uses a different `ios` layout)
|
||||
5. Clean build in Xcode / Android Studio
|
||||
|
||||
---
|
||||
|
||||
## 5. QA focus
|
||||
|
||||
- **iOS:** Register fetcher **before** any `configureNativeFetcher` from the web layer; confirm `updateStarredPlans` is no longer `UNIMPLEMENTED`.
|
||||
- **Both:** New Activity dual path: first notification should appear **after** prefetch for that cycle, not at a fixed time with stale API text.
|
||||
- **Android:** Regression-test `cancelDualSchedule` and Daily Reminder (should remain independent).
|
||||
|
||||
---
|
||||
|
||||
## 6. Assumptions
|
||||
|
||||
- Swift host implements `TimeSafariNativeFetcher`; the plugin does **not** embed `plansLastUpdatedBetween` on iOS when a host fetcher is registered (mirrors Android).
|
||||
- Module import name for the Capacitor iOS plugin follows your Pod (`CapacitorDailyNotification` in `CapacitorDailyNotification.podspec`).
|
||||
@@ -38,7 +38,7 @@ pnpm add @timesafari/daily-notification-plugin
|
||||
```xml
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.timesafari.dailynotification.fetch</string>
|
||||
<string>org.timesafari.dailynotification.fetch</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
@@ -49,7 +49,7 @@ import BackgroundTasks
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.timesafari.dailynotification.fetch",
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: "org.timesafari.dailynotification.fetch",
|
||||
using: nil) { task in
|
||||
// Handle background fetch task
|
||||
}
|
||||
@@ -64,9 +64,14 @@ func application(_ application: UIApplication,
|
||||
```xml
|
||||
<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" />
|
||||
```
|
||||
|
||||
> **Note on `USE_EXACT_ALARM`:** The `USE_EXACT_ALARM` permission is restricted
|
||||
> by Google on Android. Apps that declare it must be primarily dedicated to alarm
|
||||
> or calendar functionality. Google will reject apps from the Play Store that use
|
||||
> this permission for other purposes. This plugin uses `SCHEDULE_EXACT_ALARM`
|
||||
> instead, which is sufficient for scheduling daily notifications.
|
||||
|
||||
2. **Register WorkManager in `Application.kt`:**
|
||||
|
||||
```kotlin
|
||||
@@ -0,0 +1,280 @@
|
||||
# Documentation Consolidation Source Map
|
||||
|
||||
**Date:** 2025-12-16
|
||||
**Purpose:** Complete audit trail of all markdown file destinations during consolidation
|
||||
**Total Files Mapped:** 139
|
||||
|
||||
This document guarantees no information loss by tracking every file's destination.
|
||||
|
||||
---
|
||||
|
||||
## Legend
|
||||
|
||||
- **Canonical**: File kept in active documentation, possibly edited/merged
|
||||
- **Merged**: Content incorporated into canonical document, original archived
|
||||
- **Archived**: File preserved verbatim in archive, referenced from index
|
||||
|
||||
---
|
||||
|
||||
## Root Canonical Files (Keep As-Is)
|
||||
|
||||
**Note (2025-03):** The following were moved after consolidation: `TODO.md` → `doc/progress/TODO.md`; `TODAY_SUMMARY.md` → `doc/progress/TODAY_SUMMARY.md`; `SESSION_RECONSTITUTION.md` → `doc/progress/SESSION_RECONSTITUTION.md`; `BATCH_A_COMPLETION_SUMMARY.md` → `doc/progress/BATCH_A_COMPLETION_SUMMARY.md`; `PR_DESCRIPTION.md` → `doc/_archive/PR_DESCRIPTION.md`; `MERGE_READY_SUMMARY.md` → `doc/_archive/MERGE_READY_SUMMARY.md`.
|
||||
|
||||
| Original Path | Status | Notes |
|
||||
|--------------|--------|-------|
|
||||
| `README.md` | Canonical | Main entry point, will link to doc/00-INDEX.md |
|
||||
| `ARCHITECTURE.md` | Canonical | Foundational architecture document |
|
||||
| `BUILDING.md` | Canonical | Build instructions |
|
||||
| `CHANGELOG.md` | Canonical | Version history |
|
||||
| `CONTRIBUTING.md` | Canonical | Contribution guidelines |
|
||||
| `SECURITY.md` | Canonical | Security documentation |
|
||||
| `API.md` | Canonical | API reference |
|
||||
| `USAGE.md` | Canonical | Usage guide |
|
||||
| `TODO.md` | Moved | → `doc/progress/TODO.md` |
|
||||
| `PR_DESCRIPTION.md` | Moved | → `doc/_archive/PR_DESCRIPTION.md` |
|
||||
| `MERGE_READY_SUMMARY.md` | Moved | → `doc/_archive/MERGE_READY_SUMMARY.md` |
|
||||
|
||||
---
|
||||
|
||||
## Integration Documentation (Consolidate to `doc/integration/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `INTEGRATION_GUIDE.md` | `doc/integration/INTEGRATION_GUIDE.md` | Canonical | Primary integration guide |
|
||||
| `QUICK_INTEGRATION.md` | `doc/integration/QUICK_START.md` | Canonical | Quick start guide |
|
||||
| `AI_INTEGRATION_GUIDE.md` | `doc/ai/AI_INTEGRATION_GUIDE.md` | Canonical | AI-specific integration |
|
||||
| `doc/INTEGRATION_CHECKLIST.md` | `doc/integration/CHECKLIST.md` | Merged | Merge into INTEGRATION_GUIDE.md |
|
||||
| `doc/INTEGRATION_REFACTOR_CONTEXT.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge context into refactor notes |
|
||||
| `doc/INTEGRATION_REFACTOR_QUICK_START.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge into refactor notes |
|
||||
| `doc/aar-integration-troubleshooting.md` | `doc/integration/TROUBLESHOOTING.md` | Merged | Merge into troubleshooting guide |
|
||||
| `doc/integration-point-refactor-analysis.md` | `doc/integration/REFACTOR_NOTES.md` | Merged | Merge into refactor notes |
|
||||
|
||||
---
|
||||
|
||||
## Legacy Documentation (Archive to `doc/archive/2025-legacy-doc/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/BACKGROUND_DATA_FETCHING_PLAN.md` | `doc/archive/2025-legacy-doc/BACKGROUND_DATA_FETCHING_PLAN.md` | Archived | Historical planning doc |
|
||||
| `doc/BUILD_FIXES_SUMMARY.md` | `doc/archive/2025-legacy-doc/BUILD_FIXES_SUMMARY.md` | Archived | Historical build fixes |
|
||||
| `doc/BUILD_SCRIPT_IMPROVEMENTS.md` | `doc/archive/2025-legacy-doc/BUILD_SCRIPT_IMPROVEMENTS.md` | Archived | Historical build improvements |
|
||||
| `doc/directives/0001-Daily-Notification-Plugin-Implementation-Directive.md` | `doc/archive/2025-legacy-doc/directives/0001-Daily-Notification-Plugin-Implementation-Directive.md` | Archived | Historical directive |
|
||||
| `doc/directives/0002-Daily-Notification-Plugin-Recommendations.md` | `doc/archive/2025-legacy-doc/directives/0002-Daily-Notification-Plugin-Recommendations.md` | Archived | Historical recommendations |
|
||||
| `doc/directives/0003-iOS-Android-Parity-Directive.md` | `doc/archive/2025-legacy-doc/directives/0003-iOS-Android-Parity-Directive.md` | Archived | Historical directive |
|
||||
| `doc/implementation-roadmap.md` | `doc/archive/2025-legacy-doc/implementation-roadmap.md` | Archived | Historical roadmap |
|
||||
| `doc/IOS_ANDROID_ERROR_CODE_MAPPING.md` | `doc/archive/2025-legacy-doc/IOS_ANDROID_ERROR_CODE_MAPPING.md` | Archived | Historical mapping |
|
||||
| `doc/IOS_PHASE1_FINAL_SUMMARY.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_FINAL_SUMMARY.md` | Archived | Historical summary |
|
||||
| `doc/IOS_PHASE1_GAPS_ANALYSIS.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_GAPS_ANALYSIS.md` | Archived | Historical analysis |
|
||||
| `doc/IOS_PHASE1_IMPLEMENTATION_CHECKLIST.md` | `doc/platform/ios/IMPLEMENTATION_CHECKLIST.md` | Merged | Promote to canonical iOS docs |
|
||||
| `doc/IOS_PHASE1_QUICK_REFERENCE.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_QUICK_REFERENCE.md` | Archived | Historical quick reference |
|
||||
| `doc/IOS_PHASE1_READY_FOR_TESTING.md` | `doc/archive/2025-legacy-doc/IOS_PHASE1_READY_FOR_TESTING.md` | Archived | Historical testing status |
|
||||
| `doc/IOS_PHASE1_TESTING_GUIDE.md` | `doc/testing/IOS_PHASE1_TESTING_GUIDE.md` | Merged | Promote to testing docs |
|
||||
| `doc/IOS_TEST_APP_SETUP_GUIDE.md` | `doc/testing/IOS_TEST_APP_SETUP.md` | Merged | Promote to testing docs |
|
||||
| `doc/migration-guide.md` | `doc/platform/ios/MIGRATION_GUIDE.md` | Merged | Promote to canonical iOS docs |
|
||||
| `doc/notification-system.md` | `doc/archive/2025-legacy-doc/notification-system.md` | Archived | Historical system doc |
|
||||
| `doc/PHASE1_COMPLETION_SUMMARY.md` | `doc/archive/2025-legacy-doc/PHASE1_COMPLETION_SUMMARY.md` | Archived | Historical summary |
|
||||
| `doc/RESEARCH_COMPLETE.md` | `doc/archive/2025-legacy-doc/RESEARCH_COMPLETE.md` | Archived | Historical research doc |
|
||||
| `doc/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md` | `doc/design/STARRED_PROJECTS_POLLING_IMPLEMENTATION.md` | Canonical | Promote to design docs (large, relevant) |
|
||||
| `doc/test-app-ios/ENHANCEMENTS_APPLIED.md` | `doc/archive/2025-legacy-doc/test-app-ios/ENHANCEMENTS_APPLIED.md` | Archived | Historical enhancements |
|
||||
| `doc/test-app-ios/IOS_LOGGING_GUIDE.md` | `doc/testing/IOS_LOGGING_GUIDE.md` | Merged | Promote to testing docs |
|
||||
| `doc/test-app-ios/IOS_PREFETCH_GLOSSARY.md` | `doc/platform/ios/PREFETCH_GLOSSARY.md` | Merged | Promote to iOS docs |
|
||||
| `doc/test-app-ios/IOS_PREFETCH_TESTING.md` | `doc/testing/IOS_PREFETCH_TESTING.md` | Merged | Promote to testing docs |
|
||||
| `doc/test-app-ios/IOS_TEST_APP_REQUIREMENTS.md` | `doc/testing/IOS_TEST_APP_REQUIREMENTS.md` | Merged | Promote to testing docs |
|
||||
| `doc/UI_REQUIREMENTS.md` | `doc/archive/2025-legacy-doc/UI_REQUIREMENTS.md` | Archived | Historical requirements |
|
||||
|
||||
---
|
||||
|
||||
## Platform Documentation - iOS (Consolidate to `doc/platform/ios/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/IOS_IMPLEMENTATION_CHECKLIST.md` | `doc/platform/ios/IMPLEMENTATION_CHECKLIST.md` | Canonical | Primary iOS checklist |
|
||||
| `doc/ios-implementation-directive.md` | `doc/platform/ios/IMPLEMENTATION_DIRECTIVE.md` | Canonical | iOS implementation directive |
|
||||
| `doc/IOS_IMPLEMENTATION_DOCUMENTATION_REVIEW.md` | `doc/platform/ios/DOCUMENTATION_REVIEW.md` | Canonical | Documentation review |
|
||||
| `doc/ios-core-data-migration.md` | `doc/platform/ios/CORE_DATA_MIGRATION.md` | Canonical | Core Data migration guide |
|
||||
| `doc/ios-recovery-scenario-mapping.md` | `doc/platform/ios/RECOVERY_SCENARIO_MAPPING.md` | Canonical | Recovery scenario mapping |
|
||||
| `doc/ios-rollover-edge-case-plan.md` | `doc/platform/ios/ROLLOVER_EDGE_CASES.md` | Canonical | Rollover edge cases |
|
||||
| `doc/ios-rollover-implementation-review.md` | `doc/platform/ios/ROLLOVER_IMPLEMENTATION_REVIEW.md` | Canonical | Rollover implementation review |
|
||||
| `doc/ios-rollover-open-questions-answers.md` | `doc/platform/ios/ROLLOVER_QA.md` | Canonical | Rollover Q&A |
|
||||
| `doc/ios-troubleshooting-guide.md` | `doc/platform/ios/TROUBLESHOOTING.md` | Canonical | iOS troubleshooting |
|
||||
|
||||
---
|
||||
|
||||
## Platform Documentation - Android (Consolidate to `doc/platform/android/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/android-implementation-directive.md` | `doc/platform/android/IMPLEMENTATION_DIRECTIVE.md` | Canonical | Primary Android directive |
|
||||
| `doc/android-implementation-directive-phase1.md` | `doc/platform/android/PHASE1_DIRECTIVE.md` | Canonical | Phase 1 directive |
|
||||
| `doc/android-implementation-directive-phase2.md` | `doc/platform/android/PHASE2_DIRECTIVE.md` | Canonical | Phase 2 directive |
|
||||
| `doc/android-implementation-directive-phase3.md` | `doc/platform/android/PHASE3_DIRECTIVE.md` | Canonical | Phase 3 directive |
|
||||
| `doc/android-alarm-persistence-directive.md` | `doc/platform/android/ALARM_PERSISTENCE_DIRECTIVE.md` | Canonical | Alarm persistence directive |
|
||||
| `doc/android-app-analysis.md` | `doc/platform/android/APP_ANALYSIS.md` | Canonical | App analysis |
|
||||
| `doc/android-app-improvement-plan.md` | `doc/platform/android/APP_IMPROVEMENT_PLAN.md` | Canonical | App improvement plan |
|
||||
| `android/BUILDING.md` | `doc/platform/android/BUILDING.md` | Canonical | Android build guide |
|
||||
| `android/DATABASE_CONSOLIDATION_PLAN.md` | `doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md` | Canonical | Database consolidation plan |
|
||||
|
||||
---
|
||||
|
||||
## Testing Documentation (Consolidate to `doc/testing/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/comprehensive-testing-guide-v2.md` | `doc/testing/COMPREHENSIVE_GUIDE.md` | Canonical | Primary testing guide |
|
||||
| `doc/testing-quick-reference.md` | `doc/testing/QUICK_REFERENCE.md` | Canonical | Quick reference |
|
||||
| `doc/testing-quick-reference-v2.md` | `doc/testing/QUICK_REFERENCE.md` | Merged | Merge into QUICK_REFERENCE.md |
|
||||
| `doc/manual_smoke_test.md` | `doc/testing/MANUAL_SMOKE_TEST.md` | Canonical | Manual smoke test |
|
||||
| `doc/notification-testing-procedures.md` | `doc/testing/NOTIFICATION_PROCEDURES.md` | Canonical | Notification testing |
|
||||
| `doc/reboot-testing-procedure.md` | `doc/testing/REBOOT_PROCEDURE.md` | Canonical | Reboot testing |
|
||||
| `doc/reboot-testing-steps.md` | `doc/testing/REBOOT_PROCEDURE.md` | Merged | Merge into REBOOT_PROCEDURE.md |
|
||||
| `doc/boot-receiver-testing-guide.md` | `doc/testing/BOOT_RECEIVER_GUIDE.md` | Canonical | Boot receiver testing |
|
||||
| `doc/standalone-emulator-guide.md` | `doc/testing/EMULATOR_GUIDE.md` | Canonical | Emulator guide |
|
||||
| `doc/localhost-testing-guide.md` | `doc/testing/LOCALHOST_GUIDE.md` | Canonical | Localhost testing |
|
||||
|
||||
---
|
||||
|
||||
## Alarm System Documentation (Keep in `doc/alarms/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/alarms/000-UNIFIED-ALARM-DIRECTIVE.md` | `doc/alarms/000-UNIFIED-ALARM-DIRECTIVE.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/01-platform-capability-reference.md` | `doc/alarms/01-platform-capability-reference.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/02-plugin-behavior-exploration.md` | `doc/alarms/02-plugin-behavior-exploration.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/03-plugin-requirements.md` | `doc/alarms/03-plugin-requirements.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/ACTIVATION-GUIDE.md` | `doc/alarms/ACTIVATION-GUIDE.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE1-EMULATOR-TESTING.md` | `doc/alarms/PHASE1-EMULATOR-TESTING.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE1-VERIFICATION.md` | `doc/alarms/PHASE1-VERIFICATION.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE2-EMULATOR-TESTING.md` | `doc/alarms/PHASE2-EMULATOR-TESTING.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE2-VERIFICATION.md` | `doc/alarms/PHASE2-VERIFICATION.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE3-EMULATOR-TESTING.md` | `doc/alarms/PHASE3-EMULATOR-TESTING.md` | Canonical | Keep as-is |
|
||||
| `doc/alarms/PHASE3-VERIFICATION.md` | `doc/alarms/PHASE3-VERIFICATION.md` | Canonical | Keep as-is |
|
||||
|
||||
---
|
||||
|
||||
## AI / ChatGPT Documentation (Consolidate to `doc/ai/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `chatgpt-assessment-package.md` | `doc/ai/chatgpt-assessment-package.md` | Canonical | AI artifacts |
|
||||
| `chatgpt-files-overview.md` | `doc/ai/chatgpt-files-overview.md` | Canonical | AI artifacts |
|
||||
| `chatgpt-improvement-directives-template.md` | `doc/ai/chatgpt-improvement-directives-template.md` | Canonical | AI artifacts |
|
||||
| `code-summary-for-chatgpt.md` | `doc/ai/code-summary-for-chatgpt.md` | Canonical | AI artifacts |
|
||||
| `key-code-snippets-for-chatgpt.md` | `doc/ai/key-code-snippets-for-chatgpt.md` | Canonical | AI artifacts |
|
||||
| `doc/chatgpt-analysis-guide.md` | `doc/ai/chatgpt-analysis-guide.md` | Canonical | AI artifacts |
|
||||
|
||||
---
|
||||
|
||||
## Design & Research Documentation (Consolidate to `doc/design/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/exploration-findings-initial.md` | `doc/design/exploration-findings-initial.md` | Canonical | Design research |
|
||||
| `doc/explore-alarm-behavior-directive.md` | `doc/design/explore-alarm-behavior-directive.md` | Canonical | Design research |
|
||||
| `doc/improve-alarm-directives.md` | `doc/design/improve-alarm-directives.md` | Canonical | Design research |
|
||||
| `doc/plugin-behavior-exploration-template.md` | `doc/design/plugin-behavior-exploration-template.md` | Canonical | Design template |
|
||||
|
||||
---
|
||||
|
||||
## Deployment Documentation (Keep in `doc/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `DEPLOYMENT_CHECKLIST.md` | `doc/DEPLOYMENT_CHECKLIST.md` | Canonical | Move to doc/ |
|
||||
| `DEPLOYMENT_SUMMARY.md` | `doc/DEPLOYMENT_SUMMARY.md` | Canonical | Move to doc/ |
|
||||
| `doc/deployment-guide.md` | `doc/DEPLOYMENT_GUIDE.md` | Canonical | Primary deployment guide |
|
||||
|
||||
---
|
||||
|
||||
## Feature-Specific Documentation (Keep in `doc/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `doc/CROSS_PLATFORM_STORAGE_PATTERN.md` | `doc/CROSS_PLATFORM_STORAGE_PATTERN.md` | Canonical | Keep as-is |
|
||||
| `doc/DATABASE_INTERFACES.md` | `doc/DATABASE_INTERFACES.md` | Canonical | Keep as-is |
|
||||
| `doc/DATABASE_INTERFACES_IMPLEMENTATION.md` | `doc/DATABASE_INTERFACES_IMPLEMENTATION.md` | Canonical | Keep as-is |
|
||||
| `doc/NATIVE_FETCHER_CONFIGURATION.md` | `doc/NATIVE_FETCHER_CONFIGURATION.md` | Canonical | Keep as-is |
|
||||
| `doc/platform-capability-reference.md` | `doc/platform-capability-reference.md` | Canonical | Keep as-is |
|
||||
| `doc/plugin-requirements-implementation.md` | `doc/plugin-requirements-implementation.md` | Canonical | Keep as-is |
|
||||
| `doc/prefetch-scheduling-diagnosis.md` | `doc/prefetch-scheduling-diagnosis.md` | Canonical | Keep as-is |
|
||||
| `doc/prefetch-scheduling-trace.md` | `doc/prefetch-scheduling-trace.md` | Canonical | Keep as-is |
|
||||
| `doc/app-startup-recovery-solution.md` | `doc/app-startup-recovery-solution.md` | Canonical | Keep as-is |
|
||||
| `doc/getting-valid-plan-ids.md` | `doc/getting-valid-plan-ids.md` | Canonical | Keep as-is |
|
||||
| `doc/host-request-configuration.md` | `doc/host-request-configuration.md` | Canonical | Keep as-is |
|
||||
| `doc/hydrate-plan-implementation-guide.md` | `doc/hydrate-plan-implementation-guide.md` | Canonical | Keep as-is |
|
||||
| `doc/user-zero-stars-implementation.md` | `doc/user-zero-stars-implementation.md` | Canonical | Keep as-is |
|
||||
| `doc/accessibility-localization.md` | `doc/accessibility-localization.md` | Canonical | Keep as-is |
|
||||
| `doc/legal-store-compliance.md` | `doc/legal-store-compliance.md` | Canonical | Keep as-is |
|
||||
| `doc/observability-dashboards.md` | `doc/observability-dashboards.md` | Canonical | Keep as-is |
|
||||
| `doc/file-organization-summary.md` | `doc/file-organization-summary.md` | Canonical | Keep as-is |
|
||||
| `doc/capacitor-platform-service-clean-changes.md` | `doc/capacitor-platform-service-clean-changes.md` | Canonical | Keep as-is |
|
||||
|
||||
---
|
||||
|
||||
## Test App Documentation (Keep with Test Apps, Index in `doc/testing/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `test-apps/BUILD_PROCESS.md` | `test-apps/BUILD_PROCESS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/android-test-app/doc/PHASE1_TEST0_GOLDEN.md` | `test-apps/android-test-app/doc/PHASE1_TEST0_GOLDEN.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/android-test-app/doc/PHASE1_TEST1_GOLDEN.md` | `test-apps/android-test-app/doc/PHASE1_TEST1_GOLDEN.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/BUILD_QUICK_REFERENCE.md` | `test-apps/daily-notification-test/doc/BUILD_QUICK_REFERENCE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/NOTIFICATION_STACK_IMPROVEMENT_PLAN.md` | `test-apps/daily-notification-test/doc/NOTIFICATION_STACK_IMPROVEMENT_PLAN.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/PLUGIN_DETECTION_GUIDE.md` | `test-apps/daily-notification-test/doc/PLUGIN_DETECTION_GUIDE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/doc/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md` | `test-apps/daily-notification-test/doc/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md` | `test-apps/daily-notification-test/IMPLEMENTATION_COMPLETE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md` | `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md` | `test-apps/daily-notification-test/INVESTIGATION_JWT_ALGORITHM_RESULTS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/README.md` | `test-apps/daily-notification-test/README.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` | `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/BUILD_NOTES.md` | `test-apps/ios-test-app/BUILD_NOTES.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/BUILD_SUCCESS.md` | `test-apps/ios-test-app/BUILD_SUCCESS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/COMPILATION_FIXES.md` | `test-apps/ios-test-app/COMPILATION_FIXES.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/COMPILATION_STATUS.md` | `test-apps/ios-test-app/COMPILATION_STATUS.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/COMPILATION_SUMMARY.md` | `test-apps/ios-test-app/COMPILATION_SUMMARY.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/README.md` | `test-apps/ios-test-app/README.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/SETUP_COMPLETE.md` | `test-apps/ios-test-app/SETUP_COMPLETE.md` | Canonical | Keep with test apps |
|
||||
| `test-apps/ios-test-app/SETUP_STATUS.md` | `test-apps/ios-test-app/SETUP_STATUS.md` | Canonical | Keep with test apps |
|
||||
|
||||
---
|
||||
|
||||
## Plugin-Specific Documentation (Keep in `ios/Plugin/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `ios/Plugin/README.md` | `ios/Plugin/README.md` | Canonical | Keep with plugin code |
|
||||
|
||||
---
|
||||
|
||||
## Cursor Rules Documentation (Keep in `.cursor/rules/`)
|
||||
|
||||
| Original Path | New Path | Status | Notes |
|
||||
|--------------|----------|--------|-------|
|
||||
| `.cursor/rules/README.md` | `.cursor/rules/README.md` | Canonical | Keep with cursor rules |
|
||||
| `.cursor/rules/architecture/README.md` | `.cursor/rules/architecture/README.md` | Canonical | Keep with cursor rules |
|
||||
| `.cursor/rules/meta_rule_architecture.md` | `.cursor/rules/meta_rule_architecture.md` | Canonical | Keep with cursor rules |
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
- **Total Files:** 139
|
||||
- **Canonical (Active):** ~95 files
|
||||
- **Merged:** ~15 files
|
||||
- **Archived:** ~29 files
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] All 139 files have a destination
|
||||
- [ ] No file is marked for deletion
|
||||
- [ ] All merged content is traceable
|
||||
- [ ] Archive structure preserves original paths
|
||||
- [ ] Index references all canonical files
|
||||
- [ ] README.md links to doc/00-INDEX.md
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-16
|
||||
**Status:** Complete - Ready for Implementation
|
||||
|
||||
@@ -50,7 +50,7 @@ fi
|
||||
xcrun simctl install "$SIMULATOR_ID" "$APP_PATH"
|
||||
|
||||
# Launch app
|
||||
xcrun simctl launch "$SIMULATOR_ID" com.timesafari.dailynotification.test
|
||||
xcrun simctl launch "$SIMULATOR_ID" org.timesafari.dailynotification.test
|
||||
```
|
||||
|
||||
**Result:** ✅ Simulator now boots and app launches automatically
|
||||
@@ -94,7 +94,7 @@ po UNUserNotificationCenter.current().pendingNotificationRequests()
|
||||
po await UNUserNotificationCenter.current().notificationSettings()
|
||||
|
||||
// Manually trigger BGTask (Simulator only)
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.timesafari.dailynotification.fetch"]
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"org.timesafari.dailynotification.fetch"]
|
||||
```
|
||||
|
||||
---
|
||||
@@ -196,7 +196,7 @@ DailyNotificationScheduler: Scheduling notification: [id]
|
||||
|
||||
**Solution:** Use simulator-only LLDB command:
|
||||
```swift
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"com.timesafari.dailynotification.fetch"]
|
||||
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"org.timesafari.dailynotification.fetch"]
|
||||
```
|
||||
|
||||
### Notifications Not Delivering
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user