Compare commits
61 Commits
f38b06abed
...
android-6
| Author | SHA1 | Date | |
|---|---|---|---|
| 4565e43479 | |||
|
|
cff7b659dc | ||
|
|
d3df4d9115 | ||
|
|
bc3bf484cc | ||
|
|
25f83cf1fa | ||
|
|
7188d32ae6 | ||
|
|
1157a0f1ef | ||
|
|
c2b1a60804 | ||
|
|
fa8028a698 | ||
|
|
9feaf60c84 | ||
|
|
aaeb71d31d | ||
|
|
531ce9f709 | ||
|
|
0b61d33f21 | ||
|
|
02a44a3e7b | ||
|
|
cb3cb5a78e | ||
|
|
a62f54b8a8 | ||
|
|
7702bd3b81 | ||
|
|
602eafc892 | ||
|
|
a77f08052f | ||
|
|
442b826401 | ||
|
|
0bc75372b5 | ||
|
|
57c7ddb7eb | ||
|
|
a3afefeda9 | ||
|
|
bf90f158ac | ||
|
|
5dbe0d1455 | ||
|
|
7f79c5990b | ||
|
|
bef88ad844 | ||
|
|
d0155f0b22 | ||
|
|
dd55c6b4e1 | ||
|
|
2915fe7438 | ||
|
|
5247ebeecb | ||
|
|
20b33f6e31 | ||
|
|
630fd3de81 | ||
|
|
aaac23111c | ||
|
|
d2a1041cc4 | ||
|
|
243cbd08f1 | ||
|
|
7e93cbd771 | ||
|
|
6d64f71988 | ||
|
|
65379aedd6 | ||
|
|
66c7eca33d | ||
|
|
d88978259d | ||
|
|
66cbe763fc | ||
|
|
766d56c661 | ||
|
|
f446362984 | ||
|
|
20f15ebcea | ||
|
|
b230a8e7b5 | ||
|
|
f97b3bec5b | ||
|
|
911aabf671 | ||
|
|
5ae63e6f6d | ||
|
|
edc4082f72 | ||
|
|
c8919480d9 | ||
|
|
2d353c877c | ||
|
|
2f0d733b10 | ||
|
|
a7d33e2d37 | ||
|
|
83ec604a4b | ||
|
|
8b116db095 | ||
|
|
76c05e3690 | ||
|
|
f19ff4c127 | ||
|
|
839e167c98 | ||
|
|
f40562b68a | ||
|
|
f1830e5f6f |
101
BUILDING.md
101
BUILDING.md
@@ -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
|
||||||
|
|||||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
108
docs/ACTION_PLAN_INTEGRATION_FIXES.md
Normal file
108
docs/ACTION_PLAN_INTEGRATION_FIXES.md
Normal 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 ~206–226) still runs. On "re-set same time," the DB still has the same `nextRunAt`, so the check returns early and **no alarm is scheduled**.
|
||||||
|
|
||||||
|
**Change:** Wrap the entire DB idempotence block so it runs only when `!skipPendingIntentIdempotence`. When `skipPendingIntentIdempotence=true`, log and skip the DB check.
|
||||||
|
|
||||||
|
- **Locate:** The block starting with `// DB-LEVEL IDEMPOTENCE CHECK` that loads `existingSchedule` and compares `existingSchedule.nextRunAt` with `triggerAtMillis` (60s tolerance), and `return@runBlocking` on duplicate.
|
||||||
|
- **Wrap:** Put that block inside `if (!skipPendingIntentIdempotence) { ... }` and add an `else` that logs:
|
||||||
|
`"Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId"`.
|
||||||
|
|
||||||
|
**Verification:** After editing a reminder without changing time, logs should show both "Skipping PendingIntent idempotence..." and "Skipping DB idempotence (skipPendingIntentIdempotence=true)...", and the alarm should fire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Bug B: Preserve static reminder on rollover
|
||||||
|
|
||||||
|
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
|
||||||
|
|
||||||
|
**Problem:** In `scheduleNextNotification()`, the call to `NotifyReceiver.scheduleExactNotification()` uses **hardcoded** `false` for `isStaticReminder` and `null` for `reminderId`. So the *next* occurrence is treated as non-static and content is loaded from storage/default → fallback text.
|
||||||
|
|
||||||
|
**Change:**
|
||||||
|
1. At the start of `scheduleNextNotification()`, read from WorkManager input:
|
||||||
|
`boolean preserveStaticReminder = getInputData().getBoolean("is_static_reminder", false);`
|
||||||
|
2. When choosing `scheduleId`: if `preserveStaticReminder && notificationId != null && !notificationId.isEmpty()`, set `scheduleId = notificationId`. Otherwise keep existing logic (`daily_*` → use as scheduleId, else `daily_rollover_` + timestamp).
|
||||||
|
3. Replace the existing `scheduleExactNotification(...)` call with:
|
||||||
|
- `isStaticReminder` = `preserveStaticReminder`
|
||||||
|
- `reminderId` = `preserveStaticReminder ? scheduleId : null`
|
||||||
|
- `scheduleId` = the chosen `scheduleId` (stable for static reminders).
|
||||||
|
4. (Optional but useful) Add log before scheduling:
|
||||||
|
`Log.d("DN|ROLLOVER", "next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);`
|
||||||
|
|
||||||
|
**Verification:** Set a custom title/body, let it fire once, then confirm the next scheduled run still uses the same text; logs should show `DN|ROLLOVER ... scheduleId=daily_timesafari_reminder static=true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Integration: Add Android `cancelDailyReminder`
|
||||||
|
|
||||||
|
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
|
||||||
|
|
||||||
|
**Problem:** The app calls `DailyNotification.cancelDailyReminder({ reminderId })`. iOS implements this; Android only has `cancelAllNotifications()` and `scheduleDailyReminder()` alias. On Android the call fails (method missing / not implemented), so "turn off" and "reset" flows cannot rely on explicit cancel.
|
||||||
|
|
||||||
|
**Change:** Add a new `@PluginMethod fun cancelDailyReminder(call: PluginCall)` (e.g. immediately after `scheduleDailyReminder()`).
|
||||||
|
|
||||||
|
- **Parse ID:** `reminderId = call.getString("reminderId") ?: call.getString("id") ?: call.getString("reminder_id") ?: call.getString("scheduleId")`. Reject if null/blank.
|
||||||
|
- **Cancel alarm:** `NotifyReceiver.cancelNotification(context, scheduleId = reminderId)`.
|
||||||
|
- **DB cleanup (best-effort):** In a try/catch, `runBlocking`:
|
||||||
|
- `db = getDatabase()` (or `DailyNotificationDatabase.getDatabase(context)` as used elsewhere in plugin).
|
||||||
|
- `db.scheduleDao().setEnabled(reminderId, false)` and `db.scheduleDao().updateRunTimes(reminderId, null, null)`.
|
||||||
|
- ScheduleDao already has `setEnabled` and `updateRunTimes` (see `DatabaseSchema.kt`).
|
||||||
|
- On success: `call.resolve()`. On exception: log and `call.reject("cancelDailyReminder failed: ...")`.
|
||||||
|
|
||||||
|
**Verification:** From the app, call `cancelDailyReminder({ reminderId: "daily_notification" })` (or your app’s id); it should resolve and the alarm for that id should be gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Checklist (plugin)
|
||||||
|
|
||||||
|
After implementing the three items above:
|
||||||
|
|
||||||
|
1. **Reset test:** Schedule reminder 2–3 minutes from now → Edit and re-save **without changing time** → Confirm it still fires. Logs: "Skipping DB idempotence (skipPendingIntentIdempotence=true)...".
|
||||||
|
2. **Rollover test:** Set custom title/body → Let it fire once → Confirm next scheduled notification keeps the same title/body. Logs: `DN|ROLLOVER ... static=true scheduleId=daily_timesafari_reminder`.
|
||||||
|
3. **Cancel test:** Call `cancelDailyReminder({ reminderId })` from app or test harness; no error and alarm cleared.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consuming App Work
|
||||||
|
|
||||||
|
App-side changes are described in a separate document intended for the **crowd-funder-for-time-pwa** (Time Safari) repo: **CONSUMING_APP_CURSOR_BRIEF.md**. That document is written so you can paste it into Cursor in the app repo to implement:
|
||||||
|
|
||||||
|
- Gate cancel in `editReminderNotification()` so Android skips pre-cancel (schedule path already cancels internally).
|
||||||
|
- Replace `TimeSafariNativeFetcher` placeholder with real content fetch and token persistence if using native fetcher for daily content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- NotifyReceiver: DB idempotence at ~206–226; skipPendingIntentIdempotence at ~159–204.
|
||||||
|
- DailyNotificationWorker: `scheduleNextNotification()` ~512–594; pass `preserveStaticReminder` and stable `scheduleId` into `scheduleExactNotification`.
|
||||||
|
- DailyNotificationPlugin: add `cancelDailyReminder` after `scheduleDailyReminder`; use `NotifyReceiver.cancelNotification` and ScheduleDao `setEnabled` / `updateRunTimes`.
|
||||||
|
- DatabaseSchema.kt: ScheduleDao `getById`, `upsert`, `setEnabled`, `updateRunTimes`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions & Limits
|
||||||
|
|
||||||
|
- App uses a stable reminder id (e.g. `daily_timesafari_reminder`); plugin preserves that id for static reminders on rollover.
|
||||||
|
- DAO method names are as in DatabaseSchema.kt; if the plugin’s Schedule entity uses different field names, adjust the `updateRunTimes` call accordingly (signature is `id, lastRunAt, nextRunAt`).
|
||||||
|
- Native fetcher and token persistence are app responsibilities; the plugin only needs to preserve static reminder semantics and provide cancel-by-id.
|
||||||
37
docs/CONSUMING_APP_ANDROID_NOTES.md
Normal file
37
docs/CONSUMING_APP_ANDROID_NOTES.md
Normal 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 app’s schedule row when handling rollover work that uses a `daily_rollover_*` id, so the app’s `nextRunAt` stays correct after a notification fires.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ACTION_PLAN_INTEGRATION_FIXES.md](./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
|
||||||
136
docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md
Normal file
136
docs/CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Optional: Use a Single Stable Schedule ID on iOS and Android
|
||||||
|
|
||||||
|
**Audience:** Consuming apps (e.g. TimeSafari / crowd-funder-for-time-pwa) that use `@timesafari/daily-notification-plugin`.
|
||||||
|
**Purpose:** Describe an optional app-side cleanup now that the plugin’s Android second-schedule bug is fixed (plugin v1.1.2+).
|
||||||
|
**Use:** Feed this doc into Cursor (or any editor) in the consuming app repo when implementing the cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- **Plugin fix (v1.1.2):** After cancel-then-schedule on Android, the plugin no longer skips the new schedule due to PendingIntent cache. Rescheduling works reliably whether or not the app passes an explicit `id` to `scheduleDailyNotification`.
|
||||||
|
- **Previous workaround:** Some apps avoided passing `id` on Android and used the plugin default `"daily_notification"` so that the (now-fixed) second-schedule bug would not trigger. On iOS they passed a stable id (e.g. `"daily_timesafari_reminder"`) for getStatus/cancel and verification.
|
||||||
|
- **Optional cleanup:** You can use the **same** stable schedule id on both iOS and Android. That simplifies code (one id everywhere), makes getStatus/cancel and verification consistent across platforms, and is safe with plugin v1.1.2+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Depend on **`@timesafari/daily-notification-plugin@1.1.2`** (or `^1.1.2`) so the Android fix is in effect.
|
||||||
|
- No other code changes are required for the bug fix; this doc is only for the optional id cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to Change in the Consuming App
|
||||||
|
|
||||||
|
### 1. Single stable reminder ID (both platforms)
|
||||||
|
|
||||||
|
Use one reminder id for schedule, cancel, and getStatus on both iOS and Android.
|
||||||
|
|
||||||
|
**Example (current pattern):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before: different id per platform
|
||||||
|
private get reminderId(): string {
|
||||||
|
return Capacitor.getPlatform() === "ios"
|
||||||
|
? "daily_timesafari_reminder"
|
||||||
|
: "daily_notification";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (optional cleanup):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// After: same stable id on both platforms (requires plugin >= 1.1.2)
|
||||||
|
private readonly reminderId = "daily_timesafari_reminder";
|
||||||
|
```
|
||||||
|
|
||||||
|
Or keep a getter if you prefer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private get reminderId(): string {
|
||||||
|
return "daily_timesafari_reminder";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use whatever stable string your app already uses on iOS (e.g. `"daily_timesafari_reminder"`); no need to change the value.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Pass `id` when scheduling on Android
|
||||||
|
|
||||||
|
Today you may only add `scheduleOptions.id` on iOS. Add it for Android too so the plugin stores and returns this id (getStatus, getScheduledReminders, cancel all use it).
|
||||||
|
|
||||||
|
**Example (current pattern):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const scheduleOptions = {
|
||||||
|
time: options.time,
|
||||||
|
title: options.title,
|
||||||
|
body: options.body,
|
||||||
|
sound: true,
|
||||||
|
priority: (options.priority || "normal") as "low" | "default" | "high",
|
||||||
|
};
|
||||||
|
if (Capacitor.getPlatform() === "ios") {
|
||||||
|
scheduleOptions.id = this.reminderId;
|
||||||
|
}
|
||||||
|
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (optional cleanup):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const scheduleOptions = {
|
||||||
|
time: options.time,
|
||||||
|
title: options.title,
|
||||||
|
body: options.body,
|
||||||
|
sound: true,
|
||||||
|
priority: (options.priority || "normal") as "low" | "default" | "high",
|
||||||
|
id: this.reminderId, // same id on iOS and Android (plugin >= 1.1.2)
|
||||||
|
};
|
||||||
|
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
||||||
|
```
|
||||||
|
|
||||||
|
So: always pass `id: this.reminderId` (or your chosen constant) for both platforms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Update comments
|
||||||
|
|
||||||
|
Remove or update comments that say Android must not receive an `id` to avoid the second-schedule bug, and that the plugin uses `"daily_notification"` on Android. Replace with a short note that a single stable id is used on both platforms and requires plugin v1.1.2+.
|
||||||
|
|
||||||
|
**Example comment to add/update:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Stable schedule/reminder ID used for schedule, cancel, and getStatus.
|
||||||
|
* Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id).
|
||||||
|
*/
|
||||||
|
private readonly reminderId = "daily_timesafari_reminder";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Touch (typical)
|
||||||
|
|
||||||
|
- **Native notification service** (e.g. `src/services/notifications/NativeNotificationService.ts`):
|
||||||
|
- `reminderId`: use single value for both platforms.
|
||||||
|
- `scheduleDailyNotification`: always pass `id` in `scheduleOptions` (include Android).
|
||||||
|
- Adjust comments as above.
|
||||||
|
|
||||||
|
No changes are required to cancel or getStatus if they already use `this.reminderId`; they will now resolve the same schedule on Android as on iOS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Android:** Schedule a daily notification, then change time and save again (reschedule). The second scheduled time should fire; no need to reinstall.
|
||||||
|
2. **getStatus:** After scheduling on Android, getStatus should return the scheduled reminder with the same id you pass (e.g. `daily_timesafari_reminder`).
|
||||||
|
3. **Cancel:** Cancelling by that id on Android should clear the scheduled notification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Plugin CHANGELOG: `[1.1.2] - 2026-02-13` — Android second daily notification not firing after reschedule.
|
||||||
|
- Issue context (if present in consuming app): `doc/android-daily-notification-second-schedule-issue.md`.
|
||||||
462
docs/TIMESAFARI_ANDROID_COMPARISON.md
Normal file
462
docs/TIMESAFARI_ANDROID_COMPARISON.md
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
# Android Notification Implementation Comparison
|
||||||
|
|
||||||
|
**Test App (Working)** vs **TimeSafari (Not Working)**
|
||||||
|
|
||||||
|
This document identifies the critical differences between the test app where notifications work correctly and the TimeSafari app where notifications don't work at all. Use this as a checklist to fix TimeSafari.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Issues (Must Fix)
|
||||||
|
|
||||||
|
### 1. Missing Custom Application Class
|
||||||
|
|
||||||
|
**This is likely the primary cause of failure.**
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```xml
|
||||||
|
<!-- AndroidManifest.xml -->
|
||||||
|
<application
|
||||||
|
android:name=".TestApplication"
|
||||||
|
...>
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
// TestApplication.java
|
||||||
|
public class TestApplication extends Application {
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
Context context = getApplicationContext();
|
||||||
|
NativeNotificationContentFetcher testFetcher =
|
||||||
|
new com.timesafari.dailynotification.test.TestNativeFetcher(context);
|
||||||
|
DailyNotificationPlugin.setNativeFetcher(testFetcher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```xml
|
||||||
|
<!-- AndroidManifest.xml - NO android:name attribute -->
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
...>
|
||||||
|
```
|
||||||
|
- No custom Application class exists
|
||||||
|
- No native fetcher is registered
|
||||||
|
- Plugin cannot fetch notification content
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
1. Create `TimeSafariApplication.java` in `android/app/src/main/java/app/timesafari/`
|
||||||
|
2. Implement `NativeNotificationContentFetcher` specific to TimeSafari
|
||||||
|
3. Add `android:name=".TimeSafariApplication"` to AndroidManifest.xml
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Missing Capacitor Plugin Configuration
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```typescript
|
||||||
|
// capacitor.config.ts
|
||||||
|
plugins: {
|
||||||
|
DailyNotification: {
|
||||||
|
debugMode: true,
|
||||||
|
enableNotifications: true,
|
||||||
|
timesafariConfig: {
|
||||||
|
activeDid: "did:ethr:0x...",
|
||||||
|
endpoints: {
|
||||||
|
projectsLastUpdated: "http://..."
|
||||||
|
},
|
||||||
|
starredProjectsConfig: {
|
||||||
|
enabled: true,
|
||||||
|
starredPlanHandleIds: [...],
|
||||||
|
fetchInterval: '0 8 * * *'
|
||||||
|
},
|
||||||
|
credentialConfig: {
|
||||||
|
jwtSecret: '...',
|
||||||
|
tokenExpirationMinutes: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
networkConfig: {
|
||||||
|
timeout: 30000,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 1000
|
||||||
|
},
|
||||||
|
contentFetch: {
|
||||||
|
enabled: true,
|
||||||
|
schedule: '0 00 * * *',
|
||||||
|
fetchLeadTimeMinutes: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```typescript
|
||||||
|
// capacitor.config.ts - NO DailyNotification configuration at all
|
||||||
|
plugins: {
|
||||||
|
App: { ... },
|
||||||
|
SplashScreen: { ... },
|
||||||
|
CapSQLite: { ... }
|
||||||
|
// DailyNotification is MISSING
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
Add `DailyNotification` configuration to `capacitor.config.ts` with appropriate values for TimeSafari.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Missing Permissions in AndroidManifest.xml
|
||||||
|
|
||||||
|
**Test App has these permissions that TimeSafari is missing:**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Add to TimeSafari's AndroidManifest.xml -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current TimeSafari permissions (incomplete):**
|
||||||
|
- ✅ `INTERNET`
|
||||||
|
- ✅ `POST_NOTIFICATIONS`
|
||||||
|
- ✅ `SCHEDULE_EXACT_ALARM`
|
||||||
|
- ✅ `USE_EXACT_ALARM`
|
||||||
|
- ✅ `RECEIVE_BOOT_COMPLETED`
|
||||||
|
- ✅ `WAKE_LOCK`
|
||||||
|
- ❌ `ACCESS_NETWORK_STATE` - **MISSING**
|
||||||
|
- ❌ `FOREGROUND_SERVICE` - **MISSING**
|
||||||
|
- ❌ `SYSTEM_ALERT_WINDOW` - **MISSING**
|
||||||
|
- ❌ `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` - **MISSING**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Missing Gradle Dependencies
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```gradle
|
||||||
|
// android/app/build.gradle
|
||||||
|
dependencies {
|
||||||
|
// Capacitor annotation processor for automatic plugin discovery
|
||||||
|
annotationProcessor project(':capacitor-android')
|
||||||
|
|
||||||
|
// Required dependencies for the plugin
|
||||||
|
implementation 'androidx.work:work-runtime:2.9.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```gradle
|
||||||
|
dependencies {
|
||||||
|
// Missing: annotationProcessor project(':capacitor-android')
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.9.0" // Using Kotlin version
|
||||||
|
// Missing: androidx.lifecycle:lifecycle-service
|
||||||
|
// Missing: com.google.code.gson:gson
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix Required:**
|
||||||
|
Add to TimeSafari's `android/app/build.gradle`:
|
||||||
|
```gradle
|
||||||
|
annotationProcessor project(':capacitor-android')
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secondary Issues (Should Fix)
|
||||||
|
|
||||||
|
### 5. DailyNotificationReceiver Export Status
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```xml
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"> <!-- Note: false -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```xml
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"> <!-- Note: true - potential security issue -->
|
||||||
|
```
|
||||||
|
|
||||||
|
The test app uses `exported="false"` because the plugin creates PendingIntents with explicit component targeting. Using `exported="true"` is unnecessary and a potential security concern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Missing Network Security Config
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```xml
|
||||||
|
<application
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
...>
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
```xml
|
||||||
|
<application>
|
||||||
|
<!-- No networkSecurityConfig -->
|
||||||
|
```
|
||||||
|
|
||||||
|
This may affect HTTP (non-HTTPS) requests during development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Missing Java Compile Options
|
||||||
|
|
||||||
|
**Test App (Working):**
|
||||||
|
```gradle
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TimeSafari (Broken):**
|
||||||
|
No explicit compile options set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Complete Fix Checklist
|
||||||
|
|
||||||
|
### Step 1: Create Custom Application Class
|
||||||
|
|
||||||
|
Create file: `android/app/src/main/java/app/timesafari/TimeSafariApplication.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import com.timesafari.dailynotification.DailyNotificationPlugin;
|
||||||
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
|
||||||
|
public class TimeSafariApplication extends Application {
|
||||||
|
|
||||||
|
private static final String TAG = "TimeSafariApplication";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
Log.i(TAG, "Initializing TimeSafari notifications");
|
||||||
|
|
||||||
|
// Register native fetcher with application context
|
||||||
|
Context context = getApplicationContext();
|
||||||
|
NativeNotificationContentFetcher fetcher =
|
||||||
|
new TimeSafariNativeFetcher(context);
|
||||||
|
DailyNotificationPlugin.setNativeFetcher(fetcher);
|
||||||
|
|
||||||
|
Log.i(TAG, "Native fetcher registered");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Native Fetcher Implementation
|
||||||
|
|
||||||
|
Create file: `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`
|
||||||
|
|
||||||
|
```java
|
||||||
|
package app.timesafari;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
|
import com.timesafari.dailynotification.NotificationContent;
|
||||||
|
|
||||||
|
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
public TimeSafariNativeFetcher(Context context) {
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public NotificationContent fetchContent(String scheduleId) {
|
||||||
|
// TODO: Implement actual content fetching for TimeSafari
|
||||||
|
// This should query the TimeSafari API for notification content
|
||||||
|
return new NotificationContent(
|
||||||
|
"timesafari_" + System.currentTimeMillis(),
|
||||||
|
"TimeSafari Update",
|
||||||
|
"Check your starred projects for updates!",
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
null,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Update AndroidManifest.xml
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:name=".TimeSafariApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<!-- ... existing content ... -->
|
||||||
|
|
||||||
|
<!-- Fix: Change exported to false -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.timesafari.daily.NOTIFICATION" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- ... rest of receivers ... -->
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Existing permissions -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- ADD these missing permissions -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
</manifest>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Update build.gradle
|
||||||
|
|
||||||
|
Add to `android/app/build.gradle`:
|
||||||
|
|
||||||
|
```gradle
|
||||||
|
android {
|
||||||
|
// ... existing config ...
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// ... existing dependencies ...
|
||||||
|
|
||||||
|
// ADD these for notification plugin
|
||||||
|
annotationProcessor project(':capacitor-android')
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Update capacitor.config.ts
|
||||||
|
|
||||||
|
Add DailyNotification configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
plugins: {
|
||||||
|
// ... existing plugins ...
|
||||||
|
|
||||||
|
DailyNotification: {
|
||||||
|
debugMode: true,
|
||||||
|
enableNotifications: true,
|
||||||
|
timesafariConfig: {
|
||||||
|
activeDid: '', // Will be set dynamically from user's DID
|
||||||
|
endpoints: {
|
||||||
|
projectsLastUpdated: 'https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween'
|
||||||
|
},
|
||||||
|
starredProjectsConfig: {
|
||||||
|
enabled: true,
|
||||||
|
starredPlanHandleIds: [],
|
||||||
|
fetchInterval: '0 8 * * *'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
networkConfig: {
|
||||||
|
timeout: 30000,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelay: 1000
|
||||||
|
},
|
||||||
|
contentFetch: {
|
||||||
|
enabled: true,
|
||||||
|
schedule: '0 8 * * *',
|
||||||
|
fetchLeadTimeMinutes: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Rebuild
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx cap sync android
|
||||||
|
cd android && ./gradlew clean
|
||||||
|
cd .. && npx cap build android
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After implementing fixes, verify:
|
||||||
|
|
||||||
|
1. **Check logs for Application initialization:**
|
||||||
|
```bash
|
||||||
|
adb logcat | grep -E "TimeSafariApplication|Native fetcher"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check alarm scheduling:**
|
||||||
|
```bash
|
||||||
|
adb shell dumpsys alarm | grep -i timesafari
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test receiver manually:**
|
||||||
|
```bash
|
||||||
|
adb shell am broadcast -a com.timesafari.daily.NOTIFICATION \
|
||||||
|
--es id "test_notification" \
|
||||||
|
-n app.timesafari.app/com.timesafari.dailynotification.DailyNotificationReceiver
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check notification permissions:**
|
||||||
|
```bash
|
||||||
|
adb shell dumpsys package app.timesafari.app | grep -A 5 "granted=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Critical Differences
|
||||||
|
|
||||||
|
| Component | Test App (Working) | TimeSafari (Broken) |
|
||||||
|
|-----------|-------------------|---------------------|
|
||||||
|
| Custom Application class | ✅ TestApplication.java | ❌ None |
|
||||||
|
| Native fetcher registration | ✅ In Application.onCreate() | ❌ Not registered |
|
||||||
|
| DailyNotification config | ✅ Full config in capacitor.config.ts | ❌ Not configured |
|
||||||
|
| ACCESS_NETWORK_STATE | ✅ Present | ❌ Missing |
|
||||||
|
| FOREGROUND_SERVICE | ✅ Present | ❌ Missing |
|
||||||
|
| REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | ✅ Present | ❌ Missing |
|
||||||
|
| Gson dependency | ✅ Present | ❌ Missing |
|
||||||
|
| lifecycle-service dependency | ✅ Present | ❌ Missing |
|
||||||
|
| Capacitor annotation processor | ✅ Present | ❌ Missing |
|
||||||
|
|
||||||
|
**The most critical missing piece is the custom Application class with native fetcher registration.** Without this, the plugin has no way to fetch notification content when the alarm fires.
|
||||||
@@ -1,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 app’s `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
|
||||||
|
|||||||
519
docs/testing/PHYSICAL_DEVICE_GUIDE.md
Normal file
519
docs/testing/PHYSICAL_DEVICE_GUIDE.md
Normal 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. 📱
|
||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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/",
|
||||||
|
|||||||
@@ -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
271
scripts/clean-build.sh
Executable 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 ""
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
25
test-apps/daily-notification-test/scripts/pod-install.sh
Executable file
25
test-apps/daily-notification-test/scripts/pod-install.sh
Executable 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 "$@"
|
||||||
@@ -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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
102
test-apps/daily-notification-test/src/components/StatusList.vue
Normal file
102
test-apps/daily-notification-test/src/components/StatusList.vue
Normal 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>
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user