61 Commits

Author SHA1 Message Date
4565e43479 attempt to stop crashes on Android 6 (but they didn't work) 2026-02-26 20:26:43 -07:00
Jose Olarte III
cff7b659dc chore(version): bump plugin to 1.2.0
- package.json, README.md, podspec
- src: definitions.ts, observability.ts, web.ts
- android: DailyNotificationPlugin.kt, DailyNotificationWorker.java,
  FetchWorker.kt, NotifyReceiver.kt, ReactivationManager.kt,
  DailyNotificationStorageRoom.java
2026-02-26 18:30:32 +08:00
Jose Olarte III
d3df4d9115 fix(android): single rollover alarm, user content, no main-thread DB
- Receiver: stop reading Room on main thread; pass schedule_id to Worker
  so title/body are resolved on a background thread (fixes
  db_fallback_failed / "Cannot access database on the main thread").
- Worker: use stable schedule_id for rollover so one alarm per reminder
  and reschedule cancels it; resolve user title/body by schedule_id when
  Intent lacks them; skip prefetch for static reminders to avoid a
  second alarm.
- ScheduleHelper: persist NotificationContentEntity for scheduleId when
  scheduling daily notification so rollover and post-reboot show user
  text.

Refs: plugin-feedback-android-rollover-double-fire-and-user-content
2026-02-26 18:28:40 +08:00
Jose Olarte III
bc3bf484cc chore: bump plugin version to 1.1.9 2026-02-24 19:20:45 +08:00
Jose Olarte III
25f83cf1fa fix(android): always reschedule alarm on boot by skipping PendingIntent idempotence
Boot recovery was skipping reschedule when it found an "existing" PendingIntent.
AlarmManager alarms are not guaranteed to persist across reboot; on devices that
clear them, the skip caused the next notification (initial or rollover) to never
fire until the app was opened. Pass skipPendingIntentIdempotence = true for all
BOOT_RECOVERY call sites (BootReceiver, ReactivationManager.rescheduleAlarmForBoot)
so the alarm is always re-registered after reboot. Setting the same PendingIntent
again replaces any existing alarm, so no duplicate alarms.
2026-02-24 19:19:22 +08:00
Jose Olarte III
7188d32ae6 chore: bump plugin version to 1.1.8
- package.json: 1.1.7 → 1.1.8
- Android: ReactivationManager, FetchWorker, NotifyReceiver,
  DailyNotificationStorageRoom, DailyNotificationWorker (entity pluginVersion)
- TypeScript: web.ts, observability.ts, definitions.ts (@version)
2026-02-23 18:37:02 +08:00
Jose Olarte III
1157a0f1ef fix(android): restore user title/body after reboot so notification doesn't show fallback text
After device restart, PendingIntent extras (title, body, is_static_reminder) can be
missing when the alarm fires, so the worker took the Room/JIT path and showed
fallback text instead of the user's message.

- DailyNotificationReceiver: when intent has notification_id but missing title/body,
  load NotificationContentEntity from Room and pass title/body into Worker input
  with is_static_reminder=true.
- ReactivationManager: add getTitleBodyForSchedule(); use persisted title/body in
  rescheduleAlarm and rescheduleAlarmForBoot (and inner boot helper) instead of
  hardcoded "Daily Notification" / "Your daily update is ready".
- BootReceiver: use ReactivationManager.getTitleBodyForSchedule() when building
  UserNotificationConfig for notify schedules after boot.
- DailyNotificationWorker: when content from Room has both title and body, skip
  performJITFreshnessCheck so user text is not overwritten by fetcher placeholder.

Ref: plugin-feedback-android-post-reboot-fallback-text (crowd-funder-for-time-pwa)
2026-02-23 18:00:36 +08:00
Jose Olarte III
c2b1a60804 chore(release): 1.1.7 2026-02-18 18:30:26 +08:00
Jose Olarte III
fa8028a698 fix(android): prevent duplicate reminder notification on first-time setup
Do not enqueue DailyNotificationFetchWorker for static reminder schedules.
Display is already handled by the single NotifyReceiver alarm; prefetch was
using fallback content and scheduling a second alarm via legacy
DailyNotificationScheduler, causing two notifications at fire time.
2026-02-18 18:29:11 +08:00
Jose Olarte III
9feaf60c84 chore: bump plugin version to 1.1.6 2026-02-16 19:19:09 +08:00
Jose Olarte III
aaeb71d31d fix(android): do not cancel PendingIntent before setAlarmClock on reschedule
Remove existingPendingIntent.cancel() in the "cancel existing alarm before
rescheduling" block. The cached PendingIntent can be the same instance passed
to setAlarmClock; cancelling it can prevent the new alarm from firing. Keep
only alarmManager.cancel(existingPendingIntent) to clear the previous alarm.
2026-02-16 19:16:18 +08:00
Jose Olarte III
531ce9f709 chore: bump plugin version to 1.1.5 2026-02-16 18:18:09 +08:00
Jose Olarte III
0b61d33f21 fix(android): avoid overwriting app schedule when rollover uses daily_rollover_ id
NotifyReceiver's post-schedule DB update no longer uses the "first enabled
notify schedule" fallback when stableScheduleId starts with "daily_rollover_".
That fallback was updating the app's schedule row (e.g. daily_timesafari_reminder)
with the rollover time and could leave the app's next alarm in a bad state after
a notification fired.

Add docs/CONSUMING_APP_ANDROID_NOTES.md with notes for consuming apps: debounce
double scheduleDailyNotification calls, and include DailyNotificationReceiver
in logcat when debugging alarms that are scheduled but do not fire.
2026-02-16 18:16:20 +08:00
Jose Olarte III
02a44a3e7b chore(release): bump plugin version to 1.1.4
Align version across package.json, iOS podspec, Android/TS sources,
README, and CHANGELOG after 1.1.4 fixes (reset alarm, static rollover,
cancelDailyReminder).

Changes:
- package.json: 1.1.3 → 1.1.4
- ios/DailyNotificationPlugin.podspec: 1.1.1 → 1.1.4
- Android: NotifyReceiver, ReactivationManager, FetchWorker,
  DailyNotificationStorageRoom — plugin version strings/comments
- src: web.ts, observability.ts, definitions.ts — @version headers
- README.md: version line
- CHANGELOG.md: add [1.1.4] - 2026-02-16 entry (fixes + cancelDailyReminder)

Files modified:
- package.json
- ios/DailyNotificationPlugin.podspec
- android/.../NotifyReceiver.kt, ReactivationManager.kt, FetchWorker.kt,
  storage/DailyNotificationStorageRoom.java
- src/web.ts, observability.ts, definitions.ts
- README.md, CHANGELOG.md
2026-02-16 17:02:38 +08:00
Jose Olarte III
cb3cb5a78e fix(android): reset alarm and static reminder rollover; add cancelDailyReminder
Fixes two integration bugs with the consuming app (Time Safari) and adds
Android parity for cancel-by-id.

Problem:
- Re-setting a daily notification (edit/save same time) could cancel the
  alarm then skip re-scheduling because DB idempotence still ran and
  treated the update as a duplicate.
- After the first fire, rollover scheduled the next run with
  isStaticReminder=false, so title/body reverted to fallback.
- App calls cancelDailyReminder({ reminderId }) but Android had no
  implementation (only cancelAllNotifications and scheduleDailyReminder).

Changes:
- NotifyReceiver.kt: Run DB idempotence only when
  !skipPendingIntentIdempotence. When true (e.g. app reset flow), skip
  the check and log; prevents "no alarm" after cancel-then-schedule.
- DailyNotificationWorker.java: In scheduleNextNotification(), read
  is_static_reminder from WorkManager input; keep stable scheduleId for
  static reminders; pass preserveStaticReminder and reminderId into
  scheduleExactNotification(); add DN|ROLLOVER log.
- DailyNotificationPlugin.kt: Add cancelDailyReminder(call) that parses
  reminderId (or id, reminder_id, scheduleId), calls
  NotifyReceiver.cancelNotification(context, scheduleId), and does
  best-effort DB cleanup (setEnabled false, updateRunTimes null).

Files modified:
- android/.../NotifyReceiver.kt
- android/.../DailyNotificationWorker.java
- android/.../DailyNotificationPlugin.kt
2026-02-16 16:57:01 +08:00
Jose Olarte III
a62f54b8a8 fix(android): Java call sites for scheduleExactNotification need 8th parameter
Add skipPendingIntentIdempotence (false) to NotifyReceiver.scheduleExactNotification
calls in DailyNotificationReceiver.java and DailyNotificationWorker.java so
consuming apps compile. Rollover paths do not skip idempotence.

- DailyNotificationReceiver: scheduleNextNotification() — add 8th arg false
- DailyNotificationWorker: scheduleNextNotification() — add 8th arg false
- Bump version to 1.1.3 (package.json, CHANGELOG, native/TS refs)
2026-02-13 19:51:49 +08:00
Jose Olarte III
7702bd3b81 fix(android): second daily notification not firing after reschedule
Cancel-then-schedule was skipped because the idempotence check still
found the cancelled PendingIntent in Android's cache. Skip
PendingIntent idempotence on the cancel-then-schedule path so the
new schedule is always set.

- NotifyReceiver.scheduleExactNotification: add
  skipPendingIntentIdempotence (used only from scheduleDailyNotification)
- ScheduleHelper: pass skipPendingIntentIdempotence=true after
  cancelNotification(scheduleId)
- Version 1.1.2: package.json, CHANGELOG, README, TS/Android refs
- docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md: optional app
  cleanup to use one stable id on both platforms
2026-02-13 19:26:09 +08:00
Jose Olarte III
602eafc892 docs(testing): add PHYSICAL_DEVICE_GUIDE for Android hardware testing
Covers USB debugging setup, battery optimization settings for major OEMs
(Samsung, Xiaomi, OnePlus, Huawei, Oppo), log monitoring, and
troubleshooting. Complements EMULATOR_GUIDE for real-device validation.
2026-02-12 17:19:45 +08:00
Jose Olarte III
a77f08052f chore(version): bump to 1.1.1 after Android alarm fix and EMULATOR_GUIDE docs 2026-02-05 19:36:33 +08:00
Jose Olarte III
442b826401 Merge branch 'android-fixes-2026-02' 2026-02-05 19:29:16 +08:00
Jose Olarte III
0bc75372b5 fix(android): target alarm broadcast to app package so receiver is triggered
Set Intent.setPackage(context.packageName) when creating PendingIntents
for AlarmManager so the broadcast is delivered to DailyNotificationReceiver
on all OEMs. Alarms were firing but the receiver was not invoked when the
component was not explicitly package-targeted.

- NotifyReceiver: setPackage on schedule, cancel, and isAlarmScheduled intents
- ReactivationManager: alarmsExist() use DailyNotificationReceiver + setPackage
- DailyNotificationScheduler: setPackage on ExactAlarmManager path intent
2026-02-05 19:28:30 +08:00
Jose Olarte III
57c7ddb7eb docs(testing): EMULATOR_GUIDE prerequisites, API 35, Apple Silicon; build.sh Android-only sync
EMULATOR_GUIDE.md:
- Add "Checking and Installing Prerequisites" (how to check Node, npm, Java,
  ANDROID_HOME, adb, emulator, AVDs; install steps; reference to
  scripts/check-environment.js)
- Use API 35 and Pixel8_API35 throughout to match project compileSdk/targetSdk
- Document arm64-v8a for Apple Silicon and x86_64 for Intel; add
  troubleshooting for "x86_64 not supported on aarch64 host"
- Bump version to 1.1.0 and last-updated date

test-apps/daily-notification-test/scripts/build.sh:
- When building only Android (--android / --run-android), run
  cap:sync:android instead of cap:sync so iOS pod install is skipped and
  Android build/run succeeds without fixing the iOS Podfile
2026-02-05 18:13:09 +08:00
Jose Olarte III
a3afefeda9 docs(testing): EMULATOR_GUIDE prerequisites and API 35
- Add "Checking and Installing Prerequisites" section:
  - How to check Node, npm, Java, ANDROID_HOME, adb, emulator, AVDs
  - Reference scripts/check-environment.js for partial check
  - Install steps for Node, Java, Android SDK (cmdline-tools only),
    sdkmanager packages, and avdmanager AVD creation
- Align SDK and AVD with project: use API 35 (android-35, build-tools 35.0.0,
  Pixel8_API35) to match compileSdk/targetSdk in variables.gradle
- Bump guide version to 1.1.0 and last-updated date
2026-02-05 17:48:46 +08:00
Jose Olarte III
bf90f158ac chore(version): bump to 1.1.0 after ios-2 merge
Version bump reflects new features merged from ios-2 branch:
- iOS rollover recovery for background/inactive app scenarios
- Build script improvements and iOS support
- Test app enhancements and UI improvements

This is a MINOR version bump per semantic versioning due to
backward-compatible feature additions.
2026-01-22 15:18:00 +08:00
Jose Olarte III
5dbe0d1455 Merge branch 'ios-2' 2026-01-22 14:43:52 +08:00
Jose Olarte III
7f79c5990b fix: include source files and build configs in package files for git installs 2026-01-19 18:57:53 +08:00
Jose Olarte III
bef88ad844 feat: add prepare script for automatic build on git install 2026-01-19 18:47:32 +08:00
Jose Olarte III
d0155f0b22 docs(building): update BUILDING.md with iOS prerequisites and clean-build script
Updates BUILDING.md to reflect recent changes in build-native.sh, especially
the Xcode Command Line Tools prerequisite check and the clean-build script.

Problem:
- BUILDING.md didn't mention Xcode Command Line Tools prerequisite
  (recently added to build-native.sh)
- clean-build.sh script exists but wasn't documented
- iOS build troubleshooting lacked Command Line Tools guidance

Changes:
- Add Xcode Command Line Tools to Prerequisites section
  - Document installation command (xcode-select --install)
  - Include verification steps (xcode-select -p, xcodebuild -version)
  - Note that build script automatically checks for these tools
  - Explain that sqlite3 is part of Command Line Tools

- Document clean-build.sh script in Build Scripts section
  - Basic usage: ./scripts/clean-build.sh
  - All options: --all, --clean-gradle-cache, --clean-derived-data,
    --reinstall-node
  - Explain when to use clean builds

- Enhance iOS Native Build Process section
  - Add prerequisite note about Command Line Tools
  - Include troubleshooting commands for pod install issues
  - Reference prerequisites section for details

- Add comprehensive troubleshooting sections
  - Clean Build section at start of Troubleshooting
    - Recommends clean-build as first step for many issues
    - Lists when to use clean builds
  - iOS Build Issues section
    - Command Line Tools configuration errors
    - SQLite/linker issues and pkgx conflicts
    - CocoaPods installation problems
    - All with clear solutions and commands

The documentation now accurately reflects:
- Xcode Command Line Tools as required iOS prerequisite
- clean-build.sh as available build tool
- Complete iOS troubleshooting workflow

Files modified:
- BUILDING.md
2026-01-16 15:38:41 +08:00
Matthew
dd55c6b4e1 fix(ios): use wrapper script for pod install in cap:sync:ios
The cap:sync:ios npm script was failing with "pod: command not found"
because npm scripts don't inherit the same PATH environment as shell
scripts, preventing detection of pod installed via rbenv shims.

Added pod-install.sh wrapper script that:
- Detects pod via command -v or rbenv shims ($HOME/.rbenv/shims/pod)
- Executes pod install with the found command
- Matches the pod detection logic used in build-native.sh

Updated cap:sync:ios script to use the wrapper instead of calling
pod install directly, ensuring consistent pod detection across
all build contexts.
2026-01-15 23:05:45 -08:00
Jose Olarte III
2915fe7438 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
2026-01-15 18:34:33 +08:00
Jose Olarte III
5247ebeecb fix(build): add Capacitor sync and auto-fix for Android test app builds
Fixed Android test app build failures by ensuring Capacitor is synced
before building and automatically fixing missing file references.

Changes:
- Add Capacitor Android sync step before building test app
  - Runs `npm run cap:sync:android` to create required project structure
  - Ensures `:capacitor-cordova-android-plugins` project exists before Gradle resolves dependencies
- Add automatic fix for missing cordova.variables.gradle reference
  - Detects when capacitor.build.gradle references non-existent file
  - Automatically comments out problematic `apply from` line
  - Uses platform-agnostic sed command (handles macOS and Linux)
  - Provides clear logging about what was fixed
- Reorganize build flow to match iOS pattern
  - Sync Capacitor first, then navigate to platform directory
  - Apply fixes, then build

This fixes the build error:
  "Could not resolve project :capacitor-cordova-android-plugins"
  "No matching variant of project :capacitor-cordova-android-plugins was found"

The build now completes successfully even when:
- Capacitor hasn't been synced yet
- capacitor.build.gradle references missing files

Files modified:
- scripts/build-native.sh
2026-01-15 15:51:08 +08:00
Jose Olarte III
20b33f6e31 fix(build): handle missing dependencies and Capacitor files during iOS build
Fixed build failures when test app dependencies aren't installed and when
Capacitor files don't exist during initial install.

Changes:
- Use `npx run-p` in test app build script to work without local install
- Add dependency check in build-native.sh before building Vue app
- Make fix-capacitor-plugins.js resilient to missing files during postinstall
  - Gracefully handles missing capacitor.plugins.json on first install
  - Provides clear messaging about when fixes will be applied
  - No longer exits with error when Capacitor hasn't been synced yet

This allows the build to complete successfully even when:
- npm dependencies aren't installed in test app
- Capacitor files don't exist yet (will be created during cap:sync:ios)

Files modified:
- scripts/build-native.sh
- test-apps/daily-notification-test/package.json
- test-apps/daily-notification-test/scripts/fix-capacitor-plugins.js
2026-01-15 15:37:32 +08:00
Jose Olarte III
630fd3de81 fix(ios): resolve SQLite linking conflicts with pkgx
Fixes iOS build failures caused by linker picking up macOS SQLite
libraries from pkgx instead of iOS system SQLite, resulting in
undefined symbol errors for all sqlite3 functions.

Changes:
- Explicitly link system SQLite library (-lsqlite3) in podspec
- Detect and unset pkgx environment variables during iOS builds
- Add warnings to guide users if manual intervention needed

The issue occurs when pkgx (or similar package managers) set
DYLD_LIBRARY_PATH or PKGX_DIR, causing the linker to find macOS
SQLite dylibs at /Users/*/.pkgx/sqlite.org/*/lib/libsqlite3.0.dylib
instead of the iOS system SQLite library.

This fix ensures the iOS build always uses the correct system SQLite
library regardless of environment variable interference.
2026-01-14 18:41:46 +08:00
Jose Olarte III
aaac23111c chore(build): add clean-build script for troubleshooting
Adds a comprehensive clean script that removes all build artifacts,
caches, and dependencies to help reproduce build issues across
different development environments.

The script cleans:
- TypeScript build output (dist/)
- iOS plugin artifacts (Pods, Podfile.lock, build dirs)
- Android plugin artifacts (build dirs, optional .gradle cache)
- Test app artifacts (node_modules, dist/, iOS/Android builds)
- Optional: Xcode DerivedData, Gradle cache, node_modules reinstall

Usage:
  ./scripts/clean-build.sh              # Basic clean
  ./scripts/clean-build.sh --all        # Full clean with reinstall

This is particularly useful when troubleshooting build failures
that may be environment-specific (different Xcode, CocoaPods,
macOS, or Node versions).
2026-01-12 20:50:02 +08:00
Jose Olarte III
d2a1041cc4 feat(ios): add missed rollover recovery for background/inactive app scenarios
Implement enhanced app launch recovery to detect and schedule missed rollover
notifications that occurred while the app was terminated, backgrounded, or
inactive.

Key improvements:
- Detect missed rollovers on app launch by checking for past notifications
  without next scheduled notification
- Add active rollover check when app becomes active (handles inactive app
  scenario where notifications fire silently)
- Calculate forward to future time when next scheduled time is in the past
  (handles delays > rollover interval)
- Enhance duplicate detection to exclude original notification from checks
- Retry rollover if previous attempt failed (rollover time set but no next
  notification exists)

Changes:
- DailyNotificationReactivationManager: Add detectAndProcessMissedRollovers()
  method and performActiveRolloverCheck() for app becoming active
- DailyNotificationReactivationManager: Enhance warm start scenario to check
  for missed rollovers
- DailyNotificationScheduler: Add forward calculation loop when next scheduled
  time is in the past
- DailyNotificationPlugin: Register observer for UIApplication.didBecomeActiveNotification
  to trigger rollover check when app becomes active

Fixes rollover scheduling for:
- App terminated: Rollover now detected and scheduled on next launch
- App inactive/backgrounded: Rollover detected when app becomes active
- Delayed recovery: Handles cases where app reopened after rollover interval
  has passed by calculating forward to next future time

All scenarios now properly schedule rollover notifications regardless of app
state when notification fires.
2026-01-09 20:02:40 +08:00
Jose Olarte III
243cbd08f1 docs(ios): add testing instructions for rollover interval
Add inline comments and documentation explaining how to temporarily
change rollover notification intervals from 24 hours to 2 minutes
for testing purposes. Comments specify exact line numbers and values
to change, making it easy to switch between production and testing
modes without losing context.

Changes:
- Add TESTING section to calculateNextScheduledTime() documentation
- Add inline TESTING comments at three change points:
  * Calendar date addition (24 hours → 2 minutes)
  * Fallback time calculation (24 hours → 2 minutes)
  * Duplicate prevention threshold (1 hour → 1 minute)

All code remains at production settings (24-hour intervals).
2026-01-08 21:25:37 +08:00
Jose Olarte III
7e93cbd771 fix(test-app): convert boolean pending to number in display
The pending field in NotificationStatus is a boolean, but the UI
was displaying it directly, causing "true" to appear instead of
a numeric count when notifications were scheduled.

Added a pendingCount computed property that converts boolean
values to numbers (true → 1, false → 0) while also handling
number types for future compatibility.
2026-01-08 19:00:55 +08:00
Jose Olarte III
6d64f71988 fix(ios): save rollover notification content to storage
Save notification content to storage immediately after scheduling
rollover notification so it can be retrieved when the notification
fires. Without this, processRollover fails to find the content
and cannot schedule the next notification.

The rollover flow creates a new notification with a new ID
(daily_rollover_*) but was only scheduling it with the system,
not saving it to storage. When the notification fired, the
lookup by ID failed because the content wasn't stored.

This matches the pattern used in DailyNotificationScheduleHelper
which saves content before scheduling.
2026-01-08 17:29:46 +08:00
Jose Olarte III
65379aedd6 refactor(test-app): extract reusable StatusList component
Created standardized StatusList component to eliminate duplicate status
display code across views. Standardized styling:
- Flexbox layout with space-between justification
- Border-bottom dividers (removed via :last-child)
- Optional status-based color coding via show-status-colors prop
- Consistent padding (12px 0) and spacing

Migrated HomeView and StatusView to use the new component:
- HomeView: Replaced inline status list, removed ~50 lines of duplicate CSS
- StatusView: Replaced diagnostics info items, removed ~25 lines of duplicate CSS
- Removed unused helper functions (getStatusType, getStatusDescription)
- Fixed TypeScript type assertions for status values
- Added diagnosticsItems computed property in StatusView

Reduces code duplication by ~75 lines and provides single source of truth
for status list styling across the application.
2026-01-07 21:20:49 +08:00
Jose Olarte III
66c7eca33d refactor(test-app): simplify System Status section layout
Replaced StatusCard components with simpler inline list layout:
- Removed card bounding boxes and side padding
- Added horizontal dividers between status items
- Title and value on same line using grid layout (left/right justified)
- Reduced padding and margins for more compact display
- Removed unused StatusCard import
2026-01-07 18:53:16 +08:00
Jose Olarte III
d88978259d fix(ios): correct userInfo scope error in notification delivery handler
Fixed compilation error where userInfo was referenced outside its scope
in handleNotificationDelivery error logging. Changed to use
notification.userInfo directly.
2026-01-07 18:52:45 +08:00
Jose Olarte III
66cbe763fc fix(ios): add diagnostic logging for rollover notification flow
Add comprehensive logging to trace rollover notification handling
from AppDelegate delivery through to next notification scheduling.
This enables diagnosis of why rollover notifications fail to
schedule the next 24-hour notification.

Changes:
- Log observer registration on plugin load
- Log handler entry and data extraction in handleNotificationDelivery
- Log processing steps in processRollover including:
  * Missing scheduler/storage detection
  * Content lookup failures with available IDs
  * ScheduleNextNotification success/failure

These logs will help identify whether the issue is:
- Observer not receiving notifications
- Content missing from storage
- Scheduling logic failing silently
2026-01-07 16:51:40 +08:00
Jose Olarte III
766d56c661 feat(test-app): default notification time to current + 3 minutes
Replace hardcoded '09:00' default with dynamic calculation that sets
the notification time to 3 minutes from now (rounded up to next minute).
This makes it more convenient for users to quickly test notifications
without manually adjusting the time field.
2026-01-06 19:27:05 +08:00
Jose Olarte III
f446362984 fix(ui): make notification countdown update reactively
The "Time Until" field in NotificationsView was not updating when
refresh was clicked or over time because the computed property used
Date.now() directly, which is evaluated once and doesn't trigger
reactive updates.

Changes:
- Add reactive currentTime property that updates every second
- Set up interval in mounted() to keep countdown live
- Clean up interval in beforeUnmount() to prevent memory leaks
- Update timeUntilNext computed to use reactive currentTime
- Update refreshNotifications() to immediately refresh currentTime

The countdown now updates automatically every second and immediately
when the refresh button is clicked, without requiring navigation away
and back to the view.
2026-01-06 17:38:59 +08:00
Jose Olarte III
20f15ebcea fix(ios): post notification delivery event to trigger rollover
The AppDelegate's willPresent method was not posting the
DailyNotificationDelivered notification event that the plugin
observes to trigger rollover scheduling. This caused rollover
notifications (scheduled 24 hours after the current notification)
to never be created, even though the rollover logic was fully
implemented in the plugin.

The fix extracts notification_id and scheduled_time from the
notification's userInfo and posts them via NotificationCenter using
the decoupled pattern. This allows the plugin to detect notification
delivery and automatically schedule the next day's notification.

Rollover now works correctly: when a notification is delivered,
the plugin schedules the next notification for 24 hours later,
and the NotificationsView properly displays the next notification
timestamp.
2026-01-06 17:19:18 +08:00
Jose Olarte III
b230a8e7b5 feat(test-app): add emoji icons to platform and status badges
Add platform-specific emojis (🤖 Android, 🍎 iOS, 🌐 Web) and status
indicators ( Ready, ⚠️ Not Ready,  Unknown) to improve visual
clarity in the home view welcome section.
2026-01-06 16:23:20 +08:00
Jose Olarte III
f97b3bec5b feat(test-apps/daily-notification-test): implement notification status display in NotificationsView
Replace placeholder content with functional notification status viewer that
displays scheduled notifications and rollover information. Enables verification
of both manually scheduled notifications and automatic rollover scheduling
(24-hour recurrence).

Features:
- Display next scheduled notification time with formatted date/time
- Show time until next notification (days, hours, minutes)
- Display pending notification count
- Show last notification delivery time
- Display rollover status (enabled/disabled, last rollover time) when available
- Additional status info (enabled, scheduled, errors)
- Manual refresh button for status updates
- Loading and error states with platform detection

Uses typed plugin wrapper for type safety with fallback to raw plugin access
for rollover fields not in TypeScript interface (iOS-specific fields).
2026-01-05 20:57:15 +08:00
Jose Olarte III
911aabf671 fix(test-app): use Capacitor for platform detection in views
Replace hardcoded platform values and appStore.platform with
Capacitor.getPlatform() for accurate runtime platform detection.

Changes:
- HomeView: Use Capacitor.getPlatform() instead of appStore.platform
- StatusView: Use Capacitor.getPlatform() for initial diagnostics
- diagnostics-export: Replace hardcoded 'Android' with Capacitor detection
- StatusView: Fix timezone to use actual timezone instead of 'Unknown'
- diagnostics-export: Show 'N/A' for API Level on iOS/web (Android-specific)

Fixes platform badge showing "web" on iOS native and diagnostics
showing incorrect platform information.
2026-01-05 20:38:55 +08:00
Jose Olarte III
5ae63e6f6d fix(test-app): constrain JSON diagnostics output width
The Raw Diagnostics pre element was overflowing its container and
causing the entire page to require horizontal scrolling, making field
values in the diagnostics info section inaccessible.

Added min-width: 0 and overflow: hidden to .diagnostics-json container
to allow proper grid constraint propagation. Added max-width: 100%,
width: 100%, and box-sizing: border-box to .json-output to ensure
it respects container bounds while maintaining horizontal scroll
within the pre element itself.
2026-01-05 20:21:48 +08:00
Jose Olarte III
edc4082f72 feat(ios): implement testAlarm method and fix plugin discovery
Add testAlarm() method to iOS plugin for quick notification testing.
Fix plugin method discovery by registering testAlarm in CAPBridgedPlugin
pluginMethods array. Add force-load code in AppDelegate to ensure plugin
is discovered by Capacitor's objc_getClassList scan.

Changes:
- Add testAlarm() implementation in DailyNotificationPlugin.swift
- Register testAlarm in pluginMethods array (required for Capacitor discovery)
- Add force-load code in test app AppDelegate (matches working ios-test-app)
- Add UNUserNotificationCenterDelegate to show notifications in foreground
- Add test notification button to ScheduleView with immediate feedback
- Add debug logging for method discovery and plugin loading

Fixes issue where testAlarm was implemented but returned "UNIMPLEMENTED"
because it wasn't registered in the pluginMethods array. Also ensures
plugin class is loaded before Capacitor's discovery phase.
2025-12-31 17:25:52 +08:00
Jose Olarte III
c8919480d9 fix(test-app): conditionally call getExactAlarmStatus on Android only
getExactAlarmStatus() is an Android-only API and was causing
UNIMPLEMENTED errors on iOS. Now only calls the method on Android
platforms, with safe defaults for iOS.

- Check platform before calling getExactAlarmStatus()
- Use default values { enabled: false, supported: false } on iOS
- Add error handling for Android call failures
- Make exact alarm status logging conditional on Android
2025-12-31 14:27:08 +08:00
Jose Olarte III
2d353c877c feat(test-app): add dedicated Request Permissions view
Create a new RequestPermissionsView that provides a dedicated interface
for checking and requesting notification permissions, matching the
functionality found in the iOS test app.

The view includes:
- Status display with color-coded states (requesting/granted/error)
- Permission status grid showing notifications, exact alarm, and
  background refresh status
- Request Permissions button with same functionality as iOS test app
- Platform-specific settings access buttons
- Automatic status refresh on mount

Updated HomeView to navigate to the new view instead of calling
permission request function directly, providing better UX with
dedicated screen for permission management.
2025-12-31 14:20:49 +08:00
Jose Olarte III
2f0d733b10 feat(test-app): add back navigation and improve mobile layout
- Add back buttons to all sub-views (Schedule, Status, Notifications, History, Logs, Settings, UserZero, About)
- Fix router navigation by importing router instance directly (resolves TypeScript errors with vue-facing-decorator)
- Update back button styling: flex layout with page title, arrow-only label
- Fix "Check Status" action to navigate to StatusView instead of checking status inline
- Remove horizontal padding on mobile views (max-width 768px) for edge-to-edge layout
- Simplify badge styling in HomeView (remove padding and border-radius)
2025-12-31 14:04:42 +08:00
Jose Olarte III
a7d33e2d37 feat(build): add iOS support to build-native.sh
Adds iOS platform support to the unified build script, enabling
building of test-apps/daily-notification-test for iOS alongside
existing Android support.

Changes:
- Add build_plugin_for_test_app_ios() to build iOS test app
- Add build_ios() function for iOS platform handling
- Make environment checks conditional based on target platform
- Add get_pod_command() helper to handle CocoaPods via rbenv
- Update main() to accept --platform ios and include iOS in "all"

This aligns the script with BUILDING.md documentation (lines 71, 75)
which implied iOS support was already available. The iOS build
process mirrors Android: creates plugin symlink, builds Vue app,
syncs Capacitor iOS (handles Podfile fixes), and builds with
xcodebuild for simulator.

Platform-specific environment checks allow iOS-only builds without
requiring Android toolchain, and vice versa.
2025-12-31 13:11:08 +08:00
Jose Olarte III
83ec604a4b fix(build): resolve Android build failures with Java 21 and Capacitor context
Fixed two build issues preventing Android plugin compilation:

1. Build script now builds from test app context instead of standalone
   - Capacitor Android is only available as a project dependency, not from Maven
   - Plugin must be built within a Capacitor app's Android project
   - Changed build_plugin_for_test_app() to build from test app's android/
     directory where Capacitor is available as :capacitor-android project

2. Added JVM arguments for Java 17+ KAPT compatibility
   - Java 21's module system blocks KAPT from accessing internal compiler classes
   - Added --add-opens flags to both org.gradle.jvmargs and kotlin.daemon.jvmargs
   - Kotlin compiler daemon runs separately and needs its own configuration
   - Applied to both plugin and test app gradle.properties files

These changes allow the plugin to build successfully with Java 21 and ensure
it's built in the correct context where Capacitor dependencies are available.
2025-12-31 12:53:02 +08:00
Jose Olarte III
8b116db095 refactor(test-app): reset default margins and padding in HTML
Add inline style resets to html, body, and #app elements to
eliminate browser default margins and padding. This ensures consistent layout
baseline across browsers and complements the centralized padding
management in App.vue.
2025-12-31 10:30:03 +08:00
Jose Olarte III
76c05e3690 refactor(test-app): centralize padding in root container
Remove individual padding declarations from view components and
set padding to 0 on App.vue root container. This consolidates
padding management in one place for easier maintenance and
consistent spacing control.
2025-12-31 10:17:22 +08:00
Jose Olarte III
f19ff4c127 Merge branch 'master' into ios-2 2025-12-31 09:56:16 +08:00
Matthew Raymer
839e167c98 chore: sync test app UI with improved recovery timing
- Update test app index.html with improved UI refresh timing after force-stop
- Includes immediate + delayed refresh pattern for better recovery detection
- Better error handling for database not ready scenarios
2025-12-30 10:26:24 +00:00
Matthew Raymer
f40562b68a fix: improve UI refresh timing after force-stop recovery
- Increase recovery delay from 1s to 3s (force-stop recovery can take time)
- Add immediate refresh + delayed refresh to catch recovery at different stages
- Better error handling for database not ready yet (shows 'Checking...' instead of error)
- Add logging to indicate when config not found is normal (after uninstall/reinstall)

Previously, after force-stop recovery:
- Configuration check happened too early (1s delay not enough)
- UI showed error immediately if database not ready
- Single refresh might miss recovery completion

The fix:
- Immediate refresh when app becomes visible (catches fast recovery)
- Delayed refresh after 3 seconds (catches slower recovery)
- Better error messages indicating database might not be ready yet
- Note that config not found is normal after uninstall/reinstall (database wiped)

This ensures UI properly refreshes configuration status and notification info
after force-stop recovery completes.
2025-12-30 10:07:14 +00:00
Matthew Raymer
f1830e5f6f fix: properly cancel alarms using FLAG_NO_CREATE and pendingIntent.cancel()
- Use FLAG_NO_CREATE to get existing PendingIntent instead of creating new one
- Call both alarmManager.cancel() AND pendingIntent.cancel() (matches scheduleExactNotification pattern)
- Fixes issue where cancellation failed, causing idempotence check to skip scheduling

Previously, cancelNotification():
- Used FLAG_UPDATE_CURRENT which could create new PendingIntent
- Only called alarmManager.cancel(), not pendingIntent.cancel()
- Result: PendingIntent still existed after cancellation, causing 'duplicate schedule' skip

The fix:
- Use FLAG_NO_CREATE to get existing PendingIntent (don't create if missing)
- Call both alarmManager.cancel() and pendingIntent.cancel() (matches pattern in scheduleExactNotification)
- This ensures proper cancellation so new alarms can be scheduled

This fixes Test 1 failure where alarms weren't appearing after scheduling.
2025-12-30 09:50:29 +00:00
60 changed files with 5260 additions and 623 deletions

View File

@@ -44,9 +44,11 @@ npx cap run android
## Prerequisites ## Prerequisites
### Required Software ### Required Software
- **Android Studio** (latest stable version) - **Android Studio** (latest stable version) - for Android development
- **Java 11+** (for Kotlin compilation) - **Java 11+** (for Kotlin compilation)
- **Android SDK** with API level 21+ - **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) - **Node.js** 16+ (for TypeScript compilation)
- **npm** or **yarn** (for dependency management) - **npm** or **yarn** (for dependency management)
@@ -54,11 +56,35 @@ npx cap run android
- **Gradle Wrapper** (included in project) - **Gradle Wrapper** (included in project)
- **Kotlin** (configured in build.gradle) - **Kotlin** (configured in build.gradle)
- **TypeScript** (for plugin interface) - **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 ### System Requirements
- **RAM**: 4GB minimum, 8GB recommended - **RAM**: 4GB minimum, 8GB recommended
- **Storage**: 2GB free space - **Storage**: 2GB free space
- **OS**: Windows 10+, macOS 10.14+, or Linux - **OS**: Windows 10+, macOS 10.14+, or Linux (iOS development requires macOS)
## Build Methods ## Build Methods
@@ -68,6 +94,7 @@ The project includes an automated build script that handles both TypeScript and
```bash ```bash
# Build all platforms # Build all platforms
# Requires npm & gradle (with Java)
./scripts/build-native.sh ./scripts/build-native.sh
# Build specific platform # Build specific platform
@@ -297,6 +324,8 @@ android/build/reports/tests/test/index.html
### iOS Native Build Process ### 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 #### 1. Navigate to iOS Directory
```bash ```bash
cd ios cd ios
@@ -307,6 +336,12 @@ cd ios
pod install 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 #### 3. Build Commands
```bash ```bash
# Build using Xcode command line # Build using Xcode command line
@@ -782,6 +817,13 @@ The project includes several automated build scripts in the `scripts/` directory
./scripts/build-native.sh --platform ios ./scripts/build-native.sh --platform ios
./scripts/build-native.sh --verbose ./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 # TimeSafari-specific builds
node scripts/build-timesafari.js node scripts/build-timesafari.js
@@ -948,6 +990,28 @@ adb logcat | grep DailyNotification
## Troubleshooting ## 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 ### Common Issues
#### Gradle Sync Failures #### Gradle Sync Failures
@@ -1019,6 +1083,39 @@ File → Project Structure → SDK Location
# Solution: Check Kotlin version in build.gradle # 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 #### Capacitor Integration Issues
```bash ```bash
# Problem: Plugin not found in Capacitor app # Problem: Plugin not found in Capacitor app

View File

@@ -5,6 +5,55 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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**: `docs/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 ## [2.1.0] - 2025-01-02
### Added ### Added

View File

@@ -1,76 +1,46 @@
refactor(android): Complete plugin refactoring and safety fixes (Batches 0-7) docs(building): update BUILDING.md with iOS prerequisites and clean-build script
Comprehensive refactoring to make DailyNotificationPlugin a thin adapter, Updates BUILDING.md to reflect recent changes in build-native.sh, especially
eliminate duplicated logic, remove unsafe operations, and harden security. the Xcode Command Line Tools prerequisite check and the clean-build script.
Batch 0 - Constants Centralization: Problem:
- Created DailyNotificationConstants.kt to eliminate magic numbers and duplicates - BUILDING.md didn't mention Xcode Command Line Tools prerequisite
- Centralized: PERMISSION_REQUEST_CODE, channel constants, intent actions/extras, (recently added to build-native.sh)
SharedPreferences keys, WorkManager tags, notification IDs - clean-build.sh script exists but wasn't documented
- Replaced duplicates across Plugin, PermissionManager, ChannelManager, Scheduler - iOS build troubleshooting lacked Command Line Tools guidance
Batch 1 - Permission Flow Unification: Changes:
- Created PermissionStatus.kt data class for unified permission reporting - Add Xcode Command Line Tools to Prerequisites section
- Added PermissionManager.getPermissionStatus() as single source of truth - Document installation command (xcode-select --install)
- Implemented PendingPermissionRequest tracking for reliable resume resolution - Include verification steps (xcode-select -p, xcodebuild -version)
- Replaced method-name-based resume logic with token-based tracking - Note that build script automatically checks for these tools
- Plugin now delegates all permission checks to PermissionManager - Explain that sqlite3 is part of Command Line Tools
Batch 2 - Notification Status Checker Hardening: - Document clean-build.sh script in Build Scripts section
- Modified NotificationStatusChecker to always check OS-level notification - Basic usage: ./scripts/clean-build.sh
enablement via NotificationManagerCompat.areNotificationsEnabled() - All options: --all, --clean-gradle-cache, --clean-derived-data,
- Added getReadinessReport() method providing comprehensive status with issues --reinstall-node
and actionable guidance - Explain when to use clean builds
- Plugin checkStatus() now uses readiness report
Batch 3 - Cancel Semantics Safety: - Enhance iOS Native Build Process section
- Removed unsafe brute-force cancellation loop (was trying request codes 0-100) - Add prerequisite note about Command Line Tools
- Cancellation now only targets alarms proven to exist in database - Include troubleshooting commands for pod install issues
- Prevents accidental cancellation of other alarms and false confidence - Reference prerequisites section for details
Batch 4 - Legacy Scheduler Removal: - Add comprehensive troubleshooting sections
- Removed unused legacy scheduleExactAlarm() method (48 lines) - Clean Build section at start of Troubleshooting
- All scheduling now goes through modern paths: - Recommends clean-build as first step for many issues
1. exactAlarmManager.scheduleAlarm() (if available) - Lists when to use clean builds
2. pendingIntentManager.scheduleExactAlarm() (modern path) - iOS Build Issues section
3. pendingIntentManager.scheduleWindowedAlarm() (fallback) - Command Line Tools configuration errors
- SQLite/linker issues and pkgx conflicts
- CocoaPods installation problems
- All with clear solutions and commands
Batch 5 - Input Contract Tightening: The documentation now accurately reflects:
- Enforced single input shape for updateStarredPlans: { planIds: string[] } - Xcode Command Line Tools as required iOS prerequisite
- Added validation: rejects non-array, non-string elements, empty strings - clean-build.sh as available build tool
- Legacy support: single string normalized to array (with warning) - Complete iOS troubleshooting workflow
- Clear error messages for contract violations
Batch 6 - Token Storage Security:
- Added explicit opt-in for JWT token persistence (persistToken: true)
- Default behavior: tokens NOT persisted (secure default)
- Security warnings logged when persistence is enabled
- Documents unencrypted storage risk
Batch 7 - Plugin Thinning:
- Moved getExactAlarmStatus() to PermissionManager.getExactAlarmStatus()
- Moved canRequestExactAlarmPermission() to PermissionManager
- Removed direct AlarmManager access in cancelAllNotifications()
- Delegated scheduleUserNotification/scheduleDualNotification permission
handling to PermissionManager.requestExactAlarmPermission()
- Removed unused imports: AlarmManager, PendingIntent, PowerManager,
NotificationManagerCompat
Result:
- Plugin is now a thin adapter delegating to services
- No duplicated permission logic
- No unsafe cancellation operations
- No legacy scheduler paths
- Secure token storage defaults
- Clear input contracts
- Comprehensive status reporting
Files modified: Files modified:
- DailyNotificationConstants.kt (new) - BUILDING.md
- PermissionStatus.kt (new)
- DailyNotificationPlugin.kt (thinned, ~500 lines refactored)
- PermissionManager.java (enhanced with status methods)
- NotificationStatusChecker.java (hardened)
- DailyNotificationScheduler.java (legacy removed)
Refs: Cursor directive Batches 0-7

View File

@@ -1,7 +1,7 @@
# Daily Notification Plugin # Daily Notification Plugin
**Author**: Matthew Raymer **Author**: Matthew Raymer
**Version**: 1.0.11 (see `package.json` for source of truth) **Version**: 1.2.0 (see `package.json` for source of truth)
**Created**: 2025-09-22 09:22:32 UTC **Created**: 2025-09-22 09:22:32 UTC
**Last Updated**: 2025-12-23 UTC **Last Updated**: 2025-12-23 UTC

View File

@@ -37,6 +37,7 @@ android {
} }
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8 sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8
} }
@@ -116,6 +117,8 @@ dependencies {
implementation "com.google.code.gson:gson:2.10.1" implementation "com.google.code.gson:gson:2.10.1"
implementation "androidx.core:core:1.12.0" implementation "androidx.core:core:1.12.0"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
// Room annotation processor - use kapt for Kotlin, annotationProcessor for Java // Room annotation processor - use kapt for Kotlin, annotationProcessor for Java
kapt "androidx.room:room-compiler:2.6.1" kapt "androidx.room:room-compiler:2.6.1"
annotationProcessor "androidx.room:room-compiler:2.6.1" annotationProcessor "androidx.room:room-compiler:2.6.1"

View File

@@ -22,7 +22,32 @@ org.gradle.caching=true
org.gradle.parallel=true org.gradle.parallel=true
# Increase memory for Gradle daemon # Increase memory for Gradle daemon
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m # Java 17+ requires --add-opens flags for KAPT to access internal compiler classes
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# Kotlin compiler daemon JVM arguments (required for KAPT with Java 17+)
# The Kotlin daemon runs separately and needs the same --add-opens flags
kotlin.daemon.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# Enable configuration cache # Enable configuration cache
org.gradle.configuration-cache=true org.gradle.configuration-cache=true

View File

@@ -76,11 +76,13 @@ class BootReceiver : BroadcastReceiver() {
// Reschedule AlarmManager notification // Reschedule AlarmManager notification
val nextRunTime = calculateNextRunTime(schedule) val nextRunTime = calculateNextRunTime(schedule)
if (nextRunTime > System.currentTimeMillis()) { if (nextRunTime > System.currentTimeMillis()) {
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig( val config = UserNotificationConfig(
enabled = schedule.enabled, enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification", title = title,
body = "Your daily update is ready", body = body,
sound = true, sound = true,
vibration = true, vibration = true,
priority = "normal" priority = "normal"
@@ -90,7 +92,8 @@ class BootReceiver : BroadcastReceiver() {
nextRunTime, nextRunTime,
config, config,
scheduleId = schedule.id, scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
) )
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}") Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
} }

View File

@@ -229,7 +229,7 @@ public class DailyNotificationFetcher {
content.getTitle(), content.getTitle(),
content.getBody(), content.getBody(),
content.getScheduledTime(), content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId() java.util.TimeZone.getDefault().getID()
); );
entity.priority = mapPriority(content.getPriority()); entity.priority = mapPriority(content.getPriority());
try { try {

View File

@@ -706,6 +706,34 @@ open class DailyNotificationPlugin : Plugin() {
scheduleDailyNotification(call) 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 * Check if exact alarms can be scheduled
* Helper method for internal use * Helper method for internal use
@@ -1134,12 +1162,12 @@ open class DailyNotificationPlugin : Plugin() {
} else { } else {
call.reject("Daily notification scheduling failed") call.reject("Daily notification scheduling failed")
} }
} catch (e: Exception) { } catch (e: Throwable) {
Log.e(TAG, "Failed to schedule daily notification", e) Log.e(TAG, "Failed to schedule daily notification", e)
call.reject("Daily notification scheduling failed: ${e.message}") call.reject("Daily notification scheduling failed: ${e.message}")
} }
} }
} catch (e: Exception) { } catch (e: Throwable) {
Log.e(TAG, "Schedule daily notification error", e) Log.e(TAG, "Schedule daily notification error", e)
call.reject("Daily notification error: ${e.message}") call.reject("Daily notification error: ${e.message}")
} }
@@ -2642,7 +2670,8 @@ object ScheduleHelper {
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime") Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
// Schedule AlarmManager notification as static reminder // Schedule AlarmManager notification as static reminder
// (doesn't require cached content) // (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
// this scheduleId and Android may still return the cancelled PendingIntent from cache.
NotifyReceiver.scheduleExactNotification( NotifyReceiver.scheduleExactNotification(
context, context,
nextRunTime, nextRunTime,
@@ -2650,50 +2679,15 @@ object ScheduleHelper {
isStaticReminder = true, isStaticReminder = true,
reminderId = scheduleId, reminderId = scheduleId,
scheduleId = scheduleId, scheduleId = scheduleId,
source = ScheduleSource.INITIAL_SETUP source = ScheduleSource.INITIAL_SETUP,
skipPendingIntentIdempotence = true
) )
// Always schedule prefetch 2 minutes before notification // Do not enqueue prefetch for static reminders: display is already in the NotifyReceiver
// (URL is optional - native fetcher will be used if registered) // alarm. Prefetch is for "fetch content then show"; for static reminders there is nothing
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before // to fetch. Enqueueing prefetch would cause the worker to use fallback content and
val delayMs = fetchTime - System.currentTimeMillis() // schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
// notifications at fire time.
if (delayMs > 0) {
// Schedule delayed prefetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", fetchTime)
.putInt("retry_count", 0)
.putBoolean("immediate", false)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i("ScheduleHelper", "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime, delayMs=$delayMs")
} else {
// Fetch time is in the past, schedule immediate fetch
val inputData = Data.Builder()
.putLong("scheduled_time", nextRunTime)
.putLong("fetch_time", System.currentTimeMillis())
.putInt("retry_count", 0)
.putBoolean("immediate", true)
.build()
val workRequest = OneTimeWorkRequestBuilder<DailyNotificationFetchWorker>()
.setInputData(inputData)
.addTag("prefetch")
.build()
WorkManager.getInstance(context).enqueue(workRequest)
Log.i("ScheduleHelper", "Immediate prefetch scheduled: notificationTime=$nextRunTime")
}
// Store schedule in database // Store schedule in database
val schedule = Schedule( val schedule = Schedule(
@@ -2706,8 +2700,36 @@ object ScheduleHelper {
) )
database.scheduleDao().upsert(schedule) database.scheduleDao().upsert(schedule)
true // 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 = com.timesafari.dailynotification.entities.NotificationContentEntity(
scheduleId,
"1.2.0",
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) { } catch (e: Exception) {
Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
}
true
} catch (e: Throwable) {
Log.e("ScheduleHelper", "Failed to schedule daily notification", e) Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
false false
} }

View File

@@ -109,7 +109,9 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
String workName = "display_" + notificationId; String workName = "display_" + notificationId;
// Extract static reminder extras from intent if present // Extract static reminder extras from intent if present
// Static reminders have title/body in Intent extras, not in storage // Static reminders have title/body in Intent extras, not in storage.
// Do NOT access DB on main thread here (Room disallows it); Worker will resolve
// missing title/body by schedule_id on a background thread (see plugin-feedback-android-rollover-double-fire).
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false); boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
String title = intent.getStringExtra("title"); String title = intent.getStringExtra("title");
String body = intent.getStringExtra("body"); String body = intent.getStringExtra("body");
@@ -119,13 +121,17 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
if (priority == null) { if (priority == null) {
priority = "normal"; priority = "normal";
} }
String scheduleId = intent.getStringExtra("schedule_id");
Data.Builder dataBuilder = new Data.Builder() Data.Builder dataBuilder = new Data.Builder()
.putString("notification_id", notificationId) .putString("notification_id", notificationId)
.putString("action", "display") .putString("action", "display")
.putBoolean("is_static_reminder", isStaticReminder); .putBoolean("is_static_reminder", isStaticReminder);
if (scheduleId != null && !scheduleId.isEmpty()) {
dataBuilder.putString("schedule_id", scheduleId);
}
// Add static reminder data if present // Add static reminder data when present (from Intent; Worker resolves from DB by schedule_id if missing)
if (isStaticReminder && title != null && body != null) { if (isStaticReminder && title != null && body != null) {
dataBuilder.putString("title", title) dataBuilder.putString("title", title)
.putString("body", body) .putString("body", body)
@@ -445,7 +451,8 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
false, // isStaticReminder false, // isStaticReminder
null, // reminderId null, // reminderId
scheduleId, scheduleId,
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
false // skipPendingIntentIdempotence rollover path does not skip
); );
Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId); Log.i(TAG, "Next notification scheduled via centralized function: scheduleId=" + scheduleId);

View File

@@ -154,8 +154,9 @@ public class DailyNotificationScheduler {
cancelNotification(duplicateId); cancelNotification(duplicateId);
} }
// Create intent for the notification // Create intent for the notification; setPackage ensures AlarmManager delivery on all OEMs
Intent intent = new Intent(context, DailyNotificationReceiver.class); Intent intent = new Intent(context, DailyNotificationReceiver.class);
intent.setPackage(context.getPackageName());
intent.setAction(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION); intent.setAction(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
intent.putExtra(com.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId()); intent.putExtra(com.timesafari.dailynotification.DailyNotificationConstants.EXTRA_NOTIFICATION_ID, content.getId());

View File

@@ -133,7 +133,7 @@ public class DailyNotificationWorker extends Worker {
NotificationContent content; NotificationContent content;
if (isStaticReminder) { if (isStaticReminder) {
// Static reminder: create NotificationContent from input data // Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
String title = inputData.getString("title"); String title = inputData.getString("title");
String body = inputData.getString("body"); String body = inputData.getString("body");
boolean sound = inputData.getBoolean("sound", true); boolean sound = inputData.getBoolean("sound", true);
@@ -142,7 +142,18 @@ public class DailyNotificationWorker extends Worker {
if (priority == null) { if (priority == null) {
priority = "normal"; priority = "normal";
} }
// Post-reboot/rollover: Intent may lack title/body; resolve from DB by canonical schedule_id
String scheduleId = inputData.getString("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) { if (title == null || body == null) {
Log.w(TAG, "DN|DISPLAY_SKIP static_reminder_missing_data id=" + notificationId); Log.w(TAG, "DN|DISPLAY_SKIP static_reminder_missing_data id=" + notificationId);
return Result.success(); return Result.success();
@@ -160,25 +171,35 @@ public class DailyNotificationWorker extends Worker {
Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title); Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title);
} else { } else {
// Regular notification: load from storage // Regular notification: load from storage (by notification_id, then by schedule_id for rollover/user content)
// Prefer Room storage; fallback to legacy SharedPreferences storage
content = getContentFromRoomOrLegacy(notificationId); content = getContentFromRoomOrLegacy(notificationId);
// Rollover/notify_* runs: prefer canonical reminder content by schedule_id so user text is shown
if (content == null) { String scheduleId = inputData.getString("schedule_id");
// Content not found - likely removed during deduplication or cleanup if (scheduleId != null && (content == null || content.getTitle() == null || content.getTitle().isEmpty()
// Return success instead of failure to prevent retries for intentionally removed notifications || content.getBody() == null || content.getBody().isEmpty())) {
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)"); NotificationContent canonical = getContentByScheduleId(scheduleId);
return Result.success(); // Success prevents retry loops for removed notifications if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
content = canonical;
content.setId(notificationId); // keep run id for display/dismiss
Log.d(TAG, "DN|DISPLAY_USE_CANONICAL id=" + notificationId + " schedule_id=" + scheduleId);
}
}
if (content == null) {
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
return Result.success();
} }
// Check if notification is ready to display
if (!content.isReadyToDisplay()) { if (!content.isReadyToDisplay()) {
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId); Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
return Result.success(); return Result.success();
} }
// JIT Freshness Re-check (Soft TTL) - skip when content has title/body from Room
// JIT Freshness Re-check (Soft TTL) - skip for static reminders boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
&& content.getBody() != null && !content.getBody().isEmpty();
if (!hasTitleBody) {
content = performJITFreshnessCheck(content); content = performJITFreshnessCheck(content);
} else {
Log.d(TAG, "DN|DISPLAY_USE_ROOM_CONTENT id=" + notificationId + " (skip JIT)");
}
} }
// Display the notification // Display the notification
@@ -540,18 +561,22 @@ public class DailyNotificationWorker extends Worker {
return; return;
} }
// Extract scheduleId from notificationId pattern or use fallback // Preserve static reminder semantics across rollover; use stable schedule_id so reschedule cancels this alarm
// Notification IDs are often "daily_${scheduleId}" Data inputData = getInputData();
String scheduleId = null; boolean preserveStaticReminder = inputData.getBoolean("is_static_reminder", false);
String cronExpression = null; String scheduleId = inputData.getString("schedule_id");
if (scheduleId == null || scheduleId.isEmpty()) {
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
String notificationId = content.getId(); String notificationId = content.getId();
if (notificationId != null && notificationId.startsWith("daily_")) { if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
scheduleId = notificationId; // Use notificationId as scheduleId scheduleId = notificationId;
} else if (notificationId != null && notificationId.startsWith("daily_")) {
scheduleId = notificationId;
} else { } else {
scheduleId = "daily_rollover_" + System.currentTimeMillis(); scheduleId = "daily_rollover_" + System.currentTimeMillis();
} }
}
String cronExpression = null;
String notificationId = content.getId();
// Calculate cron from current scheduled time (extract hour:minute) // Calculate cron from current scheduled time (extract hour:minute)
try { try {
@@ -581,20 +606,26 @@ public class DailyNotificationWorker extends Worker {
); );
// Use centralized scheduling function with ROLLOVER_ON_FIRE source // Use centralized scheduling function with ROLLOVER_ON_FIRE source
Log.d(TAG, "DN|ROLLOVER next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);
com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification( com.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
getApplicationContext(), getApplicationContext(),
nextScheduledTime, nextScheduledTime,
config, config,
false, // isStaticReminder preserveStaticReminder, // isStaticReminder preserve so next run keeps title/body
null, // reminderId preserveStaticReminder ? scheduleId : null, // reminderId
scheduleId, scheduleId,
com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE com.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
false // skipPendingIntentIdempotence rollover path does not skip
); );
// Log next scheduled time in readable format // Log next scheduled time in readable format
String nextTimeStr = formatScheduledTime(nextScheduledTime); String nextTimeStr = formatScheduledTime(nextScheduledTime);
Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId); 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) // Schedule background fetch for next notification (5 minutes before scheduled time)
try { try {
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext()); DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
@@ -604,25 +635,18 @@ public class DailyNotificationWorker extends Worker {
storageForFetcher, storageForFetcher,
roomStorageForFetcher roomStorageForFetcher
); );
// Calculate fetch time (5 minutes before notification)
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5); long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
long currentTime = System.currentTimeMillis(); long currentTime = System.currentTimeMillis();
if (fetchTime > currentTime) { if (fetchTime > currentTime) {
fetcher.scheduleFetch(fetchTime); fetcher.scheduleFetch(fetchTime);
Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() + Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() + " next_fetch=" + fetchTime + " next_notification=" + nextScheduledTime);
" next_fetch=" + fetchTime +
" next_notification=" + nextScheduledTime);
} else { } else {
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + " fetch_time=" + fetchTime + " current=" + currentTime);
" fetch_time=" + fetchTime +
" current=" + currentTime);
fetcher.scheduleImmediateFetch(); fetcher.scheduleImmediateFetch();
} }
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + " error scheduling prefetch", e);
" error scheduling prefetch", e); }
} }
} catch (Exception e) { } catch (Exception e) {
@@ -632,6 +656,28 @@ public class DailyNotificationWorker extends Worker {
} }
} }
/**
* Load notification content by canonical schedule id (for static reminder / rollover user text).
* Tries id then "daily_" + id to match getTitleBodyForSchedule / BootReceiver.
*/
private NotificationContent getContentByScheduleId(String scheduleId) {
if (scheduleId == null || scheduleId.isEmpty()) return null;
try {
com.timesafari.dailynotification.DailyNotificationDatabase db =
com.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 * Try to load content from Room; fallback to legacy storage
*/ */
@@ -688,13 +734,13 @@ public class DailyNotificationWorker extends Worker {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext()); DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
NotificationContentEntity entity = new NotificationContentEntity( NotificationContentEntity entity = new NotificationContentEntity(
content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(), content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
"1.0.0", "1.2.0",
null, null,
"daily", "daily",
content.getTitle(), content.getTitle(),
content.getBody(), content.getBody(),
content.getScheduledTime(), content.getScheduledTime(),
java.time.ZoneId.systemDefault().getId() java.util.TimeZone.getDefault().getID()
); );
entity.priority = mapPriorityToInt(content.getPriority()); entity.priority = mapPriorityToInt(content.getPriority());
try { try {

View File

@@ -17,7 +17,7 @@ import org.json.JSONObject
* Implements exponential backoff and network constraints * Implements exponential backoff and network constraints
* *
* @author Matthew Raymer * @author Matthew Raymer
* @version 1.1.0 * @version 1.2.0
*/ */
class FetchWorker( class FetchWorker(
appContext: Context, appContext: Context,
@@ -205,13 +205,13 @@ class FetchWorker(
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity( val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId, notificationId,
"1.0.2", // Plugin version "1.2.0", // Plugin version
null, // timesafariDid - can be set if available null, // timesafariDid - can be set if available
"daily", "daily",
title, title,
body, body,
notificationTime, notificationTime,
java.time.ZoneId.systemDefault().id java.util.TimeZone.getDefault().id
) )
entity.priority = 0 // default priority entity.priority = 0 // default priority
entity.vibrationEnabled = true entity.vibrationEnabled = true
@@ -301,7 +301,7 @@ class FetchWorker(
"timestamp": ${System.currentTimeMillis()}, "timestamp": ${System.currentTimeMillis()},
"content": "Daily notification content", "content": "Daily notification content",
"source": "mock_generator", "source": "mock_generator",
"version": "1.1.0" "version": "1.2.0"
} }
""".trimIndent() """.trimIndent()
return mockData.toByteArray() return mockData.toByteArray()

View File

@@ -21,7 +21,7 @@ import kotlinx.coroutines.runBlocking
* Implements TTL-at-fire logic and notification delivery * Implements TTL-at-fire logic and notification delivery
* *
* @author Matthew Raymer * @author Matthew Raymer
* @version 1.1.0 * @version 1.2.0
*/ */
/** /**
* Source of schedule request - tracks which code path triggered scheduling * Source of schedule request - tracks which code path triggered scheduling
@@ -122,6 +122,10 @@ class NotifyReceiver : BroadcastReceiver() {
* @param reminderId Optional reminder ID for tracking (used as scheduleId if provided) * @param reminderId Optional reminder ID for tracking (used as scheduleId if provided)
* @param scheduleId Stable identifier for the schedule (used for requestCode stability) * @param scheduleId Stable identifier for the schedule (used for requestCode stability)
* @param source Source of the scheduling request (for debugging duplicate alarms) * @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 @JvmStatic
fun scheduleExactNotification( fun scheduleExactNotification(
@@ -131,7 +135,8 @@ class NotifyReceiver : BroadcastReceiver() {
isStaticReminder: Boolean = false, isStaticReminder: Boolean = false,
reminderId: String? = null, reminderId: String? = null,
scheduleId: String? = null, scheduleId: String? = null,
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE,
skipPendingIntentIdempotence: Boolean = false
) { ) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
@@ -142,14 +147,16 @@ class NotifyReceiver : BroadcastReceiver() {
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time) // Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
val notificationId = reminderId ?: "notify_${triggerAtMillis}" 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 requestCode = getRequestCode(stableScheduleId)
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION" action = "com.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 // Check 1: Same scheduleId (stable requestCode) - most reliable
var existingPendingIntent = PendingIntent.getBroadcast( var existingPendingIntent = PendingIntent.getBroadcast(
context, context,
@@ -159,8 +166,6 @@ class NotifyReceiver : BroadcastReceiver() {
) )
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance) // 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) { if (existingPendingIntent == null) {
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis) val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
existingPendingIntent = PendingIntent.getBroadcast( existingPendingIntent = PendingIntent.getBroadcast(
@@ -171,15 +176,12 @@ class NotifyReceiver : BroadcastReceiver() {
) )
} }
// Check 3: Also check if AlarmManager already has an alarm for this exact time // Check 3: AlarmManager next alarm (Android 5.0+)
// 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) { if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val nextAlarm = alarmManager.nextAlarmClock val nextAlarm = alarmManager.nextAlarmClock
if (nextAlarm != null) { if (nextAlarm != null) {
val nextAlarmTime = nextAlarm.triggerTime val nextAlarmTime = nextAlarm.triggerTime
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis) 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) { if (timeDiff < 60000) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US) val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis)) .format(java.util.Date(triggerAtMillis))
@@ -197,9 +199,14 @@ class NotifyReceiver : BroadcastReceiver() {
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled") Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
return 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 // DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
// This prevents logical duplicates before even hitting AlarmManager // When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't
// cancel the alarm and then skip re-scheduling, resulting in no alarm.
if (!skipPendingIntentIdempotence) {
try { try {
runBlocking { runBlocking {
val db = DailyNotificationDatabase.getDatabase(context) val db = DailyNotificationDatabase.getDatabase(context)
@@ -220,6 +227,9 @@ class NotifyReceiver : BroadcastReceiver() {
} catch (e: Exception) { } catch (e: Exception) {
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e) 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) val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis)) .format(java.util.Date(triggerAtMillis))
@@ -241,13 +251,13 @@ class NotifyReceiver : BroadcastReceiver() {
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context) val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity( val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId, notificationId,
"1.0.2", // Plugin version "1.2.0", // Plugin version
null, // timesafariDid - can be set if available null, // timesafariDid - can be set if available
"daily", "daily",
config.title, config.title,
config.body ?: (if (contentCache != null) String(contentCache.payload) else ""), config.body ?: (if (contentCache != null) String(contentCache.payload) else ""),
triggerAtMillis, triggerAtMillis,
java.time.ZoneId.systemDefault().id java.util.TimeZone.getDefault().id
) )
entity.priority = when (config.priority) { entity.priority = when (config.priority) {
"high", "max" -> 2 "high", "max" -> 2
@@ -265,13 +275,15 @@ class NotifyReceiver : BroadcastReceiver() {
roomStorage.saveNotificationContent(entity).get() roomStorage.saveNotificationContent(entity).get()
Log.d(TAG, "Stored notification content in database: id=$notificationId (for recovery tracking)") Log.d(TAG, "Stored notification content in database: id=$notificationId (for recovery tracking)")
} }
} catch (e: Exception) { } catch (e: Throwable) {
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e) 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: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
// FIX: Set action to match manifest registration // FIX: Set action to match manifest registration; setPackage() ensures AlarmManager
// delivery reaches this app on all OEMs (see daily-notification-plugin-android-receiver-issue)
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
@@ -309,7 +321,8 @@ class NotifyReceiver : BroadcastReceiver() {
if (existingPendingIntent != null) { if (existingPendingIntent != null) {
Log.w(SCHEDULE_TAG, "Cancelling existing alarm before rescheduling: requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source") Log.w(SCHEDULE_TAG, "Cancelling existing alarm before rescheduling: requestCode=$requestCode, scheduleId=$stableScheduleId, source=$source")
alarmManager.cancel(existingPendingIntent) alarmManager.cancel(existingPendingIntent)
existingPendingIntent.cancel() // Do not call existingPendingIntent.cancel(): the cached PendingIntent may be the same
// object we pass to setAlarmClock below; cancelling it can prevent the new alarm from firing.
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(SCHEDULE_TAG, "Failed to cancel existing alarm before scheduling: $stableScheduleId", e) Log.w(SCHEDULE_TAG, "Failed to cancel existing alarm before scheduling: $stableScheduleId", e)
@@ -387,14 +400,18 @@ class NotifyReceiver : BroadcastReceiver() {
Log.i(TAG, "Exact alarm scheduled (setExact): triggerAt=$triggerAtMillis, requestCode=$requestCode") Log.i(TAG, "Exact alarm scheduled (setExact): triggerAt=$triggerAtMillis, requestCode=$requestCode")
} }
} catch (e: SecurityException) { } catch (e: Throwable) {
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e) Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e)
try {
alarmManager.set( alarmManager.set(
AlarmManager.RTC_WAKEUP, AlarmManager.RTC_WAKEUP,
triggerAtMillis, triggerAtMillis,
pendingIntent pendingIntent
) )
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode") Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
} catch (fallbackError: Throwable) {
Log.e(TAG, "Fallback alarm scheduling also failed", fallbackError)
}
} }
// Update database schedule with new nextRunAt so getNotificationStatus() returns correct value // Update database schedule with new nextRunAt so getNotificationStatus() returns correct value
@@ -408,9 +425,11 @@ class NotifyReceiver : BroadcastReceiver() {
// First, try to find schedule by the provided stableScheduleId // First, try to find schedule by the provided stableScheduleId
var scheduleToUpdate = db.scheduleDao().getById(stableScheduleId) var scheduleToUpdate = db.scheduleDao().getById(stableScheduleId)
// If not found by ID, find the existing enabled notify schedule (for rollover scenarios) // If not found by ID, only use "first enabled notify" fallback when this is NOT
// getNotificationStatus() finds schedules with kind="notify" && enabled=true // a rollover id (daily_rollover_*). Rollover work may use a different notification_id
if (scheduleToUpdate == null) { // (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() val allSchedules = db.scheduleDao().getAll()
scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled } scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled }
} }
@@ -447,8 +466,7 @@ class NotifyReceiver : BroadcastReceiver() {
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis") Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
} }
} }
} catch (e: Exception) { } catch (e: Throwable) {
// 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) Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
} }
} }
@@ -464,6 +482,7 @@ class NotifyReceiver : BroadcastReceiver() {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// FIX: Use DailyNotificationReceiver to match what was scheduled // FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION" action = "com.timesafari.daily.NOTIFICATION"
} }
val requestCode = when { val requestCode = when {
@@ -474,13 +493,21 @@ class NotifyReceiver : BroadcastReceiver() {
return return
} }
} }
val pendingIntent = PendingIntent.getBroadcast(
// CRITICAL: Use FLAG_NO_CREATE to get existing PendingIntent, don't create new one
// This matches the pattern used in scheduleExactNotification for proper cancellation
val existingPendingIntent = PendingIntent.getBroadcast(
context, context,
requestCode, requestCode,
intent, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
) )
alarmManager.cancel(pendingIntent)
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") Log.i(TAG, "DNP-CANCEL: Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
// Verify cancellation by checking if alarm still exists // Verify cancellation by checking if alarm still exists
@@ -495,6 +522,9 @@ class NotifyReceiver : BroadcastReceiver() {
} else { } else {
Log.w(TAG, "DNP-CANCEL: ⚠️ Cancellation may have failed - PendingIntent still exists for requestCode=$requestCode") 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")
}
} }
/** /**
@@ -508,6 +538,7 @@ class NotifyReceiver : BroadcastReceiver() {
fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean { fun isAlarmScheduled(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null): Boolean {
// FIX: Use DailyNotificationReceiver to match what was scheduled // FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION" action = "com.timesafari.daily.NOTIFICATION"
} }
val requestCode = when { val requestCode = when {

View File

@@ -42,6 +42,26 @@ class ReactivationManager(private val context: Context) {
private const val TAG = "DNP-REACTIVATION" private const val TAG = "DNP-REACTIVATION"
private const val RECOVERY_TIMEOUT_SECONDS = 2L 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 * Run boot-time recovery
* *
@@ -247,13 +267,13 @@ class ReactivationManager(private val context: Context) {
// Create new notification content entry for missed alarm // Create new notification content entry for missed alarm
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity( val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId, notificationId,
"1.0.2", // Plugin version "1.2.0", // Plugin version
null, // timesafariDid null, // timesafariDid
"daily", // notificationType "daily", // notificationType
"Daily Notification", "Daily Notification",
"Your daily update is ready", "Your daily update is ready",
scheduledTime, scheduledTime,
java.time.ZoneId.systemDefault().id java.util.TimeZone.getDefault().id
) )
notification.deliveryStatus = "missed" notification.deliveryStatus = "missed"
notification.lastDeliveryAttempt = System.currentTimeMillis() notification.lastDeliveryAttempt = System.currentTimeMillis()
@@ -275,11 +295,13 @@ class ReactivationManager(private val context: Context) {
db: DailyNotificationDatabase db: DailyNotificationDatabase
) { ) {
try { try {
val (title, body) = getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig( val config = UserNotificationConfig(
enabled = schedule.enabled, enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification", title = title,
body = "Your daily update is ready", body = body,
sound = true, sound = true,
vibration = true, vibration = true,
priority = "normal" priority = "normal"
@@ -290,7 +312,8 @@ class ReactivationManager(private val context: Context) {
nextRunTime, nextRunTime,
config, config,
scheduleId = schedule.id, scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
) )
// Update schedule in database (best effort) // Update schedule in database (best effort)
@@ -440,9 +463,9 @@ class ReactivationManager(private val context: Context) {
*/ */
private fun alarmsExist(): Boolean { private fun alarmsExist(): Boolean {
return try { return try {
// Check if any PendingIntent for our receiver exists // Check if any PendingIntent for our receiver exists (must match NotifyReceiver schedule path)
// This is more reliable than nextAlarmClock val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
val intent = Intent(context, NotifyReceiver::class.java).apply { setPackage(context.packageName)
action = "com.timesafari.daily.NOTIFICATION" action = "com.timesafari.daily.NOTIFICATION"
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
@@ -816,13 +839,13 @@ class ReactivationManager(private val context: Context) {
db: DailyNotificationDatabase db: DailyNotificationDatabase
) { ) {
try { try {
// Use existing BootReceiver logic for calculating next run time val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
// For now, use schedule.nextRunAt directly ?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig( val config = UserNotificationConfig(
enabled = schedule.enabled, enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification", title = title,
body = "Your daily update is ready", body = body,
sound = true, sound = true,
vibration = true, vibration = true,
priority = "normal" priority = "normal"
@@ -1014,13 +1037,13 @@ class ReactivationManager(private val context: Context) {
// Create new notification content entry for missed alarm // Create new notification content entry for missed alarm
val notification = com.timesafari.dailynotification.entities.NotificationContentEntity( val notification = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId, notificationId,
"1.0.2", // Plugin version "1.2.0", // Plugin version
null, // timesafariDid null, // timesafariDid
"daily", // notificationType "daily", // notificationType
"Daily Notification", "Daily Notification",
"Your daily update is ready", "Your daily update is ready",
scheduledTime, scheduledTime,
java.time.ZoneId.systemDefault().id java.util.TimeZone.getDefault().id
) )
notification.deliveryStatus = "missed" notification.deliveryStatus = "missed"
notification.lastDeliveryAttempt = System.currentTimeMillis() notification.lastDeliveryAttempt = System.currentTimeMillis()
@@ -1045,11 +1068,13 @@ class ReactivationManager(private val context: Context) {
db: DailyNotificationDatabase db: DailyNotificationDatabase
) { ) {
try { try {
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
?: Pair("Daily Notification", "Your daily update is ready")
val config = UserNotificationConfig( val config = UserNotificationConfig(
enabled = schedule.enabled, enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *", schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification", title = title,
body = "Your daily update is ready", body = body,
sound = true, sound = true,
vibration = true, vibration = true,
priority = "normal" priority = "normal"
@@ -1060,7 +1085,8 @@ class ReactivationManager(private val context: Context) {
nextRunTime, nextRunTime,
config, config,
scheduleId = schedule.id, scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY source = ScheduleSource.BOOT_RECOVERY,
skipPendingIntentIdempotence = true
) )
// Update schedule in database (best effort) // Update schedule in database (best effort)

View File

@@ -52,7 +52,7 @@ public class DailyNotificationStorageRoom {
private final ExecutorService executorService; private final ExecutorService executorService;
// Plugin version for migration tracking // Plugin version for migration tracking
private static final String PLUGIN_VERSION = "1.0.0"; private static final String PLUGIN_VERSION = "1.2.0";
/** /**
* Constructor * Constructor

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
# Running Android App in Standalone Emulator (Without Android Studio) # Running Android App in Standalone Emulator (Without Android Studio)
**Author**: Matthew Raymer **Author**: Matthew Raymer
**Last Updated**: 2025-10-12 06:50:00 UTC **Last Updated**: 2026-02-05
**Version**: 1.0.0 **Version**: 1.1.0
## Overview ## Overview
@@ -22,6 +22,81 @@ This guide demonstrates how to run the DailyNotification plugin test app in a st
- **Storage**: 2GB free space for emulator - **Storage**: 2GB free space for emulator
- **OS**: Linux, macOS, or Windows with WSL - **OS**: Linux, macOS, or Windows with WSL
## Checking and Installing Prerequisites
### How to check
Run these in a terminal. If a command is missing or a check fails, use the install steps below.
| Requirement | How to check |
|------------------|--------------|
| **Node.js** | `node --version` (v14+ recommended; test app may require 20+) |
| **npm** | `npm --version` |
| **Java** | `java -version` (Java 11+; build scripts expect 11+) |
| **ANDROID_HOME** | `echo $ANDROID_HOME` (must be set to your Android SDK root) |
| **adb** | `adb version` (must be on PATH; usually `$ANDROID_HOME/platform-tools/adb`) |
| **emulator** | `emulator -version` (must be on PATH; usually `$ANDROID_HOME/emulator/emulator`) |
| **At least one AVD** | `emulator -list-avds` (must list at least one device name) |
**Project script:** From the repo root you can run:
```bash
node scripts/check-environment.js
```
This checks Node, npm, Java, and `ANDROID_HOME`. It does **not** check `adb`, `emulator`, or AVDs—verify those manually as above.
### How to install
- **Node.js and npm**
- Install from [nodejs.org](https://nodejs.org/) (LTS), or on macOS: `brew install node`.
- **Java (JDK 11+)**
- macOS: `brew install openjdk@17` and follow the caveats to link (e.g. `sudo ln -sfn $(brew --prefix)/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk`).
- Or install [Eclipse Temurin](https://adoptium.net/) / [Oracle JDK](https://www.oracle.com/java/technologies/downloads/) and ensure `java` and `javac` are on your PATH.
- **Android SDK (without Android Studio)**
1. Download the [Command-line tools only](https://developer.android.com/studio#command-tools) package for your OS.
2. Create an SDK directory, e.g. `mkdir -p ~/android-sdk` and extract the zip so that you have `~/android-sdk/cmdline-tools/latest/` (the `bin` folder with `sdkmanager` and `avdmanager` must be inside `cmdline-tools/latest/`).
3. Set environment variables (add to `~/.zshrc` or `~/.bashrc`):
```bash
export ANDROID_HOME=$HOME/android-sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator
```
4. Install required SDK packages (accept licenses when prompted):
```bash
sdkmanager "platform-tools"
sdkmanager "emulator"
sdkmanager "platforms;android-35"
sdkmanager "build-tools;35.0.0"
```
Install a system image that matches your host CPU:
- **Apple Silicon (M1/M2/M3, aarch64):** `sdkmanager "system-images;android-35;google_apis;arm64-v8a"`
- **Intel Mac / Windows / Linux (x86_64):** `sdkmanager "system-images;android-35;google_apis;x86_64"`
5. Create at least one AVD (use the same image type you installed):
**Apple Silicon:**
```bash
avdmanager create avd -n Pixel8_API35 -k "system-images;android-35;google_apis;arm64-v8a" -d "pixel_8"
```
**Intel / x86_64:**
```bash
avdmanager create avd -n Pixel8_API35 -k "system-images;android-35;google_apis;x86_64" -d "pixel_8"
```
Then start the emulator with: `emulator -avd Pixel8_API35 -no-snapshot-load &` and use `adb wait-for-device` before building/installing the app.
- **Gradle**
The project uses the Gradle Wrapper (`gradlew`) inside the apps `android` directory. No separate Gradle install is needed.
After installing, run the checks again to confirm `adb`, `emulator`, and `emulator -list-avds` work.
## Step-by-Step Process ## Step-by-Step Process
### 1. Check Available Emulators ### 1. Check Available Emulators
@@ -31,21 +106,21 @@ This guide demonstrates how to run the DailyNotification plugin test app in a st
emulator -list-avds emulator -list-avds
# Example output: # Example output:
# Pixel8_API34 # Pixel8_API35
``` ```
### 2. Start the Emulator ### 2. Start the Emulator
```bash ```bash
# Start emulator in background (recommended) # Start emulator in background (recommended)
emulator -avd Pixel8_API34 -no-snapshot-load & emulator -avd Pixel8_API35 -no-snapshot-load &
# Alternative: Start in foreground # Alternative: Start in foreground
emulator -avd Pixel8_API34 emulator -avd Pixel8_API35
``` ```
**Flags Explained:** **Flags Explained:**
- `-avd Pixel8_API34` - Specifies the AVD to use - `-avd Pixel8_API35` - Specifies the AVD to use
- `-no-snapshot-load` - Forces fresh boot (recommended for testing) - `-no-snapshot-load` - Forces fresh boot (recommended for testing)
- `&` - Runs in background (optional) - `&` - Runs in background (optional)
@@ -141,7 +216,7 @@ adb logcat -c && adb logcat
```bash ```bash
# 1. Start emulator # 1. Start emulator
emulator -avd Pixel8_API34 -no-snapshot-load & emulator -avd Pixel8_API35 -no-snapshot-load &
# 2. Wait for emulator # 2. Wait for emulator
adb wait-for-device adb wait-for-device
@@ -211,7 +286,17 @@ ps aux | grep emulator
pkill -f emulator pkill -f emulator
# Start with verbose logging # Start with verbose logging
emulator -avd Pixel8_API34 -verbose emulator -avd Pixel8_API35 -verbose
```
#### "x86_64 is not supported by the QEMU2 emulator on aarch64 host"
On Apple Silicon (M1/M2/M3), the emulator cannot run x86_64 system images. Use an ARM64 image and AVD instead:
```bash
sdkmanager "system-images;android-35;google_apis;arm64-v8a"
avdmanager delete avd -n Pixel8_API35 # if you already created an x86_64 AVD
avdmanager create avd -n Pixel8_API35 -k "system-images;android-35;google_apis;arm64-v8a" -d "pixel_8"
emulator -avd Pixel8_API35 -no-snapshot-load
``` ```
#### ADB Connection Issues #### ADB Connection Issues
@@ -256,13 +341,13 @@ cd android && ./gradlew clean
#### Emulator Performance #### Emulator Performance
```bash ```bash
# Start with hardware acceleration # Start with hardware acceleration
emulator -avd Pixel8_API34 -accel on emulator -avd Pixel8_API35 -accel on
# Start with specific RAM allocation # Start with specific RAM allocation
emulator -avd Pixel8_API34 -memory 2048 emulator -avd Pixel8_API35 -memory 2048
# Start with GPU acceleration # Start with GPU acceleration
emulator -avd Pixel8_API34 -gpu host emulator -avd Pixel8_API35 -gpu host
``` ```
#### Build Performance #### Build Performance
@@ -336,7 +421,7 @@ adb shell am start -n com.timesafari.dailynotification/.MainActivity
### Automated Testing ### Automated Testing
```bash ```bash
# CI/CD pipeline # CI/CD pipeline
emulator -avd Pixel8_API34 -no-snapshot-load & emulator -avd Pixel8_API35 -no-snapshot-load &
adb wait-for-device adb wait-for-device
./scripts/build-native.sh --platform android ./scripts/build-native.sh --platform android
cd android && ./gradlew :app:assembleDebug cd android && ./gradlew :app:assembleDebug

View File

@@ -0,0 +1,519 @@
# 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 com.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 "com.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 "com.timesafari"
```
### Test Force Stop Recovery
1. Schedule a notification
2. Force stop the app:
```bash
adb shell am force-stop com.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 com.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 com.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 "com.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 "com.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 com.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 com.timesafari.dailynotification.test/.MainActivity
```
## Wireless ADB (Optional)
For cable-free development after initial setup:
```bash
# 1. Connect device via USB first
# 2. Enable TCP/IP mode on port 5555
adb tcpip 5555
# 3. Find device IP (Settings → About phone → Status → IP address)
# Or:
adb shell ip addr show wlan0 | grep inet
# 4. Disconnect USB and connect wirelessly
adb connect 192.168.1.100:5555
# 5. Verify connection
adb devices
# Should show: 192.168.1.100:5555 device
```
**Note:** Wireless ADB is slower than USB and may disconnect. Use USB for large APK transfers.
## Next Steps
### Testing Workflow
1. **Build** → Make changes, rebuild APK
2. **Install** → Push to device with `adb install -r`
3. **Test** → Exercise notification features
4. **Monitor** → Watch logs for issues
5. **Iterate** → Fix and repeat
### Recommended Test Sequence
1. ✅ Immediate notification display
2. ✅ Scheduled notification (1-2 min delay)
3. ✅ App backgrounded notification
4. ✅ Screen off notification
5. ✅ Device reboot alarm persistence
6. ✅ Force stop recovery (if implemented)
7. ✅ Battery optimization scenarios
---
**Physical device testing is essential for production-quality notification behavior.** While emulators are great for development, only real hardware reveals the true behavior of Android's notification and alarm systems. 📱

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'DailyNotificationPlugin' s.name = 'DailyNotificationPlugin'
s.version = '1.0.0' s.version = '1.2.0'
s.summary = 'Daily Notification Plugin for Capacitor' s.summary = 'Daily Notification Plugin for Capacitor'
s.license = 'MIT' s.license = 'MIT'
s.homepage = 'https://github.com/timesafari/daily-notification-plugin' s.homepage = 'https://github.com/timesafari/daily-notification-plugin'
@@ -11,7 +11,13 @@ Pod::Spec.new do |s|
s.dependency 'Capacitor', '>= 5.0.0' s.dependency 'Capacitor', '>= 5.0.0'
s.dependency 'CapacitorCordova', '>= 5.0.0' s.dependency 'CapacitorCordova', '>= 5.0.0'
s.swift_version = '5.1' s.swift_version = '5.1'
s.xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1' } # Explicitly link against system SQLite library to avoid conflicts with
# macOS SQLite libraries (e.g., from pkgx or other package managers that
# may set DYLD_LIBRARY_PATH or similar environment variables)
s.xcconfig = {
'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) COCOAPODS=1',
'OTHER_LDFLAGS' => '$(inherited) -lsqlite3'
}
s.deprecated = false s.deprecated = false
# Set to false so Capacitor can discover the plugin # Set to false so Capacitor can discover the plugin
# Capacitor iOS does not scan static frameworks for plugin discovery # Capacitor iOS does not scan static frameworks for plugin discovery

View File

@@ -7,10 +7,12 @@
// //
import Foundation import Foundation
import UIKit
import Capacitor import Capacitor
import UserNotifications import UserNotifications
import BackgroundTasks import BackgroundTasks
import CoreData import CoreData
import ObjectiveC
/** /**
* iOS implementation of Daily Notification Plugin * iOS implementation of Daily Notification Plugin
@@ -80,8 +82,50 @@ public class DailyNotificationPlugin: CAPPlugin {
object: nil object: nil
) )
NSLog("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
print("DNP-ROLLOVER: Observer registered for DailyNotificationDelivered notification")
// Register for app becoming active to check for missed rollovers
NotificationCenter.default.addObserver(
self,
selector: #selector(handleAppBecameActive(_:)),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NSLog("DNP-ROLLOVER: Observer registered for app becoming active")
print("DNP-ROLLOVER: Observer registered for app becoming active")
NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done") NSLog("DNP-DEBUG: DailyNotificationPlugin.load() completed - initialization done")
print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS") print("DNP-PLUGIN: Daily Notification Plugin loaded on iOS")
// Debug: Log all available @objc methods for Capacitor discovery
let methods = getObjCMethods()
NSLog("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
print("DNP-DEBUG: Available @objc methods: \(methods.joined(separator: ", "))")
}
/**
* Debug helper: Get all @objc methods for this class
*/
private func getObjCMethods() -> [String] {
var methods: [String] = []
var methodCount: UInt32 = 0
let methodList = class_copyMethodList(type(of: self), &methodCount)
for i in 0..<Int(methodCount) {
if let method = methodList?[i] {
let selector = method_getName(method)
let methodName = NSStringFromSelector(selector)
// Filter for methods that look like plugin methods (take CAPPluginCall)
if methodName.contains(":") && !methodName.hasPrefix("_") {
methods.append(methodName)
}
}
}
free(methodList)
return methods.sorted()
} }
// MARK: - Configuration Methods // MARK: - Configuration Methods
@@ -1120,6 +1164,129 @@ public class DailyNotificationPlugin: CAPPlugin {
} }
} }
/**
* Test method: Schedule an alarm to fire in a few seconds
* Useful for verifying alarm delivery works correctly
*
* @param call Plugin call with optional secondsFromNow (default: 5)
* @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number)
*/
@objc func testAlarm(_ call: CAPPluginCall) {
NSLog("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm() method CALLED - method is being invoked!")
print("DNP-DEBUG: testAlarm call data: \(call.jsObjectRepresentation)")
guard let scheduler = scheduler else {
NSLog("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
print("DNP-DEBUG: testAlarm() - scheduler is nil, rejecting")
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.PLUGIN_NOT_INITIALIZED,
message: "Plugin not initialized"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Get secondsFromNow parameter (default: 5)
let secondsFromNow = call.getInt("secondsFromNow") ?? 5
// Ensure minimum of 1 second (iOS requirement)
let validSeconds = max(1, secondsFromNow)
Task {
do {
// Check permissions first
let permissionStatus = await notificationCenter.notificationSettings()
if permissionStatus.authorizationStatus != .authorized && permissionStatus.authorizationStatus != .provisional {
let error = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.NOTIFICATIONS_DENIED,
message: "Notification permissions not granted"
)
let errorMessage = error["message"] as? String ?? "Unknown error"
let errorCode = error["error"] as? String ?? "unknown_error"
call.reject(errorMessage, errorCode)
return
}
// Create test notification content
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "Test Notification"
notificationContent.body = "This is a test notification scheduled \(validSeconds) seconds from now"
notificationContent.sound = .default
notificationContent.categoryIdentifier = "DAILY_NOTIFICATION"
notificationContent.userInfo = [
"notification_id": "test_\(Date().timeIntervalSince1970)",
"is_test": true
]
// Create time interval trigger (fires in X seconds)
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(validSeconds), repeats: false)
// Create notification request with unique ID
let notificationId = "test_alarm_\(Date().timeIntervalSince1970)"
let request = UNNotificationRequest(
identifier: notificationId,
content: notificationContent,
trigger: trigger
)
// Schedule notification
try await notificationCenter.add(request)
// Calculate trigger time in milliseconds
let triggerAtMillis = Int64((Date().timeIntervalSince1970 + Double(validSeconds)) * 1000)
let result: [String: Any] = [
"scheduled": true,
"secondsFromNow": validSeconds,
"triggerAtMillis": triggerAtMillis
]
print("DNP-PLUGIN: Test alarm scheduled for \(validSeconds) seconds from now (triggerAtMillis=\(triggerAtMillis))")
NSLog("DNP-DEBUG: testAlarm() - Successfully scheduled, resolving with result: \(result)")
DispatchQueue.main.async {
NSLog("DNP-DEBUG: testAlarm() - Resolving call with result")
call.resolve(result)
}
} catch {
NSLog("DNP-DEBUG: testAlarm() - Error caught: \(error)")
print("DNP-PLUGIN: Error scheduling test alarm: \(error)")
let errorResponse = DailyNotificationErrorCodes.createErrorResponse(
code: DailyNotificationErrorCodes.SCHEDULING_FAILED,
message: "Failed to schedule test alarm: \(error.localizedDescription)"
)
let errorMessage = errorResponse["message"] as? String ?? "Unknown error"
let errorCode = errorResponse["error"] as? String ?? "unknown_error"
NSLog("DNP-DEBUG: testAlarm() - Rejecting with error: \(errorMessage) (\(errorCode))")
DispatchQueue.main.async {
call.reject(errorMessage, errorCode)
}
}
}
}
/**
* Debug method: List all available plugin methods
* Useful for verifying Capacitor method discovery
*
* @param call Plugin call
*/
@objc func listAvailableMethods(_ call: CAPPluginCall) {
let methods = getObjCMethods()
let result: [String: Any] = [
"methods": methods,
"count": methods.count,
"testAlarmFound": methods.contains("testAlarm:")
]
NSLog("DNP-DEBUG: listAvailableMethods() - Found \(methods.count) methods")
NSLog("DNP-DEBUG: testAlarm: found: \(methods.contains("testAlarm:"))")
call.resolve(result)
}
/** /**
* Get the last notification that was delivered * Get the last notification that was delivered
* *
@@ -1253,13 +1420,22 @@ public class DailyNotificationPlugin: CAPPlugin {
* @param notification NSNotification with userInfo containing notification_id and scheduled_time * @param notification NSNotification with userInfo containing notification_id and scheduled_time
*/ */
@objc private func handleNotificationDelivery(_ notification: Notification) { @objc private func handleNotificationDelivery(_ notification: Notification) {
NSLog("DNP-ROLLOVER: handleNotificationDelivery called")
print("DNP-ROLLOVER: handleNotificationDelivery called")
// Extract notification data from userInfo // Extract notification data from userInfo
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let notificationId = userInfo["notification_id"] as? String, let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 else { let scheduledTime = userInfo["scheduled_time"] as? Int64 else {
NSLog("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=%@", notification.userInfo ?? "nil")
print("DNP-ROLLOVER: ERROR handleNotificationDelivery missing required data userInfo=\(notification.userInfo ?? [:])")
return return
} }
let scheduledTimeStr = formatTime(scheduledTime)
NSLog("DNP-ROLLOVER: handleNotificationDelivery processing id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
print("DNP-ROLLOVER: handleNotificationDelivery processing id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
// Track notify execution // Track notify execution
let currentTime = Int64(Date().timeIntervalSince1970 * 1000) let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
storage?.saveLastNotifyExecution(timestamp: currentTime) storage?.saveLastNotifyExecution(timestamp: currentTime)
@@ -1270,6 +1446,23 @@ public class DailyNotificationPlugin: CAPPlugin {
} }
} }
/**
* Handle app becoming active (foreground)
*
* This is called when the app becomes active to check for missed rollovers
* that occurred while the app was backgrounded.
*
* @param notification NSNotification for app becoming active
*/
@objc private func handleAppBecameActive(_ notification: Notification) {
NSLog("DNP-ROLLOVER: handleAppBecameActive called")
print("DNP-ROLLOVER: handleAppBecameActive called")
// Perform lightweight rollover check when app becomes active
// This handles cases where notifications fired while app was backgrounded
reactivationManager?.performActiveRolloverCheck()
}
/** /**
* Process rollover for delivered notification * Process rollover for delivered notification
* *
@@ -1277,15 +1470,33 @@ public class DailyNotificationPlugin: CAPPlugin {
* @param scheduledTime Scheduled time of delivered notification * @param scheduledTime Scheduled time of delivered notification
*/ */
private func processRollover(notificationId: String, scheduledTime: Int64) async { private func processRollover(notificationId: String, scheduledTime: Int64) async {
let scheduledTimeStr = formatTime(scheduledTime)
NSLog("DNP-ROLLOVER: processRollover START id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
print("DNP-ROLLOVER: processRollover START id=\(notificationId) scheduled_time=\(scheduledTimeStr)")
guard let scheduler = scheduler, let storage = storage else { guard let scheduler = scheduler, let storage = storage else {
NSLog("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=%@ storage=%@",
scheduler != nil ? "present" : "nil", storage != nil ? "present" : "nil")
print("DNP-ROLLOVER: ERROR processRollover missing scheduler or storage scheduler=\(scheduler != nil ? "present" : "nil") storage=\(storage != nil ? "present" : "nil")")
return return
} }
// Get the notification content that was delivered // Get the notification content that was delivered
guard let content = storage.getNotificationContent(id: notificationId) else { guard let content = storage.getNotificationContent(id: notificationId) else {
NSLog("DNP-ROLLOVER: ERROR processRollover content not found in storage id=%@", notificationId)
print("DNP-ROLLOVER: ERROR processRollover content not found in storage id=\(notificationId)")
// Log available notification IDs for debugging
let allNotifications = storage.getAllNotifications()
let availableIds = allNotifications.map { $0.id }.joined(separator: ", ")
NSLog("DNP-ROLLOVER: Available notification IDs in storage: [%@]", availableIds)
print("DNP-ROLLOVER: Available notification IDs in storage: [\(availableIds)]")
return return
} }
NSLog("DNP-ROLLOVER: processRollover found content id=%@ calling scheduleNextNotification", notificationId)
print("DNP-ROLLOVER: processRollover found content id=\(notificationId) calling scheduleNextNotification")
// Delegate to scheduler to schedule next notification (glue logic - will be moved to service) // Delegate to scheduler to schedule next notification (glue logic - will be moved to service)
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented) // Note: fetcher parameter is unused - scheduler uses fetchScheduler instead (already implemented)
let scheduled = await scheduler.scheduleNextNotification( let scheduled = await scheduler.scheduleNextNotification(
@@ -1294,8 +1505,15 @@ public class DailyNotificationPlugin: CAPPlugin {
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
) )
if scheduled {
NSLog("DNP-ROLLOVER: processRollover SUCCESS id=%@ next notification scheduled", notificationId)
print("DNP-ROLLOVER: processRollover SUCCESS id=\(notificationId) next notification scheduled")
} else {
NSLog("DNP-ROLLOVER: processRollover FAILED id=%@ scheduleNextNotification returned false", notificationId)
print("DNP-ROLLOVER: processRollover FAILED id=\(notificationId) scheduleNextNotification returned false")
}
// Rollover processing is non-fatal - recovery will handle on next launch if needed // Rollover processing is non-fatal - recovery will handle on next launch if needed
_ = scheduled
} }
/** /**
@@ -1943,6 +2161,7 @@ public class DailyNotificationPlugin: CAPPlugin {
methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "configureNativeFetcher", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "configureNativeFetcher", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "scheduleDailyNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "testAlarm", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getLastNotification", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "cancelAllNotifications", returnType: CAPPluginReturnPromise))
methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise)) methods.append(CAPPluginMethod(name: "getNotificationStatus", returnType: CAPPluginReturnPromise))

View File

@@ -94,6 +94,35 @@ class DailyNotificationReactivationManager {
// MARK: - Recovery Execution // MARK: - Recovery Execution
/**
* Perform lightweight rollover check when app becomes active
*
* This is called when the app becomes active (foreground) to check for
* missed rollovers that occurred while the app was backgrounded.
*
* This is a lightweight check that only:
* 1. Checks for delivered notifications and triggers rollover
* 2. Detects and processes missed rollovers
*
* It does NOT perform full recovery (missed notification marking, rescheduling, etc.)
* Full recovery only happens on app launch.
*
* This handles the "inactive app" scenario where notifications fire while
* the app is backgrounded and rollover doesn't happen.
*/
func performActiveRolloverCheck() {
Task {
NSLog("\(Self.TAG): Performing active rollover check (app became active)")
// Check for delivered notifications and trigger rollover
await checkAndProcessDeliveredNotifications()
// Check for missed rollovers (notifications that should have rolled over)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Active rollover check completed: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
}
}
/** /**
* Perform recovery on app launch * Perform recovery on app launch
* *
@@ -171,7 +200,22 @@ class DailyNotificationReactivationManager {
self.updateLastLaunchTime() self.updateLastLaunchTime()
return return
case .warmStart: case .warmStart:
NSLog("\(Self.TAG): Warm start detected - no recovery needed") NSLog("\(Self.TAG): Warm start detected - checking for missed rollovers")
// Even in warm start, we need to check for missed rollovers
// This handles cases where notifications fired while app was backgrounded
let warmStartTime = Date()
// Check for delivered notifications and trigger rollover
await self.checkAndProcessDeliveredNotifications()
// Check for missed rollovers (notifications that should have rolled over)
let rolloverResult = await self.detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Warm start rollover check: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
let warmEndTime = Date()
let duration = warmEndTime.timeIntervalSince(warmStartTime) * 1000 // ms
NSLog("\(Self.TAG): Warm start rollover check completed: duration=%.0fms", duration)
self.updateLastLaunchTime() self.updateLastLaunchTime()
return return
case .coldStart: case .coldStart:
@@ -398,6 +442,11 @@ class DailyNotificationReactivationManager {
// This handles notifications that were delivered while app was not running // This handles notifications that were delivered while app was not running
await checkAndProcessDeliveredNotifications() await checkAndProcessDeliveredNotifications()
// Step 4.6: Check for missed rollovers (notifications that should have rolled over)
// This handles notifications that fired but rollover didn't happen (app was terminated)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Missed rollover recovery: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
// Record recovery in history // Record recovery in history
let result = RecoveryResult( let result = RecoveryResult(
missedCount: missedCount, missedCount: missedCount,
@@ -734,6 +783,11 @@ class DailyNotificationReactivationManager {
let verificationResult = try await verifyFutureNotifications() let verificationResult = try await verifyFutureNotifications()
NSLog("\(Self.TAG): Final verification: found=\(verificationResult.notificationsFound), missing=\(verificationResult.notificationsMissing)") NSLog("\(Self.TAG): Final verification: found=\(verificationResult.notificationsFound), missing=\(verificationResult.notificationsMissing)")
// Step 8: Check for missed rollovers (notifications that should have rolled over)
// This handles notifications that fired but rollover didn't happen (app was terminated)
let rolloverResult = await detectAndProcessMissedRollovers()
NSLog("\(Self.TAG): Missed rollover recovery: processed=\(rolloverResult.processedCount), failed=\(rolloverResult.failedCount), checked=\(rolloverResult.totalChecked)")
// Record recovery in history // Record recovery in history
let result = RecoveryResult( let result = RecoveryResult(
missedCount: missedCount, missedCount: missedCount,
@@ -1084,6 +1138,185 @@ class DailyNotificationReactivationManager {
print("DNP-ROLLOVER: RECOVERY_COMPLETE processed=\(processedCount) skipped=\(skippedCount)") print("DNP-ROLLOVER: RECOVERY_COMPLETE processed=\(processedCount) skipped=\(skippedCount)")
} }
/**
* Detect and process missed rollovers on app launch
*
* This method identifies notifications that should have rolled over but didn't,
* and schedules the next notification(s) for them.
*
* Detection Logic:
* 1. Find notifications where scheduledTime < currentTime (should have fired)
* 2. Check if next notification exists (in storage or pending)
* 3. Check if rollover was already processed (via lastRolloverTime)
* 4. If no next notification and rollover not processed, schedule it
*
* This handles cases where:
* - Notification fired while app was terminated
* - Notification was dismissed before app launched
* - Rollover didn't happen because app wasn't active
*
* Error Handling:
* - Individual notification errors are caught and counted
* - Partial results returned if some operations fail
* - All errors logged but don't stop recovery process
*
* @return RolloverRecoveryResult with counts of processed rollovers
*/
private func detectAndProcessMissedRollovers() async -> RolloverRecoveryResult {
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_CHECK_START")
print("DNP-ROLLOVER: MISSED_ROLLOVER_CHECK_START")
let currentTime = Int64(Date().timeIntervalSince1970 * 1000)
let currentTimeStr = formatTime(currentTime)
// Step 1: Get all notifications from storage
let allNotifications: [NotificationContent]
do {
allNotifications = storage.getAllNotifications()
} catch {
// Non-fatal: Log error and return empty result
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
return RolloverRecoveryResult(processedCount: 0, failedCount: 0, totalChecked: 0)
}
// Step 2: Get pending notifications from system
let pendingRequests: [UNNotificationRequest]
do {
pendingRequests = try await notificationCenter.pendingNotificationRequests()
} catch {
// Non-fatal: Log error and continue with empty pending list
NSLog("\(Self.TAG): Error getting pending notifications (non-fatal): \(error.localizedDescription)")
return RolloverRecoveryResult(processedCount: 0, failedCount: 0, totalChecked: allNotifications.count)
}
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Step 3: Find notifications that should have rolled over
var missedRollovers: [NotificationContent] = []
for notification in allNotifications {
// Check if notification should have fired (scheduledTime < currentTime)
if notification.scheduledTime >= currentTime {
continue // Future notification, skip
}
// Check if rollover was already processed
// Only skip if rollover was processed AND next notification exists
// This handles cases where rollover was attempted but failed
let lastRolloverTime = await storage.getLastRolloverTime(for: notification.id)
// Calculate next scheduled time first to check if it exists
var nextScheduledTime = scheduler.calculateNextScheduledTime(notification.scheduledTime)
// If next scheduled time is in the past, keep calculating forward until we get a future time
// This handles cases where the notification fired more than 2 minutes ago
while nextScheduledTime < currentTime {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_NEXT_IN_PAST id=\(notification.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
print("DNP-ROLLOVER: MISSED_ROLLOVER_NEXT_IN_PAST id=\(notification.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
nextScheduledTime = scheduler.calculateNextScheduledTime(nextScheduledTime)
}
// Check if next notification actually exists
let toleranceMs: Int64 = 60 * 1000 // 1 minute tolerance
var nextNotificationExists = false
// Quick check in storage (exclude original)
for existing in allNotifications {
if existing.id == notification.id {
continue
}
if abs(existing.scheduledTime - nextScheduledTime) <= toleranceMs {
nextNotificationExists = true
break
}
}
// Quick check in pending
if !nextNotificationExists {
for pending in pendingRequests {
if pending.identifier == notification.id {
continue
}
if let trigger = pending.trigger as? UNCalendarNotificationTrigger,
let nextDate = trigger.nextTriggerDate() {
let pendingTime = Int64(nextDate.timeIntervalSince1970 * 1000)
if abs(pendingTime - nextScheduledTime) <= toleranceMs {
nextNotificationExists = true
break
}
}
}
}
// If rollover was processed AND next notification exists, skip
// Otherwise, process it (either rollover wasn't attempted, or it failed)
if let lastTime = lastRolloverTime, lastTime >= notification.scheduledTime, nextNotificationExists {
let lastTimeStr = formatTime(lastTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_SKIP id=\(notification.id) already_processed last_rollover=\(lastTimeStr) next_exists=true")
print("DNP-ROLLOVER: MISSED_ROLLOVER_SKIP id=\(notification.id) already_processed last_rollover=\(lastTimeStr) next_exists=true")
continue // Already processed and next notification exists
}
// If rollover was attempted but next notification doesn't exist, log and continue processing
if let lastTime = lastRolloverTime, lastTime >= notification.scheduledTime, !nextNotificationExists {
let lastTimeStr = formatTime(lastTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_RETRY id=\(notification.id) rollover_attempted=\(lastTimeStr) but_next_missing, will_retry")
print("DNP-ROLLOVER: MISSED_ROLLOVER_RETRY id=\(notification.id) rollover_attempted=\(lastTimeStr) but_next_missing, will_retry")
// Continue to process - rollover was attempted but failed
}
// Re-check if next notification exists (we already calculated nextScheduledTime above)
// This is the final check before adding to missed rollovers list
if !nextNotificationExists {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_DETECTED id=\(notification.id) scheduled_time=\(formatTime(notification.scheduledTime)) next_time=\(nextTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_DETECTED id=\(notification.id) scheduled_time=\(formatTime(notification.scheduledTime)) next_time=\(nextTimeStr)")
missedRollovers.append(notification)
}
}
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_FOUND count=\(missedRollovers.count) current_time=\(currentTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_FOUND count=\(missedRollovers.count) current_time=\(currentTimeStr)")
// Step 4: Process missed rollovers
var processedCount = 0
var failedCount = 0
for notification in missedRollovers {
let scheduledTimeStr = formatTime(notification.scheduledTime)
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_PROCESS id=\(notification.id) scheduled_time=\(scheduledTimeStr)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_PROCESS id=\(notification.id) scheduled_time=\(scheduledTimeStr)")
// Schedule next notification using existing rollover logic
// Note: fetcher parameter is unused - scheduler uses fetchScheduler instead
let scheduled = await scheduler.scheduleNextNotification(
notification,
storage: storage,
fetcher: nil // Unused - fetchScheduler handles prefetch scheduling
)
if scheduled {
processedCount += 1
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_SUCCESS id=\(notification.id)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_SUCCESS id=\(notification.id)")
} else {
failedCount += 1
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_FAILED id=\(notification.id)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_FAILED id=\(notification.id)")
}
}
NSLog("DNP-ROLLOVER: MISSED_ROLLOVER_COMPLETE processed=\(processedCount) failed=\(failedCount) total_checked=\(allNotifications.count)")
print("DNP-ROLLOVER: MISSED_ROLLOVER_COMPLETE processed=\(processedCount) failed=\(failedCount) total_checked=\(allNotifications.count)")
return RolloverRecoveryResult(
processedCount: processedCount,
failedCount: failedCount,
totalChecked: allNotifications.count
)
}
/** /**
* Format time for logging * Format time for logging
* *
@@ -1132,6 +1365,15 @@ struct VerificationResult {
let missingIds: [String] let missingIds: [String]
} }
/**
* Rollover recovery result
*/
struct RolloverRecoveryResult {
let processedCount: Int
let failedCount: Int
let totalChecked: Int
}
/** /**
* Reactivation errors * Reactivation errors
*/ */

View File

@@ -389,6 +389,10 @@ class DailyNotificationScheduler {
* *
* @param currentScheduledTime Current scheduled time in milliseconds * @param currentScheduledTime Current scheduled time in milliseconds
* @return Next scheduled time in milliseconds (24 hours later) * @return Next scheduled time in milliseconds (24 hours later)
*
* TESTING: To test with shorter intervals (e.g., 2 minutes), change:
* - Line ~404: `.hour, value: 24` `.minute, value: 2`
* - Line ~407: `(24 * 60 * 60 * 1000)` `(2 * 60 * 1000)`
*/ */
func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 { func calculateNextScheduledTime(_ currentScheduledTime: Int64) -> Int64 {
let calendar = Calendar.current let calendar = Calendar.current
@@ -396,8 +400,10 @@ class DailyNotificationScheduler {
let currentTimeStr = formatTime(currentScheduledTime) let currentTimeStr = formatTime(currentScheduledTime)
// Add 24 hours (handles DST transitions automatically) // Add 24 hours (handles DST transitions automatically)
// TESTING: Change `.hour, value: 24` to `.minute, value: 2` for 2-minute testing
guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else { guard let nextDate = calendar.date(byAdding: .hour, value: 24, to: currentDate) else {
// Fallback to simple 24-hour addition if calendar calculation fails // Fallback to simple 24-hour addition if calendar calculation fails
// TESTING: Change `(24 * 60 * 60 * 1000)` to `(2 * 60 * 1000)` for 2-minute testing
let fallbackTime = currentScheduledTime + (24 * 60 * 60 * 1000) let fallbackTime = currentScheduledTime + (24 * 60 * 60 * 1000)
let fallbackTimeStr = formatTime(fallbackTime) let fallbackTimeStr = formatTime(fallbackTime)
NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)") NSLog("DNP-ROLLOVER: DST_CALC_FAILED current=\(currentTimeStr) using_fallback=\(fallbackTimeStr)")
@@ -456,6 +462,7 @@ class DailyNotificationScheduler {
let lastRolloverTime = await storage.getLastRolloverTime(for: content.id) let lastRolloverTime = await storage.getLastRolloverTime(for: content.id)
// If rollover was processed recently (< 1 hour ago), skip // If rollover was processed recently (< 1 hour ago), skip
// TESTING: Change `(60 * 60 * 1000)` to `(60 * 1000)` for 1-minute threshold when testing with 2-minute intervals
if let lastTime = lastRolloverTime, if let lastTime = lastRolloverTime,
(currentTime - lastTime) < (60 * 60 * 1000) { (currentTime - lastTime) < (60 * 60 * 1000) {
let lastTimeStr = formatTime(lastTime) let lastTimeStr = formatTime(lastTime)
@@ -467,7 +474,17 @@ class DailyNotificationScheduler {
} }
// Calculate next occurrence using DST-safe calculation // Calculate next occurrence using DST-safe calculation
let nextScheduledTime = calculateNextScheduledTime(content.scheduledTime) var nextScheduledTime = calculateNextScheduledTime(content.scheduledTime)
// If next scheduled time is in the past, keep calculating forward until we get a future time
// This handles cases where the notification fired more than 2 minutes ago
while nextScheduledTime < currentTime {
let nextTimeStr = formatTime(nextScheduledTime)
NSLog("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
print("DNP-ROLLOVER: NEXT_IN_PAST id=\(content.id) next_time=\(nextTimeStr) current_time=\(currentTimeStr), calculating forward")
nextScheduledTime = calculateNextScheduledTime(nextScheduledTime)
}
let nextScheduledTimeStr = formatTime(nextScheduledTime) let nextScheduledTimeStr = formatTime(nextScheduledTime)
let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0 let hoursUntilNext = Double(nextScheduledTime - currentTime) / 1000.0 / 60.0 / 60.0
@@ -538,6 +555,12 @@ class DailyNotificationScheduler {
let scheduled = await scheduleNotification(nextContent) let scheduled = await scheduleNotification(nextContent)
if scheduled { if scheduled {
// Save notification content to storage so it can be retrieved when rollover fires
// This is critical: without saving, processRollover won't find the content
storage?.saveNotificationContent(nextContent)
NSLog("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
print("DNP-ROLLOVER: SAVED id=\(content.id) next_id=\(nextId) content saved to storage")
// Verify the notification was actually scheduled // Verify the notification was actually scheduled
let pendingCount = await getPendingNotificationCount() let pendingCount = await getPendingNotificationCount()
let isScheduled = await isNotificationScheduled(id: nextId) let isScheduled = await isNotificationScheduled(id: nextId)

View File

@@ -1,29 +0,0 @@
PODS:
- Capacitor (6.2.1):
- CapacitorCordova
- CapacitorCordova (6.2.1)
- DailyNotificationPlugin (1.0.0):
- Capacitor (>= 5.0.0)
- CapacitorCordova (>= 5.0.0)
DEPENDENCIES:
- "Capacitor (from `../node_modules/@capacitor/ios`)"
- "CapacitorCordova (from `../node_modules/@capacitor/ios`)"
- DailyNotificationPlugin (from `.`)
EXTERNAL SOURCES:
Capacitor:
:path: "../node_modules/@capacitor/ios"
CapacitorCordova:
:path: "../node_modules/@capacitor/ios"
DailyNotificationPlugin:
:path: "."
SPEC CHECKSUMS:
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
DailyNotificationPlugin: bb72fde9eab3704a4e70af3c868a789da0650ddc
PODFILE CHECKSUM: ac8c229d24347f6f83e67e6b95458e0b81e68f7c
COCOAPODS: 1.16.2

View File

@@ -1,6 +1,6 @@
{ {
"name": "@timesafari/daily-notification-plugin", "name": "@timesafari/daily-notification-plugin",
"version": "1.0.11", "version": "1.2.0",
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms", "description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
"main": "dist/plugin.js", "main": "dist/plugin.js",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",
@@ -14,6 +14,7 @@
"build:all": "npm run build:timesafari", "build:all": "npm run build:timesafari",
"clean": "rimraf ./dist", "clean": "rimraf ./dist",
"watch": "tsc --watch", "watch": "tsc --watch",
"prepare": "npm run build",
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
"test": "jest", "test": "jest",
"test:workspaces": "npm test --workspaces", "test:workspaces": "npm test --workspaces",
@@ -99,6 +100,10 @@
}, },
"files": [ "files": [
"dist/", "dist/",
"src/",
"rollup.config.js",
"tsconfig.json",
"scripts/",
"android/", "android/",
"ios/Plugin/", "ios/Plugin/",
"ios/Tests/", "ios/Tests/",

View File

@@ -25,22 +25,103 @@ log_error() {
# Validation functions # Validation functions
check_command() { check_command() {
if ! command -v $1 &> /dev/null; then if ! command -v $1 &> /dev/null; then
# Try rbenv shims for pod command
if [ "$1" = "pod" ] && [ -f "$HOME/.rbenv/shims/pod" ]; then
log_info "Found pod in rbenv shims"
return 0
fi
log_error "$1 is not installed. Please install it first." log_error "$1 is not installed. Please install it first."
exit 1 exit 1
fi fi
} }
check_environment() { # Get pod command (handles rbenv)
# Check for required tools get_pod_command() {
check_command "node" if command -v pod &> /dev/null; then
check_command "npm" echo "pod"
check_command "java" elif [ -f "$HOME/.rbenv/shims/pod" ]; then
echo "$HOME/.rbenv/shims/pod"
# Check for Gradle Wrapper instead of system gradle else
if [ ! -f "android/gradlew" ]; then log_error "CocoaPods (pod) not found. Please install CocoaPods first."
log_error "Gradle wrapper not found at android/gradlew"
exit 1 exit 1
fi fi
}
check_sqlite_conflicts() {
local PLATFORM=$1
if [ "$PLATFORM" != "ios" ] && [ "$PLATFORM" != "all" ]; then
return 0
fi
# Check for pkgx SQLite that might interfere with iOS builds
if [ -d "$HOME/.pkgx" ]; then
# Look for pkgx SQLite installations
PKGX_SQLITE=$(find "$HOME/.pkgx" -name "libsqlite3*.dylib" 2>/dev/null | head -1)
if [ -n "$PKGX_SQLITE" ]; then
log_warn "⚠️ pkgx SQLite detected: $PKGX_SQLITE"
log_warn " This may cause iOS build failures (linking macOS dylib for iOS simulator)"
log_warn " The build script will unset problematic environment variables"
# Check if library paths point to pkgx
if [ -n "$DYLD_LIBRARY_PATH" ] && echo "$DYLD_LIBRARY_PATH" | grep -q "\.pkgx"; then
log_warn " DYLD_LIBRARY_PATH contains pkgx paths - will be unset for build"
fi
if [ -n "$LD_LIBRARY_PATH" ] && echo "$LD_LIBRARY_PATH" | grep -q "\.pkgx"; then
log_warn " LD_LIBRARY_PATH contains pkgx paths - will be unset for build"
fi
fi
fi
# Check for system SQLite (from Command Line Tools)
if command -v sqlite3 &> /dev/null; then
SQLITE_PATH=$(which sqlite3)
if [ "$SQLITE_PATH" = "/usr/bin/sqlite3" ]; then
log_info "✅ System SQLite found (from Command Line Tools): $SQLITE_PATH"
else
log_warn "⚠️ SQLite found at non-standard location: $SQLITE_PATH"
log_warn " Ensure this is compatible with iOS builds"
fi
else
log_warn "⚠️ sqlite3 command not found - Command Line Tools may not be installed"
log_warn " Install with: xcode-select --install"
fi
}
check_command_line_tools() {
if [ "$(uname)" != "Darwin" ]; then
return 0
fi
# Check if xcode-select points to a valid developer directory
if ! xcode-select -p &> /dev/null; then
log_error "Xcode Command Line Tools not configured"
log_error " Run: xcode-select --install"
exit 1
fi
# Check if xcodebuild is available
if ! command -v xcodebuild &> /dev/null; then
log_error "xcodebuild not found - Command Line Tools may be incomplete"
log_error " Run: xcode-select --install"
exit 1
fi
# Verify sqlite3 is available (part of Command Line Tools)
if ! command -v sqlite3 &> /dev/null; then
log_warn "⚠️ sqlite3 not found - Command Line Tools may be incomplete"
log_warn " This may cause iOS build issues"
log_warn " Run: xcode-select --install"
fi
}
check_environment() {
local PLATFORM=$1
# Check for required tools (always needed)
check_command "node"
check_command "npm"
# Check Node.js version # Check Node.js version
NODE_VERSION=$(node -v | cut -d. -f1 | tr -d 'v') NODE_VERSION=$(node -v | cut -d. -f1 | tr -d 'v')
@@ -49,6 +130,16 @@ check_environment() {
exit 1 exit 1
fi fi
# Platform-specific checks
if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "all" ]; then
check_command "java"
# Check for Gradle Wrapper instead of system gradle
if [ ! -f "android/gradlew" ]; then
log_error "Gradle wrapper not found at android/gradlew"
exit 1
fi
# Check Java version # Check Java version
JAVA_VERSION=$(java -version 2>&1 | head -n 1 | cut -d'"' -f2 | cut -d. -f1) JAVA_VERSION=$(java -version 2>&1 | head -n 1 | cut -d'"' -f2 | cut -d. -f1)
if [ "$JAVA_VERSION" -lt 11 ]; then if [ "$JAVA_VERSION" -lt 11 ]; then
@@ -61,6 +152,19 @@ check_environment() {
log_error "ANDROID_HOME environment variable is not set" log_error "ANDROID_HOME environment variable is not set"
exit 1 exit 1
fi fi
fi
if [ "$PLATFORM" = "ios" ] || [ "$PLATFORM" = "all" ]; then
# Check Command Line Tools
check_command_line_tools
# Check for SQLite conflicts
check_sqlite_conflicts "$PLATFORM"
# iOS-specific checks are done in build_ios() function
# to avoid failing if iOS tools aren't available when building Android only
:
fi
} }
# Build functions # Build functions
@@ -76,30 +180,8 @@ build_typescript() {
build_plugin_for_test_app() { build_plugin_for_test_app() {
log_info "Building plugin for test app..." log_info "Building plugin for test app..."
# Build the plugin AAR # Ensure symlink is in place FIRST (before building)
log_info "Building plugin AAR..." # The test app expects the plugin at node_modules/@timesafari/daily-notification-plugin
cd android || exit 1
if ! ./gradlew :plugin:clean :plugin:assembleDebug; then
log_error "Plugin build failed"
exit 1
fi
AAR_FILE="plugin/build/outputs/aar/plugin-debug.aar"
if [ ! -f "$AAR_FILE" ]; then
log_error "AAR file not found at $AAR_FILE"
exit 1
fi
log_info "Plugin AAR built: $AAR_FILE"
# Remove any stale AAR from test app's libs directory
if [ -f "../test-apps/daily-notification-test/android/app/libs/plugin-debug.aar" ]; then
log_info "Removing stale AAR from test app libs..."
rm "../test-apps/daily-notification-test/android/app/libs/plugin-debug.aar"
fi
cd ..
# Ensure symlink is in place
SYMLINK="test-apps/daily-notification-test/node_modules/@timesafari/daily-notification-plugin" SYMLINK="test-apps/daily-notification-test/node_modules/@timesafari/daily-notification-plugin"
if [ ! -L "$SYMLINK" ] || [ ! -e "$SYMLINK" ]; then if [ ! -L "$SYMLINK" ] || [ ! -e "$SYMLINK" ]; then
log_info "Creating symlink to plugin..." log_info "Creating symlink to plugin..."
@@ -108,27 +190,213 @@ build_plugin_for_test_app() {
ln -sf "../../../" "$SYMLINK" ln -sf "../../../" "$SYMLINK"
fi fi
# Build test app # Navigate to test app directory
log_info "Building test app..." cd test-apps/daily-notification-test || exit 1
cd test-apps/daily-notification-test/android || exit 1
if ! ./gradlew clean assembleDebug; then # Sync Capacitor Android (creates necessary project structure)
log_error "Test app build failed" log_info "Syncing Capacitor Android..."
if ! npm run cap:sync:android; then
log_error "Capacitor Android sync failed"
exit 1 exit 1
fi fi
# Navigate to android directory
cd android || exit 1
# Fix capacitor.build.gradle if it references missing cordova.variables.gradle
# This file is auto-generated by Capacitor and may reference files that don't exist
if [ -f "app/capacitor.build.gradle" ]; then
if grep -q "^apply from: \"../capacitor-cordova-android-plugins/cordova.variables.gradle\"" "app/capacitor.build.gradle"; then
# Check if the referenced file actually exists
if [ ! -f "capacitor-cordova-android-plugins/cordova.variables.gradle" ]; then
log_info "🔧 Applying fix to capacitor.build.gradle..."
log_info " Reason: Referenced cordova.variables.gradle doesn't exist"
log_info " Fix: Commenting out problematic 'apply from' line"
# Apply the fix by commenting out the problematic line
# Handle macOS vs Linux sed differences
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS sed requires empty string after -i
# Use a simpler approach: just comment out the line
sed -i '' 's|^apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"|// &|' "app/capacitor.build.gradle"
else
# Linux sed
sed -i 's|^apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"|// &|' "app/capacitor.build.gradle"
fi
log_info "✅ Fix applied successfully"
log_warn "⚠️ Note: This fix will be lost if you run 'npx cap sync' or 'npx cap update'"
fi
fi
fi
# Build the plugin and test app from within the test app's Android project context
# This is necessary because Capacitor Android is only available as a project dependency
# The plugin will be built automatically as a dependency of the app
log_info "Building plugin and test app from test app context..."
# Build test app (this will automatically build the plugin as a dependency)
if ! ./gradlew clean assembleDebug; then
log_error "Build failed"
exit 1
fi
# Verify plugin was built (optional check)
PLUGIN_AAR="$(find . -name "*.aar" -path "*/timesafari-daily-notification-plugin/*" 2>/dev/null | head -1)"
if [ -n "$PLUGIN_AAR" ]; then
log_info "Plugin AAR built: $PLUGIN_AAR"
fi
# Verify APK was built
APK_FILE="app/build/outputs/apk/debug/app-debug.apk" APK_FILE="app/build/outputs/apk/debug/app-debug.apk"
if [ ! -f "$APK_FILE" ]; then if [ ! -f "$APK_FILE" ]; then
log_error "APK file not found at $APK_FILE" log_error "APK file not found at $APK_FILE"
exit 1 exit 1
fi fi
log_info "Test app build successful: $APK_FILE" log_info "Build successful: $APK_FILE"
log_info "Install with: adb install -r $APK_FILE" log_info "Install with: adb install -r $APK_FILE"
cd ../../.. cd ../../..
} }
build_plugin_for_test_app_ios() {
log_info "Building iOS test app..."
# Fix pkgx interference with SQLite linking
# pkgx installs SQLite built for macOS, which causes linker errors when building for iOS simulator
if [ -n "$PKGX_DIR" ] || [ -d "$HOME/.pkgx" ]; then
log_warn "⚠️ pkgx detected - fixing SQLite linking conflicts..."
# Check for pkgx SQLite specifically
PKGX_SQLITE=$(find "$HOME/.pkgx" -name "libsqlite3*.dylib" 2>/dev/null | head -1)
if [ -n "$PKGX_SQLITE" ]; then
log_warn " Found pkgx SQLite: $PKGX_SQLITE"
log_warn " This is built for macOS and will cause iOS simulator build failures"
fi
log_warn " Temporarily unsetting PKGX_DIR and library paths for build..."
unset PKGX_DIR
unset DYLD_LIBRARY_PATH
unset LD_LIBRARY_PATH
# Also unset any pkgx-related paths that might be in PATH
export PATH=$(echo "$PATH" | tr ':' '\n' | grep -v "\.pkgx" | tr '\n' ':' | sed 's/:$//')
fi
# Ensure symlink is in place FIRST (before building)
# The test app expects the plugin at node_modules/@timesafari/daily-notification-plugin
SYMLINK="test-apps/daily-notification-test/node_modules/@timesafari/daily-notification-plugin"
if [ ! -L "$SYMLINK" ] || [ ! -e "$SYMLINK" ]; then
log_info "Creating symlink to plugin..."
mkdir -p "$(dirname "$SYMLINK")"
rm -f "$SYMLINK"
ln -sf "../../../" "$SYMLINK"
fi
# Navigate to test app directory
cd test-apps/daily-notification-test || exit 1
# Install dependencies if node_modules doesn't exist or is missing key packages
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.bin/run-p" ]; then
log_info "Installing test app dependencies..."
if ! npm install; then
log_error "Failed to install test app dependencies"
exit 1
fi
fi
# Build Vue app
log_info "Building Vue app..."
if ! npm run build; then
log_error "Vue app build failed"
exit 1
fi
# Sync Capacitor iOS (handles Podfile fixes and pod install)
log_info "Syncing Capacitor iOS..."
if ! npm run cap:sync:ios; then
log_error "Capacitor iOS sync failed"
exit 1
fi
# Build with xcodebuild
cd ios/App || exit 1
# Find workspace
if [ -d "App.xcworkspace" ]; then
WORKSPACE="App.xcworkspace"
SCHEME="App"
elif [ -d "App.xcodeproj" ]; then
PROJECT="App.xcodeproj"
SCHEME="App"
else
log_error "No Xcode workspace or project found in ios/App"
log_info "Expected: App.xcworkspace or App.xcodeproj"
exit 1
fi
# Auto-detect available iPhone simulator
log_info "Detecting available iPhone simulator..."
SIMULATOR_LINE=$(xcrun simctl list devices available 2>&1 | grep -i "iPhone" | head -1)
if [ -n "$SIMULATOR_LINE" ]; then
# Extract device ID (UUID in parentheses)
SIMULATOR_ID=$(echo "$SIMULATOR_LINE" | sed -E 's/.*\(([A-F0-9-]+)\).*/\1/')
# Extract device name (everything before the first parenthesis)
SIMULATOR_NAME=$(echo "$SIMULATOR_LINE" | sed -E 's/^[[:space:]]*([^(]+).*/\1/' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -n "$SIMULATOR_ID" ] && [ "$SIMULATOR_ID" != "Shutdown" ] && [ "$SIMULATOR_ID" != "Booted" ]; then
DESTINATION="platform=iOS Simulator,id=$SIMULATOR_ID"
log_info "Building for iOS Simulator ($SIMULATOR_NAME, ID: $SIMULATOR_ID)..."
elif [ -n "$SIMULATOR_NAME" ]; then
DESTINATION="platform=iOS Simulator,name=$SIMULATOR_NAME"
log_info "Building for iOS Simulator ($SIMULATOR_NAME)..."
else
DESTINATION="platform=iOS Simulator,name=iPhone 14"
log_warn "Using default simulator destination: iPhone 14"
fi
else
DESTINATION="platform=iOS Simulator,name=iPhone 14"
log_warn "No iPhone simulators found, using default: iPhone 14"
fi
# Build for simulator
log_info "Building iOS app for simulator..."
if [ -n "$WORKSPACE" ]; then
if ! xcodebuild build \
-workspace "$WORKSPACE" \
-scheme "$SCHEME" \
-configuration Debug \
-sdk iphonesimulator \
-destination "$DESTINATION" \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO; then
log_error "iOS build failed"
exit 1
fi
else
if ! xcodebuild build \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Debug \
-sdk iphonesimulator \
-destination "$DESTINATION" \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO; then
log_error "iOS build failed"
exit 1
fi
fi
log_info "iOS build successful!"
log_info "App built in DerivedData. To run: xcrun simctl boot <simulator-id> && xcrun simctl install booted <app-path>"
cd ../../..
}
build_android() { build_android() {
log_info "Building Android..." log_info "Building Android..."
@@ -237,6 +505,75 @@ build_android() {
cd .. cd ..
} }
build_ios() {
log_info "Building iOS..."
# Check iOS-specific requirements
check_command "xcodebuild"
check_command "pod"
# Check for Xcode
if ! xcodebuild -version &> /dev/null; then
log_error "Xcode is not installed or not properly configured"
exit 1
fi
# Check for SQLite conflicts (already done in check_environment, but remind here)
if [ -d "$HOME/.pkgx" ]; then
PKGX_SQLITE=$(find "$HOME/.pkgx" -name "libsqlite3*.dylib" 2>/dev/null | head -1)
if [ -n "$PKGX_SQLITE" ]; then
log_warn "⚠️ pkgx SQLite detected - build script will handle this automatically"
log_warn " If you still see linker errors, manually run:"
log_warn " unset PKGX_DIR DYLD_LIBRARY_PATH LD_LIBRARY_PATH && ./scripts/build-native.sh --platform ios"
fi
fi
# Detect project type
if [ -d "test-apps/daily-notification-test" ]; then
log_info "Detected test app. Building plugin and test app together..."
build_plugin_for_test_app_ios
return 0
fi
# For plugin-only builds, navigate to iOS directory
cd ios || exit 1
log_warn "This appears to be a plugin development project"
log_warn "iOS plugin source code has been built successfully"
log_warn "To test the plugin, use it in a Capacitor app instead"
# Install CocoaPods dependencies if Podfile exists
if [ -f "Podfile" ]; then
log_info "Installing CocoaPods dependencies..."
POD_CMD=$(get_pod_command)
if ! $POD_CMD install; then
log_error "CocoaPods installation failed"
exit 1
fi
fi
# Build plugin framework (if workspace exists)
if [ -d "DailyNotificationPlugin.xcworkspace" ]; then
log_info "Building iOS plugin framework..."
if ! xcodebuild build \
-workspace DailyNotificationPlugin.xcworkspace \
-scheme DailyNotificationPlugin \
-configuration Release \
-sdk iphoneos \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO; then
log_error "iOS plugin build failed"
exit 1
fi
log_info "iOS plugin build successful"
else
log_warn "No Xcode workspace found - plugin may need to be built in a host app"
fi
cd ..
}
# Main build process # Main build process
main() { main() {
log_info "Starting build process..." log_info "Starting build process..."
@@ -256,22 +593,26 @@ main() {
esac esac
done done
# Check environment # Build TypeScript (needed for all platforms)
check_environment
# Build TypeScript
build_typescript build_typescript
# Check environment (after parsing platform so we can check conditionally)
check_environment "$BUILD_PLATFORM"
# Build based on platform # Build based on platform
case $BUILD_PLATFORM in case $BUILD_PLATFORM in
"android") "android")
build_android build_android
;; ;;
"ios")
build_ios
;;
"all") "all")
build_android build_android
build_ios
;; ;;
*) *)
log_error "Invalid platform: $BUILD_PLATFORM. Use 'android' or 'all'" log_error "Invalid platform: $BUILD_PLATFORM. Use 'android', 'ios', or 'all'"
exit 1 exit 1
;; ;;
esac esac

271
scripts/clean-build.sh Executable file
View File

@@ -0,0 +1,271 @@
#!/bin/bash
# Exit on error
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_step() {
echo -e "${BLUE}[STEP]${NC} $1"
}
# Parse command line arguments
REINSTALL_NODE_MODULES=false
CLEAN_GRADLE_CACHE=false
CLEAN_DERIVED_DATA=false
while [[ $# -gt 0 ]]; do
case $1 in
--reinstall-node)
REINSTALL_NODE_MODULES=true
shift
;;
--clean-gradle-cache)
CLEAN_GRADLE_CACHE=true
shift
;;
--clean-derived-data)
CLEAN_DERIVED_DATA=true
shift
;;
--all)
REINSTALL_NODE_MODULES=true
CLEAN_GRADLE_CACHE=true
CLEAN_DERIVED_DATA=true
shift
;;
*)
log_error "Unknown option: $1"
echo "Usage: $0 [--reinstall-node] [--clean-gradle-cache] [--clean-derived-data] [--all]"
exit 1
;;
esac
done
log_info "Starting clean build process..."
log_warn "This will remove all build artifacts and caches"
# Step 1: Clean TypeScript build output
log_step "1. Cleaning TypeScript build output..."
npm run clean 2>/dev/null || log_warn "npm run clean failed (may not exist)"
rm -rf dist/
log_info "✓ TypeScript build output cleaned"
# Step 2: Clean iOS plugin build artifacts
log_step "2. Cleaning iOS plugin build artifacts..."
if [ -d "ios" ]; then
cd ios
# Remove Pods and Podfile.lock
if [ -d "Pods" ]; then
rm -rf Pods/
log_info " ✓ Removed Pods/"
fi
if [ -f "Podfile.lock" ]; then
rm -f Podfile.lock
log_info " ✓ Removed Podfile.lock"
fi
# Remove Xcode build artifacts
if [ -d "DailyNotificationPlugin.xcworkspace/xcuserdata" ]; then
rm -rf DailyNotificationPlugin.xcworkspace/xcuserdata/
log_info " ✓ Removed workspace user data"
fi
if [ -d "DailyNotificationPlugin.xcodeproj/xcuserdata" ]; then
rm -rf DailyNotificationPlugin.xcodeproj/xcuserdata/
log_info " ✓ Removed project user data"
fi
# Remove build directories
find . -type d -name "build" -exec rm -rf {} + 2>/dev/null || true
find . -type d -name "DerivedData" -exec rm -rf {} + 2>/dev/null || true
cd ..
log_info "✓ iOS plugin cleaned"
fi
# Step 3: Clean Android plugin build artifacts
log_step "3. Cleaning Android plugin build artifacts..."
if [ -d "android" ]; then
cd android
# Remove build directories
if [ -d "build" ]; then
rm -rf build/
log_info " ✓ Removed build/"
fi
if [ -d "app/build" ]; then
rm -rf app/build/
log_info " ✓ Removed app/build/"
fi
if [ -d ".gradle" ]; then
if [ "$CLEAN_GRADLE_CACHE" = true ]; then
rm -rf .gradle/
log_info " ✓ Removed .gradle/ (cache cleaned)"
else
log_info " .gradle/ preserved (use --clean-gradle-cache to remove)"
fi
fi
cd ..
log_info "✓ Android plugin cleaned"
fi
# Step 4: Clean test app build artifacts
log_step "4. Cleaning test app build artifacts..."
if [ -d "test-apps/daily-notification-test" ]; then
cd test-apps/daily-notification-test
# Remove symlink
if [ -L "node_modules/@timesafari/daily-notification-plugin" ]; then
rm -f node_modules/@timesafari/daily-notification-plugin
log_info " ✓ Removed plugin symlink"
fi
# Remove node_modules (if requested)
if [ "$REINSTALL_NODE_MODULES" = true ]; then
if [ -d "node_modules" ]; then
# Use find to handle permission issues on macOS
find node_modules -delete 2>/dev/null || rm -rf node_modules/ 2>/dev/null || {
log_warn " ⚠ Some files in node_modules/ could not be deleted (permission issues)"
log_warn " You may need to manually remove node_modules/ or use sudo"
}
log_info " ✓ Removed node_modules/ (will reinstall)"
fi
fi
# Remove build output
if [ -d "dist" ]; then
rm -rf dist/
log_info " ✓ Removed dist/"
fi
# Clean iOS test app
if [ -d "ios" ]; then
cd ios/App
# Remove Pods and Podfile.lock
if [ -d "Pods" ]; then
rm -rf Pods/
log_info " ✓ Removed iOS test app Pods/"
fi
if [ -f "Podfile.lock" ]; then
rm -f Podfile.lock
log_info " ✓ Removed iOS test app Podfile.lock"
fi
# Remove Xcode build artifacts
if [ -d "App.xcworkspace/xcuserdata" ]; then
rm -rf App.xcworkspace/xcuserdata/
fi
if [ -d "App.xcodeproj/xcuserdata" ]; then
rm -rf App.xcodeproj/xcuserdata/
fi
# Remove build directories
find . -type d -name "build" -exec rm -rf {} + 2>/dev/null || true
cd ../..
fi
# Clean Android test app
if [ -d "android" ]; then
cd android
# Remove build directories
if [ -d "build" ]; then
rm -rf build/
log_info " ✓ Removed Android test app build/"
fi
if [ -d "app/build" ]; then
rm -rf app/build/
log_info " ✓ Removed Android test app app/build/"
fi
if [ -d "capacitor-cordova-android-plugins/build" ]; then
rm -rf capacitor-cordova-android-plugins/build/
log_info " ✓ Removed Android test app plugin build/"
fi
if [ -d ".gradle" ] && [ "$CLEAN_GRADLE_CACHE" = true ]; then
rm -rf .gradle/
log_info " ✓ Removed Android test app .gradle/ (cache cleaned)"
fi
cd ..
fi
cd ../..
log_info "✓ Test app cleaned"
fi
# Step 5: Clean Xcode DerivedData (if requested)
if [ "$CLEAN_DERIVED_DATA" = true ]; then
log_step "5. Cleaning Xcode DerivedData..."
DERIVED_DATA_DIR="$HOME/Library/Developer/Xcode/DerivedData"
if [ -d "$DERIVED_DATA_DIR" ]; then
# Find and remove project-specific DerivedData
find "$DERIVED_DATA_DIR" -maxdepth 1 -type d \( -name "*DailyNotification*" -o -name "*daily-notification*" \) -exec rm -rf {} + 2>/dev/null || true
log_info "✓ Xcode DerivedData cleaned (project-specific)"
log_warn " To clean all DerivedData, manually delete: $DERIVED_DATA_DIR"
else
log_warn " DerivedData directory not found: $DERIVED_DATA_DIR"
fi
fi
# Step 6: Reinstall node_modules (if requested)
if [ "$REINSTALL_NODE_MODULES" = true ]; then
log_step "6. Reinstalling node_modules..."
# Root node_modules
if [ -d "node_modules" ]; then
find node_modules -delete 2>/dev/null || rm -rf node_modules/ 2>/dev/null || {
log_warn " ⚠ Some files in root node_modules/ could not be deleted (permission issues)"
log_warn " You may need to manually remove node_modules/ or use sudo"
}
fi
log_info " Installing root dependencies..."
npm install
# Test app node_modules
if [ -d "test-apps/daily-notification-test" ]; then
cd test-apps/daily-notification-test
log_info " Installing test app dependencies..."
npm install
cd ../..
fi
log_info "✓ Dependencies reinstalled"
fi
log_info ""
log_info "════════════════════════════════════════════════════════════"
log_info "Clean build complete! 🎉"
log_info "════════════════════════════════════════════════════════════"
log_info ""
log_info "Next steps:"
log_info " 1. Run: ./scripts/build-native.sh --platform all"
log_info " 2. Or run: ./scripts/build-native.sh --platform ios"
log_info " 3. Or run: ./scripts/build-native.sh --platform android"
log_info ""
log_info "Options used:"
[ "$REINSTALL_NODE_MODULES" = true ] && log_info " ✓ Reinstalled node_modules"
[ "$CLEAN_GRADLE_CACHE" = true ] && log_info " ✓ Cleaned Gradle cache"
[ "$CLEAN_DERIVED_DATA" = true ] && log_info " ✓ Cleaned Xcode DerivedData"
log_info ""

View File

@@ -5,7 +5,7 @@
* Aligned with Android implementation and test requirements * Aligned with Android implementation and test requirements
* *
* @author Matthew Raymer * @author Matthew Raymer
* @version 1.0.11 (see package.json for source of truth) * @version 1.2.0 (see package.json for source of truth)
*/ */
// Import SPI types from content-fetcher.ts // Import SPI types from content-fetcher.ts

View File

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

View File

@@ -7,7 +7,7 @@
* This implementation provides clear error messages for all methods. * This implementation provides clear error messages for all methods.
* *
* @author Matthew Raymer * @author Matthew Raymer
* @version 1.0.0 * @version 1.2.0
*/ */
import type { import type {

View File

@@ -65,6 +65,7 @@ emulator -avd AVD_NAME
adb devices adb devices
# Now install on the emulator # Now install on the emulator
# ... which can take a looooooong time
adb install -r ./app/build/outputs/apk/debug/app-debug.apk adb install -r ./app/build/outputs/apk/debug/app-debug.apk
# Now start the app # Now start the app

View File

@@ -346,6 +346,60 @@
} }
} }
// Load configuration status (plugin settings and native fetcher)
function loadConfigurationStatus() {
console.log('[Config Check] Checking configuration status...');
const configStatus = document.getElementById('configStatus');
const fetcherStatus = document.getElementById('fetcherStatus');
if (!window.DailyNotification) {
console.warn('[Config Check] DailyNotification plugin not available');
configStatus.innerHTML = '❌ Plugin unavailable';
fetcherStatus.innerHTML = '❌ Plugin unavailable';
return;
}
// Check if plugin settings are configured
// Plugin settings are stored internally, so we check by trying to get status
// For now, we'll check if native fetcher config exists as a proxy
// TODO: Add explicit plugin settings check method
window.DailyNotification.getConfig({ key: 'native_fetcher_config' })
.then(result => {
console.log('[Config Check] Native fetcher config result:', JSON.stringify(result));
if (result && result.config && result.config.configValue) {
try {
const configValue = JSON.parse(result.config.configValue);
if (configValue.apiBaseUrl && configValue.apiBaseUrl.length > 0) {
console.log('[Config Check] ✅ Native fetcher is configured');
fetcherStatus.innerHTML = '✅ Configured';
} else {
console.log('[Config Check] ⚠️ Native fetcher config exists but is empty');
fetcherStatus.innerHTML = '❌ Not configured';
}
} catch (e) {
console.warn('[Config Check] Failed to parse config value:', e);
fetcherStatus.innerHTML = '❌ Error';
}
} else {
console.log('[Config Check] ❌ Native fetcher config not found');
fetcherStatus.innerHTML = '❌ Not configured';
}
// For plugin settings, we assume configured if native fetcher is configured
// This is a heuristic - in production, add explicit check
if (fetcherStatus.innerHTML.includes('✅')) {
configStatus.innerHTML = '✅ Configured';
} else {
configStatus.innerHTML = '❌ Not configured';
}
})
.catch(error => {
console.error('[Config Check] Failed to check configuration:', error);
configStatus.innerHTML = '❌ Error';
fetcherStatus.innerHTML = '❌ Error';
});
}
function loadChannelStatus() { function loadChannelStatus() {
const channelStatus = document.getElementById('channelStatus'); const channelStatus = document.getElementById('channelStatus');
@@ -573,6 +627,7 @@
loadPluginStatus(); loadPluginStatus();
loadPermissionStatus(); loadPermissionStatus();
loadChannelStatus(); loadChannelStatus();
loadConfigurationStatus();
// Initialize last known next notification time // Initialize last known next notification time
if (window.DailyNotification) { if (window.DailyNotification) {
@@ -600,6 +655,7 @@
loadPluginStatus(); loadPluginStatus();
loadPermissionStatus(); loadPermissionStatus();
loadChannelStatus(); loadChannelStatus();
loadConfigurationStatus();
// Also check for recent notifications that might have been missed // Also check for recent notifications that might have been missed
if (window.DailyNotification) { if (window.DailyNotification) {

View File

@@ -17,6 +17,7 @@ android {
} }
} }
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
@@ -62,6 +63,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-service:2.7.0' implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.google.code.gson:gson:2.10.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

@@ -9,7 +9,32 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m # Java 17+ requires --add-opens flags for KAPT to access internal compiler classes
org.gradle.jvmargs=-Xmx1536m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# Kotlin compiler daemon JVM arguments (required for KAPT with Java 17+)
# The Kotlin daemon runs separately and needs the same --add-opens flags
kotlin.daemon.jvmargs=-Xmx1536m \
--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="en" style="margin: 0; padding: 0;">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Vite App</title>
</head> </head>
<body> <body style="margin: 0; padding: 0;">
<div id="app"></div> <div id="app" style="margin: 0; padding: 0;"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -1,12 +1,52 @@
import UIKit import UIKit
import Capacitor import Capacitor
import BackgroundTasks
import DailyNotificationPlugin
import ObjectiveC
import UserNotifications
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
var window: UIWindow? var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
NSLog("DNP-DEBUG: AppDelegate.application(_:didFinishLaunchingWithOptions:) called")
// CRITICAL: Force-load the plugin framework before Capacitor initializes
// objc_getClassList may not include classes from frameworks that haven't been loaded yet
// Even though NSClassFromString can find the class, Capacitor's discovery uses objc_getClassList
// which only includes loaded classes. We need to ensure the framework is loaded.
NSLog("DNP-DEBUG: Force-loading DailyNotificationPlugin framework...")
_ = DailyNotificationPlugin.self // Force class load
NSLog("DNP-DEBUG: DailyNotificationPlugin class reference created - framework should be loaded")
// Verify class is now in objc_getClassList
let classCount = objc_getClassList(nil, 0)
let classes = UnsafeMutablePointer<AnyClass?>.allocate(capacity: Int(classCount))
defer { classes.deallocate() }
let releasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classes)
let numClasses = objc_getClassList(releasingClasses, Int32(classCount))
var foundInClassList = false
for i in 0..<Int(numClasses) {
if let aClass = classes[i] {
let className = NSStringFromClass(aClass)
if className == "DailyNotificationPlugin" {
foundInClassList = true
NSLog("DNP-DEBUG: ✅ DailyNotificationPlugin found in objc_getClassList")
break
}
}
}
if !foundInClassList {
NSLog("DNP-DEBUG: ❌ DailyNotificationPlugin NOT found in objc_getClassList (this is the problem!)")
}
// Set notification center delegate to show notifications in foreground
UNUserNotificationCenter.current().delegate = self
NSLog("DNP-DEBUG: UNUserNotificationCenter delegate set to AppDelegate")
// Override point for customization after application launch. // Override point for customization after application launch.
return true return true
} }
@@ -27,6 +67,72 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
// Re-set delegate when app becomes active (in case Capacitor resets it)
UNUserNotificationCenter.current().delegate = self
NSLog("DNP-DEBUG: UNUserNotificationCenter delegate re-set in applicationDidBecomeActive")
}
// MARK: - UNUserNotificationCenterDelegate
/**
* Show notifications even when app is in foreground
* This is required for test app to see notifications during testing
*/
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
NSLog("DNP-DEBUG: ✅ userNotificationCenter willPresent called!")
NSLog("DNP-DEBUG: Notification received in foreground: %@", notification.request.identifier)
NSLog("DNP-DEBUG: Notification title: %@", notification.request.content.title)
NSLog("DNP-DEBUG: Notification body: %@", notification.request.content.body)
// Extract notification info from userInfo for rollover
let userInfo = notification.request.content.userInfo
if let notificationId = userInfo["notification_id"] as? String,
let scheduledTime = userInfo["scheduled_time"] as? Int64 {
// Format scheduled time for logging
let scheduledDate = Date(timeIntervalSince1970: Double(scheduledTime) / 1000.0)
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
let scheduledTimeStr = formatter.string(from: scheduledDate)
NSLog("DNP-ROLLOVER: APPDELGATE_DETECTED id=%@ scheduled_time=%@", notificationId, scheduledTimeStr)
NSLog("DNP-DEBUG: Posted rollover notification for id=%@", notificationId)
// Post notification to trigger rollover (decoupled pattern)
NotificationCenter.default.post(
name: NSNotification.Name("DailyNotificationDelivered"),
object: nil,
userInfo: [
"notification_id": notificationId,
"scheduled_time": scheduledTime
]
)
} else {
NSLog("DNP-ROLLOVER: APPDELGATE_MISSING_DATA id=%@ userInfo=%@", notification.request.identifier, userInfo)
}
// Show notification with banner, sound, and badge
// Use .banner for iOS 14+, fallback to .alert for iOS 13
if #available(iOS 14.0, *) {
completionHandler([.banner, .sound, .badge])
} else {
completionHandler([.alert, .sound, .badge])
}
NSLog("DNP-DEBUG: ✅ Completion handler called with presentation options")
}
/**
* Handle notification tap/interaction
*/
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
NSLog("DNP-DEBUG: Notification tapped: %@", response.notification.request.identifier)
NSLog("DNP-DEBUG: Action identifier: %@", response.actionIdentifier)
// Handle notification tap if needed
completionHandler()
} }
func applicationWillTerminate(_ application: UIApplication) { func applicationWillTerminate(_ application: UIApplication) {

View File

@@ -8,14 +8,14 @@
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "npx run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"cap:sync": "npx cap sync && node scripts/fix-capacitor-plugins.js", "cap:sync": "npx cap sync && node scripts/fix-capacitor-plugins.js",
"cap:sync:android": "npx cap sync android && node scripts/fix-capacitor-plugins.js", "cap:sync:android": "npx cap sync android && node scripts/fix-capacitor-plugins.js",
"cap:sync:ios": "npx cap copy ios && node scripts/fix-capacitor-plugins.js && cd ios/App && pod install && cd ../..", "cap:sync:ios": "npx cap copy ios && node scripts/fix-capacitor-plugins.js && cd ios/App && ../../scripts/pod-install.sh && cd ../..",
"postinstall": "node scripts/fix-capacitor-plugins.js" "postinstall": "node scripts/fix-capacitor-plugins.js"
}, },
"dependencies": { "dependencies": {

View File

@@ -220,11 +220,23 @@ if ! npm run build; then
fi fi
log_info "Web assets built successfully" log_info "Web assets built successfully"
# Step 2: Sync Capacitor # Step 2: Sync Capacitor (Android-only when building only Android to avoid iOS pod install failure)
log_step "Syncing Capacitor with native projects..." log_step "Syncing Capacitor with native projects..."
if ! npm run cap:sync; then if [ "$BUILD_ALL" = true ]; then
if ! npm run cap:sync; then
log_error "Capacitor sync failed" log_error "Capacitor sync failed"
exit 1 exit 1
fi
elif [ "$BUILD_ANDROID" = true ]; then
if ! npm run cap:sync:android; then
log_error "Capacitor sync (Android) failed"
exit 1
fi
elif [ "$BUILD_IOS" = true ]; then
if ! npm run cap:sync:ios; then
log_error "Capacitor sync (iOS) failed"
exit 1
fi
fi fi
log_info "Capacitor sync completed" log_info "Capacitor sync completed"

View File

@@ -35,13 +35,17 @@ const PLUGIN_ENTRY = {
function fixCapacitorPlugins() { function fixCapacitorPlugins() {
console.log('🔧 Fixing capacitor.plugins.json...'); console.log('🔧 Fixing capacitor.plugins.json...');
// Check if the file exists - if not, it means Capacitor hasn't been synced yet
if (!fs.existsSync(PLUGINS_JSON_PATH)) {
console.log(' capacitor.plugins.json not found (Capacitor not synced yet - will be fixed after cap sync)');
return;
}
try { try {
// Read current content // Read current content
let plugins = []; let plugins = [];
if (fs.existsSync(PLUGINS_JSON_PATH)) {
const content = fs.readFileSync(PLUGINS_JSON_PATH, 'utf8'); const content = fs.readFileSync(PLUGINS_JSON_PATH, 'utf8');
plugins = JSON.parse(content); plugins = JSON.parse(content);
}
// Check if our plugin is already there // Check if our plugin is already there
const hasPlugin = plugins.some(p => p.name === PLUGIN_ENTRY.name); const hasPlugin = plugins.some(p => p.name === PLUGIN_ENTRY.name);
@@ -56,7 +60,8 @@ function fixCapacitorPlugins() {
} catch (error) { } catch (error) {
console.error('❌ Error fixing capacitor.plugins.json:', error.message); console.error('❌ Error fixing capacitor.plugins.json:', error.message);
process.exit(1); // Don't exit - this might be a first-time install
console.log(' This is normal on first install. Run "npm run cap:sync" to generate Capacitor files.');
} }
} }
@@ -207,8 +212,9 @@ function fixAll() {
fixCapacitorSettingsGradle(); fixCapacitorSettingsGradle();
fixPodfile(); fixPodfile();
console.log('\n✅ All fixes applied successfully!'); console.log('\n✅ Fix script completed!');
console.log('💡 These fixes will persist until the next "npx cap sync"'); console.log('💡 Note: Some fixes may be skipped if Capacitor files don\'t exist yet.');
console.log('💡 Run "npm run cap:sync" to generate Capacitor files, then this script will apply fixes.');
} }
// Run if called directly // Run if called directly

View File

@@ -0,0 +1,25 @@
#!/bin/bash
#
# Wrapper script to find and run pod install
# Handles rbenv shims and standard CocoaPods installations
#
# @author Matthew Raymer
set -e
# Get pod command (handles rbenv)
get_pod_command() {
if command -v pod &> /dev/null; then
echo "pod"
elif [ -f "$HOME/.rbenv/shims/pod" ]; then
echo "$HOME/.rbenv/shims/pod"
else
echo "pod" # Let it fail with standard error message
fi
}
# Find pod command
POD_CMD=$(get_pod_command)
# Run pod install
exec "$POD_CMD" install "$@"

View File

@@ -63,6 +63,7 @@ export default toNative(App)
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
} }

View File

@@ -0,0 +1,102 @@
<!--
/**
* Status List Component - Standardized Label/Value List Display
*
* Reusable component for displaying status items in a consistent format
* with optional status-based color coding
*
* @author Matthew Raymer
* @version 1.0.0
*/
-->
<template>
<div class="status-list">
<div
v-for="item in items"
:key="item.label"
class="status-item"
:class="showStatusColors && item.status ? `status-${item.status}` : ''"
>
<div class="status-row">
<span class="status-label">{{ item.label }}</span>
<span
class="status-value"
:class="showStatusColors && item.status ? `value-${item.status}` : ''"
>
{{ item.value }}
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface StatusListItem {
label: string
value: string
status?: 'success' | 'warning' | 'error' | 'info'
}
interface Props {
items: StatusListItem[]
showStatusColors?: boolean
}
defineProps<Props>()
</script>
<style scoped>
.status-list {
display: flex;
flex-direction: column;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
}
.status-item:last-child {
border-bottom: none;
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.status-label {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.status-value {
font-size: 14px;
font-weight: 600;
color: white;
}
.value-success {
color: #4caf50;
}
.value-warning {
color: #ff9800;
}
.value-error {
color: #f44336;
}
.value-info {
color: #2196f3;
}
</style>

View File

@@ -8,6 +8,7 @@
* @version 1.0.0 * @version 1.0.0
*/ */
import { Capacitor } from '@capacitor/core'
import { import {
type PermissionStatus, type PermissionStatus,
type NotificationStatus, type NotificationStatus,
@@ -119,10 +120,17 @@ export class DiagnosticsExporter {
// Calculate performance metrics // Calculate performance metrics
const loadTime = performance.now() - startTime const loadTime = performance.now() - startTime
// Detect platform using Capacitor
const platform = Capacitor.getPlatform()
const platformDisplayName = platform.charAt(0).toUpperCase() + platform.slice(1)
// API Level is Android-specific, show N/A for iOS/web
const apiLevel = platform === 'android' ? 'Unknown' : 'N/A'
return { return {
appVersion: '1.0.0', // TODO: Get from app info appVersion: '1.0.0', // TODO: Get from app info
platform: 'Android', // TODO: Detect platform platform: platformDisplayName,
apiLevel: 'Unknown', // TODO: Get from device info apiLevel: apiLevel, // TODO: Get from device info for Android
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
lastUpdated: new Date().toLocaleString(), lastUpdated: new Date().toLocaleString(),
postNotificationsGranted: permissions.notifications === 'granted', postNotificationsGranted: permissions.notifications === 'granted',

View File

@@ -77,6 +77,15 @@ const router = createRouter({
requiresAuth: false requiresAuth: false
} }
}, },
{
path: '/request-permissions',
name: 'RequestPermissions',
component: (): Promise<typeof import('../views/RequestPermissionsView.vue')> => import('../views/RequestPermissionsView.vue'),
meta: {
title: 'Request Permissions',
requiresAuth: false
}
},
{ {
path: '/about', path: '/about',
name: 'About', name: 'About',

View File

@@ -1,10 +1,61 @@
<template> <template>
<div class="about"> <div class="about">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1>This is an about page</h1> <h1>This is an about page</h1>
</div> </div>
</div>
</div>
</template> </template>
<style> <script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function goBack() {
router.push('/')
}
</script>
<style scoped>
.view-header {
text-align: center;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
@media (min-width: 1024px) { @media (min-width: 1024px) {
.about { .about {
min-height: 100vh; min-height: 100vh;
@@ -12,4 +63,17 @@
align-items: center; align-items: center;
} }
} }
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style> </style>

View File

@@ -1,7 +1,12 @@
<template> <template>
<div class="history-view"> <div class="history-view">
<div class="view-header"> <div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📋 History</h1> <h1 class="page-title">📋 History</h1>
</div>
<p class="page-subtitle">Notification history and activity</p> <p class="page-subtitle">Notification history and activity</p>
</div> </div>
<div class="placeholder-content"> <div class="placeholder-content">
@@ -12,15 +17,19 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator' import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
@Component @Component
class HistoryView extends Vue {} class HistoryView extends Vue {
goBack() {
router.push('/')
}
}
export default toNative(HistoryView) export default toNative(HistoryView)
</script> </script>
<style scoped> <style scoped>
.history-view { .history-view {
padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@@ -30,6 +39,36 @@ export default toNative(HistoryView)
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title { .page-title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 28px; font-size: 28px;
@@ -52,4 +91,17 @@ export default toNative(HistoryView)
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style> </style>

View File

@@ -21,10 +21,10 @@
</p> </p>
<div class="platform-info"> <div class="platform-info">
<span class="platform-badge" :class="platformClass"> <span class="platform-badge" :class="platformClass">
{{ platformName }} {{ platformEmoji }} {{ platformName }}
</span> </span>
<span class="status-badge" :class="statusClass"> <span class="status-badge" :class="statusClass">
{{ statusText }} {{ statusEmoji }} {{ statusText }}
</span> </span>
</div> </div>
</div> </div>
@@ -44,15 +44,13 @@
icon="📊" icon="📊"
title="Check Status" title="Check Status"
description="View notification system status" description="View notification system status"
@click="checkSystemStatus" @click="navigateToStatus"
:loading="isCheckingStatus"
/> />
<ActionCard <ActionCard
icon="🔐" icon="🔐"
title="Request Permissions" title="Request Permissions"
description="Check and request notification permissions" description="Check and request notification permissions"
@click="checkAndRequestPermissions" @click="navigateToRequestPermissions"
:loading="isRequestingPermissions"
/> />
<ActionCard <ActionCard
icon="🔔" icon="🔔"
@@ -84,21 +82,12 @@
<!-- System Status --> <!-- System Status -->
<div class="system-status"> <div class="system-status">
<h2 class="section-title">System Status</h2> <h2 class="section-title">System Status</h2>
<div class="status-grid"> <StatusList :items="systemStatus" :show-status-colors="true" />
<StatusCard
v-for="item in systemStatus"
:key="item.label"
:title="item.label"
:status="getStatusType(item.status)"
:value="item.value"
:description="getStatusDescription(item.label)"
@refresh="refreshSystemStatus"
/>
</div>
<!-- Diagnostic Actions --> <!-- Diagnostic Actions -->
<div class="section"> <div class="section">
<h2 class="section-title">🔧 Diagnostics</h2> <h2 class="section-title">🔧 Diagnostics</h2>
<div class="action-grid">
<ActionCard <ActionCard
title="Plugin Diagnostics" title="Plugin Diagnostics"
description="Check plugin loading and availability" description="Check plugin loading and availability"
@@ -114,6 +103,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -121,7 +111,7 @@ import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import ActionCard from '@/components/cards/ActionCard.vue' import ActionCard from '@/components/cards/ActionCard.vue'
import StatusCard from '@/components/cards/StatusCard.vue' import StatusList from '@/components/StatusList.vue'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { DailyNotification } from '@timesafari/daily-notification-plugin' import { DailyNotification } from '@timesafari/daily-notification-plugin'
import { TEST_USER_ZERO_CONFIG, generateEndorserJWT } from '@/config/test-user-zero' import { TEST_USER_ZERO_CONFIG, generateEndorserJWT } from '@/config/test-user-zero'
@@ -136,11 +126,25 @@ const isRequestingPermissions = ref(false)
const nativeFetcherConfigured = ref(false) const nativeFetcherConfigured = ref(false)
const platformName = computed(() => { const platformName = computed(() => {
const platform = appStore.platform const platform = Capacitor.getPlatform()
return platform.charAt(0).toUpperCase() + platform.slice(1) return platform.charAt(0).toUpperCase() + platform.slice(1)
}) })
const platformClass = computed(() => `platform-${appStore.platform}`) const platformEmoji = computed(() => {
const platform = Capacitor.getPlatform()
switch (platform) {
case 'android':
return '🤖'
case 'ios':
return '🍎'
case 'web':
return '🌐'
default:
return '📱'
}
})
const platformClass = computed(() => `platform-${Capacitor.getPlatform()}`)
const statusClass = computed(() => { const statusClass = computed(() => {
const status = appStore.notificationStatus const status = appStore.notificationStatus
@@ -156,21 +160,31 @@ const statusText = computed(() => {
return 'Not Ready' return 'Not Ready'
}) })
const statusEmoji = computed(() => {
const status = appStore.notificationStatus
if (!status) return '❓'
if (status.canScheduleNow) return '✅'
return '⚠️'
})
const systemStatus = computed(() => { const systemStatus = computed(() => {
const status = appStore.notificationStatus const status = appStore.notificationStatus
if (!status) { if (!status) {
return [ return [
{ label: 'Platform', value: platformName.value, status: 'info' }, { label: 'Platform', value: platformName.value, status: 'info' as const },
{ label: 'Plugin', value: 'Not Available', status: 'error' } { label: 'Plugin', value: 'Not Available', status: 'error' as const }
] ]
} }
const permissionsStatus: 'success' | 'warning' = status.postNotificationsGranted ? 'success' : 'warning'
const canScheduleStatus: 'success' | 'warning' = status.canScheduleNow ? 'success' : 'warning'
return [ return [
{ label: 'Platform', value: platformName.value, status: 'info' }, { label: 'Platform', value: platformName.value, status: 'info' as const },
{ label: 'Plugin', value: 'Available', status: 'success' }, { label: 'Plugin', value: 'Available', status: 'success' as const },
{ label: 'Permissions', value: status.postNotificationsGranted ? 'Granted' : 'Not Granted', status: status.postNotificationsGranted ? 'success' : 'warning' }, { label: 'Permissions', value: status.postNotificationsGranted ? 'Granted' : 'Not Granted', status: permissionsStatus },
{ label: 'Can Schedule', value: status.canScheduleNow ? 'Yes' : 'No', status: status.canScheduleNow ? 'success' : 'warning' }, { label: 'Can Schedule', value: status.canScheduleNow ? 'Yes' : 'No', status: canScheduleStatus },
{ label: 'Next Scheduled', value: status.nextScheduledAt ? new Date(status.nextScheduledAt).toLocaleTimeString() : 'None', status: 'info' } { label: 'Next Scheduled', value: status.nextScheduledAt ? new Date(status.nextScheduledAt).toLocaleTimeString() : 'None', status: 'info' as const }
] ]
}) })
@@ -199,6 +213,16 @@ const navigateToSettings = (): void => {
router.push('/settings') router.push('/settings')
} }
const navigateToStatus = (): void => {
console.log('🔄 CLICK: Navigate to Status')
router.push('/status')
}
const navigateToRequestPermissions = (): void => {
console.log('🔄 CLICK: Navigate to Request Permissions')
router.push('/request-permissions')
}
const checkSystemStatus = async (): Promise<void> => { const checkSystemStatus = async (): Promise<void> => {
console.log('🔄 CLICK: Check System Status') console.log('🔄 CLICK: Check System Status')
isCheckingStatus.value = true isCheckingStatus.value = true
@@ -227,7 +251,17 @@ const checkSystemStatus = async (): Promise<void> => {
const status = await plugin.getNotificationStatus() const status = await plugin.getNotificationStatus()
// Use checkPermissionStatus() which is the correct method name for iOS // Use checkPermissionStatus() which is the correct method name for iOS
const permissions = await plugin.checkPermissionStatus() const permissions = await plugin.checkPermissionStatus()
const exactAlarmStatus = await plugin.getExactAlarmStatus()
// getExactAlarmStatus() is Android-only - only call on Android
let exactAlarmStatus = { enabled: false, supported: false }
if (platform === 'android') {
try {
exactAlarmStatus = await plugin.getExactAlarmStatus()
} catch (exactAlarmError) {
console.warn('⚠️ getExactAlarmStatus() failed (non-critical):', exactAlarmError)
// Use defaults if call fails
}
}
console.log('📊 Plugin status object:', status) console.log('📊 Plugin status object:', status)
console.log('📊 Status values:') console.log('📊 Status values:')
@@ -244,7 +278,9 @@ const checkSystemStatus = async (): Promise<void> => {
console.log(' - exactAlarmEnabled:', permissions.exactAlarmEnabled) console.log(' - exactAlarmEnabled:', permissions.exactAlarmEnabled)
console.log(' - wakeLockEnabled:', permissions.wakeLockEnabled) console.log(' - wakeLockEnabled:', permissions.wakeLockEnabled)
console.log(' - allPermissionsGranted:', permissions.allPermissionsGranted) console.log(' - allPermissionsGranted:', permissions.allPermissionsGranted)
if (platform === 'android') {
console.log('📊 Exact alarm status:', exactAlarmStatus) console.log('📊 Exact alarm status:', exactAlarmStatus)
}
// Map plugin response to app store format // Map plugin response to app store format
// checkPermissionStatus() returns PermissionStatusResult with boolean flags // checkPermissionStatus() returns PermissionStatusResult with boolean flags
@@ -325,35 +361,6 @@ const checkSystemStatus = async (): Promise<void> => {
} }
} }
const getStatusType = (status: string): 'success' | 'warning' | 'error' | 'info' => {
switch (status) {
case 'success':
case 'warning':
case 'error':
case 'info':
return status
default:
return 'info'
}
}
const getStatusDescription = (label: string): string => {
switch (label) {
case 'Platform':
return 'Current platform information'
case 'Plugin':
return 'DailyNotification plugin availability'
case 'Permissions':
return 'Notification permission status'
case 'Can Schedule':
return 'Ready to schedule notifications'
case 'Next Scheduled':
return 'Next scheduled notification time'
default:
return 'System status information'
}
}
const refreshSystemStatus = async (): Promise<void> => { const refreshSystemStatus = async (): Promise<void> => {
console.log('🔄 CLICK: Refresh System Status') console.log('🔄 CLICK: Refresh System Status')
await checkSystemStatus() await checkSystemStatus()
@@ -623,7 +630,6 @@ onMounted(async () => {
<style scoped> <style scoped>
.home-view { .home-view {
padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@@ -654,8 +660,6 @@ onMounted(async () => {
} }
.platform-badge { .platform-badge {
padding: 6px 12px;
border-radius: 16px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
@@ -663,8 +667,6 @@ onMounted(async () => {
} }
.status-badge { .status-badge {
padding: 6px 12px;
border-radius: 16px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
} }
@@ -688,17 +690,18 @@ onMounted(async () => {
} }
.system-status { .system-status {
margin-bottom: 40px; margin-bottom: 32px;
} }
.section { .section {
margin-bottom: 30px; margin-bottom: 24px;
margin-top: 32px;
} }
/* Mobile responsiveness */ /* Mobile responsiveness */
@media (max-width: 768px) { @media (max-width: 768px) {
.home-view { .home-view {
padding: 16px; padding: 16px 0;
} }
.welcome-title { .welcome-title {
@@ -708,10 +711,5 @@ onMounted(async () => {
.action-grid { .action-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.platform-info {
flex-direction: column;
align-items: center;
}
} }
</style> </style>

View File

@@ -12,7 +12,12 @@
<template> <template>
<div class="logs-view"> <div class="logs-view">
<div class="view-header"> <div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📋 System Logs</h1> <h1 class="page-title">📋 System Logs</h1>
</div>
<p class="page-subtitle">View and copy DailyNotification plugin logs</p> <p class="page-subtitle">View and copy DailyNotification plugin logs</p>
</div> </div>
@@ -73,6 +78,7 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator' import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
type LogLine = { ts: number; msg: string; level: string } type LogLine = { ts: number; msg: string; level: string }
@Component @Component
@@ -82,6 +88,10 @@ class LogsView extends Vue {
logs: LogLine[] = [] logs: LogLine[] = []
lastUpdated: number | null = null lastUpdated: number | null = null
goBack() {
router.push('/')
}
get hasLogs() { return this.logs.length > 0 } get hasLogs() { return this.logs.length > 0 }
formatTimestamp(ts: number) { return new Date(ts).toLocaleString() } formatTimestamp(ts: number) { return new Date(ts).toLocaleString() }
@@ -121,7 +131,6 @@ export default toNative(LogsView)
<style scoped> <style scoped>
.logs-view { .logs-view {
padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@@ -131,6 +140,36 @@ export default toNative(LogsView)
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title { .page-title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 28px; font-size: 28px;
@@ -279,7 +318,7 @@ export default toNative(LogsView)
/* Mobile responsiveness */ /* Mobile responsiveness */
@media (max-width: 768px) { @media (max-width: 768px) {
.logs-view { .logs-view {
padding: 16px; padding: 16px 0;
} }
.logs-controls { .logs-controls {
@@ -302,5 +341,15 @@ export default toNative(LogsView)
.log-timestamp { .log-timestamp {
font-size: 11px; font-size: 11px;
} }
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
} }
</style> </style>

View File

@@ -21,7 +21,6 @@ export default toNative(NotFoundView)
<style scoped> <style scoped>
.not-found-view { .not-found-view {
padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }

View File

@@ -1,26 +1,313 @@
<template> <template>
<div class="notifications-view"> <div class="notifications-view">
<div class="view-header"> <div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">🔔 Notifications</h1> <h1 class="page-title">🔔 Notifications</h1>
<p class="page-subtitle">Manage scheduled notifications</p>
</div> </div>
<div class="placeholder-content"> <p class="page-subtitle">View scheduled notifications and rollover status</p>
<p>Notifications management coming soon...</p> </div>
<div class="notifications-content">
<div class="actions-bar">
<button
class="action-button refresh"
@click="refreshNotifications"
:disabled="isLoading"
>
🔄 {{ isLoading ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<div v-if="!isLoading && notificationStatus" class="status-cards">
<!-- Main Status Card -->
<div class="status-card main">
<div class="card-header">
<h2>📅 Scheduled Notification</h2>
<span class="status-badge" :class="statusClass">
{{ statusText }}
</span>
</div>
<div class="card-content">
<div v-if="nextNotificationTime" class="info-row">
<span class="label">Next Notification:</span>
<span class="value highlight">{{ formattedNextTime }}</span>
</div>
<div v-else class="info-row">
<span class="label">Next Notification:</span>
<span class="value">None scheduled</span>
</div>
<div v-if="timeUntilNext" class="info-row">
<span class="label">Time Until:</span>
<span class="value">{{ timeUntilNext }}</span>
</div>
<div class="info-row">
<span class="label">Pending Count:</span>
<span class="value">{{ pendingCount }}</span>
</div>
<div v-if="notificationStatus.lastNotificationTime" class="info-row">
<span class="label">Last Notification:</span>
<span class="value">{{ formattedLastTime }}</span>
</div>
</div>
</div>
<!-- Rollover Status Card -->
<div v-if="rolloverInfo" class="status-card rollover">
<div class="card-header">
<h2>🔄 Rollover Status</h2>
<span class="status-badge" :class="rolloverInfo.enabled ? 'success' : 'warning'">
{{ rolloverInfo.enabled ? 'Active' : 'Inactive' }}
</span>
</div>
<div class="card-content">
<div v-if="rolloverInfo.lastRolloverTime" class="info-row">
<span class="label">Last Rollover:</span>
<span class="value">{{ formattedRolloverTime }}</span>
</div>
<div v-else class="info-row">
<span class="label">Last Rollover:</span>
<span class="value">Never</span>
</div>
<div class="info-row">
<span class="label">Rollover Enabled:</span>
<span class="value">{{ rolloverInfo.enabled ? 'Yes' : 'No' }}</span>
</div>
</div>
</div>
<!-- Additional Info Card -->
<div class="status-card info">
<div class="card-header">
<h2> Additional Information</h2>
</div>
<div class="card-content">
<div class="info-row">
<span class="label">Notifications Enabled:</span>
<span class="value">{{ notificationStatus.isEnabled ? 'Yes' : 'No' }}</span>
</div>
<div class="info-row">
<span class="label">Is Scheduled:</span>
<span class="value">{{ notificationStatus.isScheduled ? 'Yes' : 'No' }}</span>
</div>
<div v-if="notificationStatus.error" class="info-row error">
<span class="label">Error:</span>
<span class="value">{{ notificationStatus.error }}</span>
</div>
</div>
</div>
</div>
<div v-else-if="!isLoading && !notificationStatus" class="empty-state">
<p>No notification status available. Try refreshing.</p>
</div>
<div v-if="isLoading" class="loading-state">
<p>Loading notification status...</p>
</div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator' import { Vue, Component, toNative } from 'vue-facing-decorator'
import { Capacitor } from '@capacitor/core'
import router from '../router'
import { createTypedPlugin } from '../lib/typed-plugin'
import type { NotificationStatus } from '../lib/bridge'
interface RolloverInfo {
enabled: boolean
lastRolloverTime?: number
}
@Component @Component
class NotificationsView extends Vue {} class NotificationsView extends Vue {
isLoading = false
errorMessage = ''
notificationStatus: NotificationStatus | null = null
rolloverInfo: RolloverInfo | null = null
currentTime = Date.now()
private timeUpdateInterval: number | null = null
async mounted() {
await this.refreshNotifications()
// Update current time every second to keep countdown reactive
this.timeUpdateInterval = window.setInterval(() => {
this.currentTime = Date.now()
}, 1000)
}
beforeUnmount() {
if (this.timeUpdateInterval !== null) {
clearInterval(this.timeUpdateInterval)
this.timeUpdateInterval = null
}
}
goBack() {
router.push('/')
}
async refreshNotifications() {
this.isLoading = true
this.errorMessage = ''
// Update current time immediately to refresh countdown
this.currentTime = Date.now()
try {
const platform = Capacitor.getPlatform()
const isNative = platform !== 'web'
if (!isNative) {
this.errorMessage = 'Notification status is only available on native platforms (iOS/Android)'
this.isLoading = false
return
}
const typedPlugin = await createTypedPlugin()
if (!typedPlugin) {
throw new Error('Plugin not available')
}
// Get notification status
const status = await typedPlugin.getNotificationStatus()
this.notificationStatus = status
// Try to get rollover info (may not be in type definition but could be in response)
// The iOS plugin returns rolloverEnabled and lastRolloverTime
try {
const { DailyNotification } = await import('@timesafari/daily-notification-plugin')
const rawStatus = await DailyNotification.getNotificationStatus() as any
if (rawStatus.rolloverEnabled !== undefined || rawStatus.lastRolloverTime !== undefined) {
this.rolloverInfo = {
enabled: rawStatus.rolloverEnabled ?? false,
lastRolloverTime: rawStatus.lastRolloverTime
}
}
} catch (rolloverError) {
// Rollover info not available, that's okay
console.log('Rollover info not available:', rolloverError)
}
} catch (error: any) {
console.error('Failed to fetch notification status:', error)
this.errorMessage = error.message || 'Failed to load notification status'
} finally {
this.isLoading = false
}
}
get statusClass(): string {
if (!this.notificationStatus) return 'info'
if (this.notificationStatus.isScheduled && this.nextNotificationTime) return 'success'
if (this.notificationStatus.error) return 'error'
return 'warning'
}
get statusText(): string {
if (!this.notificationStatus) return 'Unknown'
if (this.notificationStatus.isScheduled && this.nextNotificationTime) return 'Scheduled'
if (this.notificationStatus.error) return 'Error'
return 'Not Scheduled'
}
get nextNotificationTime(): number | null {
if (!this.notificationStatus?.nextNotificationTime) return null
const time = this.notificationStatus.nextNotificationTime
return typeof time === 'number' ? time : null
}
get formattedNextTime(): string {
if (!this.nextNotificationTime) return 'N/A'
const date = new Date(this.nextNotificationTime)
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
get timeUntilNext(): string | null {
if (!this.nextNotificationTime) return null
const diff = this.nextNotificationTime - this.currentTime
if (diff < 0) return 'Past due'
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const parts: string[] = []
if (days > 0) parts.push(`${days} day${days !== 1 ? 's' : ''}`)
if (hours > 0) parts.push(`${hours} hour${hours !== 1 ? 's' : ''}`)
if (minutes > 0 || parts.length === 0) parts.push(`${minutes} minute${minutes !== 1 ? 's' : ''}`)
return parts.join(', ')
}
get formattedLastTime(): string {
if (!this.notificationStatus?.lastNotificationTime) return 'N/A'
const date = new Date(this.notificationStatus.lastNotificationTime)
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
get formattedRolloverTime(): string {
if (!this.rolloverInfo?.lastRolloverTime) return 'N/A'
const date = new Date(this.rolloverInfo.lastRolloverTime)
return date.toLocaleString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
get pendingCount(): number {
if (!this.notificationStatus) return 0
const pending = this.notificationStatus.pending
// Handle both boolean and number types
if (typeof pending === 'boolean') {
return pending ? 1 : 0
}
if (typeof pending === 'number') {
return pending
}
return 0
}
}
export default toNative(NotificationsView) export default toNative(NotificationsView)
</script> </script>
<style scoped> <style scoped>
.notifications-view { .notifications-view {
padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@@ -30,6 +317,36 @@ export default toNative(NotificationsView)
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title { .page-title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 28px; font-size: 28px;
@@ -43,7 +360,162 @@ export default toNative(NotificationsView)
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
} }
.placeholder-content { .notifications-content {
padding: 0 16px;
}
.actions-bar {
margin-bottom: 24px;
display: flex;
justify-content: flex-end;
}
.action-button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button.refresh {
background: #1976d2;
color: white;
}
.action-button.refresh:hover:not(:disabled) {
background: #1565c0;
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.4);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
color: #e57373;
font-size: 14px;
}
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 24px;
}
.status-card {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 24px;
backdrop-filter: blur(10px);
}
.status-card.main {
grid-column: 1 / -1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.card-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: white;
}
.status-badge {
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.success {
background: rgba(76, 175, 80, 0.3);
color: #81c784;
border: 1px solid rgba(76, 175, 80, 0.5);
}
.status-badge.warning {
background: rgba(255, 152, 0, 0.3);
color: #ffb74d;
border: 1px solid rgba(255, 152, 0, 0.5);
}
.status-badge.error {
background: rgba(244, 67, 54, 0.3);
color: #e57373;
border: 1px solid rgba(244, 67, 54, 0.5);
}
.status-badge.info {
background: rgba(33, 150, 243, 0.3);
color: #64b5f6;
border: 1px solid rgba(33, 150, 243, 0.5);
}
.card-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.info-row:last-child {
border-bottom: none;
}
.info-row.error {
color: #e57373;
}
.info-row .label {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
flex: 0 0 40%;
}
.info-row .value {
font-size: 14px;
color: white;
text-align: right;
flex: 1;
word-break: break-word;
}
.info-row .value.highlight {
color: #81c784;
font-weight: 600;
font-size: 15px;
}
.empty-state,
.loading-state {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px; border-radius: 12px;
@@ -52,4 +524,38 @@ export default toNative(NotificationsView)
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
.status-cards {
grid-template-columns: 1fr;
}
.status-card.main {
grid-column: 1;
}
.info-row {
flex-direction: column;
gap: 8px;
}
.info-row .label {
flex: 1;
}
.info-row .value {
text-align: left;
}
}
</style> </style>

View File

@@ -0,0 +1,576 @@
<!--
/**
* Request Permissions View - Permission Management Interface
*
* Platform-neutral permissions view with status display and request functionality
*
* @author Matthew Raymer
* @version 1.0.0
*/
-->
<template>
<div class="request-permissions-view">
<div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">🔐 Request Permissions</h1>
</div>
<p class="page-subtitle">Check and request notification permissions</p>
</div>
<!-- Status Display -->
<div class="status-section">
<div class="status-card" :class="statusCardClass">
<div class="status-content">
<div class="status-icon">{{ statusIcon }}</div>
<div class="status-text">{{ statusText }}</div>
<div v-if="statusDetails" class="status-details">{{ statusDetails }}</div>
</div>
</div>
</div>
<!-- Permission Details -->
<div class="permissions-section">
<h2 class="section-title">Permission Status</h2>
<div class="permissions-grid">
<div class="permission-item">
<span class="permission-label">🔔 Notifications:</span>
<span class="permission-value" :class="notificationStatusClass">
{{ notificationStatusText }}
</span>
</div>
<div v-if="exactAlarmStatusText" class="permission-item">
<span class="permission-label"> Exact Alarm:</span>
<span class="permission-value" :class="exactAlarmStatusClass">
{{ exactAlarmStatusText }}
</span>
</div>
<div v-if="backgroundRefreshStatusText" class="permission-item">
<span class="permission-label">🔄 Background Refresh:</span>
<span class="permission-value" :class="backgroundRefreshStatusClass">
{{ backgroundRefreshStatusText }}
</span>
</div>
</div>
</div>
<!-- Action Button -->
<div class="action-section">
<button
class="action-button primary"
@click="requestPermissions"
:disabled="isRequesting"
>
{{ isRequesting ? 'Requesting...' : 'Request Permissions' }}
</button>
<button
class="action-button secondary"
@click="refreshStatus"
:disabled="isRefreshing"
>
{{ isRefreshing ? 'Refreshing...' : 'Refresh Status' }}
</button>
</div>
<!-- Additional Actions -->
<div class="additional-actions">
<button
class="action-button tertiary"
@click="openNotificationSettings"
>
Open Notification Settings
</button>
<button
v-if="showBackgroundRefreshButton"
class="action-button tertiary"
@click="openBackgroundRefreshSettings"
>
Open Background Refresh Settings
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Capacitor } from '@capacitor/core'
import { DailyNotification } from '@timesafari/daily-notification-plugin'
const router = useRouter()
const isRequesting = ref(false)
const isRefreshing = ref(false)
const statusText = ref('Ready to check permissions...')
const statusDetails = ref('')
const notificationGranted = ref(false)
const exactAlarmGranted = ref(false)
const backgroundRefreshEnabled = ref(false)
const showBackgroundRefreshButton = ref(false)
const statusCardClass = computed(() => {
if (isRequesting.value || isRefreshing.value) return 'status-yellow'
if (notificationGranted.value) return 'status-green'
return 'status-gray'
})
const statusIcon = computed(() => {
if (isRequesting.value || isRefreshing.value) return '⏳'
if (notificationGranted.value) return '✅'
return '⚠️'
})
const notificationStatusClass = computed(() => {
return notificationGranted.value ? 'status-success' : 'status-error'
})
const notificationStatusText = computed(() => {
return notificationGranted.value ? 'Granted' : 'Not Granted'
})
const exactAlarmStatusClass = computed(() => {
return exactAlarmGranted.value ? 'status-success' : 'status-warning'
})
const exactAlarmStatusText = computed(() => {
if (!exactAlarmGranted.value && Capacitor.getPlatform() === 'android') {
return 'Not Granted'
}
return null
})
const backgroundRefreshStatusClass = computed(() => {
return backgroundRefreshEnabled.value ? 'status-success' : 'status-warning'
})
const backgroundRefreshStatusText = computed(() => {
if (Capacitor.getPlatform() === 'ios') {
return backgroundRefreshEnabled.value ? 'Enabled' : 'Not Enabled'
}
return null
})
const checkStatus = async (): Promise<void> => {
isRefreshing.value = true
statusText.value = 'Checking permissions...'
statusDetails.value = ''
try {
if (!Capacitor.isNativePlatform()) {
statusText.value = 'Web platform - permissions not available'
statusDetails.value = 'This feature requires a native platform (iOS or Android)'
notificationGranted.value = false
return
}
const plugin = DailyNotification
if (!plugin) {
statusText.value = 'Plugin not available'
statusDetails.value = 'DailyNotification plugin could not be loaded'
notificationGranted.value = false
return
}
// Check permission status
const permissionStatus = await plugin.checkPermissionStatus()
notificationGranted.value = permissionStatus.notificationsEnabled ?? false
// Check exact alarm status (Android only)
if (Capacitor.getPlatform() === 'android') {
try {
const exactAlarmStatus = await plugin.getExactAlarmStatus()
exactAlarmGranted.value = exactAlarmStatus.enabled ?? false
} catch (error) {
console.warn('Could not check exact alarm status:', error)
}
}
// Check background refresh status (iOS only)
if (Capacitor.getPlatform() === 'ios') {
try {
if (typeof (plugin as any).getBackgroundTaskStatus === 'function') {
const backgroundStatus = await (plugin as any).getBackgroundTaskStatus()
// Check if tasks are registered (either fetchTaskRegistered or notifyTaskRegistered)
const tasksRegistered = backgroundStatus.tasksRegistered ??
backgroundStatus.fetchTaskRegistered ??
backgroundStatus.notifyTaskRegistered ??
false
backgroundRefreshEnabled.value = tasksRegistered
showBackgroundRefreshButton.value = true
}
} catch (error) {
console.warn('Could not check background refresh status:', error)
}
}
if (notificationGranted.value) {
statusText.value = 'Permission request completed! Check your device settings if needed.'
statusDetails.value = 'Notifications are enabled and ready to use.'
} else {
statusText.value = 'Permissions not granted'
statusDetails.value = 'Tap "Request Permissions" to enable notifications.'
}
} catch (error) {
console.error('❌ Failed to check permissions:', error)
statusText.value = `Status check failed: ${(error as Error).message}`
statusDetails.value = 'Please try again or check the console for details.'
notificationGranted.value = false
} finally {
isRefreshing.value = false
}
}
const requestPermissions = async (): Promise<void> => {
if (isRequesting.value) {
console.log('⏳ Permission request already in progress')
return
}
isRequesting.value = true
statusText.value = 'Requesting permissions...'
statusDetails.value = ''
try {
if (!Capacitor.isNativePlatform()) {
statusText.value = 'Web platform - permissions not available'
statusDetails.value = 'This feature requires a native platform (iOS or Android)'
return
}
const plugin = DailyNotification
if (!plugin) {
statusText.value = 'Plugin not available'
statusDetails.value = 'DailyNotification plugin could not be loaded'
return
}
// Request permissions - try iOS-specific method first, fallback to generic
if (typeof (plugin as any).requestNotificationPermissions === 'function') {
await (plugin as { requestNotificationPermissions: () => Promise<any> }).requestNotificationPermissions()
} else if (typeof (plugin as any).requestPermissions === 'function') {
await (plugin as { requestPermissions: () => Promise<any> }).requestPermissions()
} else {
throw new Error('Permission request method not available')
}
statusText.value = 'Permission request completed! Check your device settings if needed.'
statusDetails.value = 'Refreshing status...'
// Wait a moment for system to update, then refresh status
await new Promise(resolve => setTimeout(resolve, 1000))
await checkStatus()
} catch (error) {
console.error('❌ Permission request failed:', error)
statusText.value = `Permission request failed: ${(error as Error).message}`
statusDetails.value = 'Please check your device settings or try again.'
} finally {
isRequesting.value = false
}
}
const refreshStatus = async (): Promise<void> => {
await checkStatus()
}
const openNotificationSettings = async (): Promise<void> => {
try {
const plugin = DailyNotification
if (!plugin) {
alert('Plugin not available')
return
}
if (typeof (plugin as any).openNotificationSettings === 'function') {
await (plugin as any).openNotificationSettings()
} else {
alert('Open notification settings not available on this platform')
}
} catch (error) {
console.error('❌ Failed to open notification settings:', error)
alert(`Failed to open settings: ${(error as Error).message}`)
}
}
const openBackgroundRefreshSettings = async (): Promise<void> => {
try {
const plugin = DailyNotification
if (!plugin) {
alert('Plugin not available')
return
}
if (typeof (plugin as any).openBackgroundAppRefreshSettings === 'function') {
await (plugin as any).openBackgroundAppRefreshSettings()
} else {
alert('Open background refresh settings not available on this platform')
}
} catch (error) {
console.error('❌ Failed to open background refresh settings:', error)
alert(`Failed to open settings: ${(error as Error).message}`)
}
}
const goBack = (): void => {
router.push('/')
}
// Check status when component mounts
onMounted(async () => {
await checkStatus()
})
</script>
<style scoped>
.request-permissions-view {
max-width: 600px;
margin: 0 auto;
}
.view-header {
text-align: center;
margin-bottom: 30px;
}
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 700;
color: white;
}
.page-subtitle {
margin: 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
}
/* Status Section */
.status-section {
margin-bottom: 24px;
}
.status-card {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 24px;
backdrop-filter: blur(10px);
text-align: center;
}
.status-card.status-yellow {
background: rgba(255, 255, 0, 0.3);
border-color: rgba(255, 255, 0, 0.5);
}
.status-card.status-green {
background: rgba(0, 255, 0, 0.3);
border-color: rgba(0, 255, 0, 0.5);
}
.status-card.status-gray {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.status-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.status-icon {
font-size: 48px;
}
.status-text {
font-size: 18px;
font-weight: 600;
color: white;
}
.status-details {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
/* Permissions Section */
.permissions-section {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
backdrop-filter: blur(10px);
}
.section-title {
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 600;
color: white;
}
.permissions-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.permission-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.permission-label {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
}
.permission-value {
font-size: 14px;
font-weight: 600;
padding: 4px 12px;
border-radius: 12px;
}
.permission-value.status-success {
background: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.permission-value.status-error {
background: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.permission-value.status-warning {
background: rgba(251, 191, 36, 0.3);
color: #fde047;
}
/* Action Section */
.action-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.action-button {
width: 100%;
padding: 14px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button.primary {
background: #1976d2;
color: white;
}
.action-button.primary:hover:not(:disabled) {
background: #1565c0;
}
.action-button.secondary {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
}
.action-button.secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.action-button.tertiary {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
}
.action-button.tertiary:hover {
background: rgba(255, 255, 255, 0.1);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.additional-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.request-permissions-view {
padding: 16px 0;
}
.status-card,
.permissions-section {
padding: 20px;
}
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style>

View File

@@ -10,7 +10,12 @@
<template> <template>
<div class="schedule-view"> <div class="schedule-view">
<div class="view-header"> <div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📅 Schedule Notification</h1> <h1 class="page-title">📅 Schedule Notification</h1>
</div>
<p class="page-subtitle">Schedule a new daily notification</p> <p class="page-subtitle">Schedule a new daily notification</p>
</div> </div>
@@ -34,6 +39,13 @@
<button class="action-button primary" @click="scheduleNotification" :disabled="isScheduling"> <button class="action-button primary" @click="scheduleNotification" :disabled="isScheduling">
{{ isScheduling ? 'Scheduling...' : 'Schedule Notification' }} {{ isScheduling ? 'Scheduling...' : 'Schedule Notification' }}
</button> </button>
<button class="action-button secondary" @click="testNotification" :disabled="isTesting">
{{ isTesting ? 'Testing...' : '🧪 Test Notification (5 seconds)' }}
</button>
</div>
<div v-if="scheduleFeedback" class="feedback-message" :class="scheduleFeedback.type">
{{ scheduleFeedback.message }}
</div> </div>
</div> </div>
</div> </div>
@@ -41,16 +53,45 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator' import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
@Component @Component
class ScheduleView extends Vue { class ScheduleView extends Vue {
scheduleTime = '09:00' scheduleTime = this.getDefaultScheduleTime()
notificationTitle = 'Daily Update' notificationTitle = 'Daily Update'
notificationMessage = 'Your daily notification is ready!' notificationMessage = 'Your daily notification is ready!'
isScheduling = false isScheduling = false
isTesting = false
scheduleFeedback: { type: 'success' | 'error' | 'info', message: string } | null = null
/**
* Calculate default schedule time as current time + 3 minutes (rounded up)
* @returns Time string in HH:MM format
*/
getDefaultScheduleTime(): string {
const now = new Date()
// Round up to next minute if there are any seconds
if (now.getSeconds() > 0 || now.getMilliseconds() > 0) {
now.setMinutes(now.getMinutes() + 1)
now.setSeconds(0)
now.setMilliseconds(0)
}
// Add 3 minutes
now.setMinutes(now.getMinutes() + 3)
// Format as HH:MM
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
goBack() {
router.push('/')
}
async scheduleNotification() { async scheduleNotification() {
this.isScheduling = true this.isScheduling = true
this.scheduleFeedback = null
try { try {
console.log('🔄 Starting notification scheduling...') console.log('🔄 Starting notification scheduling...')
@@ -60,6 +101,17 @@ class ScheduleView extends Vue {
console.log('✅ Plugin loaded:', plugin) console.log('✅ Plugin loaded:', plugin)
// Calculate when the notification will fire
const [hours, minutes] = this.scheduleTime.split(':').map(Number)
const now = new Date()
const scheduledTime = new Date()
scheduledTime.setHours(hours, minutes, 0, 0)
// If the time has passed today, schedule for tomorrow
if (scheduledTime <= now) {
scheduledTime.setDate(scheduledTime.getDate() + 1)
}
const options = { const options = {
time: this.scheduleTime, time: this.scheduleTime,
title: this.notificationTitle, title: this.notificationTitle,
@@ -74,8 +126,22 @@ class ScheduleView extends Vue {
console.log('✅ Notification scheduled successfully!') console.log('✅ Notification scheduled successfully!')
// Show success feedback to user // Show success feedback with timing info
alert('Notification scheduled successfully!') const timeUntil = scheduledTime.getTime() - now.getTime()
const hoursUntil = Math.floor(timeUntil / (1000 * 60 * 60))
const minutesUntil = Math.floor((timeUntil % (1000 * 60 * 60)) / (1000 * 60))
let timeMessage = ''
if (hoursUntil > 0) {
timeMessage = `Notification will appear in ${hoursUntil} hour${hoursUntil > 1 ? 's' : ''} and ${minutesUntil} minute${minutesUntil !== 1 ? 's' : ''} (at ${scheduledTime.toLocaleTimeString()})`
} else {
timeMessage = `Notification will appear in ${minutesUntil} minute${minutesUntil !== 1 ? 's' : ''} (at ${scheduledTime.toLocaleTimeString()})`
}
this.scheduleFeedback = {
type: 'success',
message: `✅ Notification scheduled successfully! ${timeMessage}. Use "Test Notification" to see one immediately.`
}
} catch (error) { } catch (error) {
console.error('❌ Failed to schedule notification:', error) console.error('❌ Failed to schedule notification:', error)
@@ -85,19 +151,65 @@ class ScheduleView extends Vue {
stack: error.stack stack: error.stack
}) })
// Show error feedback to user this.scheduleFeedback = {
alert(`Failed to schedule notification: ${error.message}`) type: 'error',
message: `❌ Failed to schedule notification: ${error.message}`
}
} finally { } finally {
this.isScheduling = false this.isScheduling = false
} }
} }
async testNotification() {
this.isTesting = true
this.scheduleFeedback = null
try {
console.log('🧪 Testing notification (will fire in 5 seconds)...')
// Import and use the real plugin
const { DailyNotification } = await import('@timesafari/daily-notification-plugin')
const plugin = DailyNotification
console.log('✅ Plugin loaded:', plugin)
// Use testAlarm to schedule a notification that fires in 5 seconds
const result = await plugin.testAlarm({ secondsFromNow: 5 })
console.log('✅ Test notification scheduled:', result)
this.scheduleFeedback = {
type: 'success',
message: '✅ Test notification scheduled! It will appear in 5 seconds. Look for it at the top of your screen.'
}
// Clear feedback after 10 seconds
setTimeout(() => {
this.scheduleFeedback = null
}, 10000)
} catch (error) {
console.error('❌ Failed to schedule test notification:', error)
console.error('❌ Error details:', {
name: error.name,
message: error.message,
stack: error.stack
})
this.scheduleFeedback = {
type: 'error',
message: `❌ Failed to schedule test notification: ${error.message}`
}
} finally {
this.isTesting = false
}
}
} }
export default toNative(ScheduleView) export default toNative(ScheduleView)
</script> </script>
<style scoped> <style scoped>
.schedule-view { .schedule-view {
padding: 20px;
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
} }
@@ -107,6 +219,36 @@ export default toNative(ScheduleView)
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title { .page-title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 28px; font-size: 28px;
@@ -190,14 +332,60 @@ export default toNative(ScheduleView)
cursor: not-allowed; cursor: not-allowed;
} }
.action-button.secondary {
background: rgba(255, 255, 255, 0.2);
color: white;
margin-top: 12px;
}
.action-button.secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.feedback-message {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.feedback-message.success {
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.4);
color: #81c784;
}
.feedback-message.error {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.4);
color: #e57373;
}
.feedback-message.info {
background: rgba(33, 150, 243, 0.2);
border: 1px solid rgba(33, 150, 243, 0.4);
color: #64b5f6;
}
/* Mobile responsiveness */ /* Mobile responsiveness */
@media (max-width: 768px) { @media (max-width: 768px) {
.schedule-view { .schedule-view {
padding: 16px; padding: 16px 0;
} }
.schedule-form { .schedule-form {
padding: 20px; padding: 20px;
} }
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
} }
</style> </style>

View File

@@ -1,7 +1,12 @@
<template> <template>
<div class="settings-view"> <div class="settings-view">
<div class="view-header"> <div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title"> Settings</h1> <h1 class="page-title"> Settings</h1>
</div>
<p class="page-subtitle">Configure app preferences</p> <p class="page-subtitle">Configure app preferences</p>
</div> </div>
<div class="placeholder-content"> <div class="placeholder-content">
@@ -12,15 +17,19 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator' import { Vue, Component, toNative } from 'vue-facing-decorator'
import router from '../router'
@Component @Component
class SettingsView extends Vue {} class SettingsView extends Vue {
goBack() {
router.push('/')
}
}
export default toNative(SettingsView) export default toNative(SettingsView)
</script> </script>
<style scoped> <style scoped>
.settings-view { .settings-view {
padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@@ -30,6 +39,36 @@ export default toNative(SettingsView)
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title { .page-title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 28px; font-size: 28px;
@@ -52,4 +91,17 @@ export default toNative(SettingsView)
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
/* Mobile responsiveness */
@media (max-width: 768px) {
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
</style> </style>

View File

@@ -1,7 +1,12 @@
<template> <template>
<div class="status-view"> <div class="status-view">
<div class="view-header"> <div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">📊 Status Matrix</h1> <h1 class="page-title">📊 Status Matrix</h1>
</div>
<p class="page-subtitle">Comprehensive system status and diagnostics</p> <p class="page-subtitle">Comprehensive system status and diagnostics</p>
</div> </div>
@@ -44,28 +49,7 @@
<div class="diagnostics-section"> <div class="diagnostics-section">
<h2>System Diagnostics</h2> <h2>System Diagnostics</h2>
<div class="diagnostics-content"> <div class="diagnostics-content">
<div class="diagnostics-info"> <StatusList :items="diagnosticsItems" />
<div class="info-item">
<span class="label">App Version:</span>
<span class="value">{{ diagnostics.appVersion }}</span>
</div>
<div class="info-item">
<span class="label">Platform:</span>
<span class="value">{{ diagnostics.platform }}</span>
</div>
<div class="info-item">
<span class="label">API Level:</span>
<span class="value">{{ diagnostics.apiLevel }}</span>
</div>
<div class="info-item">
<span class="label">Timezone:</span>
<span class="value">{{ diagnostics.timezone }}</span>
</div>
<div class="info-item">
<span class="label">Last Updated:</span>
<span class="value">{{ diagnostics.lastUpdated }}</span>
</div>
</div>
<div class="diagnostics-json"> <div class="diagnostics-json">
<h3>Raw Diagnostics (JSON)</h3> <h3>Raw Diagnostics (JSON)</h3>
@@ -87,7 +71,10 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, toNative } from 'vue-facing-decorator' import { Vue, Component, toNative } from 'vue-facing-decorator'
import { Capacitor } from '@capacitor/core'
import router from '../router'
import StatusCard from '../components/cards/StatusCard.vue' import StatusCard from '../components/cards/StatusCard.vue'
import StatusList from '../components/StatusList.vue'
import { createTypedPlugin } from '../lib/typed-plugin' import { createTypedPlugin } from '../lib/typed-plugin'
import { collectDiagnostics, copyDiagnosticsToClipboard, type ComprehensiveDiagnostics } from '../lib/diagnostics-export' import { collectDiagnostics, copyDiagnosticsToClipboard, type ComprehensiveDiagnostics } from '../lib/diagnostics-export'
@@ -108,17 +95,25 @@ type Diagnostics = ComprehensiveDiagnostics
@Component({ @Component({
components: { components: {
StatusCard StatusCard,
StatusList
} }
}) })
class StatusView extends Vue { class StatusView extends Vue {
isRefreshing = false isRefreshing = false
errorMessage = '' errorMessage = ''
goBack() {
router.push('/')
}
diagnostics: Diagnostics = { diagnostics: Diagnostics = {
appVersion: '1.0.0', appVersion: '1.0.0',
platform: 'Android', platform: (() => {
apiLevel: 'Unknown', const platform = Capacitor.getPlatform()
timezone: 'Unknown', return platform.charAt(0).toUpperCase() + platform.slice(1)
})(),
apiLevel: Capacitor.getPlatform() === 'android' ? 'Unknown' : 'N/A',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
lastUpdated: 'Never', lastUpdated: 'Never',
postNotificationsGranted: false, postNotificationsGranted: false,
exactAlarmGranted: false, exactAlarmGranted: false,
@@ -225,6 +220,16 @@ class StatusView extends Vue {
return JSON.stringify(this.diagnostics, null, 2) return JSON.stringify(this.diagnostics, null, 2)
} }
get diagnosticsItems() {
return [
{ label: 'App Version:', value: this.diagnostics.appVersion },
{ label: 'Platform:', value: this.diagnostics.platform },
{ label: 'API Level:', value: this.diagnostics.apiLevel },
{ label: 'Timezone:', value: this.diagnostics.timezone },
{ label: 'Last Updated:', value: this.diagnostics.lastUpdated }
]
}
async mounted() { async mounted() {
await this.refreshStatus() await this.refreshStatus()
} }
@@ -336,7 +341,6 @@ export default toNative(StatusView)
<style scoped> <style scoped>
.status-view { .status-view {
padding: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@@ -346,6 +350,36 @@ export default toNative(StatusView)
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title { .page-title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 28px; font-size: 28px;
@@ -453,32 +487,10 @@ export default toNative(StatusView)
gap: 24px; gap: 24px;
} }
.diagnostics-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item { .diagnostics-json {
display: flex; min-width: 0;
justify-content: space-between; overflow: hidden;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.info-item:last-child {
border-bottom: none;
}
.info-item .label {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.info-item .value {
color: white;
font-weight: 600;
} }
.diagnostics-json h3 { .diagnostics-json h3 {
@@ -498,8 +510,12 @@ export default toNative(StatusView)
font-size: 12px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
overflow-x: auto; overflow-x: auto;
max-height: 300px;
overflow-y: auto; overflow-y: auto;
max-height: 300px;
max-width: 100%;
width: 100%;
box-sizing: border-box;
white-space: pre;
} }
/* Error Section */ /* Error Section */
@@ -534,7 +550,7 @@ export default toNative(StatusView)
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.status-view { .status-view {
padding: 16px; padding: 16px 0;
} }
.matrix-header { .matrix-header {
@@ -574,5 +590,15 @@ export default toNative(StatusView)
.action-button { .action-button {
justify-content: center; justify-content: center;
} }
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
} }
</style> </style>

View File

@@ -1,7 +1,12 @@
<template> <template>
<div class="user-zero-view"> <div class="user-zero-view">
<div class="view-header"> <div class="view-header">
<div class="header-title-row">
<button class="back-button" @click="goBack" aria-label="Go back to home">
</button>
<h1 class="page-title">User Zero Stars Testing</h1> <h1 class="page-title">User Zero Stars Testing</h1>
</div>
<p class="page-subtitle">Test starred projects querying with TimeSafari User Zero</p> <p class="page-subtitle">Test starred projects querying with TimeSafari User Zero</p>
</div> </div>
@@ -103,8 +108,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { TEST_USER_ZERO_CONFIG, TestUserZeroAPI } from '../config/test-user-zero' import { TEST_USER_ZERO_CONFIG, TestUserZeroAPI } from '../config/test-user-zero'
const router = useRouter()
// Reactive state // Reactive state
const config = reactive(TEST_USER_ZERO_CONFIG) const config = reactive(TEST_USER_ZERO_CONFIG)
const isTesting = ref(false) const isTesting = ref(false)
@@ -272,11 +280,17 @@ function toggleMockMode() {
function clearError() { function clearError() {
errorMessage.value = '' errorMessage.value = ''
} }
/**
* Navigate back to home
*/
function goBack() {
router.push('/')
}
</script> </script>
<style scoped> <style scoped>
.user-zero-view { .user-zero-view {
padding: 20px;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@@ -289,6 +303,36 @@ function clearError() {
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.back-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 18px;
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
height: 40px;
}
.back-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateX(-2px);
}
.page-title { .page-title {
margin: 0 0 8px 0; margin: 0 0 8px 0;
font-size: 28px; font-size: 28px;
@@ -462,7 +506,7 @@ function clearError() {
@media (max-width: 768px) { @media (max-width: 768px) {
.user-zero-view { .user-zero-view {
padding: 16px; padding: 16px 0;
} }
.config-grid { .config-grid {
@@ -476,5 +520,15 @@ function clearError() {
.test-button { .test-button {
width: 100%; width: 100%;
} }
.header-title-row {
gap: 8px;
}
.back-button {
min-width: 36px;
height: 36px;
font-size: 16px;
}
} }
</style> </style>

View File

@@ -381,7 +381,8 @@
fetcherStatus.innerHTML = '❌ Error'; fetcherStatus.innerHTML = '❌ Error';
} }
} else { } else {
console.log('[Config Check] ❌ Native fetcher config not found'); console.log('[Config Check] ❌ Native fetcher config not found in database');
console.log('[Config Check] This may be normal after app uninstall/reinstall (database wiped)');
fetcherStatus.innerHTML = '❌ Not configured'; fetcherStatus.innerHTML = '❌ Not configured';
} }
@@ -395,8 +396,15 @@
}) })
.catch(error => { .catch(error => {
console.error('[Config Check] Failed to check configuration:', error); console.error('[Config Check] Failed to check configuration:', error);
// Don't show error if database might not be ready yet (recovery in progress)
if (error.message && error.message.includes('database')) {
console.log('[Config Check] Database may not be ready yet, will retry...');
fetcherStatus.innerHTML = '⏳ Checking...';
configStatus.innerHTML = '⏳ Checking...';
} else {
configStatus.innerHTML = '❌ Error'; configStatus.innerHTML = '❌ Error';
fetcherStatus.innerHTML = '❌ Error'; fetcherStatus.innerHTML = '❌ Error';
}
}); });
} }
@@ -650,8 +658,15 @@
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (!document.hidden) { if (!document.hidden) {
console.log('[Visibility] App became visible, refreshing UI status...'); console.log('[Visibility] App became visible, refreshing UI status...');
// Small delay to allow recovery to complete // Longer delay to allow recovery to complete (force-stop recovery can take a few seconds)
// Also refresh immediately, then again after delay to catch any late recovery
loadPluginStatus();
loadPermissionStatus();
loadChannelStatus();
loadConfigurationStatus();
setTimeout(() => { setTimeout(() => {
console.log('[Visibility] Delayed refresh after recovery period...');
loadPluginStatus(); loadPluginStatus();
loadPermissionStatus(); loadPermissionStatus();
loadChannelStatus(); loadChannelStatus();
@@ -692,7 +707,7 @@
console.error('[Visibility] Failed to get notification status:', error); console.error('[Visibility] Failed to get notification status:', error);
}); });
} }
}, 1000); // Wait 1 second for recovery to complete }, 3000); // Wait 3 seconds for recovery to complete (force-stop recovery can take time)
} }
}); });