2 Commits

Author SHA1 Message Date
Matthew
4e25841fe9 fix(test-app): auto-detect Android SDK and allow build without adb
Previously, the build script would skip Android builds entirely if
adb was not in PATH, even though adb is only needed for installing/
launching apps, not for building APKs.

Changes:
- Added find_android_sdk() function that automatically detects SDK
  location via ANDROID_HOME, ANDROID_SDK_ROOT, existing local.properties,
  or common default locations (macOS/Linux)
- Automatically creates/updates android/local.properties with detected
  SDK location
- Removed early exit when adb not found - build now proceeds without adb
- Moved adb check to only when installing/launching apps (--run flags)
- Updated warning messages to clarify adb is only needed for install/launch

This allows developers to build APKs even when Android SDK platform-tools
are not in PATH, improving build script usability.
2026-02-03 00:34:25 -08:00
Matthew
367325452a fix(android): explicitly set component and package for AlarmManager broadcasts
AlarmManager was firing alarms but DailyNotificationReceiver was not
receiving broadcasts. The issue was that Intents created with
Intent(context, Class) constructor were not reliably matched by
AlarmManager when delivering broadcasts.

Solution: Explicitly set ComponentName and package on all Intents used
for AlarmManager broadcasts. This ensures AlarmManager can correctly
match PendingIntents to the registered receiver.

Changes:
- NotifyReceiver.kt: Fixed Intent creation in scheduleNotification(),
  cancelNotification(), isAlarmScheduled(), and idempotence checks
- ReactivationManager.kt: Fixed alarmsExist() to use
  DailyNotificationReceiver with explicit component/package
- DailyNotificationScheduler.java: Fixed Intent creation to explicitly
  set component and package

This fixes the critical bug where alarms fire but receivers are not
triggered, resolving the gap between AlarmManager delivery and receiver
execution.
2026-02-03 00:33:33 -08:00
337 changed files with 117623 additions and 43032 deletions

View File

@@ -1239,10 +1239,10 @@ dependencies {
-keep @androidx.room.Dao class *
# Plugin classes
-keep class org.timesafari.dailynotification.** { *; }
-keep class com.timesafari.dailynotification.** { *; }
# Capacitor plugin
-keep class org.timesafari.dailynotification.DailyNotificationPlugin { *; }
-keep class com.timesafari.dailynotification.DailyNotificationPlugin { *; }
# Encryption
-keep class javax.crypto.** { *; }

View File

@@ -116,7 +116,7 @@ Successfully completed P2.1 Batch A refactoring, delegating 7 plugin methods to
- Added `getNextAlarmTime()` method
- +50 lines
4. **`doc/progress/P2.1-BATCH-A-STATE.md`**
4. **`docs/progress/P2.1-BATCH-A-STATE.md`**
- Updated with completion status
- Documented all refactorings
- +84 lines
@@ -157,7 +157,7 @@ Successfully completed P2.1 Batch A refactoring, delegating 7 plugin methods to
## Next Steps
**Batch B:** Methods requiring validation/transformation logic
- See `doc/progress/P2.1-BATCH-2.md` for details
- See `docs/progress/P2.1-BATCH-2.md` for details
- May require more complex service setup
- Some methods may need input validation before delegation

View File

@@ -44,11 +44,9 @@ npx cap run android
## Prerequisites
### Required Software
- **Android Studio** (latest stable version) - for Android development
- **Android Studio** (latest stable version)
- **Java 11+** (for Kotlin compilation)
- **Android SDK** with API level 21+
- **Xcode** (latest stable version) - for iOS development (macOS only)
- **Xcode Command Line Tools** - required for iOS builds (includes `xcodebuild`, `sqlite3`, etc.)
- **Node.js** 16+ (for TypeScript compilation)
- **npm** or **yarn** (for dependency management)
@@ -56,35 +54,11 @@ npx cap run android
- **Gradle Wrapper** (included in project)
- **Kotlin** (configured in build.gradle)
- **TypeScript** (for plugin interface)
- **CocoaPods** - for iOS dependency management
### iOS-Specific Prerequisites
**Xcode Command Line Tools** are required for iOS builds. The build script will verify these are installed:
```bash
# Install Xcode Command Line Tools (if not already installed)
xcode-select --install
```
**Verification:**
```bash
# Check if Command Line Tools are configured
xcode-select -p
# Verify xcodebuild is available
xcodebuild -version
# Verify sqlite3 is available (part of Command Line Tools)
sqlite3 --version
```
**Note:** The build script automatically checks for Command Line Tools and will fail with clear error messages if they're missing.
### System Requirements
- **RAM**: 4GB minimum, 8GB recommended
- **Storage**: 2GB free space
- **OS**: Windows 10+, macOS 10.14+, or Linux (iOS development requires macOS)
- **OS**: Windows 10+, macOS 10.14+, or Linux
## Build Methods
@@ -323,8 +297,6 @@ android/build/reports/tests/test/index.html
### iOS Native Build Process
**Prerequisites:** Ensure Xcode Command Line Tools are installed (see [Prerequisites](#prerequisites) section). The build script will verify this automatically.
#### 1. Navigate to iOS Directory
```bash
cd ios
@@ -335,12 +307,6 @@ cd ios
pod install
```
**Note:** If you encounter issues with `pod install`, ensure Xcode Command Line Tools are properly configured:
```bash
xcode-select --install # Install if missing
xcode-select -p # Verify installation path
```
#### 3. Build Commands
```bash
# Build using Xcode command line
@@ -653,7 +619,7 @@ public class MainActivity extends BridgeActivity {
{
"plugins": {
"DailyNotification": {
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
}
}
@@ -728,7 +694,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
{
"id": "DailyNotification",
"name": "DailyNotification",
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]
```
@@ -816,13 +782,6 @@ The project includes several automated build scripts in the `scripts/` directory
./scripts/build-native.sh --platform ios
./scripts/build-native.sh --verbose
# Clean build (removes all build artifacts and caches)
./scripts/clean-build.sh
./scripts/clean-build.sh --all # Also cleans caches and reinstalls dependencies
./scripts/clean-build.sh --clean-gradle-cache # Clean Gradle cache
./scripts/clean-build.sh --clean-derived-data # Clean Xcode DerivedData
./scripts/clean-build.sh --reinstall-node # Reinstall node_modules
# TimeSafari-specific builds
node scripts/build-timesafari.js
@@ -899,7 +858,7 @@ npm install @timesafari/daily-notification-plugin
npm install /path/to/daily-notification-plugin
# Install from git repository
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
npm install git+https://github.com/timesafari/daily-notification-plugin.git
```
#### 3. Integration in Host Applications
@@ -989,28 +948,6 @@ adb logcat | grep DailyNotification
## Troubleshooting
### Clean Build (First Step for Many Issues)
If you encounter persistent build issues, try a clean build first:
```bash
# Clean all build artifacts (recommended first step)
./scripts/clean-build.sh
# Clean everything including caches (for stubborn issues)
./scripts/clean-build.sh --all
# Then rebuild
./scripts/build-native.sh --platform all
```
**When to use clean-build:**
- Build errors that don't make sense
- Dependency conflicts
- Stale build artifacts
- After switching branches
- After updating dependencies
### Common Issues
#### Gradle Sync Failures
@@ -1082,39 +1019,6 @@ File → Project Structure → SDK Location
# Solution: Check Kotlin version in build.gradle
```
#### iOS Build Issues
```bash
# Problem: "Xcode Command Line Tools not configured"
# Error: xcode-select -p fails or xcodebuild not found
# Solution: Install Command Line Tools
xcode-select --install
# Verify installation
xcode-select -p
xcodebuild -version
sqlite3 --version
# Problem: "sqlite3 not found" or linker errors with SQLite
# Solution: Ensure Command Line Tools are properly installed
# The build script checks for this automatically, but if you see linker errors:
xcode-select --install
# Problem: pkgx SQLite conflicts with iOS builds
# Error: Linker errors about libsqlite3.dylib
# Solution: The build script automatically handles this by unsetting problematic
# environment variables. If issues persist:
unset PKGX_DIR DYLD_LIBRARY_PATH LD_LIBRARY_PATH
./scripts/build-native.sh --platform ios
# Problem: "pod install" fails
# Solution: Ensure Command Line Tools are installed
xcode-select --install
# Then reinstall CocoaPods dependencies
cd ios
pod deintegrate
pod install
```
#### Capacitor Integration Issues
```bash
# Problem: Plugin not found in Capacitor app
@@ -1128,7 +1032,7 @@ npx cap sync android
#### AAR Duplicate Class Issues
```bash
# Problem: Duplicate class errors when integrating plugin AAR
# Error: "Duplicate class org.timesafari.dailynotification.BootReceiver found in modules"
# Error: "Duplicate class com.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 +1119,7 @@ daily-notification-plugin/
├── scripts/ # Build scripts and automation
├── test-apps/ # Test applications
│ └── daily-notification-test/ # Vue 3 test app
├── doc/ # Documentation
├── docs/ # Documentation
├── examples/ # Usage examples
├── tests/ # Test files
├── package.json # Node.js dependencies
@@ -1310,7 +1214,7 @@ scripts/
### Getting Help
- Check the [troubleshooting section](#troubleshooting)
- Review [GitHub issues](https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin/issues)
- Review [GitHub issues](https://github.com/timesafari/daily-notification-plugin/issues)
- Consult [Capacitor documentation](https://capacitorjs.com/docs)
- Ask in [Capacitor community](https://github.com/ionic-team/capacitor/discussions)

View File

@@ -5,158 +5,6 @@ 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).
## [4.0.1] - 2026-06-09
### Fixed
- **iOS**: Daily Reminders now use `daily_<reminderId>` UNNotificationCenter identifiers instead of sharing `api_<epochMillis>` with batch API notifications. `clearApiNotifications` no longer removes new daily reminders.
- **iOS**: Legacy `api_*` daily-reminder pending requests are cancelled on schedule/cancel/update; `getScheduledReminders` matches both `daily_*` and transitional legacy ids.
### Changed
- **TypeScript**: JSDoc documents separate iOS identifier families (`daily_*` vs `api_*`).
## [4.0.0] - 2026-06-09
### Changed
- **Breaking**: Public batch APIs renamed to `scheduleApiNotifications` and `clearApiNotifications`, replacing `scheduleNotifications`, `clearPredictiveNotifications`, and iOS `clearAllNotifications`. Scheduling behavior and `api_*` identifiers are unchanged.
- **TypeScript**: JSDoc updated for API terminology and `api_*` identifiers.
## [3.0.4] - 2026-06-09
### Added
- **Android**: `scheduleNotifications` batch API schedules one-shot notifications at epoch-ms timestamps using deterministic `api_<epochMillis>` schedule IDs.
- **Android**: `clearPredictiveNotifications` cancels only API-managed schedules (`api_*`) without affecting Daily Reminders, user schedules, dual schedules, or fetch jobs.
### Changed
- **Android / iOS**: Internal batch notification ID namespace renamed from `predictive_<epochMillis>` to `api_<epochMillis>`. Public Capacitor method names are unchanged; scheduling and lifecycle behavior are unchanged.
- **TypeScript**: JSDoc for `scheduleNotifications` and `clearPredictiveNotifications` updated to document `api_*` identifiers.
## [3.0.3] - 2026-05-22
### Added
- **iOS**: `getDeliveredNotifications` and `getNotificationSettings` diagnostics APIs for debug panels (bridge-safe, local notification state only).
- **iOS**: `getPendingNotifications` now includes `triggerTimestamp` and `triggerDateIso`; always resolves with serializable values.
- **TypeScript**: `PendingNotificationsResult`, `DeliveredNotificationsResult`, `NotificationCenterSettingsSnapshot`, and related definitions/web stubs.
## [3.0.2] - 2026-05-05
### Changed
- **iOS**: API notification identifiers use epoch **milliseconds** (`api_<timestamp>`). Daily reminders persist `predictiveEpochMillis` for cancel/update alignment.
- **iOS**: `clearApiNotifications` removes only **`api_*`** pending and delivered notifications (dual/org IDs unchanged).
- **iOS**: `scheduleApiNotifications` is additive only (no automatic clear); skips stale timestamps in the past.
### Added
- **iOS**: `clearApiNotifications` and `scheduleApiNotifications` batch APIs on `DailyNotification` (Capacitor).
## [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

47
COMMIT_MESSAGE.txt Normal file
View File

@@ -0,0 +1,47 @@
fix(build): add SQLite conflict detection and Command Line Tools verification
Prevents iOS build failures caused by pkgx SQLite linking conflicts and
ensures Xcode Command Line Tools are properly installed.
Problem:
- pkgx installs SQLite built for macOS, causing linker errors when building
for iOS simulator: "linking in dylib built for 'macOS'"
- Missing Command Line Tools cause build failures without clear error messages
Changes:
- Add check_sqlite_conflicts() function
- Detects pkgx SQLite installations in ~/.pkgx
- Warns about macOS dylibs that will cause iOS simulator build failures
- Checks for system SQLite from Command Line Tools
- Validates library paths (DYLD_LIBRARY_PATH, LD_LIBRARY_PATH)
- Add check_command_line_tools() function
- Verifies Xcode Command Line Tools are installed and configured
- Checks for xcodebuild availability
- Verifies sqlite3 is available (part of Command Line Tools)
- Provides clear error messages with installation instructions
- Enhance pkgx detection in iOS build functions
- Specifically searches for pkgx SQLite dylibs
- Automatically removes pkgx paths from PATH environment variable
- Provides detailed warnings about detected conflicts
- Cleans all problematic environment variables before building
- Integrate checks into environment validation
- Runs automatically when building for iOS
- Provides early warnings before build starts
- Fails fast with clear error messages if tools are missing
This fixes the linker error:
"ld: building for 'iOS-simulator', but linking in dylib
(/Users/trent/.pkgx/sqlite.org/v3.44.2/lib/libsqlite3.0.dylib)
built for 'macOS'"
The build script now:
- Detects pkgx SQLite conflicts before building
- Automatically fixes environment variables
- Verifies Command Line Tools are installed
- Provides clear guidance for manual fixes if needed
Files modified:
- scripts/build-native.sh

25
LICENSE
View File

@@ -1,8 +1,21 @@
The author disclaims copyright to this source code. In place of a legal notice, here is a blessing:
MIT License
May you do good and not evil.
May you find forgiveness for yourself and forgive others.
May you share freely, never taking more than you give.
Copyright (c) 2024 Matthew Raymer
________________________________________________________________
from https://www.sqlite.org/src/info/689401a6cfb4c234 and memorialized here https://spdx.org/licenses/blessing.html
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

131
README.md
View File

@@ -1,36 +1,24 @@
# 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 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, static & API:
* Periodic static reminder messages
* Periodic API requests, then notifying the user if there is new content
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.
## Quick Start
**New to the plugin?** Start here:
- **In Giftopia/Time Safari** - Import via `npm run deps:update-daily-notification-plugin` in that repo
- **[Installation & Setup](./doc/GETTING_STARTED.md)** — Installation, platform setup, and basic usage
- **[Quick Start Guide](./doc/examples/QUICK_START.md)** — Minimal working example
- **[Common Patterns](./doc/examples/COMMON_PATTERNS.md)** — Common integration patterns
- **[Troubleshooting](./doc/TROUBLESHOOTING.md)** — Common issues and solutions
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
For complete documentation, see the [Documentation Index](./doc/00-INDEX.md).
For complete documentation, see the [Documentation Index](./docs/00-INDEX.md).
### 🎯 **Native-First Architecture**
@@ -39,7 +27,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 (someday)
- **Electron**: Desktop notifications + SQLite/LocalStorage
-**Web (PWA)**: Removed for native-first focus
**Key Benefits:**
@@ -52,8 +40,12 @@ The plugin has been optimized for **native-first deployment** with the following
### **Overview**
Stand-alone tests are found in the test-apps directory.
- The daily-notification-test (that includes Vue) has worked but is not tested extensively.
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
### ✅ **Phase 2 Complete - Production Ready**
@@ -74,6 +66,7 @@ Stand-alone tests are found in the test-apps directory.
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
@@ -87,7 +80,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 ✅
@@ -96,7 +89,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
@@ -105,19 +98,25 @@ 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
### **Enterprise Features**
### 📱 **Platform Support**
- **Android**: WorkManager + AlarmManager + SQLite (Room)
- **iOS**: BGTaskScheduler + UNUserNotificationCenter + Core Data
- **Web**: ❌ Removed (native-first architecture)
### 🔧 **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 [`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
- 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
- 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
@@ -135,14 +134,18 @@ npm install @timesafari/daily-notification-plugin
Or install from Git repository:
```bash
npm install git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
npm install git+https://github.com/timesafari/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](./doc/integration/QUICK_START.md) for step-by-step setup instructions.
**New to the plugin?** Start with the [Quick Integration Guide](./docs/integration/QUICK_START.md) for step-by-step setup instructions.
The quick guide covers:
- Installation and setup
@@ -151,7 +154,7 @@ The quick guide covers:
- Basic usage examples
- Troubleshooting common issues
**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.
**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.
## Quick Start
@@ -317,12 +320,6 @@ 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 apps **`NativeNotificationContentFetcher`** is used when registered.
### Callback Methods
#### `registerCallback(name, config)`
@@ -413,13 +410,13 @@ console.log(`Will fire at: ${new Date(result.triggerAtMillis).toLocaleString()}`
For immediate validation of plugin functionality:
- **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)
- **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)
### Manual Smoke Test Documentation
Complete testing procedures: [doc/testing/MANUAL_SMOKE_TEST.md](./doc/testing/MANUAL_SMOKE_TEST.md)
Complete testing procedures: [docs/testing/MANUAL_SMOKE_TEST.md](./docs/testing/MANUAL_SMOKE_TEST.md)
## Compatibility Matrix
@@ -562,17 +559,18 @@ 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="org.timesafari.dailynotification.NotifyReceiver"
<receiver android:name="com.timesafari.dailynotification.NotifyReceiver"
android:enabled="true"
android:exported="false">
</receiver>
<receiver android:name="org.timesafari.dailynotification.BootReceiver"
<receiver android:name="com.timesafari.dailynotification.BootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
@@ -607,8 +605,8 @@ dependencies {
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
<string>com.timesafari.dailynotification.content-fetch</string>
<string>com.timesafari.dailynotification.notification-delivery</string>
</array>
```
@@ -797,7 +795,7 @@ console.log('Callbacks:', callbacks);
### Development Setup
```bash
git clone https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git
git clone https://github.com/timesafari/daily-notification-plugin.git
cd daily-notification-plugin
npm install
npm run build
@@ -820,25 +818,29 @@ 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](./doc/00-INDEX.md)** - Central hub for all project documentation
**📚 [Complete Documentation Index](./docs/00-INDEX.md)** - Central hub for all project documentation
**Key Documentation:**
- **Integration**: [Integration Guide](./doc/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
- **Integration**: [Integration Guide](./docs/integration/INTEGRATION_GUIDE.md) - Complete integration instructions
- **Platform Guides**:
- [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
- [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
- **Building Guide**: [BUILDING.md](BUILDING.md) - Comprehensive build instructions and troubleshooting
- **Design & Research**: [Design Documentation](./doc/design/) - Design research and implementation guides
- **Archive**: [Legacy Documentation](./doc/archive/2025-legacy-doc/) - Historical documentation preserved for reference
- **Design & Research**: [Design Documentation](./docs/design/) - Design research and implementation guides
- **Archive**: [Legacy Documentation](./docs/archive/2025-legacy-doc/) - Historical documentation preserved for reference
### Community
@@ -851,3 +853,10 @@ npm test
- **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

View File

@@ -1,6 +1,6 @@
# Session Reconstitution — P2.1 Batch A
**Reconstituted from:** `doc/progress/P2.1-BATCH-A-STATE.md`
**Reconstituted from:** `docs/progress/P2.1-BATCH-A-STATE.md`
**Date:** 2025-12-23
**Baseline:** `v1.0.11-p3-complete`
@@ -183,10 +183,10 @@ exactAlarmManager = null // TODO: Requires AlarmManager + DailyNotificationSched
## 🔍 Files to Review
- **State File:** `doc/progress/P2.1-BATCH-A-STATE.md`
- **Method-Service Map:** `doc/progress/P2.1-METHOD-SERVICE-MAP.md`
- **Batch A Plan:** `doc/progress/P2.1-BATCH-1.md`
- **Overall Status:** `doc/progress/00-STATUS.md`
- **State File:** `docs/progress/P2.1-BATCH-A-STATE.md`
- **Method-Service Map:** `docs/progress/P2.1-METHOD-SERVICE-MAP.md`
- **Batch A Plan:** `docs/progress/P2.1-BATCH-1.md`
- **Overall Status:** `docs/progress/00-STATUS.md`
---

View File

@@ -50,7 +50,7 @@ Completed P2.1 (iOS schema versioning) and P2.2 (iOS combined edge case tests),
**Verification:**
- Tests runnable via xcodebuild (skipped on Linux CI, expected)
- Test results logged in `doc/progress/03-TEST-RUNS.md`
- Test results logged in `docs/progress/03-TEST-RUNS.md`
- Parity matrix updated with direct test references
---
@@ -58,8 +58,8 @@ Completed P2.1 (iOS schema versioning) and P2.2 (iOS combined edge case tests),
### 📋 P2.3: Android Combined Tests Design (Design Complete)
**Design Documents Created:**
- `doc/progress/P2.3-DESIGN.md` — Complete design with scope, invariants, acceptance criteria
- `doc/progress/P2.3-IMPLEMENTATION-CHECKLIST.md` — Step-by-step implementation guide
- `docs/progress/P2.3-DESIGN.md` — Complete design with scope, invariants, acceptance criteria
- `docs/progress/P2.3-IMPLEMENTATION-CHECKLIST.md` — Step-by-step implementation guide
**Design Highlights:**
- 3 work items: P2.3.1 (test infrastructure), P2.3.2 (test helpers), P2.3.3 (combined scenarios)
@@ -86,23 +86,23 @@ Completed P2.1 (iOS schema versioning) and P2.2 (iOS combined edge case tests),
- Matches pattern established in P2.2 (direct proof references)
**Files Modified:**
- `doc/progress/04-PARITY-MATRIX.md`
- `docs/progress/04-PARITY-MATRIX.md`
---
### 📊 Documentation Updates
**Progress Documentation:**
- `doc/progress/00-STATUS.md` — Updated baseline tag, phase status, next actions
- `doc/progress/01-CHANGELOG-WORK.md` — Added P2.1 and P2.2 completion entries
- `doc/progress/03-TEST-RUNS.md` — Added P2.1 and P2.2 test run entries
- `doc/progress/04-PARITY-MATRIX.md` — Fixed invalid data handling, added combined tests row
- `doc/progress/P2-DESIGN.md` — Updated P2.3 scope, marked P2.1/P2.2 complete
- `doc/SYSTEM_INVARIANTS.md` — Updated baseline tag references
- `docs/progress/00-STATUS.md` — Updated baseline tag, phase status, next actions
- `docs/progress/01-CHANGELOG-WORK.md` — Added P2.1 and P2.2 completion entries
- `docs/progress/03-TEST-RUNS.md` — Added P2.1 and P2.2 test run entries
- `docs/progress/04-PARITY-MATRIX.md` — Fixed invalid data handling, added combined tests row
- `docs/progress/P2-DESIGN.md` — Updated P2.3 scope, marked P2.1/P2.2 complete
- `docs/SYSTEM_INVARIANTS.md` — Updated baseline tag references
**New Documentation:**
- `doc/progress/P2.3-DESIGN.md` — P2.3 design document
- `doc/progress/P2.3-IMPLEMENTATION-CHECKLIST.md` — P2.3 implementation guide
- `docs/progress/P2.3-DESIGN.md` — P2.3 design document
- `docs/progress/P2.3-IMPLEMENTATION-CHECKLIST.md` — P2.3 implementation guide
---
@@ -155,7 +155,7 @@ Completed P2.1 (iOS schema versioning) and P2.2 (iOS combined edge case tests),
## Next Steps
**Immediate:**
1. Review P2.3 design (`doc/progress/P2.3-DESIGN.md`)
1. Review P2.3 design (`docs/progress/P2.3-DESIGN.md`)
2. Approve test framework choice (Robolectric vs pure unit tests)
3. Begin P2.3.1 — Enable Android test infrastructure

View File

@@ -26,7 +26,7 @@
**Location**: `test-apps/daily-notification-test/android/app/src/androidTest/java/com/timesafari/dailynotification/NotificationInstrumentationTest.java`
**Reference**: `doc/android-app-improvement-plan.md` - Phase 2: Testing & Reliability
**Reference**: `docs/android-app-improvement-plan.md` - Phase 2: Testing & Reliability
**Completed**: Created `NotificationInstrumentationTest.java` with tests for:
- NotifyReceiver registration verification
@@ -55,9 +55,9 @@
**Completed**: Updated documentation in:
- `API.md`: Added new diagnostic methods with examples
- `README.md`: Added Android diagnostic methods section, emphasized NotifyReceiver requirement
- `doc/notification-testing-procedures.md`: Added troubleshooting for BroadcastReceiver issues, diagnostic method usage
- `docs/notification-testing-procedures.md`: Added troubleshooting for BroadcastReceiver issues, diagnostic method usage
**Reference**: `doc/android-app-improvement-plan.md` - Phase 3: Security & Performance
**Reference**: `docs/android-app-improvement-plan.md` - Phase 3: Security & Performance
---
@@ -189,7 +189,7 @@
- [ ] Rollback procedures
- [ ] Monitoring setup guide
**Reference**: [DEPLOYMENT_CHECKLIST.md](../deployment/DEPLOYMENT_CHECKLIST.md)
**Reference**: `DEPLOYMENT_CHECKLIST.md`
---
@@ -203,8 +203,8 @@
---
**Related Documents**:
- `doc/android-app-improvement-plan.md` - Detailed improvement plan
- `docs/android-app-improvement-plan.md` - Detailed improvement plan
- `doc/implementation-roadmap.md` - Implementation phases
- [DEPLOYMENT_CHECKLIST.md](../deployment/DEPLOYMENT_CHECKLIST.md) - Deployment procedures
- `DEPLOYMENT_CHECKLIST.md` - Deployment procedures
- `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` - Native fetcher TODOs

View File

@@ -1,19 +0,0 @@
// 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()
}

View File

@@ -1,16 +0,0 @@
{
"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
}
}
}

View File

@@ -1,475 +0,0 @@
<!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>

View File

@@ -1,6 +0,0 @@
<?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>

View File

@@ -14,7 +14,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
android {
namespace "org.timesafari.dailynotification.plugin"
namespace "com.timesafari.dailynotification.plugin"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {

View File

@@ -1,59 +0,0 @@
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()
}

View File

@@ -1,7 +0,0 @@
// 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 = [:]
}

View File

@@ -1,8 +0,0 @@
<?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>

View File

@@ -1,3 +0,0 @@
// 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')

View File

@@ -2,7 +2,7 @@
# These rules are applied to consuming apps when they use this plugin
# Keep plugin classes
-keep class org.timesafari.dailynotification.** { *; }
-keep class com.timesafari.dailynotification.** { *; }
# Keep Capacitor plugin interface
-keep class com.getcapacitor.Plugin { *; }

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.timesafari.dailynotification.plugin">
package="com.timesafari.dailynotification.plugin">
<!-- Plugin receivers are declared in consuming app's manifest -->
<!-- This manifest is optional and mainly for library metadata -->

View File

@@ -2,7 +2,7 @@
{
"pkg": "@timesafari/daily-notification-plugin",
"name": "DailyNotification",
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]

View File

@@ -1,4 +1,4 @@
package org.timesafari.dailynotification
package com.timesafari.dailynotification
import android.content.BroadcastReceiver
import android.content.Context
@@ -76,24 +76,21 @@ 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 = title,
body = body,
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
source = ScheduleSource.BOOT_RECOVERY
)
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
}

View File

@@ -1,4 +1,4 @@
package org.timesafari.dailynotification;
package com.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(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(com.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(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(com.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(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(com.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(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
return openChannelSettings(com.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 : org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID)
.putExtra(Settings.EXTRA_CHANNEL_ID, channelId != null ? channelId : com.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(
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID,
org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_NAME,
com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID,
com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_DESCRIPTION);
channel.setDescription(com.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 org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID;
return com.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(org.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
NotificationChannel channel = notificationManager.getNotificationChannel(com.timesafari.dailynotification.DailyNotificationConstants.DEFAULT_CHANNEL_ID);
if (channel != null) {
Log.i(TAG, "Channel Status - ID: " + channel.getId() +
", Importance: " + channel.getImportance() +

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification
package com.timesafari.dailynotification
/**
* Centralized constants for Daily Notification Plugin
@@ -22,12 +22,6 @@ 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()
@@ -62,7 +56,7 @@ object DailyNotificationConstants {
* Action string for notification broadcast intents
* Used by AlarmManager PendingIntents
*/
const val ACTION_NOTIFICATION = "org.timesafari.daily.NOTIFICATION"
const val ACTION_NOTIFICATION = "com.timesafari.daily.NOTIFICATION"
// ============================================================
// Intent Extras Keys
@@ -146,34 +140,6 @@ 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_"
/**
* Prefix for API-managed batch notification schedule IDs (`api_<epochMillis>`).
* Matches iOS [apiNotificationPrefix]; must not overlap daily reminders or dual schedules.
*/
const val API_SCHEDULE_ID_PREFIX = "api_"
// ============================================================
// Request Code Versioning

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.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 org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage; // Preferred path
private final com.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,
org.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
com.timesafari.dailynotification.storage.DailyNotificationStorageRoom roomStorage) {
this.context = context;
this.storage = storage;
this.roomStorage = roomStorage;
@@ -220,8 +220,8 @@ public class DailyNotificationFetcher {
return;
}
try {
org.timesafari.dailynotification.entities.NotificationContentEntity entity =
new org.timesafari.dailynotification.entities.NotificationContentEntity(
com.timesafari.dailynotification.entities.NotificationContentEntity entity =
new com.timesafari.dailynotification.entities.NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0",
null,

View File

@@ -9,7 +9,7 @@
* @created 2025-10-03 06:53:30 UTC
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.util.Log;
import android.content.Context;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.ContentValues;
import android.content.Context;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.os.Debug;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.BroadcastReceiver;
import android.content.Context;

View File

@@ -0,0 +1,559 @@
/**
* DailyNotificationReceiver.java
*
* Broadcast receiver for handling scheduled notification alarms
* Displays notifications when scheduled time is reached
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Trace;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
/**
* Broadcast receiver for daily notification alarms
*
* This receiver is triggered by AlarmManager when it's time to display
* a notification. It retrieves the notification content from storage
* and displays it to the user.
*/
public class DailyNotificationReceiver extends BroadcastReceiver {
private static final String TAG = "DailyNotificationReceiver";
private static final String CHANNEL_ID = "timesafari.daily";
private static final String EXTRA_NOTIFICATION_ID = "notification_id";
/**
* Handle broadcast intent when alarm triggers
*
* Ultra-lightweight receiver that only parses intent and enqueues work.
* All heavy operations (storage, JSON, scheduling) are moved to WorkManager.
*
* @param context Application context
* @param intent Broadcast intent
*/
@Override
public void onReceive(Context context, Intent intent) {
Trace.beginSection("DN:onReceive");
try {
Log.d(TAG, "DN|RECEIVE_START action=" + intent.getAction());
String action = intent.getAction();
if (action == null) {
Log.w(TAG, "DN|RECEIVE_ERR null_action");
return;
}
if ("com.timesafari.daily.NOTIFICATION".equals(action)) {
// Parse intent and enqueue work - keep receiver ultra-light
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId == null) {
Log.w(TAG, "DN|RECEIVE_ERR missing_id");
return;
}
// Enqueue work immediately - don't block receiver
// Pass the full intent to extract static reminder extras
enqueueNotificationWork(context, notificationId, intent);
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
} else if ("com.timesafari.daily.DISMISS".equals(action)) {
// Handle dismissal - also lightweight
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId != null) {
enqueueDismissalWork(context, notificationId);
Log.d(TAG, "DN|DISMISS_OK enqueued=" + notificationId);
}
} else {
Log.w(TAG, "DN|RECEIVE_ERR unknown_action=" + action);
}
} catch (Exception e) {
Log.e(TAG, "DN|RECEIVE_ERR exception=" + e.getMessage(), e);
} finally {
Trace.endSection();
}
}
/**
* Enqueue notification processing work to WorkManager with deduplication
*
* Uses unique work name based on notification ID to prevent duplicate
* work items from being enqueued for the same notification. WorkManager's
* enqueueUniqueWork automatically prevents duplicates when using the same
* work name.
*
* @param context Application context
* @param notificationId ID of notification to process
* @param intent Intent containing notification data (may include static reminder extras)
*/
private void enqueueNotificationWork(Context context, String notificationId, Intent intent) {
try {
// 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
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
String title = intent.getStringExtra("title");
String body = intent.getStringExtra("body");
boolean sound = intent.getBooleanExtra("sound", true);
boolean vibration = intent.getBooleanExtra("vibration", true);
String priority = intent.getStringExtra("priority");
if (priority == null) {
priority = "normal";
}
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 (isStaticReminder && title != null && body != null) {
dataBuilder.putString("title", title)
.putString("body", body)
.putBoolean("sound", sound)
.putBoolean("vibration", vibration)
.putString("priority", priority);
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder id=" + notificationId);
}
Data inputData = dataBuilder.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
.setInputData(inputData)
.addTag("daily_notification_display")
.build();
// Use unique work name with KEEP policy (don't replace if exists)
// This prevents duplicate work items from being enqueued even if
// the receiver is triggered multiple times for the same notification
WorkManager.getInstance(context).enqueueUniqueWork(
workName,
ExistingWorkPolicy.KEEP,
workRequest
);
Log.d(TAG, "DN|WORK_ENQUEUE display=" + notificationId + " work_name=" + workName);
} catch (Exception e) {
Log.e(TAG, "DN|WORK_ENQUEUE_ERR display=" + notificationId + " err=" + e.getMessage(), e);
}
}
/**
* Enqueue notification dismissal work to WorkManager with deduplication
*
* Uses unique work name based on notification ID to prevent duplicate
* dismissal work items.
*
* @param context Application context
* @param notificationId ID of notification to dismiss
*/
private void enqueueDismissalWork(Context context, String notificationId) {
try {
// Create unique work name based on notification ID to prevent duplicates
String workName = "dismiss_" + notificationId;
Data inputData = new Data.Builder()
.putString("notification_id", notificationId)
.putString("action", "dismiss")
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
.setInputData(inputData)
.addTag("daily_notification_dismiss")
.build();
// Use unique work name with REPLACE policy (allow new dismissal to replace pending)
WorkManager.getInstance(context).enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
workRequest
);
Log.d(TAG, "DN|WORK_ENQUEUE dismiss=" + notificationId + " work_name=" + workName);
} catch (Exception e) {
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
}
}
/**
* Handle notification intent
*
* @param context Application context
* @param intent Intent containing notification data
*/
private void handleNotificationIntent(Context context, Intent intent) {
try {
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId == null) {
Log.w(TAG, "Notification ID not found in intent");
return;
}
Log.d(TAG, "Processing notification: " + notificationId);
// Get notification content from storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
NotificationContent content = storage.getNotificationContent(notificationId);
if (content == null) {
Log.w(TAG, "Notification content not found: " + notificationId);
return;
}
// Check if notification is ready to display
if (!content.isReadyToDisplay()) {
Log.d(TAG, "Notification not ready to display yet: " + notificationId);
return;
}
// JIT Freshness Re-check (Soft TTL)
content = performJITFreshnessCheck(context, content);
// Display the notification
displayNotification(context, content);
// Schedule next notification if this is a recurring daily notification
scheduleNextNotification(context, content);
Log.i(TAG, "Notification processed successfully: " + notificationId);
} catch (Exception e) {
Log.e(TAG, "Error handling notification intent", e);
}
}
/**
* Perform JIT (Just-In-Time) freshness re-check for notification content
*
* This implements a soft TTL mechanism that attempts to refresh stale content
* just before displaying the notification. If the refresh fails or content
* is not stale, the original content is returned.
*
* @param context Application context
* @param content Original notification content
* @return Updated content if refresh succeeded, original content otherwise
*/
private NotificationContent performJITFreshnessCheck(Context context, NotificationContent content) {
try {
// Check if content is stale (older than 6 hours for JIT check)
long currentTime = System.currentTimeMillis();
long age = currentTime - content.getFetchedAt();
long staleThreshold = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
if (age < staleThreshold) {
Log.d(TAG, "Content is fresh (age: " + (age / 1000 / 60) + " minutes), skipping JIT refresh");
return content;
}
Log.i(TAG, "Content is stale (age: " + (age / 1000 / 60) + " minutes), attempting JIT refresh");
// Attempt to fetch fresh content
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(context, new DailyNotificationStorage(context));
// Attempt immediate fetch for fresh content
NotificationContent freshContent = fetcher.fetchContentImmediately();
if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) {
Log.i(TAG, "JIT refresh succeeded, using fresh content");
// Update the original content with fresh data while preserving the original ID and scheduled time
String originalId = content.getId();
long originalScheduledTime = content.getScheduledTime();
content.setTitle(freshContent.getTitle());
content.setBody(freshContent.getBody());
content.setSound(freshContent.isSound());
content.setPriority(freshContent.getPriority());
content.setUrl(freshContent.getUrl());
content.setMediaUrl(freshContent.getMediaUrl());
content.setScheduledTime(originalScheduledTime); // Preserve original scheduled time
// Note: fetchedAt remains unchanged to preserve original fetch time
// Save updated content to storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
storage.saveNotificationContent(content);
return content;
} else {
Log.w(TAG, "JIT refresh failed or returned empty content, using original content");
return content;
}
} catch (Exception e) {
Log.e(TAG, "Error during JIT freshness check", e);
return content; // Return original content on error
}
}
/**
* Display the notification to the user
*
* @param context Application context
* @param content Notification content to display
*/
private void displayNotification(Context context, NotificationContent content) {
try {
Log.d(TAG, "Displaying notification: " + content.getId());
NotificationManager notificationManager =
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager == null) {
Log.e(TAG, "NotificationManager not available");
return;
}
// Create notification builder
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(content.getTitle())
.setContentText(content.getBody())
.setPriority(getNotificationPriority(content.getPriority()))
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER);
// Add sound if enabled
if (content.isSound()) {
builder.setDefaults(NotificationCompat.DEFAULT_SOUND);
}
// Add click action if URL is available
if (content.getUrl() != null && !content.getUrl().isEmpty()) {
Intent clickIntent = new Intent(Intent.ACTION_VIEW);
clickIntent.setData(android.net.Uri.parse(content.getUrl()));
PendingIntent clickPendingIntent = PendingIntent.getActivity(
context,
content.getId().hashCode(),
clickIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
builder.setContentIntent(clickPendingIntent);
}
// Add dismiss action
Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class);
dismissIntent.setAction("com.timesafari.daily.DISMISS");
dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
context,
content.getId().hashCode() + 1000, // Different request code
dismissIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
builder.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
"Dismiss",
dismissPendingIntent
);
// Build and display notification
int notificationId = content.getId().hashCode();
notificationManager.notify(notificationId, builder.build());
Log.i(TAG, "Notification displayed successfully: " + content.getId());
} catch (Exception e) {
Log.e(TAG, "Error displaying notification", e);
}
}
/**
* Schedule the next occurrence of this daily notification
*
* Uses centralized NotifyReceiver.scheduleExactNotification() with ROLLOVER_ON_FIRE source
* to ensure idempotence and proper logging
*
* @param context Application context
* @param content Current notification content
*/
private void scheduleNextNotification(Context context, NotificationContent content) {
try {
Log.d(TAG, "Scheduling next notification for: " + content.getId());
// Extract scheduleId from notificationId pattern or use fallback
// Notification IDs are often "daily_${scheduleId}"
String scheduleId = null;
String cronExpression = null;
long nextScheduledTime = content.getScheduledTime() + (24 * 60 * 60 * 1000);
// 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 {
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
}
// Create config for next notification
com.timesafari.dailynotification.UserNotificationConfig config =
new com.timesafari.dailynotification.UserNotificationConfig(
true, // enabled
cronExpression,
content.getTitle() != null ? content.getTitle() : "Daily Notification",
content.getBody(),
content.isSound(),
true, // vibration
content.getPriority() != null ? content.getPriority() : "normal"
);
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
context,
nextScheduledTime,
config,
false, // isStaticReminder
null, // reminderId
scheduleId,
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
);
Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId);
} catch (Exception e) {
Log.e(TAG, "Error scheduling next notification", e);
}
}
/**
* Helper to convert HH:mm time to cron expression
*/
private String convertTimeToCron(String clockTime) {
try {
String[] parts = clockTime.split(":");
if (parts.length == 2) {
int hour = Integer.parseInt(parts[0]);
int minute = Integer.parseInt(parts[1]);
return String.format("%d %d * * *", minute, hour);
}
} catch (Exception e) {
Log.w(TAG, "Failed to parse clockTime: " + clockTime, e);
}
return "0 9 * * *"; // Default to 9 AM
}
/**
* Helper to calculate next run time from cron expression
*/
private long calculateNextRunTimeFromCron(String cron) {
try {
String[] parts = cron.trim().split("\\s+");
if (parts.length >= 2) {
int minute = Integer.parseInt(parts[0]);
int hour = Integer.parseInt(parts[1]);
java.util.Calendar calendar = java.util.Calendar.getInstance();
long now = calendar.getTimeInMillis();
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);
long nextRun = calendar.getTimeInMillis();
if (nextRun <= now) {
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1);
nextRun = calendar.getTimeInMillis();
}
return nextRun;
}
} catch (Exception e) {
Log.w(TAG, "Failed to calculate next run time from cron: " + cron, e);
}
// Fallback: 24 hours from now
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L);
}
/**
* Get notification priority constant
*
* @param priority Priority string from content
* @return NotificationCompat priority constant
*/
private int getNotificationPriority(String priority) {
if (priority == null) {
return NotificationCompat.PRIORITY_DEFAULT;
}
switch (priority.toLowerCase()) {
case "high":
return NotificationCompat.PRIORITY_HIGH;
case "low":
return NotificationCompat.PRIORITY_LOW;
case "min":
return NotificationCompat.PRIORITY_MIN;
case "max":
return NotificationCompat.PRIORITY_MAX;
default:
return NotificationCompat.PRIORITY_DEFAULT;
}
}
/**
* Handle notification dismissal
*
* @param context Application context
* @param notificationId ID of dismissed notification
*/
private void handleNotificationDismissal(Context context, String notificationId) {
try {
Log.d(TAG, "Handling notification dismissal: " + notificationId);
// Remove from storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
storage.removeNotification(notificationId);
// Cancel any pending alarms
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
context,
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
);
scheduler.cancelNotification(notificationId);
Log.i(TAG, "Notification dismissed successfully: " + notificationId);
} catch (Exception e) {
Log.e(TAG, "Error handling notification dismissal", e);
}
}
}

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -8,10 +8,11 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
@@ -154,11 +155,16 @@ public class DailyNotificationScheduler {
cancelNotification(duplicateId);
}
// Create intent for the notification; setPackage ensures AlarmManager delivery on all OEMs
Intent intent = new Intent(context, DailyNotificationReceiver.class);
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
// AlarmManager requires explicit component matching when delivering broadcasts
ComponentName receiverComponent = new ComponentName(
context.getPackageName(),
"com.timesafari.dailynotification.DailyNotificationReceiver"
);
Intent intent = new Intent(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
intent.setComponent(receiverComponent);
intent.setPackage(context.getPackageName());
intent.setAction(org.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
intent.putExtra(org.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());
intent.putExtra(com.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 +487,7 @@ public class DailyNotificationScheduler {
try {
Log.d(TAG, "Scheduling test alarm in " + secondsFromNow + " seconds");
// Delegate to NotifyReceiver.testAlarm()
org.timesafari.dailynotification.NotifyReceiver.Companion.testAlarm(context, secondsFromNow);
com.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 +597,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 org.timesafari.dailynotification.NotifyReceiver.Companion.isAlarmScheduled(
return com.timesafari.dailynotification.NotifyReceiver.Companion.isAlarmScheduled(
context,
scheduleId,
triggerAtMillis
@@ -624,7 +630,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 org.timesafari.dailynotification.NotifyReceiver.Companion.getNextAlarmTime(context);
return com.timesafari.dailynotification.NotifyReceiver.Companion.getNextAlarmTime(context);
} catch (Exception e) {
Log.e(TAG, "Error getting next alarm time", e);
return null;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.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 org.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
import org.timesafari.dailynotification.entities.NotificationContentEntity;
import org.timesafari.dailynotification.DailyNotificationFetcher;
import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.DailyNotificationFetcher;
/**
* WorkManager worker for processing daily notifications
@@ -127,29 +127,13 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
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)
Data inputData = getInputData();
boolean isStaticReminder = inputData.getBoolean("is_static_reminder", false);
NotificationContent content;
if (isStaticReminder) {
// Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
// Static reminder: create NotificationContent from input data
String title = inputData.getString("title");
String body = inputData.getString("body");
boolean sound = inputData.getBoolean("sound", true);
@@ -158,17 +142,7 @@ 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();
@@ -186,34 +160,25 @@ public class DailyNotificationWorker extends Worker {
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title);
} else {
// Regular notification: load from storage (by notification_id, then by schedule_id for rollover/user content)
// Regular notification: load from storage
// Prefer Room storage; fallback to legacy SharedPreferences storage
content = getContentFromRoomOrLegacy(notificationId);
// 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)");
}
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);
}
// Display the notification
@@ -396,7 +361,7 @@ public class DailyNotificationWorker extends Worker {
// Create one-time work request
androidx.work.OneTimeWorkRequest softRefetchWork = new androidx.work.OneTimeWorkRequest.Builder(
org.timesafari.dailynotification.SoftRefetchWorker.class)
com.timesafari.dailynotification.SoftRefetchWorker.class)
.setConstraints(constraints)
.setInputData(inputData)
.setInitialDelay(softRefetchTime - System.currentTimeMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
@@ -483,7 +448,7 @@ public class DailyNotificationWorker extends Worker {
// Add action buttons
// 1. Dismiss action
Intent dismissIntent = new Intent(getApplicationContext(), DailyNotificationReceiver.class);
dismissIntent.setAction("org.timesafari.daily.DISMISS");
dismissIntent.setAction("com.timesafari.daily.DISMISS");
dismissIntent.putExtra("notification_id", content.getId());
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
@@ -549,53 +514,8 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
// 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);
// Cancellation guard: if the user cancelled (row deleted, see cancelDailyReminder's
// deleteById) or disabled this schedule since the alarm fired, stop here rather than
// unconditionally re-arming the next occurrence. This closes the race described in
// PLAN-simplify-static-and-refactor.md §3.4 without this check, scheduleNextNotification
// always re-armed regardless of cancellation state, which is why "cancel" didn't
// actually stop notifications that were already in-flight when cancelled.
if (s == null || !s.getEnabled()) {
Log.i(TAG, "DN|RESCHEDULE_SKIP_CANCELLED id=" + content.getId()
+ " scheduleId=" + logicalScheduleIdForRollover
+ " reason=" + (s == null ? "deleted" : "disabled"));
return;
}
if (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());
}
// Calculate next occurrence using DST-safe ZonedDateTime
long nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
// Check for existing notification at the same time to prevent duplicates
DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
@@ -620,44 +540,37 @@ 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();
String scheduleId = scheduleIdForRollover;
if (scheduleId == null || scheduleId.isEmpty()) {
// Previously fell back to a freshly-minted "daily_rollover_<timestamp>" id here,
// which created an orphan schedule row every time it triggered itself a source
// of the "stray schedule that survives cancellation" bug (generator #4 in
// PLAN-simplify-static-and-refactor.md §2). Per §3.6, this branch should be
// unreachable for static reminders (NotifyReceiver.scheduleExactNotification
// always sets the schedule_id intent extra), so fail loudly instead of silently
// spawning an untrackable, uncancellable orphan alarm.
Log.e(TAG, "DN|RESCHEDULE_ABORT id=" + content.getId()
+ " reason=no_resolvable_schedule_id - not re-arming to avoid creating an orphan schedule");
return;
}
// 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 * * *";
}
if (notificationId != null && notificationId.startsWith("daily_")) {
scheduleId = notificationId; // Use notificationId as scheduleId
} 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));
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
}
// Create config for next notification
org.timesafari.dailynotification.UserNotificationConfig config =
new org.timesafari.dailynotification.UserNotificationConfig(
com.timesafari.dailynotification.UserNotificationConfig config =
new com.timesafari.dailynotification.UserNotificationConfig(
true, // enabled
cronExpression,
content.getTitle() != null ? content.getTitle() : "Daily Notification",
@@ -668,50 +581,48 @@ public class DailyNotificationWorker extends Worker {
);
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
Log.d(TAG, "DN|ROLLOVER next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
getApplicationContext(),
nextScheduledTime,
config,
preserveStaticReminder, // isStaticReminder preserve so next run keeps title/body
preserveStaticReminder ? scheduleId : null, // reminderId
false, // isStaticReminder
null, // reminderId
scheduleId,
org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
false // skipPendingIntentIdempotence rollover path does not skip
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE
);
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);
// 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);
// 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();
}
} catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
" error scheduling prefetch", e);
}
} catch (Exception e) {
@@ -721,28 +632,6 @@ 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
*/
@@ -751,8 +640,8 @@ public class DailyNotificationWorker extends Worker {
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// Use unified database (Kotlin schema with Java entities)
org.timesafari.dailynotification.DailyNotificationDatabase db =
org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
com.timesafari.dailynotification.DailyNotificationDatabase db =
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
if (entity != null) {
return mapEntityToContent(entity);
@@ -799,7 +688,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.2.1",
"1.0.0",
null,
"daily",
content.getTitle(),
@@ -836,21 +725,6 @@ 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
*

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
/**
* Information about a scheduled daily reminder

View File

@@ -9,7 +9,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -1,15 +1,15 @@
package org.timesafari.dailynotification
package com.timesafari.dailynotification
import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
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
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
/**
* Unified SQLite schema for Daily Notification Plugin
@@ -33,9 +33,7 @@ data class ContentCache(
val fetchedAt: Long, // epoch ms
val ttlSeconds: Int,
val payload: ByteArray, // BLOB
val meta: String? = null,
/** dual | daily | legacy — see [ContentCacheScope] */
val cacheScope: String = ContentCacheScope.LEGACY
val meta: String? = null
)
@Entity(tableName = "schedules")
@@ -49,9 +47,7 @@ data class Schedule(
val nextRunAt: Long? = null,
val jitterMs: Int = 0,
val backoffPolicy: String = "exp",
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
val stateJson: String? = null
)
@Entity(tableName = "callbacks")
@@ -87,7 +83,7 @@ data class History(
NotificationDeliveryEntity::class,
NotificationConfigEntity::class
],
version = 5, // 5: isolate static reminder rows into kind="static_reminder" (was "notify")
version = 2, // Incremented for unified schema
exportSchema = false
)
@TypeConverters(Converters::class)
@@ -122,7 +118,7 @@ abstract class DailyNotificationDatabase : RoomDatabase() {
DailyNotificationDatabase::class.java,
DATABASE_NAME
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
.addCallback(roomCallback)
.build()
INSTANCE = instance
@@ -270,49 +266,6 @@ 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'"
)
}
}
/**
* Isolate the static reminder's row into its own "static_reminder" kind, away from the
* shared "notify" kind (see PLAN-simplify-static-and-refactor.md §3.1, §3.7).
*
* The static reminder's id is caller-supplied (TimeSafari uses "daily_timesafari_reminder";
* the plugin defaults to "daily_notification" if none is given), so we can't match it by a
* fixed id. Instead, reclassify by elimination: any existing kind="notify" row that does
* NOT match one of the three other generators' known id prefixes
* (dual_notify_<ts>, notify_<ts>, daily_rollover_<ts>) must be the static reminder, since
* those are the only generators that ever wrote kind="notify" rows.
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
UPDATE schedules SET kind = 'static_reminder'
WHERE kind = 'notify'
AND id NOT LIKE 'dual_notify_%'
AND id NOT LIKE 'notify_%'
AND id NOT LIKE 'daily_rollover_%'
""".trimIndent()
)
}
}
}
}
@@ -323,9 +276,6 @@ 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>

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.content.Context;

View File

@@ -9,7 +9,7 @@
* @created 2025-10-03 06:53:30 UTC
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.util.Log;

View File

@@ -11,7 +11,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

View File

@@ -1,24 +1,23 @@
package org.timesafari.dailynotification
package com.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 kotlin.text.Charsets
import java.util.concurrent.TimeUnit
import org.json.JSONObject
/**
* WorkManager implementation for content fetching
* Implements exponential backoff and network constraints
*
*
* @author Matthew Raymer
* @version 2.0.0
* @version 1.1.0
*/
class FetchWorker(
appContext: Context,
@@ -28,85 +27,12 @@ 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(
@@ -120,18 +46,21 @@ 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(workName, ExistingWorkPolicy.REPLACE, workRequest)
.enqueueUniqueWork(
WORK_NAME,
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)
@@ -145,15 +74,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 {
@@ -165,11 +94,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)
@@ -186,23 +115,21 @@ 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)
*/
@@ -222,7 +149,7 @@ class FetchWorker(
}
}
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInputData(
@@ -233,16 +160,14 @@ 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")
}
}
@@ -254,39 +179,33 @@ 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, isDual=$isDual, scope=$cacheScope")
val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
val skipDualChainedNotify =
isDual && nextNotifyAt > 0L && isDualSkipNotificationPayload(payload)
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime")
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
val contentCache = ContentCache(
id = generateId(),
fetchedAt = System.currentTimeMillis(),
ttlSeconds = 3600, // 1 hour default TTL
payload = payload,
meta = "fetched_by_workmanager",
cacheScope = cacheScope
meta = "fetched_by_workmanager"
)
// 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 = org.timesafari.dailynotification.entities.NotificationContentEntity(
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"2.1.0", // Plugin version
"1.0.2", // Plugin version
null, // timesafariDid - can be set if available
"daily",
title,
@@ -301,7 +220,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")
@@ -310,7 +229,7 @@ class FetchWorker(
// Continue - at least ContentCache was saved
}
}
// Record success in history
db.historyDao().insert(
History(
@@ -321,91 +240,22 @@ 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,
@@ -416,22 +266,23 @@ 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) {
@@ -440,22 +291,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": "2.1.4"
"version": "1.1.0"
}
""".trimIndent()
return mockData.toByteArray()
}
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
try {
val db = DailyNotificationDatabase.getDatabase(applicationContext)
@@ -473,22 +324,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")

View File

@@ -15,10 +15,9 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -143,21 +142,5 @@ 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);
}
}

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.util.Log;
import java.util.UUID;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.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(org.timesafari.dailynotification.DailyNotificationDatabase database) {
public JSObject getNotificationStatus(com.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 org.timesafari.dailynotification.NotificationStatusHelper.getNotificationStatusBlocking(database);
return com.timesafari.dailynotification.NotificationStatusHelper.getNotificationStatusBlocking(database);
} catch (Exception e) {
Log.e(TAG, "DN|NOTIFICATION_STATUS_ERR err=" + e.getMessage(), e);

View File

@@ -1,4 +1,4 @@
package org.timesafari.dailynotification
package com.timesafari.dailynotification
import android.app.AlarmManager
import android.app.AlarmManager.AlarmClockInfo
@@ -6,6 +6,7 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.os.Build
@@ -21,7 +22,7 @@ import kotlinx.coroutines.runBlocking
* Implements TTL-at-fire logic and notification delivery
*
* @author Matthew Raymer
* @version 2.0.0
* @version 1.1.0
*/
/**
* Source of schedule request - tracks which code path triggered scheduling
@@ -122,119 +123,110 @@ 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,
skipPendingIntentIdempotence: Boolean = false
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE
) {
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, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
// AlarmManager requires explicit component matching when delivering broadcasts
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val checkIntent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
action = "org.timesafari.daily.NOTIFICATION"
}
// 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(
// 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(
context,
requestCode,
timeBasedRequestCode,
checkIntent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
if (existingPendingIntent == null) {
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
existingPendingIntent = PendingIntent.getBroadcast(
context,
timeBasedRequestCode,
checkIntent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
}
// Check 3: AlarmManager next alarm (Android 5.0+)
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val nextAlarm = alarmManager.nextAlarmClock
if (nextAlarm != null) {
val nextAlarmTime = nextAlarm.triggerTime
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
if (timeDiff < 60000) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
return
}
}
}
if (existingPendingIntent != null) {
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 {
}
// 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 PendingIntent found for requestCode=$requestCode - alarm already scheduled")
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
return
}
}
} else {
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
}
if (existingPendingIntent != null) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
return
}
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
// 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
}
// 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
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")
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
}
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
@@ -250,16 +242,14 @@ class NotifyReceiver : BroadcastReceiver() {
try {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
val contentCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
?: db.contentCacheDao().getLatest()
val contentCache = db.contentCacheDao().getLatest()
// Always create a notification content entity for recovery tracking
// Phase 1: Recovery needs NotificationContentEntity to detect missed notifications
val roomStorage = org.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"2.1.0", // Plugin version
"1.0.2", // Plugin version
null, // timesafariDid - can be set if available
"daily",
config.title,
@@ -287,14 +277,21 @@ class NotifyReceiver : BroadcastReceiver() {
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
}
// 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, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
// AlarmManager requires explicit component matching when delivering broadcasts.
// Using Intent(context, Class) constructor may not work reliably with AlarmManager
// on all Android versions, especially when the app is in certain states.
// Solution: Create Intent with action, then explicitly set component and package.
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
// Explicitly set component to ensure AlarmManager can match it to the receiver
setComponent(receiverComponent)
// Explicitly set package to ensure it matches the app's package (not plugin's)
setPackage(context.packageName)
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
// Must match manifest intent-filter action
// DailyNotificationReceiver expects this extra
putExtra("notification_id", notificationId)
// Add stable scheduleId for tracking
putExtra("schedule_id", stableScheduleId)
// Also preserve original extras for backward compatibility if needed
putExtra("title", config.title)
putExtra("body", config.body)
@@ -302,7 +299,8 @@ class NotifyReceiver : BroadcastReceiver() {
putExtra("vibration", config.vibration ?: true)
putExtra("priority", config.priority ?: "normal")
putExtra("is_static_reminder", isStaticReminder)
putExtra("trigger_time", triggerAtMillis) // Store trigger time for debugging
// Store trigger time for debugging
putExtra("trigger_time", triggerAtMillis)
if (reminderId != null) {
putExtra("reminder_id", reminderId)
}
@@ -329,8 +327,7 @@ class NotifyReceiver : BroadcastReceiver() {
if (existingPendingIntent != null) {
Log.w(SCHEDULE_TAG, "Cancelling existing alarm before rescheduling: requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source")
alarmManager.cancel(existingPendingIntent)
// 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.
existingPendingIntent.cancel()
}
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "Failed to cancel existing alarm before scheduling: $stableScheduleId", e)
@@ -417,68 +414,6 @@ 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()
// Includes "static_reminder" so this fallback can find the real static
// reminder row (now isolated from "notify") instead of falling through to
// the "create new" branch below and spawning a duplicate row.
scheduleToUpdate = allSchedules.firstOrNull {
(it.kind == "notify" || it.kind == "static_reminder") && 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)
}
}
/**
@@ -490,10 +425,14 @@ 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, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
action = "org.timesafari.daily.NOTIFICATION"
}
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)
@@ -503,38 +442,14 @@ class NotifyReceiver : BroadcastReceiver() {
return
}
}
// 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(
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
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")
}
alarmManager.cancel(pendingIntent)
Log.i(TAG, "Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
/**
@@ -546,10 +461,14 @@ class NotifyReceiver : BroadcastReceiver() {
* @return true if alarm is scheduled, false otherwise
*/
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
action = "org.timesafari.daily.NOTIFICATION"
}
val requestCode = when {
scheduleId != null -> getRequestCode(scheduleId)
@@ -677,9 +596,7 @@ class NotifyReceiver : BroadcastReceiver() {
// Existing cached content logic for regular notifications
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = db.contentCacheDao().getLatestByScope(ContentCacheScope.DAILY)
?: db.contentCacheDao().getLatestByScope(ContentCacheScope.LEGACY)
?: db.contentCacheDao().getLatest()
val latestCache = db.contentCacheDao().getLatest()
if (latestCache == null) {
Log.w(TAG, "No cached content available for notification")

View File

@@ -1,4 +1,4 @@
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.app.AlarmManager;
import android.app.PendingIntent;

View File

@@ -8,7 +8,7 @@
* @version 2.0.0 - Modular Architecture
*/
package org.timesafari.dailynotification;
package com.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},
org.timesafari.dailynotification.DailyNotificationConstants.PERMISSION_REQUEST_CODE // Centralized constant
com.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 org.timesafari.dailynotification.PermissionStatus getPermissionStatus() {
public com.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 org.timesafari.dailynotification.PermissionStatus(
return new com.timesafari.dailynotification.PermissionStatus(
postNotificationsGranted,
exactAlarmsGranted,
batteryOptimizationsIgnored,
@@ -187,7 +187,7 @@ public class PermissionManager {
try {
Log.d(TAG, "Checking permission status");
org.timesafari.dailynotification.PermissionStatus status = getPermissionStatus();
com.timesafari.dailynotification.PermissionStatus status = getPermissionStatus();
JSObject result = status.toJSObject();
result.put("success", true);

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification
package com.timesafari.dailynotification
/**
* Comprehensive permission status model

View File

@@ -1,6 +1,7 @@
package org.timesafari.dailynotification
package com.timesafari.dailynotification
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@@ -17,9 +18,9 @@ import java.util.concurrent.TimeUnit
* Phase 2: Force stop detection and recovery
*
* Implements:
* - [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)
* - [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)
*
* @author Matthew Raymer
* @version 2.0.0 - Phase 2: Force stop detection
@@ -41,33 +42,13 @@ 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](../../../../../../../doc/alarms/03-plugin-requirements.md#311-boot-event-android-only)
* Implements: [Plugin Requirements §3.1.1 - Boot Event](../docs/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.
@@ -117,26 +98,26 @@ class ReactivationManager(private val context: Context) {
enabledSchedules.forEach { schedule ->
try {
when (schedule.kind) {
"notify", "static_reminder" -> {
"notify" -> {
val nextRunTime = calculateNextRunTimeForSchedule(schedule, currentTime)
if (nextRunTime < currentTime) {
// Past alarm - mark as missed and schedule next occurrence
markMissedNotificationForSchedule(schedule, nextRunTime, db)
missedCount++
// Schedule next occurrence (use rollover interval if set, else 24h)
val nextOccurrence = calculateNextOccurrenceForSchedule(schedule, nextRunTime, currentTime)
// Schedule next occurrence if repeating
val nextOccurrence = calculateNextOccurrence(currentTime)
rescheduleAlarmForBoot(context, schedule, nextOccurrence, db)
rescheduledCount++
Log.i(TAG, "Marked missed notification: ${schedule.id}")
Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextOccurrence")
} else {
// Future alarm - reschedule immediately
rescheduleAlarmForBoot(context, schedule, nextRunTime, db)
rescheduledCount++
Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextRunTime")
}
}
@@ -154,12 +135,6 @@ 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(
@@ -244,25 +219,10 @@ 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,
@@ -286,9 +246,9 @@ class ReactivationManager(private val context: Context) {
db.notificationContentDao().updateNotification(existing)
} else {
// Create new notification content entry for missed alarm
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"2.1.0", // Plugin version
"1.0.2", // Plugin version
null, // timesafariDid
"daily", // notificationType
"Daily Notification",
@@ -316,25 +276,22 @@ 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 = title,
body = body,
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
source = ScheduleSource.BOOT_RECOVERY
)
// Update schedule in database (best effort)
@@ -484,10 +441,16 @@ 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, org.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
// Check if any PendingIntent for our receiver exists
// This is more reliable than nextAlarmClock
// CRITICAL FIX: Use DailyNotificationReceiver with explicit component/package
val receiverComponent = ComponentName(
context.packageName,
"com.timesafari.dailynotification.DailyNotificationReceiver"
)
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
setComponent(receiverComponent)
setPackage(context.packageName)
action = "org.timesafari.daily.NOTIFICATION"
}
val pendingIntent = PendingIntent.getBroadcast(
context,
@@ -627,7 +590,7 @@ class ReactivationManager(private val context: Context) {
// Step 3: Verify and reschedule future alarms
val schedules = try {
db.scheduleDao().getEnabled()
.filter { it.kind == "notify" || it.kind == "static_reminder" }
.filter { it.kind == "notify" }
} catch (e: Exception) {
Log.e(TAG, "Failed to query schedules", e)
emptyList()
@@ -731,7 +694,7 @@ class ReactivationManager(private val context: Context) {
}
when (schedule.kind) {
"notify", "static_reminder" -> {
"notify" -> {
val result = recoverNotifySchedule(schedule, currentTime, db)
missedCount += result.missedCount
rescheduledCount += result.rescheduledCount
@@ -860,13 +823,13 @@ class ReactivationManager(private val context: Context) {
db: DailyNotificationDatabase
) {
try {
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
// Use existing BootReceiver logic for calculating next run time
// For now, use schedule.nextRunAt directly
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = title,
body = body,
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
@@ -1056,9 +1019,9 @@ class ReactivationManager(private val context: Context) {
db.notificationContentDao().updateNotification(existing)
} else {
// Create new notification content entry for missed alarm
val notification = org.timesafari.dailynotification.entities.NotificationContentEntity(
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"2.1.0", // Plugin version
"1.0.2", // Plugin version
null, // timesafariDid
"daily", // notificationType
"Daily Notification",
@@ -1089,25 +1052,22 @@ 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 = title,
body = body,
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(
context,
nextRunTime,
context,
nextRunTime,
config,
scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
source = ScheduleSource.BOOT_RECOVERY
)
// Update schedule in database (best effort)

View File

@@ -11,7 +11,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

View File

@@ -8,7 +8,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.os.Trace;

View File

@@ -24,7 +24,7 @@
* @version 1.0.0
*/
package org.timesafari.dailynotification;
package com.timesafari.dailynotification;
import android.content.Context;
import android.content.SharedPreferences;

View File

@@ -9,10 +9,10 @@
* @since 2025-10-20
*/
package org.timesafari.dailynotification.dao;
package com.timesafari.dailynotification.dao;
import androidx.room.*;
import org.timesafari.dailynotification.entities.NotificationConfigEntity;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.List;

View File

@@ -9,10 +9,10 @@
* @since 2025-10-20
*/
package org.timesafari.dailynotification.dao;
package com.timesafari.dailynotification.dao;
import androidx.room.*;
import org.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import java.util.List;

View File

@@ -9,10 +9,10 @@
* @since 2025-10-20
*/
package org.timesafari.dailynotification.dao;
package com.timesafari.dailynotification.dao;
import androidx.room.*;
import org.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import java.util.List;

View File

@@ -9,7 +9,7 @@
* @since 2025-10-20
*/
package org.timesafari.dailynotification.entities;
package com.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;

View File

@@ -9,7 +9,7 @@
* @since 2025-10-20
*/
package org.timesafari.dailynotification.entities;
package com.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;

View File

@@ -9,7 +9,7 @@
* @since 2025-10-20
*/
package org.timesafari.dailynotification.entities;
package com.timesafari.dailynotification.entities;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;

View File

@@ -9,18 +9,18 @@
* @since 2025-10-20
*/
package org.timesafari.dailynotification.storage;
package com.timesafari.dailynotification.storage;
import android.content.Context;
import android.util.Log;
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 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 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.2.1";
private static final String PLUGIN_VERSION = "1.0.0";
/**
* Constructor

View File

@@ -1,11 +0,0 @@
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"
}

View File

@@ -1,241 +0,0 @@
/**
* DailyNotificationReceiver.java
*
* Broadcast receiver for handling scheduled notification alarms
* Displays notifications when scheduled time is reached
*
* @author Matthew Raymer
* @version 1.0.0
*/
package org.timesafari.dailynotification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Trace;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
/**
* Broadcast receiver for daily notification alarms
*
* This receiver is triggered by AlarmManager when it's time to display
* a notification. It retrieves the notification content from storage
* and displays it to the user.
*/
public class DailyNotificationReceiver extends BroadcastReceiver {
private static final String TAG = "DailyNotificationReceiver";
private static final String CHANNEL_ID = "timesafari.daily";
private static final String EXTRA_NOTIFICATION_ID = "notification_id";
/**
* Handle broadcast intent when alarm triggers
*
* Ultra-lightweight receiver that only parses intent and enqueues work.
* All heavy operations (storage, JSON, scheduling) are moved to WorkManager.
*
* @param context Application context
* @param intent Broadcast intent
*/
@Override
public void onReceive(Context context, Intent intent) {
Trace.beginSection("DN:onReceive");
try {
Log.d(TAG, "DN|RECEIVE_START action=" + intent.getAction());
String action = intent.getAction();
if (action == null) {
Log.w(TAG, "DN|RECEIVE_ERR null_action");
return;
}
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) {
Log.w(TAG, "DN|RECEIVE_ERR missing_id");
return;
}
// Enqueue work immediately - don't block receiver
// Pass the full intent to extract static reminder extras
enqueueNotificationWork(context, notificationId, intent);
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
} else if ("org.timesafari.daily.DISMISS".equals(action)) {
// Handle dismissal - also lightweight
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
if (notificationId != null) {
enqueueDismissalWork(context, notificationId);
Log.d(TAG, "DN|DISMISS_OK enqueued=" + notificationId);
}
} else {
Log.w(TAG, "DN|RECEIVE_ERR unknown_action=" + action);
}
} catch (Exception e) {
Log.e(TAG, "DN|RECEIVE_ERR exception=" + e.getMessage(), e);
} finally {
Trace.endSection();
}
}
/**
* Enqueue notification processing work to WorkManager with deduplication
*
* Uses unique work name based on notification ID to prevent duplicate
* work items from being enqueued for the same notification. WorkManager's
* enqueueUniqueWork automatically prevents duplicates when using the same
* work name.
*
* @param context Application context
* @param notificationId ID of notification to process
* @param intent Intent containing notification data (may include static reminder extras)
*/
private void enqueueNotificationWork(Context context, String notificationId, Intent intent) {
try {
// 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.
// 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");
boolean sound = intent.getBooleanExtra("sound", true);
boolean vibration = intent.getBooleanExtra("vibration", true);
String priority = intent.getStringExtra("priority");
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);
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)
.putBoolean("sound", sound)
.putBoolean("vibration", vibration)
.putString("priority", priority);
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder id=" + notificationId);
}
Data inputData = dataBuilder.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
.setInputData(inputData)
.addTag("daily_notification_display")
.build();
// Use unique work name with KEEP policy (don't replace if exists)
// This prevents duplicate work items from being enqueued even if
// the receiver is triggered multiple times for the same notification
WorkManager.getInstance(context).enqueueUniqueWork(
workName,
ExistingWorkPolicy.KEEP,
workRequest
);
Log.d(TAG, "DN|WORK_ENQUEUE display=" + notificationId + " work_name=" + workName);
} catch (Exception e) {
Log.e(TAG, "DN|WORK_ENQUEUE_ERR display=" + notificationId + " err=" + e.getMessage(), e);
}
}
/**
* Enqueue notification dismissal work to WorkManager with deduplication
*
* Uses unique work name based on notification ID to prevent duplicate
* dismissal work items.
*
* @param context Application context
* @param notificationId ID of notification to dismiss
*/
private void enqueueDismissalWork(Context context, String notificationId) {
try {
// Create unique work name based on notification ID to prevent duplicates
String workName = "dismiss_" + notificationId;
Data inputData = new Data.Builder()
.putString("notification_id", notificationId)
.putString("action", "dismiss")
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
.setInputData(inputData)
.addTag("daily_notification_dismiss")
.build();
// Use unique work name with REPLACE policy (allow new dismissal to replace pending)
WorkManager.getInstance(context).enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
workRequest
);
Log.d(TAG, "DN|WORK_ENQUEUE dismiss=" + notificationId + " work_name=" + workName);
} catch (Exception e) {
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
}
}
// handleNotificationIntent/performJITFreshnessCheck/displayNotification/
// scheduleNextNotification/convertTimeToCron/calculateNextRunTimeFromCron/
// getNotificationPriority were removed here (PLAN-simplify-static-and-refactor.md §3.6):
// onReceive() only ever calls enqueueNotificationWork()/enqueueDismissalWork() above, so
// this entire chain — including scheduleNextNotification's "daily_rollover_<timestamp>"
// orphan-id fallback (generator #4, the other source of stray uncancellable schedules) —
// was confirmed dead (zero callers) and fully superseded by DailyNotificationWorker.java's
// live equivalents.
/**
* Handle notification dismissal
*
* @param context Application context
* @param notificationId ID of dismissed notification
*/
private void handleNotificationDismissal(Context context, String notificationId) {
try {
Log.d(TAG, "Handling notification dismissal: " + notificationId);
// Remove from storage
DailyNotificationStorage storage = new DailyNotificationStorage(context);
storage.removeNotification(notificationId);
// Cancel any pending alarms
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
context,
(android.app.AlarmManager) context.getSystemService(Context.ALARM_SERVICE)
);
scheduler.cancelNotification(notificationId);
Log.i(TAG, "Notification dismissed successfully: " + notificationId);
} catch (Exception e) {
Log.e(TAG, "Error handling notification dismissal", e);
}
}
}

View File

@@ -1,82 +0,0 @@
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() }
)
}
}

View File

@@ -1,83 +0,0 @@
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
}
}
}

View File

@@ -1,76 +0,0 @@
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() }
)
}
}

View File

@@ -1,59 +0,0 @@
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)
}
}
}

View File

@@ -9,7 +9,7 @@
* @since 2025-12-22
*/
package org.timesafari.dailynotification
package com.timesafari.dailynotification
import android.content.Context
import androidx.test.core.app.ApplicationProvider

View File

@@ -12,7 +12,7 @@
* @since 2025-12-22
*/
package org.timesafari.dailynotification
package com.timesafari.dailynotification
import android.content.Context
import androidx.room.Room

View File

@@ -1,95 +0,0 @@
package org.timesafari.dailynotification
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class ClearApiSchedulesTest {
private lateinit var context: Context
private lateinit var database: DailyNotificationDatabase
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
database = TestDBFactory.createInMemoryDatabase(context)
}
@After
fun tearDown() {
database.close()
}
@Test
fun scheduleApiNotification_usesApiIdNamespace() {
val epochMillis = System.currentTimeMillis() + 300_000
val expectedId = "${DailyNotificationConstants.API_SCHEDULE_ID_PREFIX}${epochMillis}"
val scheduled = runBlocking {
ScheduleHelper.scheduleApiNotification(context, database, epochMillis)
}
assertEquals(true, scheduled)
runBlocking {
assertNotNull(database.scheduleDao().getById(expectedId))
}
}
@Test
fun clearApiSchedules_removesOnlyApiManagedAndPreservesDailyReminder() {
val dailyReminderId = DailyNotificationConstants.DEFAULT_SCHEDULE_ID
val apiId = "${DailyNotificationConstants.API_SCHEDULE_ID_PREFIX}1740000000000"
val userScheduleId = "notify_1740000000001"
runBlocking {
database.scheduleDao().upsert(
Schedule(
id = dailyReminderId,
kind = "notify",
clockTime = "08:00",
enabled = true,
nextRunAt = System.currentTimeMillis() + 60_000
)
)
database.scheduleDao().upsert(
Schedule(
id = apiId,
kind = "notify",
enabled = true,
nextRunAt = System.currentTimeMillis() + 120_000
)
)
database.scheduleDao().upsert(
Schedule(
id = userScheduleId,
kind = "notify",
enabled = true,
nextRunAt = System.currentTimeMillis() + 180_000
)
)
}
val cleared = runBlocking {
ScheduleHelper.clearApiSchedules(context, database)
}
assertEquals(1, cleared)
runBlocking {
assertNotNull(database.scheduleDao().getById(dailyReminderId))
assertNull(database.scheduleDao().getById(apiId))
assertNotNull(database.scheduleDao().getById(userScheduleId))
}
}
}

View File

@@ -1,143 +0,0 @@
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)
}
}
}

View File

@@ -1,7 +1,7 @@
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'org.timesafari.dailynotification',
appId: 'com.timesafari.dailynotification',
appName: 'DailyNotification Test App',
webDir: 'www',
server: {

View File

@@ -1,6 +1,6 @@
[
{
"name": "DailyNotification",
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]

View File

@@ -1,173 +0,0 @@
# 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()` ~15041520), `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 767771) with real cron parsing.
- **Reference:** Android's `calculateNextRunTime(schedule: String)` in `DailyNotificationPlugin.kt` (lines 23362378): 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 alarms “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 21952199).
- **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` (~15041520).
- **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 ~350379, scheduleBackgroundFetch/scheduleUserNotification ~731770, calculateNextRunTime ~767771, method list ~21952199), `ios/Plugin/DailyNotificationScheduleHelper.swift` (~98106), `src/definitions.ts` (DualScheduleConfiguration, cancelDualSchedule).
- **Android reference:** `android/.../DailyNotificationPlugin.kt` (scheduleDualNotification ~13691420, calculateNextRunTime ~23362378).
- **Consuming app:** `doc/plugin-feedback-ios-scheduleDualNotification.md`, `src/views/AccountViewView.vue` (~12371245, ~12591300, ~15011548 `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`).

View File

@@ -1,78 +0,0 @@
# 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`).

View File

@@ -1,280 +0,0 @@
# 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

View File

@@ -1,461 +0,0 @@
# Plan: Simplify Static Reminder Path & Remove Dead Notify Generators
**Purpose:** Fix the Android bug where cancelling a static daily reminder
doesn't stop it from firing, by giving static reminders their own isolated
schedule namespace — and remove now-unneeded legacy code along the way.
**Status:** Ready for execution
**Date:** 2026-06-07
**Platform:** Android only (the reported bug is Android-specific; iOS was not
investigated — see "iOS" section below before assuming parity)
---
## 1. The bug this fixes
In the TimeSafari PWA (`crowd-funder-for-time-pwa`), turning off the daily
reminder (`AccountViewView.vue``NativeNotificationService.cancelDailyNotification()`
→ plugin `cancelDailyReminder`) reports success, but the notification **keeps
firing every day**.
`cancelDailyReminder` (`DailyNotificationPlugin.kt:754-779`) only:
1. Cancels the AlarmManager alarm for one specific `scheduleId` (the app's
stable id, `"daily_timesafari_reminder"`), and
2. Sets `enabled = false` for that one row in the `schedules` table.
By contrast, `scheduleDailyNotification` (`DailyNotificationPlugin.kt:1096-1195`)
defensively sweeps the **entire** `schedules` table via
`ScheduleHelper.cleanupExistingNotificationSchedules()` (kills every
`kind == "notify"` row except the one being created) and cancels
prefetch/fetch WorkManager jobs via `cancelFetchRelatedWorkManagerJobs()`
explicitly because, per its own comments, other code paths can spin up a
"second (UUID) alarm" that fires duplicate/stray notifications.
So: if **any** stray `notify`-kind schedule exists with an ID other than
`"daily_timesafari_reminder"`, cancellation completely ignores it, and it
keeps re-arming itself and firing forever — because the rollover/rearm logic
(`DailyNotificationWorker.scheduleNextNotification`,
`DailyNotificationReceiver.scheduleNextNotification`) re-arms the *next*
occurrence unconditionally on every fire, **never checking the `enabled` flag**.
---
## 2. Why the defensive sweeping exists (and why it can be deleted)
The `schedules` table (`DatabaseSchema.kt:41-55`) is shared by *four* different
generators, distinguished only by a free-text `kind` column and by ID-prefix
*conventions* that are not enforced anywhere:
| # | Generator | Schedule id pattern | Where | Used by TimeSafari? |
|---|---|---|---|---|
| 1 | **Static reminder** (the only thing TimeSafari uses) | caller-provided `id`, e.g. `"daily_timesafari_reminder"` | `scheduleDailyNotification`/`scheduleDailyReminder``ScheduleHelper.scheduleDailyNotification` (`DailyNotificationPlugin.kt:2743-2826`) | **Yes — the only path in use** |
| 2 | **Dual/API-retrieval** (planned, not yet used) | `dual_fetch_<ts>` / `dual_notify_<ts>` | `scheduleDualNotification``ScheduleHelper.scheduleDualNotification` (`DailyNotificationPlugin.kt:2890-2936`) + `DualScheduleNotifyScheduler.scheduleChainedNotifyAlarm` (`DualScheduleNotifyScheduler.kt:19`) | No (planned for future) |
| 3 | **Standalone user notification** (legacy, separate public API) | `notify_<ts>` | `scheduleUserNotification` plugin method (`DailyNotificationPlugin.kt:1364`) → `ScheduleHelper.scheduleUserNotification` (`DailyNotificationPlugin.kt:2949-2980`) | **No** — not called anywhere in `crowd-funder-for-time-pwa/src`; only referenced in the plugin's own docs/examples |
| 4 | **Orphan rollover fallback** (accidental, not a real feature) | `daily_rollover_<ts>` (regenerated fresh — never stabilizes) | Inline fallback inside `DailyNotificationReceiver.scheduleNextNotification` (`DailyNotificationReceiver.java:416`) and `DailyNotificationWorker.scheduleNextNotification` (`DailyNotificationWorker.java:614-616`), triggered when the rearm code can't resolve a stable schedule id from intent extras/content id | **No** — pure bug-patch byproduct |
Generators #1 and #2 are real, intentional features (static reminders are live;
dual/API-retrieval is planned). Generators #3 and #4 are **not** part of either
of those — they are independent legacy/accidental code:
- **#3 (`scheduleUserNotification`)** is its own standalone public plugin
method, parallel to (not called by) the dual flow. Confirmed unused by the
app — eliminable as dead surface area in its own right.
- **#4 (`daily_rollover_*` fallback)** is not tied to any feature; it's a
brittle fallback branch inside the *shared* rearm logic that can fire for
*any* notify schedule when ID-threading fails, and it spins off a fresh
unstable orphan id every time it triggers — i.e. it is itself a source of
the exact "stray schedule" problem that breaks cancellation.
**Conclusion:** the cleanup-sweep machinery in `scheduleDailyNotification`
exists to paper over collisions between these four generators sharing one
table/AlarmManager namespace. If the static reminder (the only feature
TimeSafari actually uses today) is moved into its **own isolated `kind`** with
a single, always-known, stable id, none of that sweeping is needed for it —
scheduling and cancelling become trivial single-row operations, and the
"cancel doesn't stop it" bug is structurally impossible (there is nothing else
to collide with).
---
## 3. Refactor plan
### 3.1 Give static reminders their own `kind`
- Change the `kind` value used for static reminders from `"notify"` to a new,
distinct value, e.g. `"static_reminder"`.
- Touch points (search for `kind = "notify"` and `isStaticReminder`):
- `ScheduleHelper.scheduleDailyNotification` (`DailyNotificationPlugin.kt:2784`,
`Schedule(... kind = "notify" ...)`) → change to `"static_reminder"`.
- Any `getByKind("notify")` / `getByKindAndEnabled("notify", ...)` calls that
are meant to operate on static reminders specifically (audit all call
sites at `DailyNotificationPlugin.kt:702, 727-728, 1661, 1722, 2716-2717,
2852, 3079, 3283, 3288` — most of these are for the dual/fetch machinery
and should stay `"notify"`/`"fetch"`; only the ones that exist purely to
find/clean the static reminder's row need to move to `"static_reminder"`,
and after this refactor most of them become unnecessary for the static
path — see 3.2).
- This alone makes `cleanupExistingNotificationSchedules(excludeScheduleId)`
(which filters `getByKind("notify")`) blind to the static reminder row,
which is what we want — it should never need to "clean up" the one
legitimate static reminder.
### 3.2 Simplify scheduling for static reminders
Replace the sweep-and-recreate dance in `scheduleDailyNotification`
(`DailyNotificationPlugin.kt:1130-1190`) with a direct upsert-in-place:
- Remove the call to `ScheduleHelper.cleanupExistingNotificationSchedules()`
(`DailyNotificationPlugin.kt:1140-1150`) for the static-reminder path — it
exists to prevent collisions with *other kinds*; with its own `kind`, the
static reminder can never collide.
- Remove the call to `ScheduleHelper.cancelFetchRelatedWorkManagerJobs()`
(`DailyNotificationPlugin.kt:1155-1158`) for the static-reminder path —
static reminders never enqueue prefetch/fetch work in the first place
(already explicitly skipped, see comment at `DailyNotificationPlugin.kt:2775-2779`),
so there is nothing to cancel.
- Keep `NotifyReceiver.cancelNotification(context, scheduleId)` +
`scheduleExactNotification(...)` (the actual alarm arm/rearm) — that part is
correct and necessary (it ensures "one alarm per id" via stable requestCode).
### 3.3 Simplify cancellation for static reminders
Rewrite `cancelDailyReminder` (`DailyNotificationPlugin.kt:754-779`) to be a
pure single-row operation, since with its own `kind` there is now guaranteed
to be at most one row to deal with:
```kotlin
@PluginMethod
fun cancelDailyReminder(call: PluginCall) {
try {
val reminderId = call.getString("reminderId")
?: call.getString("id")
?: return call.reject("cancelDailyReminder: missing reminderId")
NotifyReceiver.cancelNotification(context, scheduleId = reminderId)
kotlinx.coroutines.runBlocking {
val db = getDatabase()
db.scheduleDao().deleteById(reminderId) // or setEnabled(false) — see note
db.notificationContentDao().deleteById(reminderId) // if such a method exists; else equivalent cleanup
}
call.resolve()
} catch (e: Exception) {
Log.e(TAG, "cancelDailyReminder failed", e)
call.reject("cancelDailyReminder failed: ${e.message}")
}
}
```
Note: decide `deleteById` vs `setEnabled(id, false)`:
- `deleteById` is cleaner (no stale rows ever accumulate) but means
`BootReceiver`'s `getEnabled()` sweep simply won't find the row — which is
correct (nothing to reschedule).
- `setEnabled(id, false)` keeps history/diagnostics but requires every rearm
path to check `enabled` before re-arming (see 3.4) — more moving parts.
- **Recommendation: `deleteById`.** It's the simplest correct option and
matches "cancel means gone," not "cancel means disabled-but-present."
### 3.4 Make the rearm chain check for cancellation
Even with `deleteById`, there is a narrow race: the alarm could already be
in-flight (fired, worker running) at the moment the user cancels. Today,
`scheduleNextNotification` in both `DailyNotificationWorker.java:547-667` and
`DailyNotificationReceiver.java:401-...` (the latter looks unreachable — see
3.6) **unconditionally** re-arms the next occurrence with no DB check.
Add a guard at the top of `DailyNotificationWorker.scheduleNextNotification`:
look up the schedule row by id (`ScheduleHelper.getScheduleBlocking`); if it's
missing (deleted) or `enabled == false`, log and return without re-arming.
This closes the cancel race definitively and is a small, isolated change.
### 3.5 Remove generator #3 — `scheduleUserNotification`
**Confirmed unused in any executable path** — not called anywhere in
`crowd-funder-for-time-pwa/src`, and the only "usages" in the plugin repo
itself are inside doc/example files
(`test-apps/daily-notification-test/docs/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md`,
`examples/timesafari-integration-example.ts`) — not actual test-app source or
test suites.
**Important nuance found on closer look:** those two doc/example usages both
call it as a one-shot, event-triggered notification —
```typescript
await DailyNotification.scheduleUserNotification({
schedule: 'immediate',
title: change.title,
body: change.message,
...
})
```
— i.e. "something happened in the app right now, show a notification." That's
a *third* semantic, distinct from both #1 (recurring static daily reminder)
and #2 (recurring fetch-then-notify cycle). **But it's also non-functional**:
the real implementation has no special case for `schedule: 'immediate'`.
`parseUserNotificationConfig` (`DailyNotificationPlugin.kt:2475-2484`) passes
the `schedule` string straight through, and
`ScheduleHelper.scheduleUserNotification` runs it through
`calculateNextRunTime``ScheduleCronUtils.calculateNextRunTimeMillis(schedule)`
(`DailyNotificationPlugin.kt:2487-2489`) — a **cron parser** that has no
concept of `"immediate"`. So even the one documented usage pattern would fail
or misbehave if anyone actually tried it. The docs describe a capability that
was never built; `scheduleUserNotification` itself is just a thin "schedule
one bare cron-based notification alarm" primitive that nothing exercises.
**Recommendation: remove it, and do not resurrect the same capability under
the same API by patching in `'immediate'` support.** If "notify the user
immediately when the app detects an event" is still wanted later, design it
as its own clearly-named one-shot API (e.g. `notifyNow(options)` /
`showImmediateNotification(options)`) with **no `schedule`/cron field at
all** — just `title`/`body`/optional `triggerAtMillis` (default "now"),
routed through a "display once, never rearm" path. That keeps the three
concerns (recurring static reminder / recurring fetch+notify cycle / one-shot
event notification) distinguished by API *shape*, not by a magic string a
cron parser silently chokes on.
**[DONE 2026-06-19]** Removed from Android only:
- `@PluginMethod fun scheduleUserNotification` (was `DailyNotificationPlugin.kt:1363-1411`)
- `ScheduleHelper.scheduleUserNotification` (was `DailyNotificationPlugin.kt:2899-2935`)
- No separate `methods.append(...)` registration existed — `@PluginMethod` annotation
is the only registration mechanism in this codebase, so nothing else to clean up there.
- Verified via `./gradlew :daily-notification-plugin:compileDebugKotlin` — compiles clean,
no other call sites referenced either removed symbol.
**Scope correction — do NOT remove the TS/iOS layer.** `scheduleUserNotification` in
`src/definitions.ts:543` / `src/web.ts:231` is a **cross-platform** method signature,
and on iOS it is genuinely load-bearing: `DailyNotificationScheduleHelper.scheduleDualNotification`
(`ios/Plugin/DailyNotificationScheduleHelper.swift:98-107`) takes a
`scheduleUserNotification` closure that's bound to the real
`@objc func scheduleUserNotification` (`ios/Plugin/DailyNotificationPlugin.swift:379`) —
i.e. on iOS this *is* the dual flow's notification half, not a dead generator.
Android's version was a standalone, never-wired-up duplicate of that concept
(Android's actual dual flow uses `scheduleDualNotification`/`DualScheduleNotifyScheduler`
instead and never called `ScheduleHelper.scheduleUserNotification`) — so removing it
from Android only does not reduce iOS capability. Leave:
- `src/definitions.ts`/`src/web.ts` — keep the TS signature
- README section `#### scheduleUserNotification(config)` (lines ~295-306) — already
documents the correct cron-based shape (`schedule: string; // Cron expression`),
no `'immediate'` mentioned, nothing broken here
- iOS Swift implementation — untouched, see
[`PLAN-ios-static-reminder-fix.md`](./PLAN-ios-static-reminder-fix.md) §7 for why
Still worth doing (low-priority, independent of this fix, not yet done): strip or
correct the broken `schedule: 'immediate'` example in
`test-apps/daily-notification-test/docs/VUE3_NOTIFICATION_IMPLEMENTATION_GUIDE.md:226-240`
and `examples/timesafari-integration-example.ts:101-109` — that pattern was never
implemented on either platform's actual cron parser, regardless of the Android
generator-#3 cleanup above.
This eliminates the Android-only `notify_<ts>` id convention entirely — one less
thing that can collide in the shared `schedules` table — without touching the
cross-platform API surface or iOS's dual-flow capability.
### 3.6 Remove generator #4 — `daily_rollover_*` fallback (and dead code around it)
- In `DailyNotificationWorker.scheduleNextNotification`
(`DailyNotificationWorker.java:556-563, 613-616`): once static reminders
always carry a known, stable `schedule_id` intent extra (true today —
`NotifyReceiver.scheduleExactNotification` always sets it,
`NotifyReceiver.kt:297`), the `scheduleIdForRollover == null` branch should
be unreachable for the static-reminder path. After confirming (via logging
or a temporary assertion in a debug build) that it never fires for static
reminders, delete the `"daily_rollover_" + System.currentTimeMillis()`
fallback branches and the `getCanonicalRolloverScheduleBlocking` resolution
hack (`DailyNotificationWorker.java:564-571`,
`ScheduleHelper.getCanonicalRolloverScheduleBlocking` at
`DailyNotificationPlugin.kt:2844-2853`) that exists solely to paper over it.
- `DailyNotificationReceiver.handleNotificationIntent` /
`scheduleNextNotification` (`DailyNotificationReceiver.java:211-...,
401-461`) appear to be **dead code**`onReceive` only calls
`enqueueNotificationWork`/`enqueueDismissalWork` (`DailyNotificationReceiver.java:62-84`),
never `handleNotificationIntent`. Confirm with a repo-wide search for
callers, then delete the whole block (it duplicates, slightly differently,
what `DailyNotificationWorker` does — a second source of the same
`daily_rollover_*` bug pattern).
### 3.7 Clean up DB after refactor
- Add a one-time migration (or lazy cleanup on plugin init) that deletes any
pre-existing `notify_<ts>` and `daily_rollover_<ts>` rows from `schedules`
(and their `notification_content` counterparts) — these are exactly the
kind of orphan that causes the bug, and existing installs may already have
them sitting in the DB from earlier buggy runs.
- Re-point the existing `"daily_notification"` / app-supplied static reminder
row to `kind = "static_reminder"` in that same migration (`UPDATE schedules
SET kind = 'static_reminder' WHERE kind = 'notify' AND id = :appReminderId`,
or simpler: `WHERE id NOT LIKE 'dual_%' AND id NOT LIKE 'notify_%' AND id NOT
LIKE 'daily_rollover_%'` — i.e. whatever's left after excluding known
dual/legacy/orphan patterns is the static reminder).
---
## 4. What NOT to touch (dual/API-retrieval flow — planned for future use)
Per the user: the dual/API-retrieval flow ("device wakes by timer or push →
calls API → stores content → reschedules next check → OS later wakes app to
show stored content") is **not used today but is planned**. Leave generator #2
and its supporting machinery alone:
- `scheduleDualNotification`, `ScheduleHelper.scheduleDualNotification`
(`DailyNotificationPlugin.kt:2890-2936`)
- `DualScheduleNotifyScheduler` (`DualScheduleNotifyScheduler.kt`)
- `FetchWorker`, `DualScheduleFetchRecovery`, content-cache (`cacheScope =
"dual"`), circuit breakers, etc.
These keep using `kind = "notify"`/`"fetch"` and the existing sweep machinery
— which is fine, since that machinery's whole job is to keep *that* subsystem
internally consistent. The point of 3.1 is just to make sure the static
reminder's single row is no longer inside that sweep's blast radius (and vice
versa).
---
## 5. iOS
**Investigated — see [`PLAN-ios-static-reminder-fix.md`](./PLAN-ios-static-reminder-fix.md)
for the full analysis and fix plan. Summary:**
iOS does **not** have an analogous shared-table-with-multiple-generators
design (no Room/SQLite `schedules` table, no AlarmManager, no rearm chain —
it uses OS-native `UNUserNotificationCenter` with `UNCalendarNotificationTrigger`,
which natively supports "repeat forever" and needs none of that machinery).
But it has its own, structurally different bug with the same user-visible
symptom: the client's `scheduleDailyNotification`/`cancelDailyReminder` calls
land in **two unrelated, non-communicating subsystems** inside
`DailyNotificationPlugin.swift` —
- `scheduleDailyNotification` (:1392) registers under a fresh
`content.id = "daily_<timestamp>"` each call, with `repeats: false`
(likely **one-shot, not actually recurring** — see the linked plan's §4)
- `cancelDailyReminder` (:1174) only ever removes `"reminder_\(id)"`-shaped
identifiers — which the schedule path above never registers, so cancel is a
**silent no-op** that still reports success
Meanwhile a *third*, already-correct subsystem
(`scheduleDailyReminder`/`cancelDailyReminder`/`updateDailyReminder`, :1088-1300)
sits unused right next to it — a single `repeats: true` calendar trigger under
a stable caller-supplied id, with matching cancel. The fix is to repoint
`scheduleDailyNotification` onto that existing correct mechanism rather than
to build Android-style sweep/rearm machinery iOS doesn't need.
This is a fully independent fix (different files, different language, no
shared code) — see the linked plan's §5 for the side-by-side comparison.
Execute in either order, or in parallel.
---
## 6. Verification plan
1. **Unit/integration**: existing plugin test suite (`npm test`) plus new
tests asserting:
- Scheduling a static reminder creates exactly one `schedules` row with
`kind = "static_reminder"`.
- Cancelling deletes that row and cancels its `PendingIntent`
(`isAlarmScheduled` returns false afterward).
- No `notify_<ts>`/`dual_*`/`daily_rollover_*` rows are created or touched
by the static-reminder schedule/cancel/rearm cycle.
- **Note:** `grep`ing `android/src/test/java/.../*.kt` today shows **zero**
existing coverage of `cancelDailyReminder`, `scheduleNextNotification`, or
`cleanupExistingNotificationSchedules` — this bug had a test-coverage
blind spot. Add a regression test asserting "after `cancelDailyReminder`,
`scheduleDao().getByKind('notify')` (or `'static_reminder'` post-3.1)
returns zero rows" so this can't silently regress.
2. **Manual on-device (Android emulator) — reproduce-then-verify sequence**:
This environment already has `adb`, `emulator`, `gradle`, and two AVDs
(`Pixel_3a_API_34_extension_level_7_arm64-v8a`, `Pixel_7`) plus a mature
ADB-based toolkit in `test-apps/android-test-app/alarm-test-lib.sh` and
`doc/alarms/PHASE1-EMULATOR-TESTING.md` using exactly the techniques below.
`APP_ID = org.timesafari.dailynotification`.
**Setup:**
```bash
emulator -avd Pixel_7 -no-snapshot-load &
adb wait-for-device
# install a debug build of the test app or the TimeSafari PWA Android shell
```
**Step A — schedule, then capture a baseline of native scheduler + DB state**
(don't trust "the call resolved" — inspect the actual OS/DB state, since
that's exactly where this bug hides):
```bash
# AlarmManager state:
adb shell dumpsys alarm | grep -A 5 -i "org.timesafari.dailynotification"
# DB state — the crucial one; reveals orphans dumpsys alone won't make obvious:
adb shell run-as org.timesafari.dailynotification sqlite3 \
databases/daily_notification_plugin.db \
"SELECT id, kind, enabled, nextRunAt FROM schedules;"
```
Record the count and exact `id`/`kind` values — this is the "before" baseline.
Pre-fix, expect to possibly see stray `daily_rollover_*`/`dual_notify_*`/
`notify_<ts>` rows even before cancelling, which alone partially explains
the bug.
**Step B — cancel via the app UI, then immediately re-run the same two
commands.** This is the actual test — not "did the app report success"
(it always does), but: does `dumpsys alarm` still show an entry, and does
`schedules` still contain *any* `kind='notify'`/`'static_reminder'` row
(enabled or not)? Pre-fix expectation per §1: the targeted row gets
`enabled=0` but isn't deleted, and/or a stray row survives untouched.
**Step C — prove it actually still fires** (closes the loop — a surviving
row could theoretically be inert):
```bash
# Time-travel the emulator clock forward (technique already documented at
# doc/alarms/PHASE1-EMULATOR-TESTING.md:412-418):
adb shell date -s "$(date -v+1d '+%Y-%m-%d %H:%M:%S')"
adb logcat -c
adb logcat -s DailyNotification:* NotifyReceiver:* DailyNotificationWorker:* \
| grep -i "notification displayed\|scheduleNextNotification\|rearm"
# Optional visual proof — capture and pull a screenshot:
adb shell screencap -p /sdcard/after-cancel.png && adb pull /sdcard/after-cancel.png
# Reset the clock when done:
adb shell date -s "$(date '+%Y-%m-%d %H:%M:%S')"
```
Seeing "notification displayed successfully" / rearm log lines *after*
cancelling is the smoking gun.
**Step D — re-run the full A→B→C sequence after applying the fix.**
Expected post-fix result: zero `dumpsys alarm` entries and zero `schedules`
rows immediately after cancel, and no log lines / no notification after the
time-jump. This A→B→C→(fix)→A→B→C before/after comparison *is* the proof
the refactor worked — anything less ("the call resolved without error") is
exactly the false confidence that let this bug ship originally.
Also still worth doing once the structural fix is confirmed:
- Let it fire and rearm at least once more (or use
`rolloverIntervalMinutes` for fast iteration); confirm the row's id stays
stable across rearms
- Reboot the device after cancelling; confirm `BootReceiver` does not
resurrect it
3. **Regression**: confirm the dual/API-retrieval test paths (if any exist in
`test-apps/`) still pass unchanged — this refactor must not touch generator
#2's behavior.
4. **Fast deterministic checks to run alongside the above** (don't touch the
native bug directly, but catch regressions in surrounding plumbing and are
cheap to run on every change):
- `npm test` — Jest suite for the TS/JS plugin layer (web stub, type defs)
- `cd android && ./gradlew test` — Kotlin unit tests (currently has the
coverage gap noted in §6.1 above)
---
## 7. Suggested execution order
1. 3.5 (remove `scheduleUserNotification`) — fully independent, smallest blast
radius, immediate dead-code win.
2. 3.1 + 3.2 + 3.3 (new `kind`, simplified schedule/cancel) — the core fix.
3. 3.7 (DB migration/cleanup for existing installs).
4. 3.4 (`enabled` guard in rearm — closes the race).
5. 3.6 (remove `daily_rollover_*` fallback + dead `handleNotificationIntent`
code) — do this last, after 3.1-3.4 make it provably unreachable for the
static path; double check it isn't somehow load-bearing for the dual flow
first (it shouldn't be — dual ids never start with `"daily_"` and dual
rearming goes through `DualScheduleNotifyScheduler`, not this fallback —
but confirm before deleting).

View File

@@ -1,108 +0,0 @@
# Action Plan: Plugin + Consuming App Integration Fixes
**Source:** Comparison output from Cursor session (daily-notification-plugin ↔ Time Safari / crowd-funder-for-time-pwa).
**Bugs addressed:** (A) Re-setting a notification doesn't fire; (B) Notification text always defaults to fallback values.
---
## Objective
Implement plugin-side and app-side changes so that:
1. **Reset works:** Editing/re-saving a daily reminder (even with the same time) reliably re-schedules and the alarm fires.
2. **Text persists:** Custom title/body persist across the first fire and rollover (next day); no silent fallback to generic text.
3. **Cancel works on Android:** App can call `cancelDailyReminder({ reminderId })` and the plugin performs per-id cancellation (parity with iOS).
---
## Plugin-Side Implementation (this repo)
### 1. Bug A: Skip DB idempotence when caller requests reset
**File:** `android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt`
**Problem:** `scheduleExactNotification()` already skips *PendingIntent* idempotence when `skipPendingIntentIdempotence=true`, but the **DB-level idempotence check** (lines ~206226) still runs. On "re-set same time," the DB still has the same `nextRunAt`, so the check returns early and **no alarm is scheduled**.
**Change:** Wrap the entire DB idempotence block so it runs only when `!skipPendingIntentIdempotence`. When `skipPendingIntentIdempotence=true`, log and skip the DB check.
- **Locate:** The block starting with `// DB-LEVEL IDEMPOTENCE CHECK` that loads `existingSchedule` and compares `existingSchedule.nextRunAt` with `triggerAtMillis` (60s tolerance), and `return@runBlocking` on duplicate.
- **Wrap:** Put that block inside `if (!skipPendingIntentIdempotence) { ... }` and add an `else` that logs:
`"Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId"`.
**Verification:** After editing a reminder without changing time, logs should show both "Skipping PendingIntent idempotence..." and "Skipping DB idempotence (skipPendingIntentIdempotence=true)...", and the alarm should fire.
---
### 2. Bug B: Preserve static reminder on rollover
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
**Problem:** In `scheduleNextNotification()`, the call to `NotifyReceiver.scheduleExactNotification()` uses **hardcoded** `false` for `isStaticReminder` and `null` for `reminderId`. So the *next* occurrence is treated as non-static and content is loaded from storage/default → fallback text.
**Change:**
1. At the start of `scheduleNextNotification()`, read from WorkManager input:
`boolean preserveStaticReminder = getInputData().getBoolean("is_static_reminder", false);`
2. When choosing `scheduleId`: if `preserveStaticReminder && notificationId != null && !notificationId.isEmpty()`, set `scheduleId = notificationId`. Otherwise keep existing logic (`daily_*` → use as scheduleId, else `daily_rollover_` + timestamp).
3. Replace the existing `scheduleExactNotification(...)` call with:
- `isStaticReminder` = `preserveStaticReminder`
- `reminderId` = `preserveStaticReminder ? scheduleId : null`
- `scheduleId` = the chosen `scheduleId` (stable for static reminders).
4. (Optional but useful) Add log before scheduling:
`Log.d("DN|ROLLOVER", "next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);`
**Verification:** Set a custom title/body, let it fire once, then confirm the next scheduled run still uses the same text; logs should show `DN|ROLLOVER ... scheduleId=daily_timesafari_reminder static=true`.
---
### 3. Integration: Add Android `cancelDailyReminder`
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
**Problem:** The app calls `DailyNotification.cancelDailyReminder({ reminderId })`. iOS implements this; Android only has `cancelAllNotifications()` and `scheduleDailyReminder()` alias. On Android the call fails (method missing / not implemented), so "turn off" and "reset" flows cannot rely on explicit cancel.
**Change:** Add a new `@PluginMethod fun cancelDailyReminder(call: PluginCall)` (e.g. immediately after `scheduleDailyReminder()`).
- **Parse ID:** `reminderId = call.getString("reminderId") ?: call.getString("id") ?: call.getString("reminder_id") ?: call.getString("scheduleId")`. Reject if null/blank.
- **Cancel alarm:** `NotifyReceiver.cancelNotification(context, scheduleId = reminderId)`.
- **DB cleanup (best-effort):** In a try/catch, `runBlocking`:
- `db = getDatabase()` (or `DailyNotificationDatabase.getDatabase(context)` as used elsewhere in plugin).
- `db.scheduleDao().setEnabled(reminderId, false)` and `db.scheduleDao().updateRunTimes(reminderId, null, null)`.
- ScheduleDao already has `setEnabled` and `updateRunTimes` (see `DatabaseSchema.kt`).
- On success: `call.resolve()`. On exception: log and `call.reject("cancelDailyReminder failed: ...")`.
**Verification:** From the app, call `cancelDailyReminder({ reminderId: "daily_notification" })` (or your apps id); it should resolve and the alarm for that id should be gone.
---
## Verification Checklist (plugin)
After implementing the three items above:
1. **Reset test:** Schedule reminder 23 minutes from now → Edit and re-save **without changing time** → Confirm it still fires. Logs: "Skipping DB idempotence (skipPendingIntentIdempotence=true)...".
2. **Rollover test:** Set custom title/body → Let it fire once → Confirm next scheduled notification keeps the same title/body. Logs: `DN|ROLLOVER ... static=true scheduleId=daily_timesafari_reminder`.
3. **Cancel test:** Call `cancelDailyReminder({ reminderId })` from app or test harness; no error and alarm cleared.
---
## Consuming App Work
App-side changes are described in a separate document intended for the **crowd-funder-for-time-pwa** (Time Safari) repo: **CONSUMING_APP_CURSOR_BRIEF.md**. That document is written so you can paste it into Cursor in the app repo to implement:
- Gate cancel in `editReminderNotification()` so Android skips pre-cancel (schedule path already cancels internally).
- Replace `TimeSafariNativeFetcher` placeholder with real content fetch and token persistence if using native fetcher for daily content.
---
## References
- NotifyReceiver: DB idempotence at ~206226; skipPendingIntentIdempotence at ~159204.
- DailyNotificationWorker: `scheduleNextNotification()` ~512594; pass `preserveStaticReminder` and stable `scheduleId` into `scheduleExactNotification`.
- DailyNotificationPlugin: add `cancelDailyReminder` after `scheduleDailyReminder`; use `NotifyReceiver.cancelNotification` and ScheduleDao `setEnabled` / `updateRunTimes`.
- DatabaseSchema.kt: ScheduleDao `getById`, `upsert`, `setEnabled`, `updateRunTimes`.
---
## Assumptions & Limits
- App uses a stable reminder id (e.g. `daily_timesafari_reminder`); plugin preserves that id for static reminders on rollover.
- DAO method names are as in DatabaseSchema.kt; if the plugins Schedule entity uses different field names, adjust the `updateRunTimes` call accordingly (signature is `id, lastRunAt, nextRunAt`).
- Native fetcher and token persistence are app responsibilities; the plugin only needs to preserve static reminder semantics and provide cancel-by-id.

View File

@@ -1,129 +0,0 @@
# Consuming App Migration: org.timesafari → org.timesafari
Use this document in your **consuming app** repo (e.g. with Cursor or as a checklist) to migrate from `org.timesafari.dailynotification` to `org.timesafari.dailynotification` after the daily-notification-plugin has been updated.
## Summary of plugin changes
The plugins package/namespace and public identifiers were renamed:
- **Package/namespace**: `org.timesafari.dailynotification``org.timesafari.dailynotification`
- **Intent action (Android)**: `org.timesafari.daily.NOTIFICATION``org.timesafari.daily.NOTIFICATION`
- **Dismiss action (Android)**: `org.timesafari.daily.DISMISS``org.timesafari.daily.DISMISS`
- **iOS BGTask identifiers**: `org.timesafari.dailynotification.fetch` / `.notify` / `.prefetch``org.timesafari.dailynotification.*`
- **Capacitor plugin class**: `org.timesafari.dailynotification.DailyNotificationPlugin``org.timesafari.dailynotification.DailyNotificationPlugin`
Your app must align with these so the plugin loads and receivers/intents work.
---
## 1. Update plugin dependency
- Ensure the consuming app depends on a **version of the plugin that already uses `org.timesafari`** (e.g. after the plugin repos com→org migration is merged/released).
- Reinstall/sync: `npm install` (or equivalent) so the updated plugin is used.
---
## 2. Capacitor plugin registration (Android & iOS)
The plugin is registered by **class name**. Update every place that references the plugin class.
**Typical locations:**
- `android/app/src/main/assets/capacitor.plugins.json` (or `public/plugins` if you use that)
- Any script or config that writes the plugin class string
**Change:**
- From: `"class": "org.timesafari.dailynotification.DailyNotificationPlugin"` or `"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"`
- To: `"class": "org.timesafari.dailynotification.DailyNotificationPlugin"` or `"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"`
If you use a script (e.g. `fix-capacitor-plugins.js`) that injects this class name, update the script to use `org.timesafari.dailynotification.DailyNotificationPlugin`.
---
## 3. Android: `AndroidManifest.xml`
**Receiver class names** (must match the plugins new package):
- `org.timesafari.dailynotification.DailyNotificationReceiver``org.timesafari.dailynotification.DailyNotificationReceiver`
- `org.timesafari.dailynotification.NotifyReceiver``org.timesafari.dailynotification.NotifyReceiver`
- `org.timesafari.dailynotification.BootReceiver``org.timesafari.dailynotification.BootReceiver`
- Any other `org.timesafari.dailynotification.*` receiver → `org.timesafari.dailynotification.*`
**Intent action** (must match plugins `DailyNotificationConstants.ACTION_NOTIFICATION`):
- `<action android:name="org.timesafari.daily.NOTIFICATION" />``<action android:name="org.timesafari.daily.NOTIFICATION" />`
Search the manifest for `org.timesafari` and replace with `org.timesafari` for these plugin-related entries.
---
## 4. Android: ProGuard / R8 (if you keep plugin classes)
If you have custom keep rules that reference the plugin package:
- `org.timesafari.dailynotification``org.timesafari.dailynotification`
(e.g. `-keep class org.timesafari.dailynotification.**``-keep class org.timesafari.dailynotification.**`).
---
## 5. iOS: `Info.plist` (BGTask identifiers)
The plugin uses these background task identifiers. Your apps `Info.plist` must list the **same** identifiers.
**In `BGTaskSchedulerPermittedIdentifiers` (or equivalent):**
- `org.timesafari.dailynotification.fetch``org.timesafari.dailynotification.fetch`
- `org.timesafari.dailynotification.notify``org.timesafari.dailynotification.notify`
If you use the prefetch identifier:
- `org.timesafari.dailynotification.prefetch``org.timesafari.dailynotification.prefetch`
Update any other `org.timesafari.dailynotification.*` task IDs to `org.timesafari.dailynotification.*`.
---
## 6. App ID / Bundle ID (optional; breaking for installs)
- **Plugin package/namespace** change does **not** require you to change your apps **applicationId** (Android) or **Bundle ID** (iOS).
- If you **do** change your apps id from `org.timesafari.*` to `org.timesafari.*`, the store and OS will treat it as a **new app** (new install, no in-place update). Only change this if you intend that.
---
## 7. Custom intent actions / deep links
If your app or backend uses custom actions that included the old prefix (e.g. `org.timesafari.dailynotification.REFRESH_DATA` or `OPEN_SETTINGS`), update them to `org.timesafari.dailynotification.*` so they still match what the plugin or your code expects.
---
## 8. Scripts and docs in the consuming app
- Any script that uses the **plugin package** or **intent action** (e.g. `adb shell am start`, `dumpsys package`, or broadcast actions) should use `org.timesafari.dailynotification` and `org.timesafari.daily.NOTIFICATION` where applicable.
- Update internal docs or runbooks that reference the old package or action strings.
---
## 9. Verification
- **Android**: Build the app, install, and confirm notifications still schedule and fire. Check `adb shell dumpsys package <your.package>` for receivers and that alarms use `org.timesafari.daily.NOTIFICATION` if you inspect with `dumpsys alarm`.
- **iOS**: Build and run; confirm BGTask registration and notification behavior. Ensure `Info.plist` identifiers match the plugins Swift constants.
- **Capacitor**: Confirm the plugin is loaded (e.g. no “class not found” or missing plugin in the bridge).
---
## Quick find-and-replace (consuming app only)
Use with care; prefer updating specific files as above. Suggested patterns:
- **Plugin class**: `org.timesafari.dailynotification.DailyNotificationPlugin``org.timesafari.dailynotification.DailyNotificationPlugin`
- **Receiver/package references**: `org.timesafari.dailynotification.``org.timesafari.dailynotification.`
- **Notification intent action**: `org.timesafari.daily.NOTIFICATION``org.timesafari.daily.NOTIFICATION`
- **BGTask identifiers**: `org.timesafari.dailynotification.fetch` / `.notify` / `.prefetch``org.timesafari.dailynotification.fetch` / `.notify` / `.prefetch`
Do **not** blindly replace `org.timesafari` in your **apps own** applicationId/Bundle ID unless you intend to ship as a new app.
---
**Reference:** This migration is aligned with the changes in the `daily-notification-plugin` repo (package rename com → org). For plugin-side details, see that repos history and docs.

View File

@@ -1,113 +0,0 @@
# Android dual schedule: native fetch, WorkManager timing, and scoped content cache
**Status:** Implemented (Android) — see CHANGELOG [2.1.5]
**Date:** 2026-03-25
**Scope:** `daily-notification-plugin` Android (Kotlin/Java), dual / “New Activity” schedule (`scheduleDualNotification`)
---
## 1. Problem summary
On Android, the dual schedule path today has three related gaps:
1. **Prefetch does not use the native fetcher.** `FetchWorker` treats a missing `contentFetch.url` as mock JSON and never calls `NativeNotificationContentFetcher`, so host apps that omit `url` on purpose never see `TimeSafariNativeFetcher`-style behavior.
2. **Prefetch runs at setup, not at `contentFetch.schedule`.** Dual scheduling calls `FetchWorker.enqueueFetch` with **no** initial delay, while the notify alarm is set for the next `userNotification.schedule` occurrence. The DB may record `dual_fetch_*` with `nextRunAt` for the fetch cron, but work is not aligned to that instant.
3. **Cache is global.** `DualScheduleHelper` uses `contentCacheDao().getLatest()`, so any other feature (e.g. daily reminder prefetch writing the same table) can **overwrite** the row dual notify expects, or dual can overwrite data other features rely on.
---
## 2. Decisions (approved direction)
| Area | Decision |
|------|----------|
| **Fetch timing** | **Option B:** Use **WorkManager** with **`setInitialDelay`** to the next `calculateNextRunTime(contentFetch.schedule)` (not an additional exact alarm for fetch in the first iteration). Keep **unique work name** `FetchWorker.WORK_NAME_DUAL` (`fetch_dual`) so `cancelDualSchedule` and daily reminder prefetch (`fetch_content`) stay isolated. |
| **Exact alarms** | Accept **best-effort** timing under Doze/OEM; document that prefetch may run late. Revisit with a dedicated alarm only if field data requires stricter wall-clock behavior. |
| **Native fetch** | When `url` is null/blank and `DailyNotificationPlugin.getNativeFetcherStatic()` is non-null, invoke **`NativeNotificationContentFetcher.fetchContent(FetchContext)`** with semantics aligned to `DailyNotificationFetchWorker` (timeout, metadata). Persist results into the **dual-scoped** cache (below). If `url` is set, preserve **HTTP GET** behavior for that path. |
| **Cache sharing** | Add an explicit **`cacheScope`** (string) column on the content cache entity. Use well-known values (e.g. **`dual`**, **`daily`**, **`legacy`**). **Dual** reads/writes only **`dual`**; daily reminder writes **`daily`**; migration defaults old rows to **`legacy`** with documented read behavior. |
---
## 3. Implementation outline
### 3.1 Dual prefetch scheduling (WorkManager + delay)
- Compute `nextFetchAt = calculateNextRunTime(contentFetch.schedule)` using the same cron resolution as today for notify.
- Compute `delayMs = max(0, nextFetchAt - now)`.
- Build `OneTimeWorkRequest` for `FetchWorker` (or shared worker) with **`setInitialDelay(delayMs, MILLISECONDS)`**, existing constraints/backoff as appropriate, and **`InputData`** that identifies this run as **dual** (and passes `url`, timeouts, etc.).
- **Reschedule:** After a successful run (or as part of the dual rollover story), enqueue the **next** dual prefetch for the following occurrence of `contentFetch.schedule` (parallel to notify rollover). **Boot recovery** must recompute delay from persisted dual config and re-enqueue `fetch_dual` if needed.
### 3.2 `FetchWorker` / native path
- When handling **dual** work with **no** `url`, call the **native fetcher** if registered; otherwise fall back policy should be explicit (mock only for dev, or failure — product choice).
- Serialize native results to the same **payload** shape `DualScheduleHelper` already expects (JSON with `title` / `body` or `content`).
- **Write** using **`cacheScope = dual`** (see §3.3).
### 3.3 Room: `cacheScope` column
- Add **`cacheScope: String`** to the `ContentCache` entity (name may match existing naming in the codebase).
- Room migration: new column **NOT NULL** with default **`legacy`** for existing rows.
- DAO:
- **`getLatestByScope(scope: String)`** (or equivalent) for dual and daily.
- Deprecate or narrow use of unscoped **`getLatest()`**; document which call sites must pass scope.
### 3.4 `DualScheduleHelper`
- Replace **`getLatest()`** with **`getLatestByScope("dual")`** (constant for the dual scope string).
- Keep **`contentTimeout` / `fallbackBehavior`** logic unchanged; only the **source row** changes.
### 3.5 Daily reminder and other writers
- Any code path that populates `ContentCache` for the **daily** reminder should set **`cacheScope = daily`**.
- Verify **all** `upsert` paths; avoid writing **`dual`** except from dual prefetch.
### 3.6 Cancellation and isolation
- **`cancelDualSchedule`:** Continue canceling **`fetch_dual`** only; do not cancel **`fetch_content`**.
- No change to notify alarm IDs (`dual_notify_*` vs daily `notify_*` / app-specific ids) beyond what already exists.
### 3.7 Tests
- Delay math: next fetch in the future; past instant triggers immediate or “run now” branch per policy.
- Native path invoked when `url` absent and fetcher registered; HTTP path unchanged when `url` set.
- **Scope isolation:** Write **`daily`**, then **`dual`**, assert **`getLatestByScope("dual")`** is not the daily payload and **`DualScheduleHelper`** resolves dual copy correctly.
- Boot / reschedule path re-enqueues **`fetch_dual`** with correct delay when dual config exists.
### 3.8 Documentation
- **CHANGELOG.md** and **README** (Android section): dual prefetch uses delayed WorkManager; content cache is **scoped**; integrators must not assume a single global cache row for all features.
---
## 4. Risks and mitigations
| Risk | Mitigation |
|------|------------|
| WorkManager runs **after** notify time | Document; consider retry or follow-up work item for stricter scheduling if needed. |
| Missed migration for a writer | Grep for `ContentCache` / `upsert` / `getLatest` and audit. |
| Old **`legacy`** rows | Dual helper reads **`dual`** only; legacy readers, if any, keep using **`legacy`** or explicit migration to **`daily`**. |
---
## 5. Out of scope (this iteration)
- iOS parity for dual native fetch / timing (track separately if needed).
- Replacing WorkManager with exact alarms for fetch (optional future enhancement).
---
## 6. References
- Prior analysis: consuming app `doc/plugin-feedback-android-dual-schedule-native-fetch-and-timing.md` (external repo).
- Plugin: `FetchWorker.kt`, `ScheduleHelper.scheduleDualNotification`, `DualScheduleHelper.kt`, `DailyNotificationFetchWorker.java`, `ContentCache` / DAO definitions.
---
## 7. Review checklist (before merge)
- [ ] All `ContentCache` writes specify **`cacheScope`** intentionally.
- [ ] `DualScheduleHelper` uses **scoped** read for **`dual`**.
- [ ] `cancelDualSchedule` does not cancel **`fetch_content`**.
- [ ] Boot path reschedules dual prefetch when appropriate.
- [ ] CHANGELOG / README updated.

View File

@@ -1,37 +0,0 @@
# Consuming App Notes — Android Daily Notifications
Brief notes for apps that integrate the daily notification plugin on Android.
---
## Double schedule (rapid successive calls)
If your app calls `scheduleDailyNotification` twice in quick succession (e.g. within a few hundred ms) for the same reminder, the second call cancels the alarm just set and reschedules. On some devices or OEMs this can contribute to the alarm not firing.
**Recommendation:** Debounce or guard in the edit-reminder success path so you only call `scheduleDailyNotification` once per user action (e.g. wait for the first call to resolve before allowing another, or coalesce rapid calls).
---
## Alarm scheduled but not firing (e.g. 6:04)
When logs show "Scheduling OS alarm" and "Updated schedule in database" but the notification never appears:
1. **Confirm the broadcast is delivered**
Run logcat including the receiver:
```bash
adb logcat -v time -s DNP-SCHEDULE:V DailyNotificationWorker:V DailyNotificationReceiver:V
```
At the scheduled time, check whether `DailyNotificationReceiver` logs anything. If the Receiver runs, the issue is downstream (WorkManager / display). If it does not run, the OS did not deliver the alarm (Doze, OEM, or alarm replacement).
2. **Avoid double schedule**
Ensure the app is not calling `scheduleDailyNotification` twice in quick succession for the same reminder (see above).
3. **Plugin fix (v1.1.6+)**
The plugin no longer overwrites the apps schedule row when handling rollover work that uses a `daily_rollover_*` id, so the apps `nextRunAt` stays correct after a notification fires.
---
## References
- [ACTION_PLAN_INTEGRATION_FIXES.md](../integration/ACTION_PLAN_INTEGRATION_FIXES.md) — plugin and app integration checklist
- [CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md](./CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md) — optional cleanup of stale schedule rows

View File

@@ -1,136 +0,0 @@
# Optional: Use a Single Stable Schedule ID on iOS and Android
**Audience:** Consuming apps (e.g. TimeSafari / crowd-funder-for-time-pwa) that use `@timesafari/daily-notification-plugin`.
**Purpose:** Describe an optional app-side cleanup now that the plugins Android second-schedule bug is fixed (plugin v1.1.2+).
**Use:** Feed this doc into Cursor (or any editor) in the consuming app repo when implementing the cleanup.
---
## Context
- **Plugin fix (v1.1.2):** After cancel-then-schedule on Android, the plugin no longer skips the new schedule due to PendingIntent cache. Rescheduling works reliably whether or not the app passes an explicit `id` to `scheduleDailyNotification`.
- **Previous workaround:** Some apps avoided passing `id` on Android and used the plugin default `"daily_notification"` so that the (now-fixed) second-schedule bug would not trigger. On iOS they passed a stable id (e.g. `"daily_timesafari_reminder"`) for getStatus/cancel and verification.
- **Optional cleanup:** You can use the **same** stable schedule id on both iOS and Android. That simplifies code (one id everywhere), makes getStatus/cancel and verification consistent across platforms, and is safe with plugin v1.1.2+.
---
## Prerequisites
- Depend on **`@timesafari/daily-notification-plugin@1.1.2`** (or `^1.1.2`) so the Android fix is in effect.
- No other code changes are required for the bug fix; this doc is only for the optional id cleanup.
---
## What to Change in the Consuming App
### 1. Single stable reminder ID (both platforms)
Use one reminder id for schedule, cancel, and getStatus on both iOS and Android.
**Example (current pattern):**
```ts
// Before: different id per platform
private get reminderId(): string {
return Capacitor.getPlatform() === "ios"
? "daily_timesafari_reminder"
: "daily_notification";
}
```
**After (optional cleanup):**
```ts
// After: same stable id on both platforms (requires plugin >= 1.1.2)
private readonly reminderId = "daily_timesafari_reminder";
```
Or keep a getter if you prefer:
```ts
private get reminderId(): string {
return "daily_timesafari_reminder";
}
```
Use whatever stable string your app already uses on iOS (e.g. `"daily_timesafari_reminder"`); no need to change the value.
---
### 2. Pass `id` when scheduling on Android
Today you may only add `scheduleOptions.id` on iOS. Add it for Android too so the plugin stores and returns this id (getStatus, getScheduledReminders, cancel all use it).
**Example (current pattern):**
```ts
const scheduleOptions = {
time: options.time,
title: options.title,
body: options.body,
sound: true,
priority: (options.priority || "normal") as "low" | "default" | "high",
};
if (Capacitor.getPlatform() === "ios") {
scheduleOptions.id = this.reminderId;
}
await DailyNotification.scheduleDailyNotification(scheduleOptions);
```
**After (optional cleanup):**
```ts
const scheduleOptions = {
time: options.time,
title: options.title,
body: options.body,
sound: true,
priority: (options.priority || "normal") as "low" | "default" | "high",
id: this.reminderId, // same id on iOS and Android (plugin >= 1.1.2)
};
await DailyNotification.scheduleDailyNotification(scheduleOptions);
```
So: always pass `id: this.reminderId` (or your chosen constant) for both platforms.
---
### 3. Update comments
Remove or update comments that say Android must not receive an `id` to avoid the second-schedule bug, and that the plugin uses `"daily_notification"` on Android. Replace with a short note that a single stable id is used on both platforms and requires plugin v1.1.2+.
**Example comment to add/update:**
```ts
/**
* Stable schedule/reminder ID used for schedule, cancel, and getStatus.
* Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id).
*/
private readonly reminderId = "daily_timesafari_reminder";
```
---
## Files to Touch (typical)
- **Native notification service** (e.g. `src/services/notifications/NativeNotificationService.ts`):
- `reminderId`: use single value for both platforms.
- `scheduleDailyNotification`: always pass `id` in `scheduleOptions` (include Android).
- Adjust comments as above.
No changes are required to cancel or getStatus if they already use `this.reminderId`; they will now resolve the same schedule on Android as on iOS.
---
## Verification
1. **Android:** Schedule a daily notification, then change time and save again (reschedule). The second scheduled time should fire; no need to reinstall.
2. **getStatus:** After scheduling on Android, getStatus should return the scheduled reminder with the same id you pass (e.g. `daily_timesafari_reminder`).
3. **Cancel:** Cancelling by that id on Android should clear the scheduled notification.
---
## References
- Plugin CHANGELOG: `[1.1.2] - 2026-02-13` — Android second daily notification not firing after reschedule.
- Issue context (if present in consuming app): `doc/android-daily-notification-second-schedule-issue.md`.

View File

@@ -1,460 +0,0 @@
# Android Notification Implementation Comparison
**Test App (Working)** vs **TimeSafari (Not Working)**
This document identifies the critical differences between the test app where notifications work correctly and the TimeSafari app where notifications don't work at all. Use this as a checklist to fix TimeSafari.
---
## Critical Issues (Must Fix)
### 1. Missing Custom Application Class
**This is likely the primary cause of failure.**
**Test App (Working):**
```xml
<!-- AndroidManifest.xml -->
<application
android:name=".TestApplication"
...>
```
```java
// TestApplication.java
public class TestApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
Context context = getApplicationContext();
NativeNotificationContentFetcher testFetcher =
new org.timesafari.dailynotification.test.TestNativeFetcher(context);
DailyNotificationPlugin.setNativeFetcher(testFetcher);
}
}
```
**TimeSafari (Broken):**
```xml
<!-- AndroidManifest.xml - NO android:name attribute -->
<application
android:allowBackup="true"
...>
```
- No custom Application class exists
- No native fetcher is registered
- Plugin cannot fetch notification content
**Fix Required:**
1. Create `TimeSafariApplication.java` in `android/app/src/main/java/app/timesafari/`
2. Implement `NativeNotificationContentFetcher` specific to TimeSafari
3. Add `android:name=".TimeSafariApplication"` to AndroidManifest.xml
---
### 2. Missing Capacitor Plugin Configuration
**Test App (Working):**
```typescript
// capacitor.config.ts
plugins: {
DailyNotification: {
debugMode: true,
enableNotifications: true,
timesafariConfig: {
activeDid: "did:ethr:0x...",
endpoints: {
projectsLastUpdated: "http://..."
},
starredProjectsConfig: {
enabled: true,
starredPlanHandleIds: [...],
fetchInterval: '0 8 * * *'
},
credentialConfig: {
jwtSecret: '...',
tokenExpirationMinutes: 1
}
},
networkConfig: {
timeout: 30000,
retryAttempts: 3,
retryDelay: 1000
},
contentFetch: {
enabled: true,
schedule: '0 00 * * *',
fetchLeadTimeMinutes: 5
}
}
}
```
**TimeSafari (Broken):**
```typescript
// capacitor.config.ts - NO DailyNotification configuration at all
plugins: {
App: { ... },
SplashScreen: { ... },
CapSQLite: { ... }
// DailyNotification is MISSING
}
```
**Fix Required:**
Add `DailyNotification` configuration to `capacitor.config.ts` with appropriate values for TimeSafari.
---
### 3. Missing Permissions in AndroidManifest.xml
**Test App has these permissions that TimeSafari is missing:**
```xml
<!-- Add to TimeSafari's AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
```
**Current TimeSafari permissions (incomplete):**
-`INTERNET`
-`POST_NOTIFICATIONS`
-`SCHEDULE_EXACT_ALARM`
-`RECEIVE_BOOT_COMPLETED`
-`WAKE_LOCK`
-`ACCESS_NETWORK_STATE` - **MISSING**
-`FOREGROUND_SERVICE` - **MISSING**
-`SYSTEM_ALERT_WINDOW` - **MISSING**
-`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` - **MISSING**
---
### 4. Missing Gradle Dependencies
**Test App (Working):**
```gradle
// android/app/build.gradle
dependencies {
// Capacitor annotation processor for automatic plugin discovery
annotationProcessor project(':capacitor-android')
// Required dependencies for the plugin
implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1'
}
```
**TimeSafari (Broken):**
```gradle
dependencies {
// Missing: annotationProcessor project(':capacitor-android')
implementation "androidx.work:work-runtime-ktx:2.9.0" // Using Kotlin version
// Missing: androidx.lifecycle:lifecycle-service
// Missing: com.google.code.gson:gson
}
```
**Fix Required:**
Add to TimeSafari's `android/app/build.gradle`:
```gradle
annotationProcessor project(':capacitor-android')
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1'
```
---
## Secondary Issues (Should Fix)
### 5. DailyNotificationReceiver Export Status
**Test App (Working):**
```xml
<receiver
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="false"> <!-- Note: false -->
```
**TimeSafari (Broken):**
```xml
<receiver
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="true"> <!-- Note: true - potential security issue -->
```
The test app uses `exported="false"` because the plugin creates PendingIntents with explicit component targeting. Using `exported="true"` is unnecessary and a potential security concern.
---
### 6. Missing Network Security Config
**Test App (Working):**
```xml
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
```
**TimeSafari (Broken):**
```xml
<application>
<!-- No networkSecurityConfig -->
```
This may affect HTTP (non-HTTPS) requests during development.
---
### 7. Missing Java Compile Options
**Test App (Working):**
```gradle
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
```
**TimeSafari (Broken):**
No explicit compile options set.
---
## Complete Fix Checklist
### Step 1: Create Custom Application Class
Create file: `android/app/src/main/java/app/timesafari/TimeSafariApplication.java`
```java
package app.timesafari;
import android.app.Application;
import android.content.Context;
import android.util.Log;
import org.timesafari.dailynotification.DailyNotificationPlugin;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
public class TimeSafariApplication extends Application {
private static final String TAG = "TimeSafariApplication";
@Override
public void onCreate() {
super.onCreate();
Log.i(TAG, "Initializing TimeSafari notifications");
// Register native fetcher with application context
Context context = getApplicationContext();
NativeNotificationContentFetcher fetcher =
new TimeSafariNativeFetcher(context);
DailyNotificationPlugin.setNativeFetcher(fetcher);
Log.i(TAG, "Native fetcher registered");
}
}
```
### Step 2: Create Native Fetcher Implementation
Create file: `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`
```java
package app.timesafari;
import android.content.Context;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent;
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
private final Context context;
public TimeSafariNativeFetcher(Context context) {
this.context = context;
}
@Override
public NotificationContent fetchContent(String scheduleId) {
// TODO: Implement actual content fetching for TimeSafari
// This should query the TimeSafari API for notification content
return new NotificationContent(
"timesafari_" + System.currentTimeMillis(),
"TimeSafari Update",
"Check your starred projects for updates!",
System.currentTimeMillis(),
null,
System.currentTimeMillis()
);
}
}
```
### Step 3: Update AndroidManifest.xml
```xml
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".TimeSafariApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<!-- ... existing content ... -->
<!-- Fix: Change exported to false -->
<receiver
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="org.timesafari.daily.NOTIFICATION" />
</intent-filter>
</receiver>
<!-- ... rest of receivers ... -->
</application>
<!-- Existing permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- ADD these missing permissions -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
</manifest>
```
### Step 4: Update build.gradle
Add to `android/app/build.gradle`:
```gradle
android {
// ... existing config ...
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
// ... existing dependencies ...
// ADD these for notification plugin
annotationProcessor project(':capacitor-android')
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1'
}
```
### Step 5: Update capacitor.config.ts
Add DailyNotification configuration:
```typescript
plugins: {
// ... existing plugins ...
DailyNotification: {
debugMode: true,
enableNotifications: true,
timesafariConfig: {
activeDid: '', // Will be set dynamically from user's DID
endpoints: {
projectsLastUpdated: 'https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween'
},
starredProjectsConfig: {
enabled: true,
starredPlanHandleIds: [],
fetchInterval: '0 8 * * *'
}
},
networkConfig: {
timeout: 30000,
retryAttempts: 3,
retryDelay: 1000
},
contentFetch: {
enabled: true,
schedule: '0 8 * * *',
fetchLeadTimeMinutes: 5
}
}
}
```
### Step 6: Rebuild
```bash
npx cap sync android
cd android && ./gradlew clean
cd .. && npx cap build android
```
---
## Verification
After implementing fixes, verify:
1. **Check logs for Application initialization:**
```bash
adb logcat | grep -E "TimeSafariApplication|Native fetcher"
```
2. **Check alarm scheduling:**
```bash
adb shell dumpsys alarm | grep -i timesafari
```
3. **Test receiver manually:**
```bash
adb shell am broadcast -a org.timesafari.daily.NOTIFICATION \
--es id "test_notification" \
-n app.timesafari.app/org.timesafari.dailynotification.DailyNotificationReceiver
```
4. **Check notification permissions:**
```bash
adb shell dumpsys package app.timesafari.app | grep -A 5 "granted=true"
```
---
## Summary of Critical Differences
| Component | Test App (Working) | TimeSafari (Broken) |
|-----------|-------------------|---------------------|
| Custom Application class | ✅ TestApplication.java | ❌ None |
| Native fetcher registration | ✅ In Application.onCreate() | ❌ Not registered |
| DailyNotification config | ✅ Full config in capacitor.config.ts | ❌ Not configured |
| ACCESS_NETWORK_STATE | ✅ Present | ❌ Missing |
| FOREGROUND_SERVICE | ✅ Present | ❌ Missing |
| REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | ✅ Present | ❌ Missing |
| Gson dependency | ✅ Present | ❌ Missing |
| lifecycle-service dependency | ✅ Present | ❌ Missing |
| Capacitor annotation processor | ✅ Present | ❌ Missing |
**The most critical missing piece is the custom Application class with native fetcher registration.** Without this, the plugin has no way to fetch notification content when the alarm fires.

File diff suppressed because it is too large Load Diff

View File

@@ -1,228 +0,0 @@
# Security Boundaries — Native Plugin vs Backend Auth
**Purpose:** Document intentional architectural boundaries so
contributors do not add backend authentication into the plugin layer.
**Owner:** Development Team
**Last Updated:** 2026-05-20
**Status:** active
---
## Summary
The **Daily Notification Plugin** is a **local notification
orchestration layer**. It schedules alarms, manages permissions, caches
content for offline delivery, and invokes **host-provided** fetch logic
when configured.
It is **not** an authentication client and **not** a backend service
client for TimeSafari identity or session management.
**Authentication belongs in the host app**, which talks to
**notification-wakeup-service**. The plugin must never own that flow.
---
## Responsibility Matrix
| Concern | Plugin | Host app | Backend
(notification-wakeup-service) |
| --- | --- | --- | --- |
| User login / session | No | Yes | Yes |
| JWT / token issuance | No | Yes (or via backend) | Yes |
| Token refresh | No | Yes | Yes |
| DID / identity selection | No | Yes | May validate |
| Schedule local notifications | Yes | Configures via API | No |
| OS permission prompts | Yes | May explain UX | No |
| Local SQLite / cache | Yes | No | No |
| Prefetch timing (WorkManager, BG tasks) | Yes | No | No |
| Fetch notification **content** | Orchestrates only | Implements fetcher | Serves API data |
| Business API contracts | No | Yes | Yes |
---
## Layer Responsibilities
### Plugin (`@timesafari/daily-notification-plugin`)
The plugin **does**:
- Schedule and cancel **local** notifications (AlarmManager,
UNUserNotificationCenter, etc.).
- Persist schedules, cached content, and delivery metadata locally.
- Run background **prefetch orchestration** (when to fetch, retries,
TTL-at-fire).
- Request and track **notification permission** status.
- Expose a **Service Provider Interface (SPI)** so the host app can
supply content in native code (`NativeNotificationContentFetcher`).
- Forward **already-obtained** credentials to a registered native
fetcher via `configureNativeFetcher()` (passthrough only; see below).
- Perform **generic HTTP GET** only when a schedule supplies an
explicit `contentFetch.url` (no login, no token refresh, no
TimeSafari-specific auth).
The plugin **does not**:
- Authenticate users.
- Communicate with **notification-wakeup-service** or other backend
auth endpoints **as an auth client**.
- Sign JWTs, manage refresh tokens, or implement OAuth/OIDC flows.
- Encode TimeSafari-specific identity or API contracts in plugin code.
### Backend (`notification-wakeup-service` and related APIs)
The backend **does**:
- Authenticate users and establish sessions (exact mechanism is owned
by the TimeSafari platform).
- Issue, validate, and refresh credentials used for API access.
- Expose wakeup / notification-related APIs consumed by the **app**,
not by the plugin directly.
The backend **does not**:
- Depend on the Capacitor plugin for login or session lifecycle.
- Assume the plugin will call auth endpoints on its own.
### Host app (TimeSafari / consuming Capacitor application)
The app **does**:
- Authenticate the user against **notification-wakeup-service**
(and any related identity services).
- Obtain and refresh tokens **before** background fetch runs.
- Register `NativeNotificationContentFetcher` in native code and
implement `fetchContent()` using app-owned HTTP clients and auth.
- Call plugin APIs to configure schedules, permissions, and optional
`configureNativeFetcher()` with **pre-generated** tokens from app code.
- Handle auth failures (401/403), logout, and identity changes without
expecting the plugin to recover sessions.
The app **does not**:
- Assume the plugin will log in or refresh credentials on its behalf.
---
## Authentication Flow (Authoritative)
Authentication **does not** go through the plugin.
```mermaid
sequenceDiagram
participant User
participant App as Host app
participant Auth as notification-wakeup-service
participant Plugin as Daily Notification Plugin
participant OS as OS notification APIs
User->>App: Sign in / unlock identity
App->>Auth: Authenticate (credentials, DID, etc.)
Auth-->>App: Session / tokens
App->>App: Build or obtain JWT for API fetch
App->>Plugin: configureNativeFetcher(tokens from app)
App->>Plugin: registerNativeFetcher(app implementation)
Plugin->>Plugin: Schedule prefetch / notify alarms
Plugin->>App: NativeNotificationContentFetcher.fetchContent()
Note over App: App uses tokens from Auth;<br/>not plugin auth logic
App-->>Plugin: NotificationContent list
Plugin->>OS: Show local notification
```
**Rule:** `App → notification-wakeup-service` for authentication.
The plugin sits **below** that boundary and only receives outputs the
app chooses to pass in (e.g. a JWT string for a registered fetcher).
---
## Native Content Fetcher (SPI Boundary)
Background workers **cannot** rely on JavaScript bridges. The plugin
calls **`NativeNotificationContentFetcher`**, which the **host app
implements and registers**.
- **Plugin:** When to fetch, timeouts, retries, cache write, notify
scheduling.
- **Host fetcher:** How to call APIs, which headers to send, how to
handle 401 and refresh **in app code**.
Example pattern (conceptual): the fetcher uses a token the app already
minted; the plugin never signs JWTs. See
`android/.../NativeNotificationContentFetcher.java` Javadoc.
`configureNativeFetcher()` stores or forwards `apiBaseUrl`, `activeDid`,
and `jwtToken` supplied by the app. It is **configuration passthrough**,
not an authentication implementation. Tokens are **not** persisted by
default (`persistToken` defaults to `false`).
---
## Explicit Non-Goals (Plugin)
The following are **intentionally out of scope** for this repository:
1. **User authentication** — no login UI, no credential storage for
platform identity, no session store tied to wakeup-service.
2. **Direct backend auth traffic** — the plugin must not implement
clients for `notification-wakeup-service` auth/login/refresh routes.
3. **Backend business logic** — plans, offers, projects, and community
rules live in app + backend, not in the plugin.
The plugins job is **local reliability**: prefetch → cache → schedule →
display.
---
## Warning — Do Not Add Backend Auth to the Plugin
```text
┌─────────────────────────────────────────────────────────────┐
│ DO NOT add backend authentication logic to the plugin │
│ layer (TypeScript bridge, Android Kotlin/Java, iOS Swift). │
│ │
│ Wrong: JWT signing, refresh flows, or HTTP clients to │
│ notification-wakeup-service auth endpoints inside │
│ DailyNotificationPlugin / FetchWorker / iOS plugin│
│ │
│ Right: Implement auth in the host app; register a native │
│ fetcher; pass short-lived tokens from app code. │
└─────────────────────────────────────────────────────────────┘
```
**Why this matters:**
- **Security:** Auth secrets and refresh logic stay in one place (the
app), subject to app review and platform keychains.
- **Coupling:** The plugin stays reusable across apps and test harnesses.
- **Background limits:** OS background tasks must stay short; auth
refresh belongs in app lifecycle, not plugin workers.
- **Audit clarity:** Security reviewers can treat wakeup-service + app
as the trust boundary; the plugin is untrusted for identity.
If integration docs elsewhere mention “plugin provides authentication,”
treat that as **orchestration of fetch timing**, not **identity
authentication**. This document is the security boundary reference.
---
## Related Documentation
- `doc/integration/REFACTOR_NOTES.md` — SPI refactor; host-owned fetch
- `doc/integration/REFACTOR_NOTES_QUICK_START.md` — “JWT generation in
host app only”
- `android/.../NativeNotificationContentFetcher.java` — SPI contract
- `ARCHITECTURE.md` — broader system design (encryption, local storage)
---
## Assumptions & Limits
- **notification-wakeup-service** is named as the TimeSafari auth/wakeup
entry point per platform architecture; endpoint details live in app
and backend repos, not here.
- Generic `contentFetch.url` HTTP GET in the plugin is a **legacy /
optional** path for URL-configured schedules; new integrations should
prefer the native fetcher SPI with app-owned auth.
- This document does not change runtime behavior; it records existing
intent for reviewers and implementers.

View File

@@ -1,519 +0,0 @@
# Running Android App on a Physical Device
**Author**: Matthew Raymer
**Last Updated**: 2026-02-12
**Version**: 1.0.0
## Overview
This guide demonstrates how to run the DailyNotification plugin test app on a physical Android device. Physical device testing is essential for validating:
- **Real notification behavior** — Emulators may not accurately simulate notification delivery timing
- **Battery optimization effects** — OEM-specific power management that affects background tasks
- **Actual alarm scheduling** — AlarmManager behavior varies between emulators and real hardware
- **Device reboot persistence** — Boot receivers and alarm recovery
## Prerequisites
### Required Hardware
- **Android phone or tablet** running Android 8.0 (API 26) or higher
- **USB cable** (data-capable, not charge-only)
- **Development computer** with USB port
### Required Software
- **Android SDK** with platform-tools (provides `adb`)
- **Gradle** (via Gradle Wrapper)
- **Node.js** and **npm** (for TypeScript compilation)
### How to Check
| Requirement | How to check |
|------------------|--------------|
| **Node.js** | `node --version` (v14+ recommended; test app may require 20+) |
| **npm** | `npm --version` |
| **Java** | `java -version` (Java 11+) |
| **ANDROID_HOME** | `echo $ANDROID_HOME` (must be set to your Android SDK root) |
| **adb** | `adb version` (must be on PATH) |
**Project script:** From the repo root:
```bash
node scripts/check-environment.js
```
## Step 1: Enable Developer Options on Your Phone
Developer Options are hidden by default. To enable them:
### Android 8.0 - 14 (Most Devices)
1. Open **Settings**
2. Scroll down to **About phone** (or **About device**)
3. Find **Build number**
4. **Tap Build number 7 times** rapidly
5. You'll see "You are now a developer!" toast message
### Samsung Devices
1. **Settings****About phone****Software information**
2. Tap **Build number** 7 times
### Xiaomi/MIUI Devices
1. **Settings****About phone**
2. Tap **MIUI version** 7 times
### OnePlus Devices
1. **Settings****About phone**
2. Tap **Build number** 7 times
## Step 2: Enable USB Debugging
After enabling Developer Options:
1. Go to **Settings****System****Developer options**
- On some phones: **Settings****Developer options** directly
2. Scroll to find **USB debugging**
3. Toggle **USB debugging ON**
4. Confirm when prompted
### Optional but Recommended Settings
While in Developer Options, also enable:
- **Stay awake** — Screen stays on while charging (useful during development)
- **Allow mock locations** — If testing location features
## Step 3: Connect and Authorize Your Device
### Physical Connection
1. Connect your phone to your computer via USB
2. On your phone, change USB mode:
- Pull down notification shade
- Tap the USB notification ("Charging this device via USB")
- Select **File transfer / Android Auto** or **PTP** (not "Charge only")
### Authorize Computer
1. On your phone, you'll see a dialog: **"Allow USB debugging?"**
2. Check **"Always allow from this computer"** (recommended)
3. Tap **Allow**
### Verify Connection
```bash
# List connected devices
adb devices
# Expected output:
# List of devices attached
# ABC123DEF456 device
```
**Troubleshooting connection states:**
| State | Meaning | Solution |
|-------|---------|----------|
| `device` | Connected and authorized | Ready to use |
| `unauthorized` | USB debugging not authorized | Check phone for auth dialog |
| `offline` | Connection issues | Unplug, replug, restart adb |
| (empty) | Device not detected | Check USB cable, USB mode |
## Step 4: Build and Install the App
### Option A: Using Build Script (Recommended)
From the `test-apps/daily-notification-test` directory:
```bash
# Build and run on connected device
./scripts/build.sh --run-android
```
### Option B: Manual Build
```bash
# 1. Navigate to test app directory
cd test-apps/daily-notification-test
# 2. Build web assets
npm run build
# 3. Sync with Capacitor
npm run cap:sync:android
# 4. Build APK
cd android
./gradlew :app:assembleDebug
# 5. Install on device
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 6. Launch app
adb shell am start -n org.timesafari.dailynotification.test/.MainActivity
```
### Option C: Using Capacitor CLI
```bash
# Build, install, and launch in one command
npx cap run android --target <device-id>
# Get device ID from:
adb devices
```
## Step 5: Configure Battery Optimization (Critical!)
**This is the most important step for notification testing.** Android OEMs aggressively kill background apps to save battery. Without proper configuration, your alarms and notifications may not fire.
### Disable Battery Optimization for Test App
1. **Settings****Apps****DailyNotification Test** (or your app name)
2. **Battery****Unrestricted** or **Don't optimize**
### Manufacturer-Specific Settings
#### Samsung (One UI)
1. **Settings****Battery****Background usage limits**
2. Remove app from "Sleeping apps" and "Deep sleeping apps"
3. Add app to "Never sleeping apps"
#### Xiaomi (MIUI)
1. **Settings****Apps****Manage apps** → Select app
2. Enable **Autostart**
3. **Battery saver****No restrictions**
4. **Security** app → **Permissions****Autostart** → Enable for app
#### OnePlus (OxygenOS)
1. **Settings****Battery****Battery optimization**
2. Select app → **Don't optimize**
3. **Settings****Apps** → Select app → **Advanced****Optimize battery usage** → Off
#### Huawei/Honor (EMUI)
1. **Settings****Battery****App launch**
2. Disable automatic management for the app
3. Enable all three toggles: Auto-launch, Secondary launch, Run in background
#### Oppo/Realme (ColorOS)
1. **Settings****Battery****More battery settings**
2. **Optimize battery use** → Select app → **Don't optimize**
3. Enable **Allow auto-start** and **Allow background activity**
### Verify Battery Settings
```bash
# Check if app is whitelisted from battery optimization
adb shell dumpsys deviceidle whitelist
# Should include your package name
```
## Step 6: Monitor Logs
### Real-time Log Streaming
```bash
# All logs from the app
adb logcat | grep -E "DailyNotification|Capacitor|Console"
# Specific tags only
adb logcat -s "DailyNotification" "Capacitor" "Console"
# Clear logs and start fresh
adb logcat -c && adb logcat -s "DailyNotification"
```
### Filter by Log Level
```bash
# Errors only
adb logcat *:E | grep DailyNotification
# Warnings and above
adb logcat *:W | grep DailyNotification
# Verbose (all levels)
adb logcat *:V | grep DailyNotification
```
### Save Logs to File
```bash
# Stream logs to file
adb logcat -s "DailyNotification" > device_logs.txt
# Press Ctrl+C to stop
```
### Check Alarm Scheduling
```bash
# View scheduled alarms (requires root or debuggable build)
adb shell dumpsys alarm | grep -A 5 "org.timesafari"
# View alarm statistics
adb shell dumpsys alarm | grep -i "daily"
```
## Step 7: Testing Notification Features
### Test Immediate Notification
1. Open the app
2. Navigate to notification testing section
3. Trigger an immediate notification
4. Verify it appears in the notification tray
### Test Scheduled Notification
1. Schedule a notification for 1-2 minutes in the future
2. Lock the phone or put app in background
3. Wait for notification to fire
4. Check logs if notification doesn't appear
### Test Alarm Persistence
1. Schedule a notification
2. Reboot the device:
```bash
adb reboot
```
3. After reboot, check if alarm was restored:
```bash
adb shell dumpsys alarm | grep -A 5 "org.timesafari"
```
### Test Force Stop Recovery
1. Schedule a notification
2. Force stop the app:
```bash
adb shell am force-stop org.timesafari.dailynotification.test
```
3. Check if alarms are recovered (implementation dependent)
## Complete Command Sequence
### Quick Start (Copy-Paste Ready)
```bash
# 1. Verify device connection
adb devices
# 2. Navigate to test app
cd test-apps/daily-notification-test
# 3. Build everything
npm run build
npm run cap:sync:android
# 4. Build and install APK
cd android
./gradlew :app:assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 5. Launch app
adb shell am start -n org.timesafari.dailynotification.test/.MainActivity
# 6. Monitor logs (in separate terminal)
adb logcat -s "DailyNotification" "Capacitor" "Console"
```
## Troubleshooting
### Device Not Detected
```bash
# Restart ADB server
adb kill-server
adb start-server
adb devices
# Check USB connection
# - Try different USB cable (use data cable, not charge-only)
# - Try different USB port
# - Check USB mode on phone (should be File transfer, not Charge only)
```
### "Unauthorized" Device
```bash
# Revoke USB debugging authorizations on phone:
# Settings → Developer options → Revoke USB debugging authorizations
# Then reconnect and re-authorize
adb kill-server
adb start-server
# Accept authorization dialog on phone
```
### APK Installation Fails
```bash
# Error: INSTALL_FAILED_UPDATE_INCOMPATIBLE
# Solution: Uninstall existing app first
adb uninstall org.timesafari.dailynotification.test
adb install app/build/outputs/apk/debug/app-debug.apk
# Error: INSTALL_FAILED_USER_RESTRICTED
# Solution: Enable "Install via USB" in Developer options
```
### Notifications Not Appearing
1. **Check notification permissions:**
```bash
adb shell dumpsys notification | grep -A 10 "org.timesafari"
```
2. **Check battery optimization:**
- Ensure app is set to "Unrestricted" or "Don't optimize"
- Check manufacturer-specific settings (see Step 5)
3. **Check Do Not Disturb:**
- Ensure DND is off, or app is allowed through DND
4. **Check notification channel:**
```bash
adb shell dumpsys notification | grep -B 5 -A 10 "channel"
```
### Alarms Not Firing
1. **Check if alarms are scheduled:**
```bash
adb shell dumpsys alarm | grep -A 10 "org.timesafari"
```
2. **Check Doze mode:**
```bash
# Check current Doze state
adb shell dumpsys deviceidle
# Force device out of Doze for testing
adb shell dumpsys deviceidle unforce
```
3. **Check exact alarm permission (Android 12+):**
```bash
adb shell appops get org.timesafari.dailynotification.test SCHEDULE_EXACT_ALARM
```
### Build Failures
```bash
# Clean build
cd android
./gradlew clean
./gradlew :app:assembleDebug
# If still failing, clean Gradle cache
rm -rf ~/.gradle/caches
./gradlew :app:assembleDebug
```
## Benefits of Physical Device Testing
### Advantages Over Emulator
- ✅ **Accurate notification timing** — Real hardware scheduler behavior
- ✅ **Real battery optimization** — Test against actual OEM restrictions
- ✅ **True Doze mode** — Emulators simulate but don't fully replicate
- ✅ **Boot receiver testing** — Actual device reboot behavior
- ✅ **Performance metrics** — Real CPU/memory usage
- ✅ **User experience** — How notifications actually feel
### When to Use Physical Device
- **Final validation** — Before release
- **Notification timing tests** — Alarm accuracy verification
- **Battery impact testing** — Real power consumption
- **Reboot persistence tests** — Boot receiver validation
- **OEM-specific testing** — Samsung, Xiaomi, etc. quirks
### When Emulator is Sufficient
- **Basic functionality** — Core feature development
- **UI testing** — Layout and interaction testing
- **Quick iteration** — Fast build-test cycles
- **CI/CD pipelines** — Automated testing
## Multiple Device Management
### List All Connected Devices
```bash
adb devices -l
# Example output:
# ABC123DEF456 device usb:1-1 product:walleye model:Pixel_2 device:walleye
# XYZ789GHI012 device usb:1-2 product:star2lte model:SM_G965F device:star2lte
```
### Target Specific Device
```bash
# Install on specific device
adb -s ABC123DEF456 install -r app/build/outputs/apk/debug/app-debug.apk
# View logs from specific device
adb -s ABC123DEF456 logcat -s "DailyNotification"
# Launch app on specific device
adb -s ABC123DEF456 shell am start -n org.timesafari.dailynotification.test/.MainActivity
```
## Wireless ADB (Optional)
For cable-free development after initial setup:
```bash
# 1. Connect device via USB first
# 2. Enable TCP/IP mode on port 5555
adb tcpip 5555
# 3. Find device IP (Settings → About phone → Status → IP address)
# Or:
adb shell ip addr show wlan0 | grep inet
# 4. Disconnect USB and connect wirelessly
adb connect 192.168.1.100:5555
# 5. Verify connection
adb devices
# Should show: 192.168.1.100:5555 device
```
**Note:** Wireless ADB is slower than USB and may disconnect. Use USB for large APK transfers.
## Next Steps
### Testing Workflow
1. **Build** → Make changes, rebuild APK
2. **Install** → Push to device with `adb install -r`
3. **Test** → Exercise notification features
4. **Monitor** → Watch logs for issues
5. **Iterate** → Fix and repeat
### Recommended Test Sequence
1. ✅ Immediate notification display
2. ✅ Scheduled notification (1-2 min delay)
3. ✅ App backgrounded notification
4. ✅ Screen off notification
5. ✅ Device reboot alarm persistence
6. ✅ Force stop recovery (if implemented)
7. ✅ Battery optimization scenarios
---
**Physical device testing is essential for production-quality notification behavior.** While emulators are great for development, only real hardware reveals the true behavior of Android's notification and alarm systems. 📱

View File

@@ -4,9 +4,9 @@
**Owner:** Development Team
**Last Updated:** 2025-12-23
**Status:** active
**Baseline:** See `doc/progress/00-STATUS.md` for current baseline tag
**Baseline:** See `docs/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](./_archive/2025-12-16-consolidation/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](./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:** `doc/SYSTEM_INVARIANTS.md` — Single authoritative document naming and explaining all enforced invariants
- **System Invariants:** `docs/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:** `doc/PERFORMANCE.md` — Performance characteristics and benchmarks
- **Troubleshooting Guide:** `doc/TROUBLESHOOTING.md` — Common issues and solutions
- **Performance Characteristics:** `docs/PERFORMANCE.md` — Performance characteristics and benchmarks
- **Troubleshooting Guide:** `docs/TROUBLESHOOTING.md` — Common issues and solutions
---
@@ -35,10 +35,6 @@ 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
---
@@ -55,15 +51,14 @@ These files define the current truth about project state, decisions, and verific
## Archive & Reference-only
- **`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
- **`docs/_archive/`** — Historical artifacts, preserved for audit trail (not part of active doc surface)
- `docs/_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)
- `doc/_archive/2025-12-16-consolidation/` — 2025-12-16 consolidation artifacts (audit trail)
- `docs/_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)
- **`doc/_reference/`** — Reference templates (not used by current workflow)
- `doc/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
- **`docs/_reference/`** — Reference templates (not used by current workflow)
- `docs/_reference/github-actions-ci.yml` — GitHub Actions CI template (reference only)
---
@@ -73,7 +68,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. **[doc/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
3. **[docs/integration/QUICK_START.md](./integration/QUICK_START.md)** - Quick integration guide
4. **[BUILDING.md](../BUILDING.md)** - Build instructions
---
@@ -95,7 +90,7 @@ These files define the current truth about project state, decisions, and verific
## Integration Documentation
**Location:** `doc/integration/`
**Location:** `docs/integration/`
- **[INTEGRATION_GUIDE.md](./integration/INTEGRATION_GUIDE.md)** - Complete integration guide
- **[QUICK_START.md](./integration/QUICK_START.md)** - Quick integration path
@@ -109,7 +104,7 @@ These files define the current truth about project state, decisions, and verific
### iOS
**Location:** `doc/platform/ios/`
**Location:** `docs/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
@@ -124,7 +119,7 @@ These files define the current truth about project state, decisions, and verific
### Android
**Location:** `doc/platform/android/`
**Location:** `docs/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
@@ -140,7 +135,7 @@ These files define the current truth about project state, decisions, and verific
## Testing Documentation
**Location:** `doc/testing/`
**Location:** `docs/testing/`
### General Testing
@@ -183,7 +178,7 @@ Test app-specific documentation remains with the test apps but is indexed here:
## Alarm System Documentation
**Location:** `doc/alarms/`
**Location:** `docs/alarms/`
The alarm system documentation is well-organized and kept in its current location:
@@ -203,7 +198,7 @@ The alarm system documentation is well-organized and kept in its current locatio
## Design & Research Documentation
**Location:** `doc/design/`
**Location:** `docs/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
@@ -213,83 +208,63 @@ The alarm system documentation is well-organized and kept in its current locatio
---
## Architecture (Storage & Core Tech)
## Feature-Specific Documentation
**Location:** `doc/architecture/`
**Location:** `docs/`
- **[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
### Storage & Database
---
- **[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
## Deployment
### Native Fetcher
**Location:** `doc/deployment/`
- **[NATIVE_FETCHER_CONFIGURATION.md](./NATIVE_FETCHER_CONFIGURATION.md)** - Native fetcher configuration
- **[deployment-guide.md](./deployment/deployment-guide.md)** - Deployment guide (primary)
- **[DEPLOYMENT_CHECKLIST.md](./deployment/DEPLOYMENT_CHECKLIST.md)** - Deployment checklist
### Prefetch & Scheduling
---
- **[prefetch-scheduling-diagnosis.md](./prefetch-scheduling-diagnosis.md)** - Prefetch scheduling diagnosis
- **[prefetch-scheduling-trace.md](./prefetch-scheduling-trace.md)** - Prefetch scheduling trace
## Compliance & Operations
### Recovery & Startup
**Location:** `doc/compliance/`
- **[app-startup-recovery-solution.md](./app-startup-recovery-solution.md)** - App startup recovery solution
- **[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 Capabilities
---
- **[platform-capability-reference.md](./platform-capability-reference.md)** - Platform capability reference
- **[plugin-requirements-implementation.md](./plugin-requirements-implementation.md)** - Plugin requirements implementation
## Feature-Specific (Integration, Design, Progress)
### Feature Implementation
### Integration (`doc/integration/`)
- **[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
- **[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
### Compliance & Operations
### Design (`doc/design/`) — plans, prefetch, recovery
- **[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
- **[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
### Progress (`doc/progress/`)
- **[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
- **[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)
### Utilities
- **[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:** `doc/ai/`
**Location:** `docs/ai/`
These are derived operational artifacts for AI-assisted development:
@@ -305,9 +280,9 @@ These are derived operational artifacts for AI-assisted development:
## Archive Documentation
**Location:** `doc/archive/2025-legacy-doc/`
**Location:** `docs/archive/2025-legacy-doc/`
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) for complete archive listing.
**Notable archived content:**
- Historical directives (`doc/directives/`)
@@ -325,20 +300,18 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
| Category | Count | Location |
|----------|-------|----------|
| **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/` |
| **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/` |
| **Test Apps** | 20+ | `test-apps/*/` |
| **Archive** | 29 | `doc/archive/2025-legacy-doc/` |
| **Archive** | 29 | `docs/archive/2025-legacy-doc/` |
### By Status
@@ -361,13 +334,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/deployment-guide.md)
- **Deploy** → See [Deployment Guide](./deployment-guide.md)
### By Platform
- **iOS**`doc/platform/ios/`
- **Android**`doc/platform/android/`
- **Cross-Platform**`doc/alarms/`, `doc/integration/`
- **iOS**`docs/platform/ios/`
- **Android**`docs/platform/android/`
- **Cross-Platform**`docs/alarms/`, `docs/integration/`
### By Phase
@@ -381,19 +354,19 @@ Historical documentation preserved verbatim. See [CONSOLIDATION_SOURCE_MAP.md](.
### Updating This Index
**Index-first rule:** New docs must be linked from `doc/00-INDEX.md` or explicitly placed under `_archive/` / `_reference/`.
**Index-first rule:** New docs must be linked from `docs/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](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md) if consolidating
4. Update [CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md) if consolidating
### Consolidation Reference
For complete consolidation audit trail, see:
- **[CONSOLIDATION_SOURCE_MAP.md](./_archive/2025-12-16-consolidation/CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
- **[CONSOLIDATION_SOURCE_MAP.md](./CONSOLIDATION_SOURCE_MAP.md)** - Complete file mapping
---

Some files were not shown because too many files have changed in this diff Show More