Compare commits
2 Commits
master
...
android-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e25841fe9 | ||
|
|
367325452a |
@@ -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.** { *; }
|
||||
|
||||
@@ -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
|
||||
|
||||
112
BUILDING.md
112
BUILDING.md
@@ -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)
|
||||
|
||||
|
||||
102
CHANGELOG.md
102
CHANGELOG.md
@@ -5,108 +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).
|
||||
|
||||
## [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
47
COMMIT_MESSAGE.txt
Normal 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
|
||||
130
README.md
130
README.md
@@ -1,35 +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:
|
||||
|
||||
* 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:
|
||||
|
||||
1. **[Installation & Setup](./doc/GETTING_STARTED.md)** — Installation, platform setup, and basic usage
|
||||
2. **[Quick Start Guide](./doc/examples/QUICK_START.md)** — Minimal working example
|
||||
3. **[Common Patterns](./doc/examples/COMMON_PATTERNS.md)** — Common integration patterns
|
||||
4. **[Troubleshooting](./doc/TROUBLESHOOTING.md)** — Common issues and solutions
|
||||
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**
|
||||
|
||||
@@ -38,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:**
|
||||
@@ -51,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**
|
||||
|
||||
@@ -73,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
|
||||
@@ -86,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 ✅
|
||||
@@ -95,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
|
||||
@@ -104,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
|
||||
@@ -134,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
|
||||
@@ -150,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
|
||||
|
||||
@@ -316,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 app’s **`NativeNotificationContentFetcher`** is used when registered.
|
||||
|
||||
### Callback Methods
|
||||
|
||||
#### `registerCallback(name, config)`
|
||||
@@ -412,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
|
||||
|
||||
@@ -561,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>
|
||||
@@ -606,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>
|
||||
```
|
||||
|
||||
@@ -796,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
|
||||
@@ -819,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
|
||||
|
||||
@@ -850,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
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 = [:]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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 { *; }
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"pkg": "@timesafari/daily-notification-plugin",
|
||||
"name": "DailyNotification",
|
||||
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.timesafari.dailynotification
|
||||
package com.timesafari.dailynotification
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -76,13 +76,11 @@ 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"
|
||||
@@ -92,8 +90,7 @@ class BootReceiver : BroadcastReceiver() {
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
)
|
||||
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
|
||||
}
|
||||
@@ -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() +
|
||||
@@ -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
|
||||
@@ -147,28 +141,6 @@ object DailyNotificationConstants {
|
||||
*/
|
||||
const val DEFAULT_SCHEDULE_ID = "daily_notification"
|
||||
|
||||
/**
|
||||
* SharedPreferences name for dual (New Activity) schedule config.
|
||||
* Used by plugin to persist config and by Worker to resolve relationship (contentTimeout/fallbackBehavior).
|
||||
*/
|
||||
const val DUAL_SCHEDULE_PREFS = "daily_notification_dual"
|
||||
|
||||
/**
|
||||
* Key for persisted dual schedule config JSON (userNotification + relationship).
|
||||
*/
|
||||
const val DUAL_SCHEDULE_CONFIG_KEY = "dual_schedule_config"
|
||||
|
||||
/**
|
||||
* Stable dual notify [Schedule.id] persisted when [ScheduleHelper.scheduleDualNotification] runs.
|
||||
* The user-visible alarm is scheduled after prefetch completes ([DualScheduleNotifyScheduler]).
|
||||
*/
|
||||
const val DUAL_NOTIFY_SCHEDULE_ID_KEY = "dual_notify_schedule_id"
|
||||
|
||||
/**
|
||||
* Prefix for dual notify schedule IDs. Receiver uses this to apply relationship at fire time.
|
||||
*/
|
||||
const val DUAL_NOTIFY_SCHEDULE_ID_PREFIX = "dual_notify_"
|
||||
|
||||
// ============================================================
|
||||
// Request Code Versioning
|
||||
// ============================================================
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -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,
|
||||
@@ -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;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Debug;
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.timesafari.dailynotification
|
||||
package com.timesafari.dailynotification
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
@@ -18,7 +18,7 @@ import androidx.work.WorkManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.Data
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.timesafari.dailynotification.DailyNotificationFetchWorker
|
||||
import com.timesafari.dailynotification.DailyNotificationFetchWorker
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
@@ -35,7 +35,7 @@ import org.json.JSONObject
|
||||
* Bridges Capacitor calls to native Android functionality
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @version 2.0.0
|
||||
* @version 1.1.0
|
||||
*/
|
||||
@CapacitorPlugin(name = "DailyNotification")
|
||||
open class DailyNotificationPlugin : Plugin() {
|
||||
@@ -519,22 +519,12 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required")
|
||||
val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required")
|
||||
|
||||
val jwtTokenPool: List<String>? = try {
|
||||
parseJwtTokenPool(options)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return call.reject(e.message ?: "Invalid jwt token pool")
|
||||
}
|
||||
|
||||
val nativeFetcher = getNativeFetcherStatic()
|
||||
if (nativeFetcher == null) {
|
||||
return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.")
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid" +
|
||||
if (jwtTokenPool != null) ", jwtTokenPoolSize=${jwtTokenPool.size}" else ""
|
||||
)
|
||||
Log.i(TAG, "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid")
|
||||
|
||||
// Call the native fetcher's configure method FIRST
|
||||
// This configures the fetcher instance with API credentials for background operations
|
||||
@@ -543,7 +533,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
|
||||
try {
|
||||
Log.d(TAG, "FETCHER|CONFIGURE_START apiBaseUrl=$apiBaseUrl, activeDid=${activeDid.take(30)}...")
|
||||
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken, jwtTokenPool)
|
||||
nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken)
|
||||
configureSuccess = true
|
||||
Log.i(TAG, "FETCHER|CONFIGURE_COMPLETE success=true")
|
||||
} catch (e: Exception) {
|
||||
@@ -576,12 +566,6 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
// Only store JWT token if explicitly opted-in
|
||||
if (persistToken) {
|
||||
put("jwtToken", jwtToken)
|
||||
if (jwtTokenPool != null && jwtTokenPool.isNotEmpty()) {
|
||||
put("jwtTokenPool", JSONArray(jwtTokenPool))
|
||||
Log.w(TAG, "JWT token pool stored (size=${jwtTokenPool.size}, persistToken=true).")
|
||||
} else {
|
||||
put("jwtTokenPool", JSONArray())
|
||||
}
|
||||
Log.w(TAG, "JWT token stored in database (persistToken=true). " +
|
||||
"Database is NOT encrypted - token is stored in plain text.")
|
||||
} else {
|
||||
@@ -601,7 +585,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val config = org.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
val config = com.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
configId, null, "native_fetcher", "config", configValue, "json"
|
||||
)
|
||||
getDatabase().notificationConfigDao().insertConfig(config)
|
||||
@@ -623,34 +607,6 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional JWT pool from `jwtTokens` (bridge array) or `jwtTokenPoolJson` (JSON array string).
|
||||
* Prefer `jwtTokens` when both are present. Empty array → null (same as omitting).
|
||||
*/
|
||||
private fun parseJwtTokenPool(options: JSObject): List<String>? {
|
||||
val arr: JSONArray = when {
|
||||
options.has("jwtTokens") -> options.optJSONArray("jwtTokens")
|
||||
!options.optString("jwtTokenPoolJson", "").isNullOrBlank() ->
|
||||
JSONArray(options.getString("jwtTokenPoolJson"))
|
||||
else -> null
|
||||
} ?: return null
|
||||
if (arr.length() == 0) return null
|
||||
if (arr.length() > DailyNotificationConstants.JWT_TOKEN_POOL_MAX) {
|
||||
throw IllegalArgumentException(
|
||||
"jwtTokens must have at most ${DailyNotificationConstants.JWT_TOKEN_POOL_MAX} entries"
|
||||
)
|
||||
}
|
||||
val out = ArrayList<String>(arr.length())
|
||||
for (i in 0 until arr.length()) {
|
||||
if (arr.isNull(i)) continue
|
||||
val s = arr.optString(i)
|
||||
if (s.isNotEmpty()) {
|
||||
out.add(s)
|
||||
}
|
||||
}
|
||||
return if (out.isEmpty()) null else out
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getNotificationStatus(call: PluginCall) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
@@ -750,34 +706,6 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
scheduleDailyNotification(call)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelDailyReminder(call: PluginCall) {
|
||||
try {
|
||||
val reminderId = call.getString("reminderId")
|
||||
?: call.getString("id")
|
||||
?: call.getString("reminder_id")
|
||||
?: call.getString("scheduleId")
|
||||
if (reminderId.isNullOrBlank()) {
|
||||
call.reject("cancelDailyReminder: missing reminderId")
|
||||
return
|
||||
}
|
||||
NotifyReceiver.cancelNotification(context, scheduleId = reminderId)
|
||||
try {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val db = getDatabase()
|
||||
db.scheduleDao().setEnabled(reminderId, false)
|
||||
db.scheduleDao().updateRunTimes(reminderId, null, null)
|
||||
}
|
||||
} catch (dbErr: Exception) {
|
||||
Log.w(TAG, "cancelDailyReminder: failed DB update for $reminderId", dbErr)
|
||||
}
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "cancelDailyReminder failed", e)
|
||||
call.reject("cancelDailyReminder failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if exact alarms can be scheduled
|
||||
* Helper method for internal use
|
||||
@@ -1099,14 +1027,51 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
return call.reject("Context not available")
|
||||
}
|
||||
|
||||
// Do not open Settings or reject when exact alarms are not granted.
|
||||
// Proceed with scheduling; underlying layer uses inexact/windowed alarms when exact is unavailable.
|
||||
// Apps that want to prompt for exact alarm can use openExactAlarmSettings() or requestExactAlarmPermission().
|
||||
// Check if exact alarms can be scheduled
|
||||
if (!canScheduleExactAlarms(context)) {
|
||||
Log.i(TAG, "Exact alarm permission not granted; scheduling will use inexact/windowed fallback.")
|
||||
// Permission not granted - request it
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && canRequestExactAlarmPermission(context)) {
|
||||
// Open Settings to let user grant permission
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
||||
data = android.net.Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Log.w(TAG, "Exact alarm permission required. Opened Settings for user to grant permission.")
|
||||
call.reject(
|
||||
"Exact alarm permission required. Please grant 'Alarms & reminders' permission in Settings, then try again.",
|
||||
"EXACT_ALARM_PERMISSION_REQUIRED"
|
||||
)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open exact alarm settings", e)
|
||||
call.reject("Failed to open exact alarm settings: ${e.message}")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Permission permanently denied - direct to app settings
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = android.net.Uri.parse("package:${context.packageName}")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
Log.w(TAG, "Exact alarm permission denied. Directing user to app settings.")
|
||||
call.reject(
|
||||
"Exact alarm permission denied. Please enable 'Alarms & reminders' in app settings.",
|
||||
"PERMISSION_DENIED"
|
||||
)
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open app settings", e)
|
||||
call.reject("Failed to open app settings: ${e.message}")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with scheduling (exact when granted, otherwise inexact/windowed)
|
||||
// Permission granted - proceed with exact alarm scheduling
|
||||
// Capacitor passes the object directly via call.data
|
||||
val options = call.data ?: return call.reject("Options are required")
|
||||
|
||||
@@ -1116,13 +1081,8 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val sound = options.getBoolean("sound") ?: true
|
||||
val priority = options.getString("priority") ?: "default"
|
||||
val url = options.getString("url") // Optional URL for prefetch (not used in helper yet)
|
||||
val rolloverIntervalMinutes = try {
|
||||
(options.getInt("rolloverIntervalMinutes") ?: 0).takeIf { it > 0 }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title, rolloverIntervalMinutes=$rolloverIntervalMinutes")
|
||||
Log.i(TAG, "Scheduling daily notification: time=$time, title=$title")
|
||||
|
||||
// Convert HH:mm time to cron expression (daily at specified time)
|
||||
val cronExpression = convertTimeToCron(time)
|
||||
@@ -1149,14 +1109,6 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
Log.i(TAG, "scheduleDailyNotification: No cleanup needed - existing schedule will be updated via upsert: $scheduleId")
|
||||
}
|
||||
|
||||
// Cancel only fetch-related WorkManager jobs so they cannot create a second (UUID) alarm
|
||||
// with fallback or placeholder text. Does not cancel display/dismiss; future fetched-content
|
||||
// flows should use distinct tags so they are not affected.
|
||||
val workCancelled = ScheduleHelper.cancelFetchRelatedWorkManagerJobs(context)
|
||||
if (workCancelled) {
|
||||
Log.i(TAG, "scheduleDailyNotification: Cancelled pending prefetch/fetch WorkManager jobs")
|
||||
}
|
||||
|
||||
val config = UserNotificationConfig(
|
||||
enabled = true,
|
||||
schedule = cronExpression,
|
||||
@@ -1174,8 +1126,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
scheduleId,
|
||||
config,
|
||||
time,
|
||||
::calculateNextRunTime,
|
||||
rolloverIntervalMinutes
|
||||
::calculateNextRunTime
|
||||
)
|
||||
|
||||
if (success) {
|
||||
@@ -1439,16 +1390,17 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Delegate to ScheduleHelper
|
||||
val success = ScheduleHelper.scheduleDualNotification(
|
||||
context,
|
||||
getDatabase(),
|
||||
contentFetchConfig,
|
||||
userNotificationConfig,
|
||||
FetchWorker::scheduleFetch,
|
||||
::calculateNextRunTime
|
||||
)
|
||||
|
||||
if (success) {
|
||||
saveDualScheduleConfig(context!!, configJson)
|
||||
call.resolve()
|
||||
} else {
|
||||
call.reject("Dual notification scheduling failed")
|
||||
@@ -1464,34 +1416,12 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveDualScheduleConfig(context: Context, configJson: JSObject) {
|
||||
try {
|
||||
val str = configJson.toString()
|
||||
if (str.isNotEmpty()) {
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, str)
|
||||
.apply()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "saveDualScheduleConfig failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearDualScheduleConfig(context: Context) {
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY)
|
||||
.remove(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY)
|
||||
.apply()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getDualScheduleStatus(call: PluginCall) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val enabledSchedules = getDatabase().scheduleDao().getEnabled()
|
||||
val latestCache = getDatabase().contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
|
||||
val latestCache = getDatabase().contentCacheDao().getLatest()
|
||||
val recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
|
||||
|
||||
val status = JSObject().apply {
|
||||
@@ -1512,73 +1442,6 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelDualSchedule(call: PluginCall) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (context == null) {
|
||||
call.reject("Context not available")
|
||||
return@launch
|
||||
}
|
||||
val ctx = context!!
|
||||
val db = getDatabase()
|
||||
ScheduleHelper.cancelDualSchedule(ctx, db)
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
|
||||
clearDualScheduleConfig(ctx)
|
||||
call.resolve()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "cancelDualSchedule failed", e)
|
||||
call.reject("Cancel dual schedule failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateDualScheduleConfig(call: PluginCall) {
|
||||
val configJson = call.getObject("config") ?: run {
|
||||
call.reject("Config is required")
|
||||
return
|
||||
}
|
||||
val contentFetchObj = configJson.getJSObject("contentFetch") ?: run {
|
||||
call.reject("contentFetch config is required")
|
||||
return
|
||||
}
|
||||
val userNotificationObj = configJson.getJSObject("userNotification") ?: run {
|
||||
call.reject("userNotification config is required")
|
||||
return
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (context == null) {
|
||||
call.reject("Context not available")
|
||||
return@launch
|
||||
}
|
||||
val ctx = context!!
|
||||
val db = getDatabase()
|
||||
ScheduleHelper.cancelDualSchedule(ctx, db)
|
||||
WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
|
||||
val contentFetchConfig = parseContentFetchConfig(contentFetchObj)
|
||||
val userNotificationConfig = parseUserNotificationConfig(userNotificationObj)
|
||||
val success = ScheduleHelper.scheduleDualNotification(
|
||||
ctx,
|
||||
db,
|
||||
contentFetchConfig,
|
||||
userNotificationConfig,
|
||||
::calculateNextRunTime
|
||||
)
|
||||
if (success) {
|
||||
saveDualScheduleConfig(ctx, configJson)
|
||||
call.resolve()
|
||||
} else {
|
||||
call.reject("Update dual schedule failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "updateDualScheduleConfig failed", e)
|
||||
call.reject("Update dual schedule failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun registerCallback(call: PluginCall) {
|
||||
try {
|
||||
@@ -1939,15 +1802,12 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val ttlSeconds = contentJson.getInt("ttlSeconds")
|
||||
?: return@launch call.reject("TTL seconds is required")
|
||||
|
||||
val scope = contentJson.getString("cacheScope")?.takeIf { it.isNotEmpty() }
|
||||
?: ContentCacheScope.LEGACY
|
||||
val cache = ContentCache(
|
||||
id = id,
|
||||
fetchedAt = System.currentTimeMillis(),
|
||||
ttlSeconds = ttlSeconds,
|
||||
payload = payload.toByteArray(),
|
||||
meta = contentJson.getString("meta"),
|
||||
cacheScope = scope
|
||||
meta = contentJson.getString("meta")
|
||||
)
|
||||
|
||||
getDatabase().contentCacheDao().upsert(cache)
|
||||
@@ -2212,8 +2072,6 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
val options = call.getObject("options")
|
||||
val timesafariDid = options?.getString("timesafariDid")
|
||||
|
||||
Log.d(TAG, "DNP-CONFIG: Loading config from database: key=$key, timesafariDid=${timesafariDid?.take(20)}...")
|
||||
|
||||
val entity = if (timesafariDid != null) {
|
||||
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
|
||||
} else {
|
||||
@@ -2221,10 +2079,8 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
|
||||
if (entity != null) {
|
||||
Log.i(TAG, "DNP-CONFIG: Configuration restored from database: key=$key, configType=${entity.configType}, hasValue=${entity.configValue.isNotEmpty()}")
|
||||
call.resolve(configToJson(entity))
|
||||
} else {
|
||||
Log.d(TAG, "DNP-CONFIG: Configuration not found in database: key=$key")
|
||||
call.resolve(JSObject().apply { put("config", null) })
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -2288,7 +2144,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
?: return@launch call.reject("Config value is required")
|
||||
val configDataType = configJson.getString("configDataType", "string")
|
||||
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
val entity = com.timesafari.dailynotification.entities.NotificationConfigEntity(
|
||||
id, timesafariDid, configType, configKey, configValue, configDataType
|
||||
)
|
||||
|
||||
@@ -2394,7 +2250,6 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
put("ttlSeconds", cache.ttlSeconds)
|
||||
put("payload", String(cache.payload))
|
||||
put("meta", cache.meta)
|
||||
put("cacheScope", cache.cacheScope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2421,7 +2276,7 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun configToJson(config: org.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
|
||||
private fun configToJson(config: com.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
|
||||
return JSObject().apply {
|
||||
put("id", config.id)
|
||||
put("timesafariDid", config.timesafariDid)
|
||||
@@ -2440,30 +2295,15 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
/**
|
||||
* Optional int from JSON: absent or JSON-null → null (aligns with TS `ContentFetchConfig` optional fields).
|
||||
* [FetchWorker] applies defaults (e.g. 30000 / 3 / 1000) when null.
|
||||
*/
|
||||
private fun JSObject.optIntOrNull(key: String): Int? =
|
||||
if (has(key) && !isNull(key)) optInt(key) else null
|
||||
|
||||
/** Optional boolean: absent or JSON-null → null (aligns with TS optional fields). */
|
||||
private fun JSObject.optBooleanOrNull(key: String): Boolean? =
|
||||
if (has(key) && !isNull(key)) optBoolean(key) else null
|
||||
|
||||
/** Optional string: absent or JSON-null → null. Present empty string is preserved. */
|
||||
private fun JSObject.optStringOrNull(key: String): String? =
|
||||
if (has(key) && !isNull(key)) optString(key) else null
|
||||
|
||||
private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
|
||||
val callbacksObj = configJson.getJSObject("callbacks")
|
||||
return ContentFetchConfig(
|
||||
enabled = configJson.getBoolean("enabled") ?: true,
|
||||
schedule = configJson.getString("schedule") ?: "0 9 * * *",
|
||||
url = configJson.getString("url"),
|
||||
timeout = configJson.optIntOrNull("timeout"),
|
||||
retryAttempts = configJson.optIntOrNull("retryAttempts"),
|
||||
retryDelay = configJson.optIntOrNull("retryDelay"),
|
||||
timeout = configJson.getInt("timeout"),
|
||||
retryAttempts = configJson.getInt("retryAttempts"),
|
||||
retryDelay = configJson.getInt("retryDelay"),
|
||||
callbacks = CallbackConfig(
|
||||
apiService = callbacksObj?.getString("apiService"),
|
||||
database = callbacksObj?.getString("database"),
|
||||
@@ -2476,16 +2316,57 @@ open class DailyNotificationPlugin : Plugin() {
|
||||
return UserNotificationConfig(
|
||||
enabled = configJson.getBoolean("enabled") ?: true,
|
||||
schedule = configJson.getString("schedule") ?: "0 9 * * *",
|
||||
title = configJson.optStringOrNull("title"),
|
||||
body = configJson.optStringOrNull("body"),
|
||||
sound = configJson.optBooleanOrNull("sound"),
|
||||
vibration = configJson.optBooleanOrNull("vibration"),
|
||||
priority = configJson.optStringOrNull("priority")
|
||||
title = configJson.getString("title"),
|
||||
body = configJson.getString("body"),
|
||||
sound = configJson.getBoolean("sound"),
|
||||
vibration = configJson.getBoolean("vibration"),
|
||||
priority = configJson.getString("priority")
|
||||
)
|
||||
}
|
||||
|
||||
private fun calculateNextRunTime(schedule: String): Long {
|
||||
return ScheduleCronUtils.calculateNextRunTimeMillis(schedule)
|
||||
// Parse cron expression: "minute hour * * *" (daily schedule)
|
||||
// Example: "9 7 * * *" = 07:09 daily
|
||||
try {
|
||||
val parts = schedule.trim().split("\\s+".toRegex())
|
||||
if (parts.size < 2) {
|
||||
Log.w(TAG, "Invalid cron format: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val minute = parts[0].toIntOrNull() ?: 0
|
||||
val hour = parts[1].toIntOrNull() ?: 9
|
||||
|
||||
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) {
|
||||
Log.w(TAG, "Invalid time values in cron: $schedule, defaulting to 24h from now")
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
// Calculate next occurrence of this time
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
val now = calendar.timeInMillis
|
||||
|
||||
// Set to today at the specified time
|
||||
calendar.set(java.util.Calendar.HOUR_OF_DAY, hour)
|
||||
calendar.set(java.util.Calendar.MINUTE, minute)
|
||||
calendar.set(java.util.Calendar.SECOND, 0)
|
||||
calendar.set(java.util.Calendar.MILLISECOND, 0)
|
||||
|
||||
var nextRun = calendar.timeInMillis
|
||||
|
||||
// If the time has already passed today, schedule for tomorrow
|
||||
if (nextRun <= now) {
|
||||
calendar.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
nextRun = calendar.timeInMillis
|
||||
}
|
||||
|
||||
Log.d(TAG, "Calculated next run time: cron=$schedule, nextRun=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(nextRun))}")
|
||||
return nextRun
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error calculating next run time for schedule: $schedule", e)
|
||||
// Fallback: 24 hours from now
|
||||
return System.currentTimeMillis() + (24 * 60 * 60 * 1000L)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2584,7 +2465,7 @@ object TestDataHelper {
|
||||
suspend fun injectInvalidNotificationData(database: DailyNotificationDatabase): Boolean {
|
||||
return try {
|
||||
val invalidNotification =
|
||||
org.timesafari.dailynotification.entities.NotificationContentEntity()
|
||||
com.timesafari.dailynotification.entities.NotificationContentEntity()
|
||||
invalidNotification.id = "" // Empty ID - should be skipped by recovery
|
||||
invalidNotification.title = "Test Invalid Notification"
|
||||
invalidNotification.body = "This has an empty ID"
|
||||
@@ -2737,7 +2618,6 @@ object ScheduleHelper {
|
||||
* @param config User notification configuration
|
||||
* @param clockTime Original HH:mm time string
|
||||
* @param calculateNextRunTime Function to calculate next run time from cron expression
|
||||
* @param rolloverIntervalMinutes When > 0, next occurrence is this many minutes after trigger (dev/testing). Null/0 = 24h.
|
||||
* @return true if successful, false otherwise
|
||||
*/
|
||||
suspend fun scheduleDailyNotification(
|
||||
@@ -2746,21 +2626,13 @@ object ScheduleHelper {
|
||||
scheduleId: String,
|
||||
config: UserNotificationConfig,
|
||||
clockTime: String,
|
||||
calculateNextRunTime: (String) -> Long,
|
||||
rolloverIntervalMinutes: Int? = null
|
||||
calculateNextRunTime: (String) -> Long
|
||||
): Boolean {
|
||||
return try {
|
||||
val nextRunTime = calculateNextRunTime(config.schedule)
|
||||
|
||||
// CRITICAL: Cancel any existing alarm for this scheduleId BEFORE scheduling new one
|
||||
// This ensures "one per day" semantics - when updating schedule time, old alarm is canceled
|
||||
// The cleanupExistingNotificationSchedules() above only cancels OTHER schedules, not the current one
|
||||
NotifyReceiver.cancelNotification(context, scheduleId)
|
||||
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
|
||||
|
||||
// Schedule AlarmManager notification as static reminder
|
||||
// (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
|
||||
// this scheduleId and Android may still return the cancelled PendingIntent from cache.
|
||||
// (doesn't require cached content)
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextRunTime,
|
||||
@@ -2768,56 +2640,62 @@ object ScheduleHelper {
|
||||
isStaticReminder = true,
|
||||
reminderId = scheduleId,
|
||||
scheduleId = scheduleId,
|
||||
source = ScheduleSource.INITIAL_SETUP,
|
||||
skipPendingIntentIdempotence = true
|
||||
source = ScheduleSource.INITIAL_SETUP
|
||||
)
|
||||
|
||||
// Do not enqueue prefetch for static reminders: display is already in the NotifyReceiver
|
||||
// alarm. Prefetch is for "fetch content then show"; for static reminders there is nothing
|
||||
// to fetch. Enqueueing prefetch would cause the worker to use fallback content and
|
||||
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
|
||||
// notifications at fire time.
|
||||
// Always schedule prefetch 2 minutes before notification
|
||||
// (URL is optional - native fetcher will be used if registered)
|
||||
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before
|
||||
val delayMs = fetchTime - System.currentTimeMillis()
|
||||
|
||||
// Store schedule in database (include rollover interval for dev/testing; survives reboot)
|
||||
if (delayMs > 0) {
|
||||
// Schedule delayed prefetch
|
||||
val inputData = Data.Builder()
|
||||
.putLong("scheduled_time", nextRunTime)
|
||||
.putLong("fetch_time", fetchTime)
|
||||
.putInt("retry_count", 0)
|
||||
.putBoolean("immediate", false)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
|
||||
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("prefetch")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(workRequest)
|
||||
|
||||
Log.i("ScheduleHelper", "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs")
|
||||
} else {
|
||||
// Fetch time is in the past, schedule immediate fetch
|
||||
val inputData = Data.Builder()
|
||||
.putLong("scheduled_time", nextRunTime)
|
||||
.putLong("fetch_time", System.currentTimeMillis())
|
||||
.putInt("retry_count", 0)
|
||||
.putBoolean("immediate", true)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
|
||||
.setInputData(inputData)
|
||||
.addTag("prefetch")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(workRequest)
|
||||
|
||||
Log.i("ScheduleHelper", "Immediate prefetch scheduled: notificationTime=$nextRunTime")
|
||||
}
|
||||
|
||||
// Store schedule in database
|
||||
val schedule = Schedule(
|
||||
id = scheduleId,
|
||||
kind = "notify",
|
||||
cron = config.schedule,
|
||||
clockTime = clockTime,
|
||||
enabled = true,
|
||||
nextRunAt = nextRunTime,
|
||||
rolloverIntervalMinutes = rolloverIntervalMinutes
|
||||
nextRunAt = nextRunTime
|
||||
)
|
||||
database.scheduleDao().upsert(schedule)
|
||||
|
||||
// Persist title/body for this scheduleId so rollover and post-reboot resolve user content
|
||||
// (see plugin-feedback-android-rollover-double-fire-and-user-content)
|
||||
try {
|
||||
val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
|
||||
scheduleId,
|
||||
"1.3.1",
|
||||
null,
|
||||
"daily",
|
||||
config.title ?: "Daily Notification",
|
||||
config.body ?: "",
|
||||
nextRunTime,
|
||||
java.time.ZoneId.systemDefault().id
|
||||
)
|
||||
entity.soundEnabled = config.sound ?: true
|
||||
entity.vibrationEnabled = config.vibration ?: true
|
||||
entity.priority = when (config.priority) {
|
||||
"high", "max" -> 2
|
||||
"low", "min" -> -1
|
||||
else -> 0
|
||||
}
|
||||
entity.createdAt = System.currentTimeMillis()
|
||||
entity.updatedAt = System.currentTimeMillis()
|
||||
database.notificationContentDao().insertNotification(entity)
|
||||
Log.d("ScheduleHelper", "Persisted title/body for scheduleId=$scheduleId (rollover/post-reboot)")
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
|
||||
}
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
|
||||
@@ -2825,53 +2703,6 @@ object ScheduleHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking load of a schedule by id (for use from Java Worker / rollover path).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getScheduleBlocking(context: Context, scheduleId: String): Schedule? {
|
||||
return kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
DailyNotificationDatabase.getDatabase(context).scheduleDao().getById(scheduleId)
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "getScheduleBlocking failed: $scheduleId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking: first enabled notify schedule with rolloverIntervalMinutes > 0 (canonical for rollover chain).
|
||||
* Used when the firing run has schedule_id = daily_rollover_* so we can still apply the interval.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getCanonicalRolloverScheduleBlocking(context: Context): Schedule? {
|
||||
return kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
DailyNotificationDatabase.getDatabase(context).scheduleDao()
|
||||
.getByKindAndEnabled("notify", true)
|
||||
.firstOrNull { it.rolloverIntervalMinutes != null && it.rolloverIntervalMinutes > 0 }
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "getCanonicalRolloverScheduleBlocking failed", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking update of schedule next run time (for use from Java Worker after rollover).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun updateScheduleNextRunTimeBlocking(context: Context, scheduleId: String, lastRunAt: Long?, nextRunAt: Long) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
try {
|
||||
DailyNotificationDatabase.getDatabase(context).scheduleDao().updateRunTimes(scheduleId, lastRunAt, nextRunAt)
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "updateScheduleNextRunTimeBlocking failed: $scheduleId", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule dual notification (fetch + notify)
|
||||
*
|
||||
@@ -2881,6 +2712,7 @@ object ScheduleHelper {
|
||||
* @param database Database instance
|
||||
* @param contentFetchConfig Content fetch configuration
|
||||
* @param userNotificationConfig User notification configuration
|
||||
* @param scheduleFetch Function to schedule fetch (FetchWorker.scheduleFetch)
|
||||
* @param calculateNextRunTime Function to calculate next run time from cron expression
|
||||
* @return true if successful, false otherwise
|
||||
*/
|
||||
@@ -2889,25 +2721,23 @@ object ScheduleHelper {
|
||||
database: DailyNotificationDatabase,
|
||||
contentFetchConfig: ContentFetchConfig,
|
||||
userNotificationConfig: UserNotificationConfig,
|
||||
scheduleFetch: (Context, ContentFetchConfig) -> Unit,
|
||||
calculateNextRunTime: (String) -> Long
|
||||
): Boolean {
|
||||
return try {
|
||||
val nextFetchAt = calculateNextRunTime(contentFetchConfig.schedule)
|
||||
val nextNotifyAt = calculateNextRunTime(userNotificationConfig.schedule)
|
||||
FetchWorker.enqueueDualFetch(
|
||||
context,
|
||||
contentFetchConfig,
|
||||
nextFetchAt,
|
||||
nextNotifyAt
|
||||
)
|
||||
// Schedule fetch
|
||||
scheduleFetch(context, contentFetchConfig)
|
||||
|
||||
// Chained dual: user notification is armed from FetchWorker after prefetch (see DualScheduleNotifyScheduler).
|
||||
val nextRunTime = nextNotifyAt
|
||||
val scheduleId = "${DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX}${System.currentTimeMillis()}"
|
||||
context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, scheduleId)
|
||||
.apply()
|
||||
// Schedule notification
|
||||
val nextRunTime = calculateNextRunTime(userNotificationConfig.schedule)
|
||||
val scheduleId = "notify_${System.currentTimeMillis()}"
|
||||
NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextRunTime,
|
||||
userNotificationConfig,
|
||||
scheduleId = scheduleId,
|
||||
source = ScheduleSource.INITIAL_SETUP
|
||||
)
|
||||
|
||||
// Store both schedules
|
||||
val fetchSchedule = Schedule(
|
||||
@@ -2915,10 +2745,10 @@ object ScheduleHelper {
|
||||
kind = "fetch",
|
||||
cron = contentFetchConfig.schedule,
|
||||
enabled = contentFetchConfig.enabled,
|
||||
nextRunAt = nextFetchAt
|
||||
nextRunAt = calculateNextRunTime(contentFetchConfig.schedule)
|
||||
)
|
||||
val notifySchedule = Schedule(
|
||||
id = scheduleId,
|
||||
id = "dual_notify_${System.currentTimeMillis()}",
|
||||
kind = "notify",
|
||||
cron = userNotificationConfig.schedule,
|
||||
enabled = userNotificationConfig.enabled,
|
||||
@@ -2984,54 +2814,6 @@ object ScheduleHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel only the dual (New Activity) schedule: alarms for dual_fetch_* / dual_notify_* and DB rows.
|
||||
* Does not cancel Daily Reminder or other schedules. Caller must also cancel WorkManager unique work
|
||||
* FetchWorker.WORK_NAME_DUAL.
|
||||
*
|
||||
* @param context Application context
|
||||
* @param database Database instance
|
||||
* @return Number of dual schedules removed
|
||||
*/
|
||||
suspend fun cancelDualSchedule(context: Context, database: DailyNotificationDatabase): Int {
|
||||
return try {
|
||||
val all = database.scheduleDao().getAll()
|
||||
val dualSchedules = all.filter { it.id.startsWith("dual_fetch_") || it.id.startsWith("dual_notify_") }
|
||||
if (dualSchedules.isEmpty()) {
|
||||
Log.d("ScheduleHelper", "cancelDualSchedule: no dual schedules found")
|
||||
return 0
|
||||
}
|
||||
cancelAlarmsForSchedules(context, dualSchedules)
|
||||
dualSchedules.forEach { database.scheduleDao().deleteById(it.id) }
|
||||
Log.i("ScheduleHelper", "cancelDualSchedule: cancelled and removed ${dualSchedules.size} dual schedule(s)")
|
||||
dualSchedules.size
|
||||
} catch (e: Exception) {
|
||||
Log.e("ScheduleHelper", "cancelDualSchedule failed", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
|
||||
* prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.
|
||||
* Use this when handling scheduleDailyNotification so pending prefetch does not run and create
|
||||
* a duplicate alarm; future fetched-content flows should use distinct tags so they are not affected.
|
||||
*
|
||||
* @param context Application context
|
||||
* @return true if cancellation was successful
|
||||
*/
|
||||
suspend fun cancelFetchRelatedWorkManagerJobs(context: Context): Boolean {
|
||||
return try {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.cancelAllWorkByTag("prefetch")
|
||||
workManager.cancelAllWorkByTag("daily_notification_fetch")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w("ScheduleHelper", "Failed to cancel fetch-related WorkManager jobs", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all WorkManager jobs by tags
|
||||
*
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -59,7 +59,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("org.timesafari.daily.NOTIFICATION".equals(action)) {
|
||||
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) {
|
||||
@@ -72,7 +72,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
enqueueNotificationWork(context, notificationId, intent);
|
||||
Log.d(TAG, "DN|RECEIVE_OK enqueued=" + notificationId);
|
||||
|
||||
} else if ("org.timesafari.daily.DISMISS".equals(action)) {
|
||||
} else if ("com.timesafari.daily.DISMISS".equals(action)) {
|
||||
// Handle dismissal - also lightweight
|
||||
String notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID);
|
||||
if (notificationId != null) {
|
||||
@@ -109,9 +109,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
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).
|
||||
// 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");
|
||||
@@ -121,17 +119,13 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
if (priority == null) {
|
||||
priority = "normal";
|
||||
}
|
||||
String scheduleId = intent.getStringExtra("schedule_id");
|
||||
|
||||
Data.Builder dataBuilder = new Data.Builder()
|
||||
.putString("notification_id", notificationId)
|
||||
.putString("action", "display")
|
||||
.putBoolean("is_static_reminder", isStaticReminder);
|
||||
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)
|
||||
// Add static reminder data if present
|
||||
if (isStaticReminder && title != null && body != null) {
|
||||
dataBuilder.putString("title", title)
|
||||
.putString("body", body)
|
||||
@@ -362,7 +356,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
|
||||
// Add dismiss action
|
||||
Intent dismissIntent = new Intent(context, DailyNotificationReceiver.class);
|
||||
dismissIntent.setAction("org.timesafari.daily.DISMISS");
|
||||
dismissIntent.setAction("com.timesafari.daily.DISMISS");
|
||||
dismissIntent.putExtra(EXTRA_NOTIFICATION_ID, content.getId());
|
||||
|
||||
PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
|
||||
@@ -432,8 +426,8 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
}
|
||||
|
||||
// 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",
|
||||
@@ -444,15 +438,14 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
||||
);
|
||||
|
||||
// Use centralized scheduling function with ROLLOVER_ON_FIRE source
|
||||
org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
|
||||
context,
|
||||
nextScheduledTime,
|
||||
config,
|
||||
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
|
||||
);
|
||||
|
||||
Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId);
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
@@ -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;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -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) {
|
||||
// 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();
|
||||
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 when content has title/body from Room
|
||||
boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
|
||||
&& content.getBody() != null && !content.getBody().isEmpty();
|
||||
if (!hasTitleBody) {
|
||||
|
||||
// JIT Freshness Re-check (Soft TTL) - skip for static reminders
|
||||
content = performJITFreshnessCheck(content);
|
||||
} else {
|
||||
Log.d(TAG, "DN|DISPLAY_USE_ROOM_CONTENT id=" + notificationId + " (skip JIT)");
|
||||
}
|
||||
}
|
||||
|
||||
// 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,41 +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);
|
||||
if (s != null && s.getRolloverIntervalMinutes() != null && s.getRolloverIntervalMinutes() > 0) {
|
||||
rolloverMinutes = s.getRolloverIntervalMinutes();
|
||||
Log.d(TAG, "DN|ROLLOVER_INTERVAL scheduleId=" + logicalScheduleIdForRollover + " minutes=" + rolloverMinutes);
|
||||
}
|
||||
}
|
||||
long nextScheduledTime;
|
||||
if (rolloverMinutes != null && rolloverMinutes > 0) {
|
||||
nextScheduledTime = addMinutesToTime(content.getScheduledTime(), rolloverMinutes);
|
||||
Log.d(TAG, "DN|ROLLOVER_NEXT using_interval_minutes=" + rolloverMinutes + " next=" + nextScheduledTime);
|
||||
} else {
|
||||
nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
|
||||
}
|
||||
// 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());
|
||||
@@ -608,35 +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()) {
|
||||
if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||
scheduleId = notificationId; // Use notificationId as scheduleId
|
||||
} else {
|
||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// When using rollover interval, next time already set; otherwise compute from cron (tomorrow same time)
|
||||
if (rolloverMinutes == null || rolloverMinutes <= 0) {
|
||||
// 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 * * *";
|
||||
}
|
||||
} 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));
|
||||
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",
|
||||
@@ -647,29 +581,20 @@ 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());
|
||||
@@ -679,18 +604,25 @@ public class DailyNotificationWorker extends Worker {
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
|
||||
" error scheduling prefetch", e);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -700,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
|
||||
*/
|
||||
@@ -730,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);
|
||||
@@ -778,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(),
|
||||
@@ -815,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
|
||||
*
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
/**
|
||||
* Information about a scheduled daily reminder
|
||||
@@ -9,7 +9,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -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 = 4, // 4: content_cache.cacheScope
|
||||
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)
|
||||
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
|
||||
.addCallback(roomCallback)
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
@@ -270,24 +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'"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,9 +277,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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.content.Context;
|
||||
@@ -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;
|
||||
@@ -11,7 +11,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -1,16 +1,15 @@
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -18,7 +17,7 @@ import org.json.JSONObject
|
||||
* 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,13 +46,16 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,8 +115,6 @@ 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")
|
||||
@@ -233,8 +160,6 @@ 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")
|
||||
@@ -254,23 +179,17 @@ 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")
|
||||
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime")
|
||||
|
||||
val payload = resolvePayload(url, timeout, retryAttempts, retryDelay, isDual, nextNotifyAt)
|
||||
val skipDualChainedNotify =
|
||||
isDual && nextNotifyAt > 0L && isDualSkipNotificationPayload(payload)
|
||||
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
|
||||
@@ -284,9 +203,9 @@ class FetchWorker(
|
||||
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,
|
||||
@@ -323,89 +242,20 @@ class FetchWorker(
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -432,6 +282,7 @@ class FetchWorker(
|
||||
} else {
|
||||
throw IOException("HTTP $responseCode: ${connection.responseMessage}")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
if (attempt < retryAttempts - 1) {
|
||||
@@ -450,7 +301,7 @@ class FetchWorker(
|
||||
"timestamp": ${System.currentTimeMillis()},
|
||||
"content": "Daily notification content",
|
||||
"source": "mock_generator",
|
||||
"version": "2.1.4"
|
||||
"version": "1.1.0"
|
||||
}
|
||||
""".trimIndent()
|
||||
return mockData.toByteArray()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.util.Log;
|
||||
import java.util.UUID;
|
||||
@@ -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);
|
||||
@@ -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,10 +123,6 @@ 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(
|
||||
@@ -135,8 +132,7 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
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
|
||||
|
||||
@@ -147,16 +143,21 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
|
||||
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
|
||||
|
||||
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling
|
||||
// This prevents duplicate alarms when multiple scheduling paths race
|
||||
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
|
||||
val requestCode = getRequestCode(stableScheduleId)
|
||||
val checkIntent = Intent(context, 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(
|
||||
context,
|
||||
@@ -166,6 +167,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
|
||||
// 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(
|
||||
@@ -176,12 +179,15 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
)
|
||||
}
|
||||
|
||||
// Check 3: AlarmManager next alarm (Android 5.0+)
|
||||
// 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))
|
||||
@@ -193,26 +199,15 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
if (existingPendingIntent != null) {
|
||||
if (source == ScheduleSource.ROLLOVER_ON_FIRE) {
|
||||
// Rollover chain: same schedule id, new trigger time — treat as update: cancel then set
|
||||
Log.d(SCHEDULE_TAG, "ROLLOVER_ON_FIRE: cancelling existing alarm for id=$stableScheduleId to set new trigger")
|
||||
alarmManager.cancel(existingPendingIntent)
|
||||
} else {
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
|
||||
}
|
||||
|
||||
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
||||
// When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't
|
||||
// cancel the alarm and then skip re-scheduling, resulting in no alarm.
|
||||
if (!skipPendingIntentIdempotence) {
|
||||
// This prevents logical duplicates before even hitting AlarmManager
|
||||
try {
|
||||
runBlocking {
|
||||
val db = DailyNotificationDatabase.getDatabase(context)
|
||||
@@ -233,9 +228,6 @@ class NotifyReceiver : BroadcastReceiver() {
|
||||
} catch (e: Exception) {
|
||||
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
||||
}
|
||||
} else {
|
||||
Log.d(SCHEDULE_TAG, "Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId")
|
||||
}
|
||||
|
||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||
.format(java.util.Date(triggerAtMillis))
|
||||
@@ -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,63 +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()
|
||||
scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled }
|
||||
}
|
||||
|
||||
// Calculate cron expression from trigger time (HH:mm format)
|
||||
val calendar = java.util.Calendar.getInstance().apply {
|
||||
timeInMillis = triggerAtMillis
|
||||
}
|
||||
val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY)
|
||||
val minute = calendar.get(java.util.Calendar.MINUTE)
|
||||
val cronExpression = "${minute} ${hour} * * *"
|
||||
val clockTime = String.format("%02d:%02d", hour, minute)
|
||||
|
||||
if (scheduleToUpdate != null) {
|
||||
// Update existing schedule with new nextRunAt
|
||||
// Use the existing schedule's ID (not stableScheduleId) to ensure we update the right one
|
||||
db.scheduleDao().updateRunTimes(scheduleToUpdate.id, scheduleToUpdate.lastRunAt, triggerAtMillis)
|
||||
Log.d(SCHEDULE_TAG, "Updated schedule in database: id=${scheduleToUpdate.id}, nextRunAt=$triggerAtMillis (rollover)")
|
||||
} else {
|
||||
// No existing schedule found - create new one (shouldn't happen in normal flow)
|
||||
val newSchedule = Schedule(
|
||||
id = stableScheduleId,
|
||||
kind = "notify",
|
||||
cron = cronExpression,
|
||||
clockTime = clockTime,
|
||||
enabled = true,
|
||||
lastRunAt = null,
|
||||
nextRunAt = triggerAtMillis,
|
||||
jitterMs = 0,
|
||||
backoffPolicy = "exp",
|
||||
stateJson = null
|
||||
)
|
||||
db.scheduleDao().upsert(newSchedule)
|
||||
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Log but don't fail - alarm is already scheduled, DB update is best-effort
|
||||
Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -485,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)
|
||||
@@ -498,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")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,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)
|
||||
@@ -672,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")
|
||||
@@ -1,4 +1,4 @@
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
@@ -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);
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification
|
||||
package com.timesafari.dailynotification
|
||||
|
||||
/**
|
||||
* Comprehensive permission status model
|
||||
@@ -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
|
||||
@@ -42,32 +43,12 @@ class ReactivationManager(private val context: Context) {
|
||||
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.
|
||||
@@ -125,8 +106,8 @@ class ReactivationManager(private val context: Context) {
|
||||
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++
|
||||
|
||||
@@ -155,12 +136,6 @@ class ReactivationManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
missedCount = missedCount,
|
||||
@@ -244,26 +219,11 @@ 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,
|
||||
scheduledTime: Long,
|
||||
@@ -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,13 +276,11 @@ 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"
|
||||
@@ -333,8 +291,7 @@ class ReactivationManager(private val context: 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,
|
||||
@@ -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,13 +1052,11 @@ 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"
|
||||
@@ -1106,8 +1067,7 @@ class ReactivationManager(private val context: Context) {
|
||||
nextRunTime,
|
||||
config,
|
||||
scheduleId = schedule.id,
|
||||
source = ScheduleSource.BOOT_RECOVERY,
|
||||
skipPendingIntentIdempotence = true
|
||||
source = ScheduleSource.BOOT_RECOVERY
|
||||
)
|
||||
|
||||
// Update schedule in database (best effort)
|
||||
@@ -11,7 +11,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -8,7 +8,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Trace;
|
||||
@@ -24,7 +24,7 @@
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification;
|
||||
package com.timesafari.dailynotification;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -12,7 +12,7 @@
|
||||
* @since 2025-12-22
|
||||
*/
|
||||
|
||||
package org.timesafari.dailynotification
|
||||
package com.timesafari.dailynotification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "DailyNotification",
|
||||
"class": "org.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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()` ~1504–1520), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/NativeNotificationService.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context: Two notification types in the consuming app
|
||||
|
||||
The consuming app (crowd-funder-for-time-pwa) has **two independent notification types**, both using a user-set time from the app DB and firing once a day at that time:
|
||||
|
||||
| Type | Content source | Plugin API | Cancel API |
|
||||
|------|----------------|------------|------------|
|
||||
| **Daily Reminder** | User-set text from app DB | `scheduleDailyReminder(id, title, body, time, …)` | `cancelDailyReminder({ reminderId })` |
|
||||
| **New Activity** | API-fetched content (native fetcher) | `scheduleDualNotification({ config })` | `cancelDualSchedule()` |
|
||||
|
||||
- Both can be **on at the same time**; the app turns each on/off and sets its time independently.
|
||||
- **Isolation requirement:** Cancelling one must not affect the other. So:
|
||||
- `cancelDualSchedule()` must cancel **only** the dual schedule (content fetch + user notification for New Activity). It must **not** remove Daily Reminder notifications (iOS uses `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
|
||||
- `cancelDailyReminder({ reminderId })` must cancel **only** that reminder; it must **not** cancel the dual content-fetch task or New Activity user notification.
|
||||
|
||||
The completion plan below assumes this separation. Any new plugin code (e.g. iOS `cancelDualSchedule`, or dual user-notification identifiers) must preserve it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin (daily-notification-plugin) — iOS
|
||||
|
||||
### 1.1 Fix UNIMPLEMENTED (bridge / integration)
|
||||
|
||||
- **Ensure the native method is actually invoked.** Capacitor returns `UNIMPLEMENTED` when the bridge doesn't call the native handler. In the **consuming app**:
|
||||
- Confirm the app depends on this plugin (or an up-to-date fork) and that `npx cap sync ios` / build includes the plugin's native code.
|
||||
- If they use Capacitor 6, check the [Capacitor 6 plugin registration / UNIMPLEMENTED issues](https://github.com/ionic-team/capacitor-docs/issues/325) and apply any required registration or build fixes so `scheduleDualNotification` is exposed and called.
|
||||
- No code changes are required in the plugin for this; the handler and registration already exist.
|
||||
|
||||
### 1.2 Cron parsing (align with Android)
|
||||
|
||||
- **Replace the stub `calculateNextRunTime(from:)`** in `ios/Plugin/DailyNotificationPlugin.swift` (lines 767–771) with real cron parsing.
|
||||
- **Reference:** Android's `calculateNextRunTime(schedule: String)` in `DailyNotificationPlugin.kt` (lines 2336–2378): supports `"minute hour * * *"`, uses device timezone, returns next occurrence (today or tomorrow).
|
||||
- **Behavior:** For a given cron string (e.g. `"25 18 * * *"`), compute the next run as `Date`/`TimeInterval` and use that for:
|
||||
- `BGAppRefreshTaskRequest.earliestBeginDate`
|
||||
- `UNCalendarNotificationTrigger` (or equivalent) for the user notification so it fires at the correct local time daily.
|
||||
- Right now the implementation ignores the cron and always uses 86400 seconds, so schedules are wrong.
|
||||
|
||||
### 1.3 Use `relationship` (contentTimeout + fallbackBehavior) — implemented
|
||||
|
||||
- **Intent:** When the user notification fires at `userNotification.schedule`, show **API-derived content** if the fetch completed and is within `relationship.contentTimeout`; otherwise show `userNotification.title` / `userNotification.body` (per `fallbackBehavior: "show_default"`).
|
||||
- **Implemented:** Dual config (userNotification + relationship) is persisted when scheduling/updating. On **iOS**, after the content fetch completes in `handleBackgroundFetch`, the plugin replaces the pending dual notification with resolved title/body (from cache if within contentTimeout, else default). On **Android**, when the Worker runs for a `dual_notify_*` schedule, it loads the persisted config and content cache and resolves title/body at fire time, then displays one notification with that content. See **§1.3a** for implementation details (retained for reference).
|
||||
|
||||
### 1.3a Implementation plan: relationship (contentTimeout / fallbackBehavior)
|
||||
|
||||
Implement when ready so the New Activity notification can show API content when the fetch succeeds in time, or default text otherwise.
|
||||
|
||||
**Prerequisite: persist dual config (both platforms)**
|
||||
|
||||
When `scheduleDualNotification` or `updateDualScheduleConfig` runs, persist enough of the config for later use:
|
||||
|
||||
- **userNotification:** `schedule` (cron), `title`, `body` (and any other fields needed to build the notification).
|
||||
- **relationship:** `contentTimeout`, `fallbackBehavior`.
|
||||
|
||||
So when we later resolve content (after fetch or at fire time), we have the default text and the rules. No new API surface; store what we already receive.
|
||||
|
||||
- **iOS:** e.g. a single key in UserDefaults (or alongside `native_fetcher_config`), e.g. `dual_schedule_config`, with this structure (e.g. JSON).
|
||||
- **Android:** e.g. SharedPreferences or a keyed config; the code that runs at notification time (or after fetch) must be able to read it.
|
||||
|
||||
**iOS: update the pending notification when the fetch completes**
|
||||
|
||||
- When the content fetch runs (e.g. in `handleBackgroundFetch`), we already store the result. After a successful fetch:
|
||||
1. **Read the persisted dual config.** If none (no dual schedule or legacy flow), skip.
|
||||
2. **Resolve content:** Load the content just stored (or latest from cache) and its timestamp. If content exists and `(now - contentTimestamp) <= relationship.contentTimeout`, use that title/body; else use `userNotification.title` / `userNotification.body`.
|
||||
3. **Replace the pending dual notification:** Remove the pending request with identifier `dualNotificationRequestIdentifier`, then add a new `UNNotificationRequest` with the same identifier, the same trigger (recompute from `userNotification.schedule` in stored config), and the resolved title/body.
|
||||
|
||||
- **Edge cases:** If the fetch completes after the notification time (next trigger already in the past), do not replace. If the fetch fails, leave the existing pending notification as-is (it already has default title/body).
|
||||
|
||||
**Android: resolve content when the notification is about to fire**
|
||||
|
||||
- On Android the “notification” is an alarm that fires at notify time and then runs code (e.g. `NotifyReceiver` / `DailyNotificationReceiver`) to display the notification. We cannot change the alarm’s “content” after the fact the same way as on iOS; we decide what to show when the alarm fires.
|
||||
- **Persist dual config** when scheduling (same as above), keyed so the receiver can find it (e.g. by schedule id or a single “current dual config” key).
|
||||
- **When the receiver runs** for a dual schedule (e.g. for `dual_notify_*` or the known dual schedule id): load the persisted dual config, load the latest content from the content cache and its timestamp, apply relationship (use cache if within `contentTimeout`, else default), then show one notification with that resolved title/body.
|
||||
- The receiver must be dual-aware: for dual schedules it resolves title/body from config + cache + relationship instead of using fixed payload from the alarm.
|
||||
|
||||
**Summary**
|
||||
|
||||
| Step | iOS | Android |
|
||||
|------|-----|---------|
|
||||
| 1. Persist dual config | Store `userNotification` + `relationship` when scheduling/updating dual (e.g. UserDefaults). | Same; store when scheduling dual (e.g. SharedPreferences), keyed for the receiver. |
|
||||
| 2. Where relationship is applied | In **handleBackgroundFetch** after storing content: resolve cache vs default, then **replace** the pending dual notification (same id, same trigger, new title/body). | In the **receiver** at notify time: load config + cache, resolve cache vs default, then **show** the notification with that title/body. |
|
||||
| 3. Edge cases | Do not replace if next trigger is in the past; if fetch fails, leave existing default notification. | Receiver runs at fire time; “too old” handled by contentTimeout; if no config, fall back to alarm payload. |
|
||||
|
||||
### 1.4 Implement and register `cancelDualSchedule()` on iOS
|
||||
|
||||
- **Current state:** `cancelDualSchedule` is in `definitions.ts` and the web implementation, but there is **no** `@objc func cancelDualSchedule(_ call: CAPPluginCall)` in the iOS plugin and **no** `CAPPluginMethod(name: "cancelDualSchedule", ...)` in the plugin's method list (around 2195–2199).
|
||||
- **Required:**
|
||||
- Add `cancelDualSchedule(_ call: CAPPluginCall)` that:
|
||||
- Cancels the BGAppRefreshTaskRequest for the dual content fetch (e.g. cancel the task with `fetchTaskIdentifier` or the identifier used for the dual schedule).
|
||||
- Cancels **only** the pending user notification(s) created for the dual (New Activity) schedule — e.g. by a **dedicated request identifier** (see below). Must **not** remove Daily Reminder notifications (those use identifier `reminder_<id>` e.g. `reminder_daily_timesafari_reminder`).
|
||||
- When implementing or refactoring the dual user notification in `scheduleUserNotification(config:)` (or the path used by `scheduleDualNotification`), use a **stable, dedicated identifier** for the dual notification (e.g. `"dual_daily_notification"` or `"new_activity"`) so `cancelDualSchedule` can remove only that request. Currently the code uses `"daily-notification-\(Date().timeIntervalSince1970)"`, which is unique per call and not suitable for targeted cancellation.
|
||||
- Append `CAPPluginMethod(name: "cancelDualSchedule", returnType: CAPPluginReturnPromise)` in the same method list.
|
||||
- **Result:** Turning off "New Activity" in the app and calling `DailyNotification.cancelDualSchedule()` will no longer get `UNIMPLEMENTED` and will clear only the dual schedule, leaving Daily Reminder untouched.
|
||||
|
||||
### 1.5 Implement `updateDualScheduleConfig` for Edit time (recommended)
|
||||
|
||||
- **Use case:** The consuming app has an **Edit** button for New Activity that lets the user **change the time** of the notification. That flow is exactly what `updateDualScheduleConfig(config: DualScheduleConfiguration)` is for: "update the existing dual schedule with new config" (same config shape as `scheduleDualNotification`).
|
||||
- **Current app behavior:** Edit is implemented by calling `scheduleNewActivityDualNotification(timeText)` again (i.e. `scheduleDualNotification({ config })` with the new time). That can create duplicate pending notifications if the plugin does not replace the existing dual schedule (e.g. iOS currently uses a unique identifier per call: `daily-notification-<timestamp>`).
|
||||
- **Recommendation:** Implement **`updateDualScheduleConfig`** on iOS (and Android) for clear semantics: "change time" → call `updateDualScheduleConfig(newConfig)`. Implementation can be cancel existing dual schedule then schedule with new config (same as cancel + scheduleDualNotification under the hood). The consuming app should then call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when the user saves a new time from the Edit dialog, instead of (or with fallback to) `scheduleDualNotification`.
|
||||
- **Replace semantics:** Whether or not the app uses `updateDualScheduleConfig`, the plugin must ensure that when a dual schedule already exists and the app calls `scheduleDualNotification` again (e.g. on Edit), the result is **replace** not **add** — no duplicate content-fetch tasks or user notifications. Using a stable dual notification identifier (see 1.4) and replacing the existing request when scheduling achieves this.
|
||||
- **Other optional methods:** `pauseDualSchedule` and `resumeDualSchedule` remain optional; they are in `definitions.ts` but not required for the current app flow.
|
||||
|
||||
### 1.6 Android parity
|
||||
|
||||
- **cancelDualSchedule / updateDualScheduleConfig:** Implemented; Android now exposes both methods and uses `FetchWorker.WORK_NAME_DUAL` so only dual fetch work is cancelled. For **relationship** (contentTimeout / fallbackBehavior), see §1.3a (resolve at fire time in receiver).
|
||||
|
||||
---
|
||||
|
||||
## 2. Consuming app (crowd-funder-for-time-pwa)
|
||||
|
||||
### 2.1 Ensure plugin is linked and built (fix UNIMPLEMENTED)
|
||||
|
||||
- **Verify** the app's iOS project is using the plugin from this repo (or a release that includes the iOS implementation):
|
||||
- `package.json` dependency points to the right plugin (path, git, or npm).
|
||||
- Run `npx cap sync ios` and confirm `DailyNotificationPlugin` (and its Swift files) are in the app's `ios/App/` or Pods.
|
||||
- Clean build and run on device/simulator; confirm in Xcode that the plugin's `scheduleDualNotification` is registered and that a breakpoint in the Swift handler is hit when turning on New Activity.
|
||||
- If the app is on **Capacitor 6**, follow any documented steps for plugin registration so native methods are not reported as unimplemented.
|
||||
- No change to `buildDualScheduleConfig` or call order is needed; the config shape and sequence (configureNativeFetcher → updateStarredPlans → scheduleDualNotification) already match the plugin's expectations.
|
||||
|
||||
### 2.2 Error handling (optional but useful)
|
||||
|
||||
- **Current:** `AccountViewView.vue` treats `code === "UNIMPLEMENTED"` with a "not yet available on this device" message and any other error as "Could not schedule… try again."
|
||||
- **Improvement:** Once the plugin implements and registers `cancelDualSchedule` on iOS, the app can:
|
||||
- Keep handling `UNIMPLEMENTED` for older builds or platforms where the method is still missing.
|
||||
- Optionally surface more specific errors (e.g. `code === "SCHEDULING_FAILED"` or message strings from the plugin) so the user gets clearer feedback when scheduling fails for a reason other than "not implemented."
|
||||
- No change is strictly required for completion; the current flow is valid.
|
||||
|
||||
### 2.3 Turn-off flow
|
||||
|
||||
- The app already calls `DailyNotification.cancelDualSchedule()` when the user turns off New Activity (with a guard for `DailyNotification?.cancelDualSchedule`). Once the plugin implements and registers `cancelDualSchedule` on iOS (and optionally on Android), this will work without any app code change.
|
||||
|
||||
### 2.4 Edit time flow (New Activity)
|
||||
|
||||
- **Current:** When the user taps **Edit** and picks a new time, the app calls `scheduleNewActivityDualNotification(timeText)` (i.e. `scheduleDualNotification({ config })` again). See `editNewActivityNotification()` in `AccountViewView.vue` (~1504–1520).
|
||||
- **Recommended:** Once the plugin implements `updateDualScheduleConfig` (see 1.5), the app should call **`updateDualScheduleConfig({ config })`** when the user saves a new time from the Edit dialog, with `config = buildDualScheduleConfig({ notifyTime: timeText })`. That makes the intent explicit ("update existing schedule") and avoids relying on replace semantics inside `scheduleDualNotification`. The app can keep a fallback to `scheduleDualNotification` when `updateDualScheduleConfig` is not available (e.g. older plugin version).
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary table
|
||||
|
||||
| Where | What | Status / action |
|
||||
|-------|------|------------------|
|
||||
| **Plugin iOS** | `scheduleDualNotification` handler + registration | Done; fix bridge/build in app if still UNIMPLEMENTED. |
|
||||
| **Plugin iOS** | Cron parsing in `calculateNextRunTime(from:)` | Done; real cron parsing (match Android semantics). |
|
||||
| **Plugin iOS** | Use `relationship.contentTimeout` and `fallbackBehavior` | **Done;** persist dual config; in handleBackgroundFetch replace pending notification with resolved content. |
|
||||
| **Plugin iOS** | `cancelDualSchedule()` implementation + registration | Done; cancel BG task + dual user notification only; stable identifier. |
|
||||
| **Plugin iOS** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
|
||||
| **Plugin Android** | `cancelDualSchedule()` | Done; cancel dual schedules + WorkManager WORK_NAME_DUAL only. |
|
||||
| **Plugin Android** | `updateDualScheduleConfig(config)` | Done; cancel then schedule with new config. |
|
||||
| **Plugin Android** | Use `relationship` (contentTimeout / fallbackBehavior) | **Done;** persist dual config; in Worker at fire time (dual_notify_*) resolve config + cache and show resolved title/body. |
|
||||
| **Plugin both** | Replace semantics for dual schedule | Done; stable dual identifier, replace before add. |
|
||||
| **Plugin both** | Isolation of Daily Reminder vs New Activity | Done; cancelDualSchedule does not touch reminder_*. |
|
||||
| **Consuming app** | Plugin linked and built for iOS | Verify dependency, `cap sync`, and build so native `scheduleDualNotification` is called. |
|
||||
| **Consuming app** | Edit time: use `updateDualScheduleConfig` | In `editNewActivityNotification()`, call `updateDualScheduleConfig(buildDualScheduleConfig({ notifyTime }))` when user saves new time; fallback to `scheduleDualNotification` if unavailable. |
|
||||
| **Consuming app** | Error handling / UX | Optional: refine messages once plugin returns specific error codes. |
|
||||
|
||||
---
|
||||
|
||||
## 4. References
|
||||
|
||||
- **Plugin:** `ios/Plugin/DailyNotificationPlugin.swift` (scheduleDualNotification ~350–379, scheduleBackgroundFetch/scheduleUserNotification ~731–770, calculateNextRunTime ~767–771, method list ~2195–2199), `ios/Plugin/DailyNotificationScheduleHelper.swift` (~98–106), `src/definitions.ts` (DualScheduleConfiguration, cancelDualSchedule).
|
||||
- **Android reference:** `android/.../DailyNotificationPlugin.kt` (scheduleDualNotification ~1369–1420, calculateNextRunTime ~2336–2378).
|
||||
- **Consuming app:** `doc/plugin-feedback-ios-scheduleDualNotification.md`, `src/views/AccountViewView.vue` (~1237–1245, ~1259–1300, ~1501–1548 `editNewActivityNotification`), `src/services/notifications/dualScheduleConfig.ts`, `src/services/notifications/reminderIds.ts` (Daily Reminder vs New Activity IDs), `src/services/notifications/NativeNotificationService.ts` (Daily Reminder uses `scheduleDailyReminder` / `cancelDailyReminder`).
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
|
||||
@@ -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 ~206–226) 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 app’s 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 2–3 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 ~206–226; skipPendingIntentIdempotence at ~159–204.
|
||||
- DailyNotificationWorker: `scheduleNextNotification()` ~512–594; 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 plugin’s 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.
|
||||
@@ -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 plugin’s 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 repo’s 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 plugin’s 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 plugin’s `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 app’s `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 app’s **applicationId** (Android) or **Bundle ID** (iOS).
|
||||
- If you **do** change your app’s 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 plugin’s 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 **app’s 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 repo’s history and docs.
|
||||
@@ -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.
|
||||
@@ -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 app’s schedule row when handling rollover work that uses a `daily_rollover_*` id, so the app’s `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
|
||||
@@ -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 plugin’s Android second-schedule bug is fixed (plugin v1.1.2+).
|
||||
**Use:** Feed this doc into Cursor (or any editor) in the consuming app repo when implementing the cleanup.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- **Plugin fix (v1.1.2):** After cancel-then-schedule on Android, the plugin no longer skips the new schedule due to PendingIntent cache. Rescheduling works reliably whether or not the app passes an explicit `id` to `scheduleDailyNotification`.
|
||||
- **Previous workaround:** Some apps avoided passing `id` on Android and used the plugin default `"daily_notification"` so that the (now-fixed) second-schedule bug would not trigger. On iOS they passed a stable id (e.g. `"daily_timesafari_reminder"`) for getStatus/cancel and verification.
|
||||
- **Optional cleanup:** You can use the **same** stable schedule id on both iOS and Android. That simplifies code (one id everywhere), makes getStatus/cancel and verification consistent across platforms, and is safe with plugin v1.1.2+.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Depend on **`@timesafari/daily-notification-plugin@1.1.2`** (or `^1.1.2`) so the Android fix is in effect.
|
||||
- No other code changes are required for the bug fix; this doc is only for the optional id cleanup.
|
||||
|
||||
---
|
||||
|
||||
## What to Change in the Consuming App
|
||||
|
||||
### 1. Single stable reminder ID (both platforms)
|
||||
|
||||
Use one reminder id for schedule, cancel, and getStatus on both iOS and Android.
|
||||
|
||||
**Example (current pattern):**
|
||||
|
||||
```ts
|
||||
// Before: different id per platform
|
||||
private get reminderId(): string {
|
||||
return Capacitor.getPlatform() === "ios"
|
||||
? "daily_timesafari_reminder"
|
||||
: "daily_notification";
|
||||
}
|
||||
```
|
||||
|
||||
**After (optional cleanup):**
|
||||
|
||||
```ts
|
||||
// After: same stable id on both platforms (requires plugin >= 1.1.2)
|
||||
private readonly reminderId = "daily_timesafari_reminder";
|
||||
```
|
||||
|
||||
Or keep a getter if you prefer:
|
||||
|
||||
```ts
|
||||
private get reminderId(): string {
|
||||
return "daily_timesafari_reminder";
|
||||
}
|
||||
```
|
||||
|
||||
Use whatever stable string your app already uses on iOS (e.g. `"daily_timesafari_reminder"`); no need to change the value.
|
||||
|
||||
---
|
||||
|
||||
### 2. Pass `id` when scheduling on Android
|
||||
|
||||
Today you may only add `scheduleOptions.id` on iOS. Add it for Android too so the plugin stores and returns this id (getStatus, getScheduledReminders, cancel all use it).
|
||||
|
||||
**Example (current pattern):**
|
||||
|
||||
```ts
|
||||
const scheduleOptions = {
|
||||
time: options.time,
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
sound: true,
|
||||
priority: (options.priority || "normal") as "low" | "default" | "high",
|
||||
};
|
||||
if (Capacitor.getPlatform() === "ios") {
|
||||
scheduleOptions.id = this.reminderId;
|
||||
}
|
||||
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
||||
```
|
||||
|
||||
**After (optional cleanup):**
|
||||
|
||||
```ts
|
||||
const scheduleOptions = {
|
||||
time: options.time,
|
||||
title: options.title,
|
||||
body: options.body,
|
||||
sound: true,
|
||||
priority: (options.priority || "normal") as "low" | "default" | "high",
|
||||
id: this.reminderId, // same id on iOS and Android (plugin >= 1.1.2)
|
||||
};
|
||||
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
||||
```
|
||||
|
||||
So: always pass `id: this.reminderId` (or your chosen constant) for both platforms.
|
||||
|
||||
---
|
||||
|
||||
### 3. Update comments
|
||||
|
||||
Remove or update comments that say Android must not receive an `id` to avoid the second-schedule bug, and that the plugin uses `"daily_notification"` on Android. Replace with a short note that a single stable id is used on both platforms and requires plugin v1.1.2+.
|
||||
|
||||
**Example comment to add/update:**
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Stable schedule/reminder ID used for schedule, cancel, and getStatus.
|
||||
* Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id).
|
||||
*/
|
||||
private readonly reminderId = "daily_timesafari_reminder";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Touch (typical)
|
||||
|
||||
- **Native notification service** (e.g. `src/services/notifications/NativeNotificationService.ts`):
|
||||
- `reminderId`: use single value for both platforms.
|
||||
- `scheduleDailyNotification`: always pass `id` in `scheduleOptions` (include Android).
|
||||
- Adjust comments as above.
|
||||
|
||||
No changes are required to cancel or getStatus if they already use `this.reminderId`; they will now resolve the same schedule on Android as on iOS.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Android:** Schedule a daily notification, then change time and save again (reschedule). The second scheduled time should fire; no need to reinstall.
|
||||
2. **getStatus:** After scheduling on Android, getStatus should return the scheduled reminder with the same id you pass (e.g. `daily_timesafari_reminder`).
|
||||
3. **Cancel:** Cancelling by that id on Android should clear the scheduled notification.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Plugin CHANGELOG: `[1.1.2] - 2026-02-13` — Android second daily notification not firing after reschedule.
|
||||
- Issue context (if present in consuming app): `doc/android-daily-notification-second-schedule-issue.md`.
|
||||
@@ -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
@@ -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. 📱
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -36,7 +36,7 @@ Extended DAOs with additional queries:
|
||||
- `CallbackDao`: Added `getAll()`, `getByEnabled()`, `getById()`, `update()`
|
||||
- `HistoryDao`: Added `getSinceByKind()`, `getRecent()`
|
||||
|
||||
### ✅ Comprehensive Documentation ([DATABASE_INTERFACES.md](./DATABASE_INTERFACES.md))
|
||||
### ✅ Comprehensive Documentation (`docs/DATABASE_INTERFACES.md`)
|
||||
|
||||
Created 600+ line documentation guide:
|
||||
- Complete API reference with examples
|
||||
@@ -127,7 +127,7 @@ daily_notification_plugin.db
|
||||
1. **`src/definitions.ts`** - Added database interface methods and types
|
||||
2. **`android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`** - Implemented PluginMethods
|
||||
3. **`android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt`** - Extended DAOs
|
||||
4. **[DATABASE_INTERFACES.md](./DATABASE_INTERFACES.md)** - Complete documentation
|
||||
4. **`docs/DATABASE_INTERFACES.md`** - Complete documentation
|
||||
5. **`android/DATABASE_CONSOLIDATION_PLAN.md`** - Updated with interface requirements
|
||||
|
||||
## Next Steps
|
||||
@@ -139,7 +139,7 @@ daily_notification_plugin.db
|
||||
|
||||
## Documentation References
|
||||
|
||||
- **Complete API Reference**: [DATABASE_INTERFACES.md](./DATABASE_INTERFACES.md)
|
||||
- **Complete API Reference**: `docs/DATABASE_INTERFACES.md`
|
||||
- **Consolidation Plan**: `android/DATABASE_CONSOLIDATION_PLAN.md`
|
||||
- **TypeScript Definitions**: `src/definitions.ts`
|
||||
- **Database Schema**: `android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt`
|
||||
@@ -138,10 +138,10 @@ npm run deploy
|
||||
- **Annually**: Security reviews
|
||||
|
||||
### Support
|
||||
- **Documentation**: See `doc/` directory
|
||||
- **Troubleshooting**: See [deployment-guide.md](./deployment-guide.md)
|
||||
- **Monitoring**: See [observability-dashboards.md](../compliance/observability-dashboards.md)
|
||||
- **Compliance**: See [legal-store-compliance.md](../compliance/legal-store-compliance.md)
|
||||
- **Documentation**: See `docs/` directory
|
||||
- **Troubleshooting**: See `docs/deployment-guide.md`
|
||||
- **Monitoring**: See `docs/observability-dashboards.md`
|
||||
- **Compliance**: See `docs/legal-store-compliance.md`
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# TimeSafari Daily Notification Plugin - Deployment Summary
|
||||
|
||||
> **See also:** [deployment-guide.md](../deployment/deployment-guide.md) for complete guide
|
||||
> **See also:** [deployment-guide.md](./deployment-guide.md) for complete guide
|
||||
|
||||
**SSH Git Path**: `ssh://git@173.199.124.46:222/trent_larson/daily-notification-plugin.git`
|
||||
**Version**: `2.2.0`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user