Compare commits
2 Commits
android-6
...
android-fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e25841fe9 | ||
|
|
367325452a |
101
BUILDING.md
101
BUILDING.md
@@ -44,11 +44,9 @@ npx cap run android
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### Required Software
|
### Required Software
|
||||||
- **Android Studio** (latest stable version) - for Android development
|
- **Android Studio** (latest stable version)
|
||||||
- **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)
|
||||||
|
|
||||||
@@ -56,35 +54,11 @@ 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 (iOS development requires macOS)
|
- **OS**: Windows 10+, macOS 10.14+, or Linux
|
||||||
|
|
||||||
## Build Methods
|
## Build Methods
|
||||||
|
|
||||||
@@ -94,7 +68,6 @@ 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
|
||||||
@@ -324,8 +297,6 @@ 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
|
||||||
@@ -336,12 +307,6 @@ 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
|
||||||
@@ -817,13 +782,6 @@ 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
|
||||||
|
|
||||||
@@ -990,28 +948,6 @@ 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
|
||||||
@@ -1083,39 +1019,6 @@ 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,55 +5,6 @@ All notable changes to the Daily Notification Plugin will be documented in this
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
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,46 +1,47 @@
|
|||||||
docs(building): update BUILDING.md with iOS prerequisites and clean-build script
|
fix(build): add SQLite conflict detection and Command Line Tools verification
|
||||||
|
|
||||||
Updates BUILDING.md to reflect recent changes in build-native.sh, especially
|
Prevents iOS build failures caused by pkgx SQLite linking conflicts and
|
||||||
the Xcode Command Line Tools prerequisite check and the clean-build script.
|
ensures Xcode Command Line Tools are properly installed.
|
||||||
|
|
||||||
Problem:
|
Problem:
|
||||||
- BUILDING.md didn't mention Xcode Command Line Tools prerequisite
|
- pkgx installs SQLite built for macOS, causing linker errors when building
|
||||||
(recently added to build-native.sh)
|
for iOS simulator: "linking in dylib built for 'macOS'"
|
||||||
- clean-build.sh script exists but wasn't documented
|
- Missing Command Line Tools cause build failures without clear error messages
|
||||||
- iOS build troubleshooting lacked Command Line Tools guidance
|
|
||||||
|
|
||||||
Changes:
|
Changes:
|
||||||
- Add Xcode Command Line Tools to Prerequisites section
|
- Add check_sqlite_conflicts() function
|
||||||
- Document installation command (xcode-select --install)
|
- Detects pkgx SQLite installations in ~/.pkgx
|
||||||
- Include verification steps (xcode-select -p, xcodebuild -version)
|
- Warns about macOS dylibs that will cause iOS simulator build failures
|
||||||
- Note that build script automatically checks for these tools
|
- Checks for system SQLite from Command Line Tools
|
||||||
- Explain that sqlite3 is part of Command Line Tools
|
- Validates library paths (DYLD_LIBRARY_PATH, LD_LIBRARY_PATH)
|
||||||
|
|
||||||
|
- Add check_command_line_tools() function
|
||||||
|
- Verifies Xcode Command Line Tools are installed and configured
|
||||||
|
- Checks for xcodebuild availability
|
||||||
|
- Verifies sqlite3 is available (part of Command Line Tools)
|
||||||
|
- Provides clear error messages with installation instructions
|
||||||
|
|
||||||
- Document clean-build.sh script in Build Scripts section
|
- Enhance pkgx detection in iOS build functions
|
||||||
- Basic usage: ./scripts/clean-build.sh
|
- Specifically searches for pkgx SQLite dylibs
|
||||||
- All options: --all, --clean-gradle-cache, --clean-derived-data,
|
- Automatically removes pkgx paths from PATH environment variable
|
||||||
--reinstall-node
|
- Provides detailed warnings about detected conflicts
|
||||||
- Explain when to use clean builds
|
- Cleans all problematic environment variables before building
|
||||||
|
|
||||||
- Enhance iOS Native Build Process section
|
- Integrate checks into environment validation
|
||||||
- Add prerequisite note about Command Line Tools
|
- Runs automatically when building for iOS
|
||||||
- Include troubleshooting commands for pod install issues
|
- Provides early warnings before build starts
|
||||||
- Reference prerequisites section for details
|
- Fails fast with clear error messages if tools are missing
|
||||||
|
|
||||||
- Add comprehensive troubleshooting sections
|
This fixes the linker error:
|
||||||
- Clean Build section at start of Troubleshooting
|
"ld: building for 'iOS-simulator', but linking in dylib
|
||||||
- Recommends clean-build as first step for many issues
|
(/Users/trent/.pkgx/sqlite.org/v3.44.2/lib/libsqlite3.0.dylib)
|
||||||
- Lists when to use clean builds
|
built for 'macOS'"
|
||||||
- iOS Build Issues section
|
|
||||||
- Command Line Tools configuration errors
|
|
||||||
- SQLite/linker issues and pkgx conflicts
|
|
||||||
- CocoaPods installation problems
|
|
||||||
- All with clear solutions and commands
|
|
||||||
|
|
||||||
The documentation now accurately reflects:
|
The build script now:
|
||||||
- Xcode Command Line Tools as required iOS prerequisite
|
- Detects pkgx SQLite conflicts before building
|
||||||
- clean-build.sh as available build tool
|
- Automatically fixes environment variables
|
||||||
- Complete iOS troubleshooting workflow
|
- Verifies Command Line Tools are installed
|
||||||
|
- Provides clear guidance for manual fixes if needed
|
||||||
|
|
||||||
Files modified:
|
Files modified:
|
||||||
- BUILDING.md
|
- scripts/build-native.sh
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Daily Notification Plugin
|
# Daily Notification Plugin
|
||||||
|
|
||||||
**Author**: Matthew Raymer
|
**Author**: Matthew Raymer
|
||||||
**Version**: 1.2.0 (see `package.json` for source of truth)
|
**Version**: 1.0.11 (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,7 +37,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -117,8 +116,6 @@ 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"
|
||||||
|
|||||||
@@ -76,24 +76,21 @@ 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 = title,
|
title = "Daily Notification",
|
||||||
body = body,
|
body = "Your daily update is ready",
|
||||||
sound = true,
|
sound = true,
|
||||||
vibration = true,
|
vibration = true,
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
)
|
)
|
||||||
NotifyReceiver.scheduleExactNotification(
|
NotifyReceiver.scheduleExactNotification(
|
||||||
context,
|
context,
|
||||||
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.util.TimeZone.getDefault().getID()
|
java.time.ZoneId.systemDefault().getId()
|
||||||
);
|
);
|
||||||
entity.priority = mapPriority(content.getPriority());
|
entity.priority = mapPriority(content.getPriority());
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -706,34 +706,6 @@ 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
|
||||||
@@ -1162,12 +1134,12 @@ open class DailyNotificationPlugin : Plugin() {
|
|||||||
} else {
|
} else {
|
||||||
call.reject("Daily notification scheduling failed")
|
call.reject("Daily notification scheduling failed")
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Exception) {
|
||||||
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: Throwable) {
|
} catch (e: Exception) {
|
||||||
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}")
|
||||||
}
|
}
|
||||||
@@ -2100,8 +2072,6 @@ open class DailyNotificationPlugin : Plugin() {
|
|||||||
val options = call.getObject("options")
|
val options = call.getObject("options")
|
||||||
val timesafariDid = options?.getString("timesafariDid")
|
val timesafariDid = options?.getString("timesafariDid")
|
||||||
|
|
||||||
Log.d(TAG, "DNP-CONFIG: Loading config from database: key=$key, timesafariDid=${timesafariDid?.take(20)}...")
|
|
||||||
|
|
||||||
val entity = if (timesafariDid != null) {
|
val entity = if (timesafariDid != null) {
|
||||||
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
|
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
|
||||||
} else {
|
} else {
|
||||||
@@ -2109,10 +2079,8 @@ open class DailyNotificationPlugin : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entity != null) {
|
if (entity != null) {
|
||||||
Log.i(TAG, "DNP-CONFIG: Configuration restored from database: key=$key, configType=${entity.configType}, hasValue=${entity.configValue.isNotEmpty()}")
|
|
||||||
call.resolve(configToJson(entity))
|
call.resolve(configToJson(entity))
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "DNP-CONFIG: Configuration not found in database: key=$key")
|
|
||||||
call.resolve(JSObject().apply { put("config", null) })
|
call.resolve(JSObject().apply { put("config", null) })
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -2663,31 +2631,59 @@ object ScheduleHelper {
|
|||||||
return try {
|
return try {
|
||||||
val nextRunTime = calculateNextRunTime(config.schedule)
|
val nextRunTime = calculateNextRunTime(config.schedule)
|
||||||
|
|
||||||
// CRITICAL: Cancel any existing alarm for this scheduleId BEFORE scheduling new one
|
|
||||||
// This ensures "one per day" semantics - when updating schedule time, old alarm is canceled
|
|
||||||
// The cleanupExistingNotificationSchedules() above only cancels OTHER schedules, not the current one
|
|
||||||
NotifyReceiver.cancelNotification(context, scheduleId)
|
|
||||||
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
|
|
||||||
|
|
||||||
// Schedule AlarmManager notification as static reminder
|
// Schedule AlarmManager notification as static reminder
|
||||||
// (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
|
// (doesn't require cached content)
|
||||||
// this scheduleId and Android may still return the cancelled PendingIntent from cache.
|
|
||||||
NotifyReceiver.scheduleExactNotification(
|
NotifyReceiver.scheduleExactNotification(
|
||||||
context,
|
context,
|
||||||
nextRunTime,
|
nextRunTime,
|
||||||
config,
|
config,
|
||||||
isStaticReminder = true,
|
isStaticReminder = true,
|
||||||
reminderId = scheduleId,
|
reminderId = scheduleId,
|
||||||
scheduleId = scheduleId,
|
scheduleId = scheduleId,
|
||||||
source = ScheduleSource.INITIAL_SETUP,
|
source = ScheduleSource.INITIAL_SETUP
|
||||||
skipPendingIntentIdempotence = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Do not enqueue prefetch for static reminders: display is already in the NotifyReceiver
|
// Always schedule prefetch 2 minutes before notification
|
||||||
// alarm. Prefetch is for "fetch content then show"; for static reminders there is nothing
|
// (URL is optional - native fetcher will be used if registered)
|
||||||
// to fetch. Enqueueing prefetch would cause the worker to use fallback content and
|
val fetchTime = nextRunTime - (2 * 60 * 1000L) // 2 minutes before
|
||||||
// schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
|
val delayMs = fetchTime - System.currentTimeMillis()
|
||||||
// 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(
|
||||||
@@ -2700,36 +2696,8 @@ object ScheduleHelper {
|
|||||||
)
|
)
|
||||||
database.scheduleDao().upsert(schedule)
|
database.scheduleDao().upsert(schedule)
|
||||||
|
|
||||||
// Persist title/body for this scheduleId so rollover and post-reboot resolve user content
|
|
||||||
// (see plugin-feedback-android-rollover-double-fire-and-user-content)
|
|
||||||
try {
|
|
||||||
val entity = 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) {
|
|
||||||
Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
true
|
||||||
} catch (e: Throwable) {
|
} catch (e: Exception) {
|
||||||
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
|
Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,11 +107,9 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
|||||||
// Create unique work name based on notification ID to prevent duplicates
|
// Create unique work name based on notification ID to prevent duplicates
|
||||||
// WorkManager will automatically skip if work with this name already exists
|
// WorkManager will automatically skip if work with this name already exists
|
||||||
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");
|
||||||
@@ -121,17 +119,13 @@ 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)
|
||||||
@@ -140,7 +134,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
|||||||
.putString("priority", priority);
|
.putString("priority", priority);
|
||||||
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder id=" + notificationId);
|
Log.d(TAG, "DN|WORK_ENQUEUE static_reminder id=" + notificationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Data inputData = dataBuilder.build();
|
Data inputData = dataBuilder.build();
|
||||||
|
|
||||||
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
|
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(DailyNotificationWorker.class)
|
||||||
@@ -201,7 +195,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
|
|||||||
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
|
Log.e(TAG, "DN|WORK_ENQUEUE_ERR dismiss=" + notificationId + " err=" + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle notification intent
|
* Handle notification intent
|
||||||
*
|
*
|
||||||
@@ -451,8 +445,7 @@ 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);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ package com.timesafari.dailynotification;
|
|||||||
|
|
||||||
import android.app.AlarmManager;
|
import android.app.AlarmManager;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
|
import android.content.ComponentName;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
@@ -154,10 +155,15 @@ public class DailyNotificationScheduler {
|
|||||||
cancelNotification(duplicateId);
|
cancelNotification(duplicateId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create intent for the notification; setPackage ensures AlarmManager delivery on all OEMs
|
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
|
||||||
Intent intent = new Intent(context, DailyNotificationReceiver.class);
|
// AlarmManager requires explicit component matching when delivering broadcasts
|
||||||
|
ComponentName receiverComponent = new ComponentName(
|
||||||
|
context.getPackageName(),
|
||||||
|
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
);
|
||||||
|
Intent intent = new Intent(com.timesafari.dailynotification.DailyNotificationConstants.ACTION_NOTIFICATION);
|
||||||
|
intent.setComponent(receiverComponent);
|
||||||
intent.setPackage(context.getPackageName());
|
intent.setPackage(context.getPackageName());
|
||||||
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());
|
||||||
|
|
||||||
// Check if this is a static reminder
|
// Check if this is a static reminder
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
NotificationContent content;
|
NotificationContent content;
|
||||||
|
|
||||||
if (isStaticReminder) {
|
if (isStaticReminder) {
|
||||||
// Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
|
// Static reminder: create NotificationContent from input data
|
||||||
String title = inputData.getString("title");
|
String 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,18 +142,7 @@ 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();
|
||||||
@@ -171,35 +160,25 @@ 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 (by notification_id, then by schedule_id for rollover/user content)
|
// Regular notification: load from storage
|
||||||
|
// Prefer Room storage; fallback to legacy SharedPreferences storage
|
||||||
content = getContentFromRoomOrLegacy(notificationId);
|
content = getContentFromRoomOrLegacy(notificationId);
|
||||||
// Rollover/notify_* runs: prefer canonical reminder content by schedule_id so user text is shown
|
|
||||||
String scheduleId = inputData.getString("schedule_id");
|
if (content == null) {
|
||||||
if (scheduleId != null && (content == null || content.getTitle() == null || content.getTitle().isEmpty()
|
// Content not found - likely removed during deduplication or cleanup
|
||||||
|| content.getBody() == null || content.getBody().isEmpty())) {
|
// Return success instead of failure to prevent retries for intentionally removed notifications
|
||||||
NotificationContent canonical = getContentByScheduleId(scheduleId);
|
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
||||||
if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
|
return Result.success(); // Success prevents retry loops for removed notifications
|
||||||
content = canonical;
|
}
|
||||||
content.setId(notificationId); // keep run id for display/dismiss
|
|
||||||
Log.d(TAG, "DN|DISPLAY_USE_CANONICAL id=" + notificationId + " schedule_id=" + scheduleId);
|
// Check if notification is ready to display
|
||||||
}
|
if (!content.isReadyToDisplay()) {
|
||||||
}
|
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
|
||||||
if (content == null) {
|
return Result.success();
|
||||||
Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
|
}
|
||||||
return Result.success();
|
|
||||||
}
|
// JIT Freshness Re-check (Soft TTL) - skip for static reminders
|
||||||
if (!content.isReadyToDisplay()) {
|
content = performJITFreshnessCheck(content);
|
||||||
Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
|
|
||||||
return Result.success();
|
|
||||||
}
|
|
||||||
// JIT Freshness Re-check (Soft TTL) - skip when content has title/body from Room
|
|
||||||
boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
|
|
||||||
&& content.getBody() != null && !content.getBody().isEmpty();
|
|
||||||
if (!hasTitleBody) {
|
|
||||||
content = performJITFreshnessCheck(content);
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "DN|DISPLAY_USE_ROOM_CONTENT id=" + notificationId + " (skip JIT)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display the notification
|
// Display the notification
|
||||||
@@ -561,22 +540,18 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve static reminder semantics across rollover; use stable schedule_id so reschedule cancels this alarm
|
// Extract scheduleId from notificationId pattern or use fallback
|
||||||
Data inputData = getInputData();
|
// Notification IDs are often "daily_${scheduleId}"
|
||||||
boolean preserveStaticReminder = inputData.getBoolean("is_static_reminder", false);
|
String scheduleId = null;
|
||||||
String scheduleId = inputData.getString("schedule_id");
|
|
||||||
if (scheduleId == null || scheduleId.isEmpty()) {
|
|
||||||
String notificationId = content.getId();
|
|
||||||
if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
|
|
||||||
scheduleId = notificationId;
|
|
||||||
} else if (notificationId != null && notificationId.startsWith("daily_")) {
|
|
||||||
scheduleId = notificationId;
|
|
||||||
} else {
|
|
||||||
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String cronExpression = null;
|
String cronExpression = null;
|
||||||
|
|
||||||
|
// Try to extract scheduleId from notificationId (e.g., "daily_1764578136269")
|
||||||
String notificationId = content.getId();
|
String notificationId = content.getId();
|
||||||
|
if (notificationId != null && notificationId.startsWith("daily_")) {
|
||||||
|
scheduleId = notificationId; // Use notificationId as scheduleId
|
||||||
|
} else {
|
||||||
|
scheduleId = "daily_rollover_" + System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate cron from current scheduled time (extract hour:minute)
|
// Calculate cron from current scheduled time (extract hour:minute)
|
||||||
try {
|
try {
|
||||||
@@ -606,47 +581,48 @@ 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,
|
||||||
preserveStaticReminder, // isStaticReminder – preserve so next run keeps title/body
|
false, // isStaticReminder
|
||||||
preserveStaticReminder ? scheduleId : 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 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)
|
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
||||||
if (preserveStaticReminder) {
|
try {
|
||||||
Log.d(TAG, "DN|RESCHEDULE_SKIP_PREFETCH static_reminder scheduleId=" + scheduleId);
|
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
|
||||||
} else {
|
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
|
||||||
// Schedule background fetch for next notification (5 minutes before scheduled time)
|
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
|
||||||
try {
|
getApplicationContext(),
|
||||||
DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
|
storageForFetcher,
|
||||||
DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
|
roomStorageForFetcher
|
||||||
DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
|
);
|
||||||
getApplicationContext(),
|
|
||||||
storageForFetcher,
|
// Calculate fetch time (5 minutes before notification)
|
||||||
roomStorageForFetcher
|
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
|
||||||
);
|
long currentTime = System.currentTimeMillis();
|
||||||
long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
|
|
||||||
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 +
|
||||||
} else {
|
" next_notification=" + nextScheduledTime);
|
||||||
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + " fetch_time=" + fetchTime + " current=" + currentTime);
|
} else {
|
||||||
fetcher.scheduleImmediateFetch();
|
Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() +
|
||||||
}
|
" fetch_time=" + fetchTime +
|
||||||
} catch (Exception e) {
|
" current=" + currentTime);
|
||||||
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + " error scheduling prefetch", e);
|
fetcher.scheduleImmediateFetch();
|
||||||
}
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() +
|
||||||
|
" error scheduling prefetch", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -656,28 +632,6 @@ public class DailyNotificationWorker extends Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Load notification content by canonical schedule id (for static reminder / rollover user text).
|
|
||||||
* Tries id then "daily_" + id to match getTitleBodyForSchedule / BootReceiver.
|
|
||||||
*/
|
|
||||||
private NotificationContent getContentByScheduleId(String scheduleId) {
|
|
||||||
if (scheduleId == null || scheduleId.isEmpty()) return null;
|
|
||||||
try {
|
|
||||||
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
|
||||||
*/
|
*/
|
||||||
@@ -734,13 +688,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.2.0",
|
"1.0.0",
|
||||||
null,
|
null,
|
||||||
"daily",
|
"daily",
|
||||||
content.getTitle(),
|
content.getTitle(),
|
||||||
content.getBody(),
|
content.getBody(),
|
||||||
content.getScheduledTime(),
|
content.getScheduledTime(),
|
||||||
java.util.TimeZone.getDefault().getID()
|
java.time.ZoneId.systemDefault().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.2.0
|
* @version 1.1.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.2.0", // Plugin version
|
"1.0.2", // 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.util.TimeZone.getDefault().id
|
java.time.ZoneId.systemDefault().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.2.0"
|
"version": "1.1.0"
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
return mockData.toByteArray()
|
return mockData.toByteArray()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -21,7 +22,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.2.0
|
* @version 1.1.0
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Source of schedule request - tracks which code path triggered scheduling
|
* Source of schedule request - tracks which code path triggered scheduling
|
||||||
@@ -122,113 +123,110 @@ 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(
|
||||||
context: Context,
|
context: Context,
|
||||||
triggerAtMillis: Long,
|
triggerAtMillis: Long,
|
||||||
config: UserNotificationConfig,
|
config: UserNotificationConfig,
|
||||||
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
|
||||||
|
|
||||||
// Generate stable scheduleId - prefer provided scheduleId, then reminderId, then generate from time
|
// Generate stable scheduleId - prefer provided scheduleId, then reminderId, then generate from time
|
||||||
// This ensures same schedule always uses same ID for idempotence checks
|
// This ensures same schedule always uses same ID for idempotence checks
|
||||||
val stableScheduleId = scheduleId ?: reminderId ?: "daily_${triggerAtMillis}"
|
val stableScheduleId = scheduleId ?: reminderId ?: "daily_${triggerAtMillis}"
|
||||||
|
|
||||||
// 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 {
|
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
|
||||||
|
// AlarmManager requires explicit component matching when delivering broadcasts
|
||||||
|
val receiverComponent = ComponentName(
|
||||||
|
context.packageName,
|
||||||
|
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
)
|
||||||
|
val checkIntent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||||
|
setComponent(receiverComponent)
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
action = "com.timesafari.daily.NOTIFICATION"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling.
|
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
||||||
// Skip PendingIntent checks when caller just cancelled this schedule (Android may still
|
var existingPendingIntent = PendingIntent.getBroadcast(
|
||||||
// return the cancelled PendingIntent from cache and cause the new schedule to be skipped).
|
context,
|
||||||
if (!skipPendingIntentIdempotence) {
|
requestCode,
|
||||||
// Check 1: Same scheduleId (stable requestCode) - most reliable
|
checkIntent,
|
||||||
var existingPendingIntent = PendingIntent.getBroadcast(
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
|
||||||
|
// This catches cases where different scheduleIds are used for the same time
|
||||||
|
// Try a range of request codes around the trigger time
|
||||||
|
if (existingPendingIntent == null) {
|
||||||
|
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
|
||||||
|
existingPendingIntent = PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
requestCode,
|
timeBasedRequestCode,
|
||||||
checkIntent,
|
checkIntent,
|
||||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
}
|
||||||
// Check 2: If no match by scheduleId, check by trigger time (within 1 minute tolerance)
|
|
||||||
if (existingPendingIntent == null) {
|
// Check 3: Also check if AlarmManager already has an alarm for this exact time
|
||||||
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis)
|
// This is a fallback for when PendingIntent checks fail but alarm still exists
|
||||||
existingPendingIntent = PendingIntent.getBroadcast(
|
// We check the next alarm clock time (Android 5.0+)
|
||||||
context,
|
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
timeBasedRequestCode,
|
val nextAlarm = alarmManager.nextAlarmClock
|
||||||
checkIntent,
|
if (nextAlarm != null) {
|
||||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
val nextAlarmTime = nextAlarm.triggerTime
|
||||||
)
|
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
|
||||||
|
// If there's an alarm within 1 minute of our target time, consider it a duplicate
|
||||||
|
if (timeDiff < 60000) {
|
||||||
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||||
|
.format(java.util.Date(triggerAtMillis))
|
||||||
|
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||||
|
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Check 3: AlarmManager next alarm (Android 5.0+)
|
|
||||||
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
if (existingPendingIntent != null) {
|
||||||
val nextAlarm = alarmManager.nextAlarmClock
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||||
if (nextAlarm != null) {
|
.format(java.util.Date(triggerAtMillis))
|
||||||
val nextAlarmTime = nextAlarm.triggerTime
|
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
||||||
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
|
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
||||||
|
// This prevents logical duplicates before even hitting AlarmManager
|
||||||
|
try {
|
||||||
|
runBlocking {
|
||||||
|
val db = DailyNotificationDatabase.getDatabase(context)
|
||||||
|
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
|
||||||
|
|
||||||
|
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
|
||||||
|
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
|
||||||
|
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
|
||||||
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))
|
||||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule for id=$stableScheduleId at $triggerTimeStr from source=$source")
|
||||||
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
|
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
|
||||||
return
|
return@runBlocking
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
if (existingPendingIntent != null) {
|
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
||||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
||||||
.format(java.util.Date(triggerAtMillis))
|
|
||||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
|
|
||||||
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
|
|
||||||
// When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't
|
|
||||||
// cancel the alarm and then skip re-scheduling, resulting in no alarm.
|
|
||||||
if (!skipPendingIntentIdempotence) {
|
|
||||||
try {
|
|
||||||
runBlocking {
|
|
||||||
val db = DailyNotificationDatabase.getDatabase(context)
|
|
||||||
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
|
|
||||||
|
|
||||||
if (existingSchedule != null && existingSchedule.nextRunAt != null) {
|
|
||||||
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis)
|
|
||||||
// If we already have a schedule for this ID with the same nextRun (within 1 minute), skip
|
|
||||||
if (timeDiff < 60000) {
|
|
||||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
|
||||||
.format(java.util.Date(triggerAtMillis))
|
|
||||||
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule for id=$stableScheduleId at $triggerTimeStr from source=$source")
|
|
||||||
Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
|
|
||||||
return@runBlocking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(SCHEDULE_TAG, "Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
|
||||||
@@ -251,13 +249,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.2.0", // Plugin version
|
"1.0.2", // 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.util.TimeZone.getDefault().id
|
java.time.ZoneId.systemDefault().id
|
||||||
)
|
)
|
||||||
entity.priority = when (config.priority) {
|
entity.priority = when (config.priority) {
|
||||||
"high", "max" -> 2
|
"high", "max" -> 2
|
||||||
@@ -275,18 +273,25 @@ 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: Throwable) {
|
} catch (e: Exception) {
|
||||||
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
|
// CRITICAL FIX: Explicitly set component and package for AlarmManager broadcasts
|
||||||
// FIX: Set action to match manifest registration; setPackage() ensures AlarmManager
|
// AlarmManager requires explicit component matching when delivering broadcasts.
|
||||||
// delivery reaches this app on all OEMs (see daily-notification-plugin-android-receiver-issue)
|
// Using Intent(context, Class) constructor may not work reliably with AlarmManager
|
||||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
// on all Android versions, especially when the app is in certain states.
|
||||||
|
// Solution: Create Intent with action, then explicitly set component and package.
|
||||||
|
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||||
|
// Explicitly set component to ensure AlarmManager can match it to the receiver
|
||||||
|
setComponent(receiverComponent)
|
||||||
|
// Explicitly set package to ensure it matches the app's package (not plugin's)
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
|
// Must match manifest intent-filter action
|
||||||
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
|
// DailyNotificationReceiver expects this extra
|
||||||
putExtra("schedule_id", stableScheduleId) // Add stable scheduleId for tracking
|
putExtra("notification_id", notificationId)
|
||||||
|
// Add stable scheduleId for tracking
|
||||||
|
putExtra("schedule_id", stableScheduleId)
|
||||||
// Also preserve original extras for backward compatibility if needed
|
// Also preserve original extras for backward compatibility if needed
|
||||||
putExtra("title", config.title)
|
putExtra("title", config.title)
|
||||||
putExtra("body", config.body)
|
putExtra("body", config.body)
|
||||||
@@ -294,7 +299,8 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
putExtra("vibration", config.vibration ?: true)
|
putExtra("vibration", config.vibration ?: true)
|
||||||
putExtra("priority", config.priority ?: "normal")
|
putExtra("priority", config.priority ?: "normal")
|
||||||
putExtra("is_static_reminder", isStaticReminder)
|
putExtra("is_static_reminder", isStaticReminder)
|
||||||
putExtra("trigger_time", triggerAtMillis) // Store trigger time for debugging
|
// Store trigger time for debugging
|
||||||
|
putExtra("trigger_time", triggerAtMillis)
|
||||||
if (reminderId != null) {
|
if (reminderId != null) {
|
||||||
putExtra("reminder_id", reminderId)
|
putExtra("reminder_id", reminderId)
|
||||||
}
|
}
|
||||||
@@ -321,8 +327,7 @@ 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)
|
||||||
// Do not call existingPendingIntent.cancel(): the cached PendingIntent may be the same
|
existingPendingIntent.cancel()
|
||||||
// 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)
|
||||||
@@ -400,74 +405,14 @@ 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: Throwable) {
|
} catch (e: SecurityException) {
|
||||||
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
|
|
||||||
// This is critical for rollover scenarios where the UI needs to show the updated time
|
|
||||||
// Strategy: Find existing enabled notify schedule and update it (there should only be one)
|
|
||||||
// This ensures getNotificationStatus() finds the updated schedule, not a stale one
|
|
||||||
try {
|
|
||||||
runBlocking {
|
|
||||||
val db = DailyNotificationDatabase.getDatabase(context)
|
|
||||||
|
|
||||||
// First, try to find schedule by the provided stableScheduleId
|
|
||||||
var scheduleToUpdate = db.scheduleDao().getById(stableScheduleId)
|
|
||||||
|
|
||||||
// If not found by ID, only use "first enabled notify" fallback when this is NOT
|
|
||||||
// a rollover id (daily_rollover_*). Rollover work may use a different notification_id
|
|
||||||
// (e.g. from recovery); updating the app's schedule row here would overwrite
|
|
||||||
// nextRunAt with the rollover time and can leave the app's alarm in a bad state.
|
|
||||||
if (scheduleToUpdate == null && !stableScheduleId.startsWith("daily_rollover_")) {
|
|
||||||
val allSchedules = db.scheduleDao().getAll()
|
|
||||||
scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate cron expression from trigger time (HH:mm format)
|
|
||||||
val calendar = java.util.Calendar.getInstance().apply {
|
|
||||||
timeInMillis = triggerAtMillis
|
|
||||||
}
|
|
||||||
val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY)
|
|
||||||
val minute = calendar.get(java.util.Calendar.MINUTE)
|
|
||||||
val cronExpression = "${minute} ${hour} * * *"
|
|
||||||
val clockTime = String.format("%02d:%02d", hour, minute)
|
|
||||||
|
|
||||||
if (scheduleToUpdate != null) {
|
|
||||||
// Update existing schedule with new nextRunAt
|
|
||||||
// Use the existing schedule's ID (not stableScheduleId) to ensure we update the right one
|
|
||||||
db.scheduleDao().updateRunTimes(scheduleToUpdate.id, scheduleToUpdate.lastRunAt, triggerAtMillis)
|
|
||||||
Log.d(SCHEDULE_TAG, "Updated schedule in database: id=${scheduleToUpdate.id}, nextRunAt=$triggerAtMillis (rollover)")
|
|
||||||
} else {
|
|
||||||
// No existing schedule found - create new one (shouldn't happen in normal flow)
|
|
||||||
val newSchedule = Schedule(
|
|
||||||
id = stableScheduleId,
|
|
||||||
kind = "notify",
|
|
||||||
cron = cronExpression,
|
|
||||||
clockTime = clockTime,
|
|
||||||
enabled = true,
|
|
||||||
lastRunAt = null,
|
|
||||||
nextRunAt = triggerAtMillis,
|
|
||||||
jitterMs = 0,
|
|
||||||
backoffPolicy = "exp",
|
|
||||||
stateJson = null
|
|
||||||
)
|
|
||||||
db.scheduleDao().upsert(newSchedule)
|
|
||||||
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,10 +425,14 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
*/
|
*/
|
||||||
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
|
fun cancelNotification(context: Context, scheduleId: String? = null, triggerAtMillis: Long? = null) {
|
||||||
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
|
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
|
||||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
val receiverComponent = ComponentName(
|
||||||
|
context.packageName,
|
||||||
|
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
)
|
||||||
|
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||||
|
setComponent(receiverComponent)
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
action = "com.timesafari.daily.NOTIFICATION"
|
|
||||||
}
|
}
|
||||||
val requestCode = when {
|
val requestCode = when {
|
||||||
scheduleId != null -> getRequestCode(scheduleId)
|
scheduleId != null -> getRequestCode(scheduleId)
|
||||||
@@ -493,38 +442,14 @@ 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_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
alarmManager.cancel(pendingIntent)
|
||||||
if (existingPendingIntent != null) {
|
Log.i(TAG, "Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
||||||
// Cancel both the alarm in AlarmManager AND the PendingIntent itself
|
|
||||||
// This matches the pattern in scheduleExactNotification (lines 311-312)
|
|
||||||
alarmManager.cancel(existingPendingIntent)
|
|
||||||
existingPendingIntent.cancel()
|
|
||||||
Log.i(TAG, "DNP-CANCEL: Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
|
|
||||||
|
|
||||||
// Verify cancellation by checking if alarm still exists
|
|
||||||
val verifyIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
requestCode,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
if (verifyIntent == null) {
|
|
||||||
Log.d(TAG, "DNP-CANCEL: ✅ Cancellation verified - no PendingIntent found for requestCode=$requestCode")
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "DNP-CANCEL: ⚠️ Cancellation may have failed - PendingIntent still exists for requestCode=$requestCode")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "DNP-CANCEL: No existing PendingIntent found to cancel: scheduleId=$scheduleId, requestCode=$requestCode")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -536,10 +461,14 @@ class NotifyReceiver : BroadcastReceiver() {
|
|||||||
* @return true if alarm is scheduled, false otherwise
|
* @return true if alarm is scheduled, false otherwise
|
||||||
*/
|
*/
|
||||||
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
|
// CRITICAL FIX: Use same Intent format as scheduling (explicit component and package)
|
||||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
val receiverComponent = ComponentName(
|
||||||
|
context.packageName,
|
||||||
|
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
)
|
||||||
|
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||||
|
setComponent(receiverComponent)
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
action = "com.timesafari.daily.NOTIFICATION"
|
|
||||||
}
|
}
|
||||||
val requestCode = when {
|
val requestCode = when {
|
||||||
scheduleId != null -> getRequestCode(scheduleId)
|
scheduleId != null -> getRequestCode(scheduleId)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.timesafari.dailynotification
|
package com.timesafari.dailynotification
|
||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
@@ -41,26 +42,6 @@ class ReactivationManager(private val context: Context) {
|
|||||||
companion object {
|
companion object {
|
||||||
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
|
||||||
@@ -267,13 +248,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.2.0", // Plugin version
|
"1.0.2", // 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.util.TimeZone.getDefault().id
|
java.time.ZoneId.systemDefault().id
|
||||||
)
|
)
|
||||||
notification.deliveryStatus = "missed"
|
notification.deliveryStatus = "missed"
|
||||||
notification.lastDeliveryAttempt = System.currentTimeMillis()
|
notification.lastDeliveryAttempt = System.currentTimeMillis()
|
||||||
@@ -295,25 +276,22 @@ 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 = title,
|
title = "Daily Notification",
|
||||||
body = body,
|
body = "Your daily update is ready",
|
||||||
sound = true,
|
sound = true,
|
||||||
vibration = true,
|
vibration = true,
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
)
|
)
|
||||||
|
|
||||||
NotifyReceiver.scheduleExactNotification(
|
NotifyReceiver.scheduleExactNotification(
|
||||||
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)
|
||||||
@@ -463,10 +441,16 @@ 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 (must match NotifyReceiver schedule path)
|
// Check if any PendingIntent for our receiver exists
|
||||||
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
|
// This is more reliable than nextAlarmClock
|
||||||
|
// CRITICAL FIX: Use DailyNotificationReceiver with explicit component/package
|
||||||
|
val receiverComponent = ComponentName(
|
||||||
|
context.packageName,
|
||||||
|
"com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
)
|
||||||
|
val intent = Intent("com.timesafari.daily.NOTIFICATION").apply {
|
||||||
|
setComponent(receiverComponent)
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
action = "com.timesafari.daily.NOTIFICATION"
|
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getBroadcast(
|
val pendingIntent = PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
@@ -839,13 +823,13 @@ class ReactivationManager(private val context: Context) {
|
|||||||
db: DailyNotificationDatabase
|
db: DailyNotificationDatabase
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val (title, body) = ReactivationManager.getTitleBodyForSchedule(db, schedule)
|
// Use existing BootReceiver logic for calculating next run time
|
||||||
?: Pair("Daily Notification", "Your daily update is ready")
|
// For now, use schedule.nextRunAt directly
|
||||||
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 = title,
|
title = "Daily Notification",
|
||||||
body = body,
|
body = "Your daily update is ready",
|
||||||
sound = true,
|
sound = true,
|
||||||
vibration = true,
|
vibration = true,
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
@@ -1037,13 +1021,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.2.0", // Plugin version
|
"1.0.2", // 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.util.TimeZone.getDefault().id
|
java.time.ZoneId.systemDefault().id
|
||||||
)
|
)
|
||||||
notification.deliveryStatus = "missed"
|
notification.deliveryStatus = "missed"
|
||||||
notification.lastDeliveryAttempt = System.currentTimeMillis()
|
notification.lastDeliveryAttempt = System.currentTimeMillis()
|
||||||
@@ -1068,25 +1052,22 @@ 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 = title,
|
title = "Daily Notification",
|
||||||
body = body,
|
body = "Your daily update is ready",
|
||||||
sound = true,
|
sound = true,
|
||||||
vibration = true,
|
vibration = true,
|
||||||
priority = "normal"
|
priority = "normal"
|
||||||
)
|
)
|
||||||
|
|
||||||
NotifyReceiver.scheduleExactNotification(
|
NotifyReceiver.scheduleExactNotification(
|
||||||
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.2.0";
|
private static final String PLUGIN_VERSION = "1.0.0";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
# Action Plan: Plugin + Consuming App Integration Fixes
|
|
||||||
|
|
||||||
**Source:** Comparison output from Cursor session (daily-notification-plugin ↔ Time Safari / crowd-funder-for-time-pwa).
|
|
||||||
**Bugs addressed:** (A) Re-setting a notification doesn't fire; (B) Notification text always defaults to fallback values.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement plugin-side and app-side changes so that:
|
|
||||||
1. **Reset works:** Editing/re-saving a daily reminder (even with the same time) reliably re-schedules and the alarm fires.
|
|
||||||
2. **Text persists:** Custom title/body persist across the first fire and rollover (next day); no silent fallback to generic text.
|
|
||||||
3. **Cancel works on Android:** App can call `cancelDailyReminder({ reminderId })` and the plugin performs per-id cancellation (parity with iOS).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Plugin-Side Implementation (this repo)
|
|
||||||
|
|
||||||
### 1. Bug A: Skip DB idempotence when caller requests reset
|
|
||||||
|
|
||||||
**File:** `android/src/main/java/com/timesafari/dailynotification/NotifyReceiver.kt`
|
|
||||||
|
|
||||||
**Problem:** `scheduleExactNotification()` already skips *PendingIntent* idempotence when `skipPendingIntentIdempotence=true`, but the **DB-level idempotence check** (lines ~206–226) still runs. On "re-set same time," the DB still has the same `nextRunAt`, so the check returns early and **no alarm is scheduled**.
|
|
||||||
|
|
||||||
**Change:** Wrap the entire DB idempotence block so it runs only when `!skipPendingIntentIdempotence`. When `skipPendingIntentIdempotence=true`, log and skip the DB check.
|
|
||||||
|
|
||||||
- **Locate:** The block starting with `// DB-LEVEL IDEMPOTENCE CHECK` that loads `existingSchedule` and compares `existingSchedule.nextRunAt` with `triggerAtMillis` (60s tolerance), and `return@runBlocking` on duplicate.
|
|
||||||
- **Wrap:** Put that block inside `if (!skipPendingIntentIdempotence) { ... }` and add an `else` that logs:
|
|
||||||
`"Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId"`.
|
|
||||||
|
|
||||||
**Verification:** After editing a reminder without changing time, logs should show both "Skipping PendingIntent idempotence..." and "Skipping DB idempotence (skipPendingIntentIdempotence=true)...", and the alarm should fire.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Bug B: Preserve static reminder on rollover
|
|
||||||
|
|
||||||
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java`
|
|
||||||
|
|
||||||
**Problem:** In `scheduleNextNotification()`, the call to `NotifyReceiver.scheduleExactNotification()` uses **hardcoded** `false` for `isStaticReminder` and `null` for `reminderId`. So the *next* occurrence is treated as non-static and content is loaded from storage/default → fallback text.
|
|
||||||
|
|
||||||
**Change:**
|
|
||||||
1. At the start of `scheduleNextNotification()`, read from WorkManager input:
|
|
||||||
`boolean preserveStaticReminder = getInputData().getBoolean("is_static_reminder", false);`
|
|
||||||
2. When choosing `scheduleId`: if `preserveStaticReminder && notificationId != null && !notificationId.isEmpty()`, set `scheduleId = notificationId`. Otherwise keep existing logic (`daily_*` → use as scheduleId, else `daily_rollover_` + timestamp).
|
|
||||||
3. Replace the existing `scheduleExactNotification(...)` call with:
|
|
||||||
- `isStaticReminder` = `preserveStaticReminder`
|
|
||||||
- `reminderId` = `preserveStaticReminder ? scheduleId : null`
|
|
||||||
- `scheduleId` = the chosen `scheduleId` (stable for static reminders).
|
|
||||||
4. (Optional but useful) Add log before scheduling:
|
|
||||||
`Log.d("DN|ROLLOVER", "next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);`
|
|
||||||
|
|
||||||
**Verification:** Set a custom title/body, let it fire once, then confirm the next scheduled run still uses the same text; logs should show `DN|ROLLOVER ... scheduleId=daily_timesafari_reminder static=true`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Integration: Add Android `cancelDailyReminder`
|
|
||||||
|
|
||||||
**File:** `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
|
|
||||||
|
|
||||||
**Problem:** The app calls `DailyNotification.cancelDailyReminder({ reminderId })`. iOS implements this; Android only has `cancelAllNotifications()` and `scheduleDailyReminder()` alias. On Android the call fails (method missing / not implemented), so "turn off" and "reset" flows cannot rely on explicit cancel.
|
|
||||||
|
|
||||||
**Change:** Add a new `@PluginMethod fun cancelDailyReminder(call: PluginCall)` (e.g. immediately after `scheduleDailyReminder()`).
|
|
||||||
|
|
||||||
- **Parse ID:** `reminderId = call.getString("reminderId") ?: call.getString("id") ?: call.getString("reminder_id") ?: call.getString("scheduleId")`. Reject if null/blank.
|
|
||||||
- **Cancel alarm:** `NotifyReceiver.cancelNotification(context, scheduleId = reminderId)`.
|
|
||||||
- **DB cleanup (best-effort):** In a try/catch, `runBlocking`:
|
|
||||||
- `db = getDatabase()` (or `DailyNotificationDatabase.getDatabase(context)` as used elsewhere in plugin).
|
|
||||||
- `db.scheduleDao().setEnabled(reminderId, false)` and `db.scheduleDao().updateRunTimes(reminderId, null, null)`.
|
|
||||||
- ScheduleDao already has `setEnabled` and `updateRunTimes` (see `DatabaseSchema.kt`).
|
|
||||||
- On success: `call.resolve()`. On exception: log and `call.reject("cancelDailyReminder failed: ...")`.
|
|
||||||
|
|
||||||
**Verification:** From the app, call `cancelDailyReminder({ reminderId: "daily_notification" })` (or your app’s id); it should resolve and the alarm for that id should be gone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification Checklist (plugin)
|
|
||||||
|
|
||||||
After implementing the three items above:
|
|
||||||
|
|
||||||
1. **Reset test:** Schedule reminder 2–3 minutes from now → Edit and re-save **without changing time** → Confirm it still fires. Logs: "Skipping DB idempotence (skipPendingIntentIdempotence=true)...".
|
|
||||||
2. **Rollover test:** Set custom title/body → Let it fire once → Confirm next scheduled notification keeps the same title/body. Logs: `DN|ROLLOVER ... static=true scheduleId=daily_timesafari_reminder`.
|
|
||||||
3. **Cancel test:** Call `cancelDailyReminder({ reminderId })` from app or test harness; no error and alarm cleared.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Consuming App Work
|
|
||||||
|
|
||||||
App-side changes are described in a separate document intended for the **crowd-funder-for-time-pwa** (Time Safari) repo: **CONSUMING_APP_CURSOR_BRIEF.md**. That document is written so you can paste it into Cursor in the app repo to implement:
|
|
||||||
|
|
||||||
- Gate cancel in `editReminderNotification()` so Android skips pre-cancel (schedule path already cancels internally).
|
|
||||||
- Replace `TimeSafariNativeFetcher` placeholder with real content fetch and token persistence if using native fetcher for daily content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- NotifyReceiver: DB idempotence at ~206–226; skipPendingIntentIdempotence at ~159–204.
|
|
||||||
- DailyNotificationWorker: `scheduleNextNotification()` ~512–594; pass `preserveStaticReminder` and stable `scheduleId` into `scheduleExactNotification`.
|
|
||||||
- DailyNotificationPlugin: add `cancelDailyReminder` after `scheduleDailyReminder`; use `NotifyReceiver.cancelNotification` and ScheduleDao `setEnabled` / `updateRunTimes`.
|
|
||||||
- DatabaseSchema.kt: ScheduleDao `getById`, `upsert`, `setEnabled`, `updateRunTimes`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Assumptions & Limits
|
|
||||||
|
|
||||||
- App uses a stable reminder id (e.g. `daily_timesafari_reminder`); plugin preserves that id for static reminders on rollover.
|
|
||||||
- DAO method names are as in DatabaseSchema.kt; if the plugin’s Schedule entity uses different field names, adjust the `updateRunTimes` call accordingly (signature is `id, lastRunAt, nextRunAt`).
|
|
||||||
- Native fetcher and token persistence are app responsibilities; the plugin only needs to preserve static reminder semantics and provide cancel-by-id.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Consuming App Notes — Android Daily Notifications
|
|
||||||
|
|
||||||
Brief notes for apps that integrate the daily notification plugin on Android.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Double schedule (rapid successive calls)
|
|
||||||
|
|
||||||
If your app calls `scheduleDailyNotification` twice in quick succession (e.g. within a few hundred ms) for the same reminder, the second call cancels the alarm just set and reschedules. On some devices or OEMs this can contribute to the alarm not firing.
|
|
||||||
|
|
||||||
**Recommendation:** Debounce or guard in the edit-reminder success path so you only call `scheduleDailyNotification` once per user action (e.g. wait for the first call to resolve before allowing another, or coalesce rapid calls).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Alarm scheduled but not firing (e.g. 6:04)
|
|
||||||
|
|
||||||
When logs show "Scheduling OS alarm" and "Updated schedule in database" but the notification never appears:
|
|
||||||
|
|
||||||
1. **Confirm the broadcast is delivered**
|
|
||||||
Run logcat including the receiver:
|
|
||||||
```bash
|
|
||||||
adb logcat -v time -s DNP-SCHEDULE:V DailyNotificationWorker:V DailyNotificationReceiver:V
|
|
||||||
```
|
|
||||||
At the scheduled time, check whether `DailyNotificationReceiver` logs anything. If the Receiver runs, the issue is downstream (WorkManager / display). If it does not run, the OS did not deliver the alarm (Doze, OEM, or alarm replacement).
|
|
||||||
|
|
||||||
2. **Avoid double schedule**
|
|
||||||
Ensure the app is not calling `scheduleDailyNotification` twice in quick succession for the same reminder (see above).
|
|
||||||
|
|
||||||
3. **Plugin fix (v1.1.6+)**
|
|
||||||
The plugin no longer overwrites the app’s schedule row when handling rollover work that uses a `daily_rollover_*` id, so the app’s `nextRunAt` stays correct after a notification fires.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ACTION_PLAN_INTEGRATION_FIXES.md](./ACTION_PLAN_INTEGRATION_FIXES.md) — plugin and app integration checklist
|
|
||||||
- [CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md](./CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md) — optional cleanup of stale schedule rows
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# Optional: Use a Single Stable Schedule ID on iOS and Android
|
|
||||||
|
|
||||||
**Audience:** Consuming apps (e.g. TimeSafari / crowd-funder-for-time-pwa) that use `@timesafari/daily-notification-plugin`.
|
|
||||||
**Purpose:** Describe an optional app-side cleanup now that the plugin’s Android second-schedule bug is fixed (plugin v1.1.2+).
|
|
||||||
**Use:** Feed this doc into Cursor (or any editor) in the consuming app repo when implementing the cleanup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
- **Plugin fix (v1.1.2):** After cancel-then-schedule on Android, the plugin no longer skips the new schedule due to PendingIntent cache. Rescheduling works reliably whether or not the app passes an explicit `id` to `scheduleDailyNotification`.
|
|
||||||
- **Previous workaround:** Some apps avoided passing `id` on Android and used the plugin default `"daily_notification"` so that the (now-fixed) second-schedule bug would not trigger. On iOS they passed a stable id (e.g. `"daily_timesafari_reminder"`) for getStatus/cancel and verification.
|
|
||||||
- **Optional cleanup:** You can use the **same** stable schedule id on both iOS and Android. That simplifies code (one id everywhere), makes getStatus/cancel and verification consistent across platforms, and is safe with plugin v1.1.2+.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Depend on **`@timesafari/daily-notification-plugin@1.1.2`** (or `^1.1.2`) so the Android fix is in effect.
|
|
||||||
- No other code changes are required for the bug fix; this doc is only for the optional id cleanup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What to Change in the Consuming App
|
|
||||||
|
|
||||||
### 1. Single stable reminder ID (both platforms)
|
|
||||||
|
|
||||||
Use one reminder id for schedule, cancel, and getStatus on both iOS and Android.
|
|
||||||
|
|
||||||
**Example (current pattern):**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// Before: different id per platform
|
|
||||||
private get reminderId(): string {
|
|
||||||
return Capacitor.getPlatform() === "ios"
|
|
||||||
? "daily_timesafari_reminder"
|
|
||||||
: "daily_notification";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (optional cleanup):**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// After: same stable id on both platforms (requires plugin >= 1.1.2)
|
|
||||||
private readonly reminderId = "daily_timesafari_reminder";
|
|
||||||
```
|
|
||||||
|
|
||||||
Or keep a getter if you prefer:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
private get reminderId(): string {
|
|
||||||
return "daily_timesafari_reminder";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use whatever stable string your app already uses on iOS (e.g. `"daily_timesafari_reminder"`); no need to change the value.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Pass `id` when scheduling on Android
|
|
||||||
|
|
||||||
Today you may only add `scheduleOptions.id` on iOS. Add it for Android too so the plugin stores and returns this id (getStatus, getScheduledReminders, cancel all use it).
|
|
||||||
|
|
||||||
**Example (current pattern):**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const scheduleOptions = {
|
|
||||||
time: options.time,
|
|
||||||
title: options.title,
|
|
||||||
body: options.body,
|
|
||||||
sound: true,
|
|
||||||
priority: (options.priority || "normal") as "low" | "default" | "high",
|
|
||||||
};
|
|
||||||
if (Capacitor.getPlatform() === "ios") {
|
|
||||||
scheduleOptions.id = this.reminderId;
|
|
||||||
}
|
|
||||||
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (optional cleanup):**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const scheduleOptions = {
|
|
||||||
time: options.time,
|
|
||||||
title: options.title,
|
|
||||||
body: options.body,
|
|
||||||
sound: true,
|
|
||||||
priority: (options.priority || "normal") as "low" | "default" | "high",
|
|
||||||
id: this.reminderId, // same id on iOS and Android (plugin >= 1.1.2)
|
|
||||||
};
|
|
||||||
await DailyNotification.scheduleDailyNotification(scheduleOptions);
|
|
||||||
```
|
|
||||||
|
|
||||||
So: always pass `id: this.reminderId` (or your chosen constant) for both platforms.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Update comments
|
|
||||||
|
|
||||||
Remove or update comments that say Android must not receive an `id` to avoid the second-schedule bug, and that the plugin uses `"daily_notification"` on Android. Replace with a short note that a single stable id is used on both platforms and requires plugin v1.1.2+.
|
|
||||||
|
|
||||||
**Example comment to add/update:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
/**
|
|
||||||
* Stable schedule/reminder ID used for schedule, cancel, and getStatus.
|
|
||||||
* Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id).
|
|
||||||
*/
|
|
||||||
private readonly reminderId = "daily_timesafari_reminder";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files to Touch (typical)
|
|
||||||
|
|
||||||
- **Native notification service** (e.g. `src/services/notifications/NativeNotificationService.ts`):
|
|
||||||
- `reminderId`: use single value for both platforms.
|
|
||||||
- `scheduleDailyNotification`: always pass `id` in `scheduleOptions` (include Android).
|
|
||||||
- Adjust comments as above.
|
|
||||||
|
|
||||||
No changes are required to cancel or getStatus if they already use `this.reminderId`; they will now resolve the same schedule on Android as on iOS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. **Android:** Schedule a daily notification, then change time and save again (reschedule). The second scheduled time should fire; no need to reinstall.
|
|
||||||
2. **getStatus:** After scheduling on Android, getStatus should return the scheduled reminder with the same id you pass (e.g. `daily_timesafari_reminder`).
|
|
||||||
3. **Cancel:** Cancelling by that id on Android should clear the scheduled notification.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Plugin CHANGELOG: `[1.1.2] - 2026-02-13` — Android second daily notification not firing after reschedule.
|
|
||||||
- Issue context (if present in consuming app): `doc/android-daily-notification-second-schedule-issue.md`.
|
|
||||||
@@ -1,462 +0,0 @@
|
|||||||
# Android Notification Implementation Comparison
|
|
||||||
|
|
||||||
**Test App (Working)** vs **TimeSafari (Not Working)**
|
|
||||||
|
|
||||||
This document identifies the critical differences between the test app where notifications work correctly and the TimeSafari app where notifications don't work at all. Use this as a checklist to fix TimeSafari.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical Issues (Must Fix)
|
|
||||||
|
|
||||||
### 1. Missing Custom Application Class
|
|
||||||
|
|
||||||
**This is likely the primary cause of failure.**
|
|
||||||
|
|
||||||
**Test App (Working):**
|
|
||||||
```xml
|
|
||||||
<!-- AndroidManifest.xml -->
|
|
||||||
<application
|
|
||||||
android:name=".TestApplication"
|
|
||||||
...>
|
|
||||||
```
|
|
||||||
|
|
||||||
```java
|
|
||||||
// TestApplication.java
|
|
||||||
public class TestApplication extends Application {
|
|
||||||
@Override
|
|
||||||
public void onCreate() {
|
|
||||||
super.onCreate();
|
|
||||||
|
|
||||||
Context context = getApplicationContext();
|
|
||||||
NativeNotificationContentFetcher testFetcher =
|
|
||||||
new 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**: 2026-02-05
|
**Last Updated**: 2025-10-12 06:50:00 UTC
|
||||||
**Version**: 1.1.0
|
**Version**: 1.0.0
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -22,81 +22,6 @@ 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
|
||||||
@@ -106,21 +31,21 @@ After installing, run the checks again to confirm `adb`, `emulator`, and `emulat
|
|||||||
emulator -list-avds
|
emulator -list-avds
|
||||||
|
|
||||||
# Example output:
|
# Example output:
|
||||||
# Pixel8_API35
|
# Pixel8_API34
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Start the Emulator
|
### 2. Start the Emulator
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start emulator in background (recommended)
|
# Start emulator in background (recommended)
|
||||||
emulator -avd Pixel8_API35 -no-snapshot-load &
|
emulator -avd Pixel8_API34 -no-snapshot-load &
|
||||||
|
|
||||||
# Alternative: Start in foreground
|
# Alternative: Start in foreground
|
||||||
emulator -avd Pixel8_API35
|
emulator -avd Pixel8_API34
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flags Explained:**
|
**Flags Explained:**
|
||||||
- `-avd Pixel8_API35` - Specifies the AVD to use
|
- `-avd Pixel8_API34` - 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)
|
||||||
|
|
||||||
@@ -216,7 +141,7 @@ adb logcat -c && adb logcat
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Start emulator
|
# 1. Start emulator
|
||||||
emulator -avd Pixel8_API35 -no-snapshot-load &
|
emulator -avd Pixel8_API34 -no-snapshot-load &
|
||||||
|
|
||||||
# 2. Wait for emulator
|
# 2. Wait for emulator
|
||||||
adb wait-for-device
|
adb wait-for-device
|
||||||
@@ -286,17 +211,7 @@ ps aux | grep emulator
|
|||||||
pkill -f emulator
|
pkill -f emulator
|
||||||
|
|
||||||
# Start with verbose logging
|
# Start with verbose logging
|
||||||
emulator -avd Pixel8_API35 -verbose
|
emulator -avd Pixel8_API34 -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
|
||||||
@@ -341,13 +256,13 @@ cd android && ./gradlew clean
|
|||||||
#### Emulator Performance
|
#### Emulator Performance
|
||||||
```bash
|
```bash
|
||||||
# Start with hardware acceleration
|
# Start with hardware acceleration
|
||||||
emulator -avd Pixel8_API35 -accel on
|
emulator -avd Pixel8_API34 -accel on
|
||||||
|
|
||||||
# Start with specific RAM allocation
|
# Start with specific RAM allocation
|
||||||
emulator -avd Pixel8_API35 -memory 2048
|
emulator -avd Pixel8_API34 -memory 2048
|
||||||
|
|
||||||
# Start with GPU acceleration
|
# Start with GPU acceleration
|
||||||
emulator -avd Pixel8_API35 -gpu host
|
emulator -avd Pixel8_API34 -gpu host
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Build Performance
|
#### Build Performance
|
||||||
@@ -421,7 +336,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_API35 -no-snapshot-load &
|
emulator -avd Pixel8_API34 -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
|
||||||
|
|||||||
@@ -1,519 +0,0 @@
|
|||||||
# Running Android App on a Physical Device
|
|
||||||
|
|
||||||
**Author**: Matthew Raymer
|
|
||||||
**Last Updated**: 2026-02-12
|
|
||||||
**Version**: 1.0.0
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This guide demonstrates how to run the DailyNotification plugin test app on a physical Android device. Physical device testing is essential for validating:
|
|
||||||
|
|
||||||
- **Real notification behavior** — Emulators may not accurately simulate notification delivery timing
|
|
||||||
- **Battery optimization effects** — OEM-specific power management that affects background tasks
|
|
||||||
- **Actual alarm scheduling** — AlarmManager behavior varies between emulators and real hardware
|
|
||||||
- **Device reboot persistence** — Boot receivers and alarm recovery
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
### Required Hardware
|
|
||||||
- **Android phone or tablet** running Android 8.0 (API 26) or higher
|
|
||||||
- **USB cable** (data-capable, not charge-only)
|
|
||||||
- **Development computer** with USB port
|
|
||||||
|
|
||||||
### Required Software
|
|
||||||
- **Android SDK** with platform-tools (provides `adb`)
|
|
||||||
- **Gradle** (via Gradle Wrapper)
|
|
||||||
- **Node.js** and **npm** (for TypeScript compilation)
|
|
||||||
|
|
||||||
### How to Check
|
|
||||||
|
|
||||||
| Requirement | How to check |
|
|
||||||
|------------------|--------------|
|
|
||||||
| **Node.js** | `node --version` (v14+ recommended; test app may require 20+) |
|
|
||||||
| **npm** | `npm --version` |
|
|
||||||
| **Java** | `java -version` (Java 11+) |
|
|
||||||
| **ANDROID_HOME** | `echo $ANDROID_HOME` (must be set to your Android SDK root) |
|
|
||||||
| **adb** | `adb version` (must be on PATH) |
|
|
||||||
|
|
||||||
**Project script:** From the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node scripts/check-environment.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 1: Enable Developer Options on Your Phone
|
|
||||||
|
|
||||||
Developer Options are hidden by default. To enable them:
|
|
||||||
|
|
||||||
### Android 8.0 - 14 (Most Devices)
|
|
||||||
|
|
||||||
1. Open **Settings**
|
|
||||||
2. Scroll down to **About phone** (or **About device**)
|
|
||||||
3. Find **Build number**
|
|
||||||
4. **Tap Build number 7 times** rapidly
|
|
||||||
5. You'll see "You are now a developer!" toast message
|
|
||||||
|
|
||||||
### Samsung Devices
|
|
||||||
|
|
||||||
1. **Settings** → **About phone** → **Software information**
|
|
||||||
2. Tap **Build number** 7 times
|
|
||||||
|
|
||||||
### Xiaomi/MIUI Devices
|
|
||||||
|
|
||||||
1. **Settings** → **About phone**
|
|
||||||
2. Tap **MIUI version** 7 times
|
|
||||||
|
|
||||||
### OnePlus Devices
|
|
||||||
|
|
||||||
1. **Settings** → **About phone**
|
|
||||||
2. Tap **Build number** 7 times
|
|
||||||
|
|
||||||
## Step 2: Enable USB Debugging
|
|
||||||
|
|
||||||
After enabling Developer Options:
|
|
||||||
|
|
||||||
1. Go to **Settings** → **System** → **Developer options**
|
|
||||||
- On some phones: **Settings** → **Developer options** directly
|
|
||||||
2. Scroll to find **USB debugging**
|
|
||||||
3. Toggle **USB debugging ON**
|
|
||||||
4. Confirm when prompted
|
|
||||||
|
|
||||||
### Optional but Recommended Settings
|
|
||||||
|
|
||||||
While in Developer Options, also enable:
|
|
||||||
|
|
||||||
- **Stay awake** — Screen stays on while charging (useful during development)
|
|
||||||
- **Allow mock locations** — If testing location features
|
|
||||||
|
|
||||||
## Step 3: Connect and Authorize Your Device
|
|
||||||
|
|
||||||
### Physical Connection
|
|
||||||
|
|
||||||
1. Connect your phone to your computer via USB
|
|
||||||
2. On your phone, change USB mode:
|
|
||||||
- Pull down notification shade
|
|
||||||
- Tap the USB notification ("Charging this device via USB")
|
|
||||||
- Select **File transfer / Android Auto** or **PTP** (not "Charge only")
|
|
||||||
|
|
||||||
### Authorize Computer
|
|
||||||
|
|
||||||
1. On your phone, you'll see a dialog: **"Allow USB debugging?"**
|
|
||||||
2. Check **"Always allow from this computer"** (recommended)
|
|
||||||
3. Tap **Allow**
|
|
||||||
|
|
||||||
### Verify Connection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List connected devices
|
|
||||||
adb devices
|
|
||||||
|
|
||||||
# Expected output:
|
|
||||||
# List of devices attached
|
|
||||||
# ABC123DEF456 device
|
|
||||||
```
|
|
||||||
|
|
||||||
**Troubleshooting connection states:**
|
|
||||||
|
|
||||||
| State | Meaning | Solution |
|
|
||||||
|-------|---------|----------|
|
|
||||||
| `device` | Connected and authorized | Ready to use |
|
|
||||||
| `unauthorized` | USB debugging not authorized | Check phone for auth dialog |
|
|
||||||
| `offline` | Connection issues | Unplug, replug, restart adb |
|
|
||||||
| (empty) | Device not detected | Check USB cable, USB mode |
|
|
||||||
|
|
||||||
## Step 4: Build and Install the App
|
|
||||||
|
|
||||||
### Option A: Using Build Script (Recommended)
|
|
||||||
|
|
||||||
From the `test-apps/daily-notification-test` directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and run on connected device
|
|
||||||
./scripts/build.sh --run-android
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B: Manual Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Navigate to test app directory
|
|
||||||
cd test-apps/daily-notification-test
|
|
||||||
|
|
||||||
# 2. Build web assets
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 3. Sync with Capacitor
|
|
||||||
npm run cap:sync:android
|
|
||||||
|
|
||||||
# 4. Build APK
|
|
||||||
cd android
|
|
||||||
./gradlew :app:assembleDebug
|
|
||||||
|
|
||||||
# 5. Install on device
|
|
||||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
||||||
|
|
||||||
# 6. Launch app
|
|
||||||
adb shell am start -n 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.2.0'
|
s.version = '1.0.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'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@timesafari/daily-notification-plugin",
|
"name": "@timesafari/daily-notification-plugin",
|
||||||
"version": "1.2.0",
|
"version": "1.0.11",
|
||||||
"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,7 +14,6 @@
|
|||||||
"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",
|
||||||
@@ -100,10 +99,6 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
"src/",
|
|
||||||
"rollup.config.js",
|
|
||||||
"tsconfig.json",
|
|
||||||
"scripts/",
|
|
||||||
"android/",
|
"android/",
|
||||||
"ios/Plugin/",
|
"ios/Plugin/",
|
||||||
"ios/Tests/",
|
"ios/Tests/",
|
||||||
|
|||||||
@@ -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.2.0 (see package.json for source of truth)
|
* @version 1.0.11 (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.2.0
|
* @version 1.1.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.2.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ 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
|
||||||
|
|||||||
@@ -196,27 +196,22 @@
|
|||||||
console.log('[UI Refresh] Calling getNotificationStatus()...');
|
console.log('[UI Refresh] Calling getNotificationStatus()...');
|
||||||
window.DailyNotification.getNotificationStatus()
|
window.DailyNotification.getNotificationStatus()
|
||||||
.then(result => {
|
.then(result => {
|
||||||
console.log('[UI Refresh] getNotificationStatus result:', JSON.stringify({
|
console.log('[UI Refresh] getNotificationStatus result:', {
|
||||||
nextNotificationTime: result.nextNotificationTime,
|
nextNotificationTime: result.nextNotificationTime,
|
||||||
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
|
|
||||||
isEnabled: result.isEnabled,
|
isEnabled: result.isEnabled,
|
||||||
pending: result.pending,
|
pending: result.pending,
|
||||||
lastNotificationTime: result.lastNotificationTime,
|
lastNotificationTime: result.lastNotificationTime
|
||||||
lastNotificationTimeDate: result.lastNotificationTime ? new Date(result.lastNotificationTime).toISOString() : null
|
});
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
|
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
|
||||||
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
||||||
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
||||||
|
|
||||||
console.log('[UI Refresh] Updating UI:', JSON.stringify({
|
console.log('[UI Refresh] Updating UI:', {
|
||||||
nextTime: nextTime,
|
nextTime: nextTime,
|
||||||
nextNotificationTimeRaw: result.nextNotificationTime,
|
|
||||||
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
|
|
||||||
hasSchedules: hasSchedules,
|
hasSchedules: hasSchedules,
|
||||||
statusIcon: statusIcon,
|
statusIcon: statusIcon
|
||||||
pending: result.pending
|
});
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
||||||
📅 Next Notification: ${nextTime}<br>
|
📅 Next Notification: ${nextTime}<br>
|
||||||
@@ -346,60 +341,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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');
|
||||||
|
|
||||||
@@ -531,14 +472,11 @@
|
|||||||
console.log('[Poll] checkNotificationDelivery called');
|
console.log('[Poll] checkNotificationDelivery called');
|
||||||
window.DailyNotification.getNotificationStatus()
|
window.DailyNotification.getNotificationStatus()
|
||||||
.then(result => {
|
.then(result => {
|
||||||
console.log('[Poll] Status check result:', JSON.stringify({
|
console.log('[Poll] Status check result:', {
|
||||||
nextNotificationTime: result.nextNotificationTime,
|
nextNotificationTime: result.nextNotificationTime,
|
||||||
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
|
|
||||||
lastNotificationTime: result.lastNotificationTime,
|
lastNotificationTime: result.lastNotificationTime,
|
||||||
lastNotificationTimeDate: result.lastNotificationTime ? new Date(result.lastNotificationTime).toISOString() : null,
|
lastKnownNextNotificationTime: lastKnownNextNotificationTime
|
||||||
lastKnownNextNotificationTime: lastKnownNextNotificationTime,
|
});
|
||||||
lastKnownNextNotificationTimeDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null
|
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
// Check for notification delivery
|
// Check for notification delivery
|
||||||
if (result.lastNotificationTime) {
|
if (result.lastNotificationTime) {
|
||||||
@@ -582,13 +520,11 @@
|
|||||||
|
|
||||||
// Detect if nextNotificationTime changed (rollover occurred)
|
// Detect if nextNotificationTime changed (rollover occurred)
|
||||||
const currentNextTime = result.nextNotificationTime;
|
const currentNextTime = result.nextNotificationTime;
|
||||||
console.log('[Poll] Comparing nextNotificationTime:', JSON.stringify({
|
console.log('[Poll] Comparing nextNotificationTime:', {
|
||||||
current: currentNextTime,
|
current: currentNextTime,
|
||||||
currentDate: currentNextTime ? new Date(currentNextTime).toISOString() : null,
|
|
||||||
lastKnown: lastKnownNextNotificationTime,
|
lastKnown: lastKnownNextNotificationTime,
|
||||||
lastKnownDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null,
|
|
||||||
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
|
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
|
||||||
}, null, 2));
|
});
|
||||||
|
|
||||||
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
|
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
|
||||||
if (lastKnownNextNotificationTime !== null) {
|
if (lastKnownNextNotificationTime !== null) {
|
||||||
@@ -622,12 +558,11 @@
|
|||||||
// Load plugin status automatically on page load
|
// Load plugin status automatically on page load
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
console.log('Page loaded, loading plugin status...');
|
console.log('Page loaded, loading plugin status...');
|
||||||
// Small delay to ensure Capacitor is ready
|
// Small delay to ensure Capacitor is ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
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) {
|
||||||
@@ -645,57 +580,6 @@
|
|||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh UI when app comes back to foreground (after force-stop, app resume, etc.)
|
|
||||||
// This ensures the UI updates after recovery from force-stop
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
console.log('[Visibility] App became visible, refreshing UI status...');
|
|
||||||
// Small delay to allow recovery to complete
|
|
||||||
setTimeout(() => {
|
|
||||||
loadPluginStatus();
|
|
||||||
loadPermissionStatus();
|
|
||||||
loadChannelStatus();
|
|
||||||
loadConfigurationStatus();
|
|
||||||
|
|
||||||
// Also check for recent notifications that might have been missed
|
|
||||||
if (window.DailyNotification) {
|
|
||||||
window.DailyNotification.getNotificationStatus()
|
|
||||||
.then(result => {
|
|
||||||
// Check if a notification was received recently (within last 2 minutes)
|
|
||||||
if (result.lastNotificationTime) {
|
|
||||||
const lastTime = new Date(result.lastNotificationTime);
|
|
||||||
const now = new Date();
|
|
||||||
const timeDiff = now - lastTime;
|
|
||||||
|
|
||||||
if (timeDiff > 0 && timeDiff < 120000) {
|
|
||||||
console.log('[Visibility] Recent notification detected, showing indicator');
|
|
||||||
const indicator = document.getElementById('notificationReceivedIndicator');
|
|
||||||
const timeSpan = document.getElementById('notificationReceivedTime');
|
|
||||||
|
|
||||||
if (indicator && timeSpan) {
|
|
||||||
indicator.style.display = 'block';
|
|
||||||
lastTime.setSeconds(0, 0);
|
|
||||||
timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true })}`;
|
|
||||||
|
|
||||||
// Hide after 30 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
indicator.style.display = 'none';
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last known next notification time
|
|
||||||
lastKnownNextNotificationTime = result.nextNotificationTime;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('[Visibility] Failed to get notification status:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000); // Wait 1 second for recovery to complete
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
console.log('Functions attached to window:', {
|
console.log('Functions attached to window:', {
|
||||||
|
|||||||
@@ -824,36 +824,18 @@ main() {
|
|||||||
info "Post-rollover alarm time: ${post_rollover_alarm_time} (normalized)"
|
info "Post-rollover alarm time: ${post_rollover_alarm_time} (normalized)"
|
||||||
|
|
||||||
# Verify alarm time changed (rollover occurred)
|
# Verify alarm time changed (rollover occurred)
|
||||||
# Compare alarm date to current date - if alarm is scheduled for tomorrow or later, rollover worked
|
# Compare dates only (YYYY-MM-DD) to detect day change
|
||||||
if [ -n "${post_rollover_alarm_time}" ]; then
|
if [ -n "${initial_alarm_time}" ] && [ -n "${post_rollover_alarm_time}" ]; then
|
||||||
local current_date=$(date +%Y-%m-%d)
|
local initial_date=$(echo "${initial_alarm_time}" | cut -d' ' -f1)
|
||||||
local post_date=$(echo "${post_rollover_alarm_time}" | cut -d' ' -f1)
|
local post_date=$(echo "${post_rollover_alarm_time}" | cut -d' ' -f1)
|
||||||
|
|
||||||
# Compare dates: if alarm date is >= current date, it's scheduled for today or future (correct)
|
if [ "${initial_date}" != "${post_date}" ]; then
|
||||||
# If we also have initial_alarm_time, check if it advanced
|
ok "Alarm date changed: ${initial_alarm_time} → ${post_rollover_alarm_time}"
|
||||||
if [ -n "${initial_alarm_time}" ]; then
|
rollover_verified=true
|
||||||
local initial_date=$(echo "${initial_alarm_time}" | cut -d' ' -f1)
|
|
||||||
if [ "${initial_date}" != "${post_date}" ]; then
|
|
||||||
ok "Alarm date changed: ${initial_alarm_time} → ${post_rollover_alarm_time}"
|
|
||||||
rollover_verified=true
|
|
||||||
elif [ "${post_date}" \> "${current_date}" ] || [ "${post_date}" = "${current_date}" ]; then
|
|
||||||
# Alarm is scheduled for today or future - this is correct
|
|
||||||
# If initial was also tomorrow, that's fine - rollover logs will confirm it occurred
|
|
||||||
info "Alarm scheduled for ${post_date} (current: ${current_date}) - date unchanged from initial, checking rollover logs"
|
|
||||||
# Don't set rollover_verified yet - let log check determine it
|
|
||||||
else
|
|
||||||
warn "Alarm date ${post_date} is in the past (current: ${current_date}) - rollover may have failed"
|
|
||||||
rollover_verified=false
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
# No initial alarm time to compare, just check if it's scheduled for future
|
warn "Alarm date did NOT change: ${post_rollover_alarm_time} (same date as initial: ${initial_date})"
|
||||||
if [ "${post_date}" \> "${current_date}" ] || [ "${post_date}" = "${current_date}" ]; then
|
warn "This indicates the notification did not fire and rollover did not occur"
|
||||||
info "Alarm scheduled for ${post_date} (current: ${current_date}) - checking rollover logs for confirmation"
|
rollover_verified=false
|
||||||
# Don't set rollover_verified yet - let log check determine it
|
|
||||||
else
|
|
||||||
warn "Alarm date ${post_date} is in the past (current: ${current_date}) - rollover may have failed"
|
|
||||||
rollover_verified=false
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -1221,62 +1203,43 @@ main() {
|
|||||||
|
|
||||||
evidence_block "test1_force_stop_recovery"
|
evidence_block "test1_force_stop_recovery"
|
||||||
|
|
||||||
# Verify alarm fires if it's scheduled within reasonable time window (< 5 minutes)
|
# Optional: Verify alarm fires (controlled by VERIFY_FIRE flag)
|
||||||
# This ensures restored alarms actually work, not just that they were restored
|
if [ "${VERIFY_FIRE}" = "true" ] && [ -n "${alarm_trigger_ms}" ] && [ "${goto_test1_end}" != "true" ]; then
|
||||||
if [ -n "${alarm_trigger_ms}" ] && [ "${goto_test1_end}" != "true" ]; then
|
substep "Step 8: Verify alarm fires at scheduled time (optional)"
|
||||||
|
|
||||||
local current_time_sec current_time_ms wait_ms wait_sec
|
local current_time_sec current_time_ms wait_ms wait_sec
|
||||||
current_time_sec=$(get_current_time)
|
current_time_sec=$(get_current_time)
|
||||||
current_time_ms=$((current_time_sec * 1000))
|
current_time_ms=$((current_time_sec * 1000))
|
||||||
wait_ms=$((alarm_trigger_ms - current_time_ms))
|
wait_ms=$((alarm_trigger_ms - current_time_ms))
|
||||||
|
|
||||||
# Auto-enable fire verification if alarm is within 5 minutes (300 seconds)
|
if [ "${wait_ms}" -lt 0 ]; then
|
||||||
# Or if VERIFY_FIRE is explicitly set to true
|
warn "Alarm time already passed (${wait_ms} ms ago); skipping fire verification"
|
||||||
local should_verify_fire=false
|
else
|
||||||
if [ "${VERIFY_FIRE}" = "true" ]; then
|
wait_sec=$((wait_ms / 1000))
|
||||||
should_verify_fire=true
|
|
||||||
elif [ "${wait_ms}" -gt 0 ] && [ "${wait_ms}" -lt 300000 ]; then
|
|
||||||
# Alarm is in the future and within 5 minutes - auto-verify
|
|
||||||
should_verify_fire=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${should_verify_fire}" = "true" ]; then
|
|
||||||
substep "Step 8: Verify restored alarm fires at scheduled time"
|
|
||||||
set_test_context "phase1" "phase1_test1" "p1_t1_s6"
|
|
||||||
|
|
||||||
if [ "${wait_ms}" -lt 0 ]; then
|
# Clamp upper bound to prevent accidentally waiting 30+ minutes
|
||||||
step_warn "p1_t1_s6" "Alarm time already passed"
|
if [ "${wait_sec}" -gt 600 ]; then
|
||||||
warn "Alarm time already passed (${wait_ms} ms ago); cannot verify fire"
|
warn "Alarm is >10 minutes away (${wait_sec}s); skipping fire verification"
|
||||||
|
info "To test fire verification, schedule alarm closer to current time"
|
||||||
else
|
else
|
||||||
wait_sec=$((wait_ms / 1000))
|
info "Alarm scheduled for: ${alarm_readable}"
|
||||||
|
info "Current time: $(date -d "@${current_time_sec}" 2>/dev/null || echo "${current_time_sec}")"
|
||||||
|
info "Waiting ~${wait_sec} seconds for alarm to fire..."
|
||||||
|
|
||||||
# Clamp upper bound to prevent accidentally waiting too long
|
clear_logs
|
||||||
if [ "${wait_sec}" -gt 600 ]; then
|
sleep ${wait_sec}
|
||||||
step_warn "p1_t1_s6" "Alarm too far in future"
|
sleep 2
|
||||||
warn "Alarm is >10 minutes away (${wait_sec}s); skipping fire verification"
|
|
||||||
info "To test fire verification, schedule alarm closer to current time"
|
info "Checking logs for fired alarm..."
|
||||||
|
local alarm_fired
|
||||||
|
alarm_fired="$($ADB_BIN logcat -d | grep -E "DNP-RECEIVE|DNP-NOTIFY|DNP-WORK|Alarm fired|Notification displayed" | tail -10)"
|
||||||
|
|
||||||
|
if [ -n "${alarm_fired}" ]; then
|
||||||
|
ok "Alarm fired! Logs:"
|
||||||
|
echo "${alarm_fired}"
|
||||||
else
|
else
|
||||||
step_start "p1_t1_s6" "Waiting for restored alarm to fire"
|
warn "No alarm fire logs found"
|
||||||
info "Restored alarm scheduled for: ${alarm_readable}"
|
info "Check notification tray manually or review recent logs"
|
||||||
info "Current time: $(date -d "@${current_time_sec}" 2>/dev/null || echo "${current_time_sec}")"
|
|
||||||
info "Waiting ~${wait_sec} seconds for restored alarm to fire..."
|
|
||||||
|
|
||||||
clear_logs
|
|
||||||
sleep ${wait_sec}
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
info "Checking logs for fired alarm..."
|
|
||||||
local alarm_fired
|
|
||||||
alarm_fired="$($ADB_BIN logcat -d | grep -E "DNP-RECEIVE|DNP-NOTIFY|DNP-WORK|Alarm fired|Notification displayed" | tail -10)"
|
|
||||||
|
|
||||||
if [ -n "${alarm_fired}" ]; then
|
|
||||||
step_pass "p1_t1_s6" "Restored alarm fired successfully"
|
|
||||||
ok "✅ Restored alarm fired! Logs:"
|
|
||||||
echo "${alarm_fired}"
|
|
||||||
else
|
|
||||||
step_fail "p1_t1_s6" "Restored alarm did not fire"
|
|
||||||
warn "⚠️ No alarm fire logs found - restored alarm may not have fired"
|
|
||||||
info "Check notification tray manually or review recent logs"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
coreLibraryDesugaringEnabled true
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -63,7 +62,6 @@ 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"
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ check_requirements() {
|
|||||||
# Check Android requirements if building Android
|
# Check Android requirements if building Android
|
||||||
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
||||||
if ! command -v adb &> /dev/null; then
|
if ! command -v adb &> /dev/null; then
|
||||||
log_warn "Android SDK not found (adb not in PATH). Android build will be skipped."
|
log_warn "Android SDK tools not found (adb not in PATH)."
|
||||||
|
log_warn "APK can still be built, but install/launch requires adb."
|
||||||
else
|
else
|
||||||
log_info "✅ Android SDK: $(adb version | head -1)"
|
log_info "✅ Android SDK: $(adb version | head -1)"
|
||||||
fi
|
fi
|
||||||
@@ -220,23 +221,11 @@ if ! npm run build; then
|
|||||||
fi
|
fi
|
||||||
log_info "Web assets built successfully"
|
log_info "Web assets built successfully"
|
||||||
|
|
||||||
# Step 2: Sync Capacitor (Android-only when building only Android to avoid iOS pod install failure)
|
# Step 2: Sync Capacitor
|
||||||
log_step "Syncing Capacitor with native projects..."
|
log_step "Syncing Capacitor with native projects..."
|
||||||
if [ "$BUILD_ALL" = true ]; then
|
if ! npm run cap:sync; 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"
|
||||||
|
|
||||||
@@ -250,28 +239,105 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_IOS" = true ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Find Android SDK location
|
||||||
|
find_android_sdk() {
|
||||||
|
local android_dir=""
|
||||||
|
local local_props="$PROJECT_DIR/android/local.properties"
|
||||||
|
|
||||||
|
# Check environment variables first
|
||||||
|
if [ -n "$ANDROID_HOME" ] && [ -d "$ANDROID_HOME" ]; then
|
||||||
|
android_dir="$ANDROID_HOME"
|
||||||
|
log_info "Found Android SDK via ANDROID_HOME: $android_dir"
|
||||||
|
elif [ -n "$ANDROID_SDK_ROOT" ] && [ -d "$ANDROID_SDK_ROOT" ]; then
|
||||||
|
android_dir="$ANDROID_SDK_ROOT"
|
||||||
|
log_info "Found Android SDK via ANDROID_SDK_ROOT: $android_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check existing local.properties
|
||||||
|
if [ -z "$android_dir" ] && [ -f "$local_props" ]; then
|
||||||
|
# Temporarily disable exit on error for grep (may not find match)
|
||||||
|
set +e
|
||||||
|
sdk_line=$(grep "^sdk.dir=" "$local_props" 2>/dev/null)
|
||||||
|
set -e
|
||||||
|
if [ -n "$sdk_line" ]; then
|
||||||
|
android_dir=$(echo "$sdk_line" | cut -d'=' -f2 | sed 's|\\\\|/|g' | sed "s|^~|$HOME|")
|
||||||
|
if [ -n "$android_dir" ] && [ -d "$android_dir" ]; then
|
||||||
|
log_info "Found Android SDK in local.properties: $android_dir"
|
||||||
|
else
|
||||||
|
android_dir=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try common locations
|
||||||
|
if [ -z "$android_dir" ]; then
|
||||||
|
# macOS default location
|
||||||
|
if [ -d "$HOME/Library/Android/sdk" ]; then
|
||||||
|
android_dir="$HOME/Library/Android/sdk"
|
||||||
|
log_info "Found Android SDK in default macOS location: $android_dir"
|
||||||
|
# Linux default location
|
||||||
|
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||||
|
android_dir="$HOME/Android/Sdk"
|
||||||
|
log_info "Found Android SDK in default Linux location: $android_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create/update local.properties if SDK found
|
||||||
|
if [ -n "$android_dir" ]; then
|
||||||
|
# Normalize path (convert to forward slashes, expand ~)
|
||||||
|
android_dir=$(echo "$android_dir" | sed 's|\\\\|/|g' | sed "s|^~|$HOME|")
|
||||||
|
|
||||||
|
# Create local.properties with SDK location
|
||||||
|
mkdir -p "$(dirname "$local_props")"
|
||||||
|
echo "## This file is automatically generated by build script" > "$local_props"
|
||||||
|
echo "## Location: $android_dir" >> "$local_props"
|
||||||
|
echo "sdk.dir=$android_dir" >> "$local_props"
|
||||||
|
log_info "✅ Configured Android SDK in local.properties"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Android SDK not found!"
|
||||||
|
log_error "Please set one of the following:"
|
||||||
|
log_error " 1. ANDROID_HOME environment variable"
|
||||||
|
log_error " 2. ANDROID_SDK_ROOT environment variable"
|
||||||
|
log_error " 3. Create android/local.properties with: sdk.dir=/path/to/android/sdk"
|
||||||
|
log_error ""
|
||||||
|
log_error "Common SDK locations:"
|
||||||
|
log_error " macOS: ~/Library/Android/sdk"
|
||||||
|
log_error " Linux: ~/Android/Sdk"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Android build
|
# Android build
|
||||||
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
||||||
log_step "Building Android app..."
|
log_step "Building Android app..."
|
||||||
|
|
||||||
# Check for Android SDK
|
# Ensure Android SDK is configured
|
||||||
if ! command -v adb &> /dev/null; then
|
if ! find_android_sdk; then
|
||||||
log_warn "adb not found. Android SDK may not be installed."
|
log_error "Cannot build Android app without SDK location"
|
||||||
log_warn "Skipping Android build. Install Android SDK to build Android."
|
exit 1
|
||||||
else
|
fi
|
||||||
cd "$PROJECT_DIR/android"
|
|
||||||
|
cd "$PROJECT_DIR/android"
|
||||||
|
|
||||||
|
# Build APK (Gradle doesn't require adb for building)
|
||||||
|
if ./gradlew :app:assembleDebug; then
|
||||||
|
log_info "Android APK built successfully"
|
||||||
|
|
||||||
# Build APK
|
APK_PATH="$PROJECT_DIR/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
if ./gradlew :app:assembleDebug; then
|
|
||||||
log_info "Android APK built successfully"
|
if [ -f "$APK_PATH" ]; then
|
||||||
|
log_info "APK location: $APK_PATH"
|
||||||
|
|
||||||
APK_PATH="$PROJECT_DIR/android/app/build/outputs/apk/debug/app-debug.apk"
|
# Run on emulator if requested (requires adb)
|
||||||
|
if [ "$RUN_ALL" = true ] || [ "$RUN_ANDROID" = true ]; then
|
||||||
if [ -f "$APK_PATH" ]; then
|
# Check for Android SDK tools (adb)
|
||||||
log_info "APK location: $APK_PATH"
|
if ! command -v adb &> /dev/null; then
|
||||||
|
log_warn "adb not found in PATH. Cannot install/launch app."
|
||||||
# Run on emulator if requested
|
log_warn "APK built successfully, but install/launch requires Android SDK."
|
||||||
if [ "$RUN_ALL" = true ] || [ "$RUN_ANDROID" = true ]; then
|
log_info "To install manually: adb install -r $APK_PATH"
|
||||||
|
log_info "Or add Android SDK platform-tools to your PATH."
|
||||||
|
else
|
||||||
log_step "Installing and launching Android app..."
|
log_step "Installing and launching Android app..."
|
||||||
|
|
||||||
# Check for running emulator
|
# Check for running emulator
|
||||||
@@ -295,16 +361,16 @@ if [ "$BUILD_ALL" = true ] || [ "$BUILD_ANDROID" = true ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
log_error "APK not found at expected location: $APK_PATH"
|
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log_error "Android build failed"
|
log_error "APK not found at expected location: $APK_PATH"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
cd "$PROJECT_DIR"
|
log_error "Android build failed"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# iOS build
|
# iOS build
|
||||||
|
|||||||
163
www/index.html
163
www/index.html
@@ -196,27 +196,22 @@
|
|||||||
console.log('[UI Refresh] Calling getNotificationStatus()...');
|
console.log('[UI Refresh] Calling getNotificationStatus()...');
|
||||||
window.DailyNotification.getNotificationStatus()
|
window.DailyNotification.getNotificationStatus()
|
||||||
.then(result => {
|
.then(result => {
|
||||||
console.log('[UI Refresh] getNotificationStatus result:', JSON.stringify({
|
console.log('[UI Refresh] getNotificationStatus result:', {
|
||||||
nextNotificationTime: result.nextNotificationTime,
|
nextNotificationTime: result.nextNotificationTime,
|
||||||
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
|
|
||||||
isEnabled: result.isEnabled,
|
isEnabled: result.isEnabled,
|
||||||
pending: result.pending,
|
pending: result.pending,
|
||||||
lastNotificationTime: result.lastNotificationTime,
|
lastNotificationTime: result.lastNotificationTime
|
||||||
lastNotificationTimeDate: result.lastNotificationTime ? new Date(result.lastNotificationTime).toISOString() : null
|
});
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
|
const nextTime = formatDateTimeNormalized(result.nextNotificationTime);
|
||||||
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
const hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
|
||||||
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
const statusIcon = hasSchedules ? '✅' : '⏸️';
|
||||||
|
|
||||||
console.log('[UI Refresh] Updating UI:', JSON.stringify({
|
console.log('[UI Refresh] Updating UI:', {
|
||||||
nextTime: nextTime,
|
nextTime: nextTime,
|
||||||
nextNotificationTimeRaw: result.nextNotificationTime,
|
|
||||||
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
|
|
||||||
hasSchedules: hasSchedules,
|
hasSchedules: hasSchedules,
|
||||||
statusIcon: statusIcon,
|
statusIcon: statusIcon
|
||||||
pending: result.pending
|
});
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
|
||||||
📅 Next Notification: ${nextTime}<br>
|
📅 Next Notification: ${nextTime}<br>
|
||||||
@@ -346,68 +341,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 in database');
|
|
||||||
console.log('[Config Check] This may be normal after app uninstall/reinstall (database wiped)');
|
|
||||||
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);
|
|
||||||
// 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';
|
|
||||||
fetcherStatus.innerHTML = '❌ Error';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadChannelStatus() {
|
function loadChannelStatus() {
|
||||||
const channelStatus = document.getElementById('channelStatus');
|
const channelStatus = document.getElementById('channelStatus');
|
||||||
|
|
||||||
@@ -539,14 +472,11 @@
|
|||||||
console.log('[Poll] checkNotificationDelivery called');
|
console.log('[Poll] checkNotificationDelivery called');
|
||||||
window.DailyNotification.getNotificationStatus()
|
window.DailyNotification.getNotificationStatus()
|
||||||
.then(result => {
|
.then(result => {
|
||||||
console.log('[Poll] Status check result:', JSON.stringify({
|
console.log('[Poll] Status check result:', {
|
||||||
nextNotificationTime: result.nextNotificationTime,
|
nextNotificationTime: result.nextNotificationTime,
|
||||||
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
|
|
||||||
lastNotificationTime: result.lastNotificationTime,
|
lastNotificationTime: result.lastNotificationTime,
|
||||||
lastNotificationTimeDate: result.lastNotificationTime ? new Date(result.lastNotificationTime).toISOString() : null,
|
lastKnownNextNotificationTime: lastKnownNextNotificationTime
|
||||||
lastKnownNextNotificationTime: lastKnownNextNotificationTime,
|
});
|
||||||
lastKnownNextNotificationTimeDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null
|
|
||||||
}, null, 2));
|
|
||||||
|
|
||||||
// Check for notification delivery
|
// Check for notification delivery
|
||||||
if (result.lastNotificationTime) {
|
if (result.lastNotificationTime) {
|
||||||
@@ -590,13 +520,11 @@
|
|||||||
|
|
||||||
// Detect if nextNotificationTime changed (rollover occurred)
|
// Detect if nextNotificationTime changed (rollover occurred)
|
||||||
const currentNextTime = result.nextNotificationTime;
|
const currentNextTime = result.nextNotificationTime;
|
||||||
console.log('[Poll] Comparing nextNotificationTime:', JSON.stringify({
|
console.log('[Poll] Comparing nextNotificationTime:', {
|
||||||
current: currentNextTime,
|
current: currentNextTime,
|
||||||
currentDate: currentNextTime ? new Date(currentNextTime).toISOString() : null,
|
|
||||||
lastKnown: lastKnownNextNotificationTime,
|
lastKnown: lastKnownNextNotificationTime,
|
||||||
lastKnownDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null,
|
|
||||||
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
|
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
|
||||||
}, null, 2));
|
});
|
||||||
|
|
||||||
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
|
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
|
||||||
if (lastKnownNextNotificationTime !== null) {
|
if (lastKnownNextNotificationTime !== null) {
|
||||||
@@ -630,12 +558,11 @@
|
|||||||
// Load plugin status automatically on page load
|
// Load plugin status automatically on page load
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
console.log('Page loaded, loading plugin status...');
|
console.log('Page loaded, loading plugin status...');
|
||||||
// Small delay to ensure Capacitor is ready
|
// Small delay to ensure Capacitor is ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
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) {
|
||||||
@@ -653,64 +580,6 @@
|
|||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh UI when app comes back to foreground (after force-stop, app resume, etc.)
|
|
||||||
// This ensures the UI updates after recovery from force-stop
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
console.log('[Visibility] App became visible, refreshing UI status...');
|
|
||||||
// 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(() => {
|
|
||||||
console.log('[Visibility] Delayed refresh after recovery period...');
|
|
||||||
loadPluginStatus();
|
|
||||||
loadPermissionStatus();
|
|
||||||
loadChannelStatus();
|
|
||||||
loadConfigurationStatus();
|
|
||||||
|
|
||||||
// Also check for recent notifications that might have been missed
|
|
||||||
if (window.DailyNotification) {
|
|
||||||
window.DailyNotification.getNotificationStatus()
|
|
||||||
.then(result => {
|
|
||||||
// Check if a notification was received recently (within last 2 minutes)
|
|
||||||
if (result.lastNotificationTime) {
|
|
||||||
const lastTime = new Date(result.lastNotificationTime);
|
|
||||||
const now = new Date();
|
|
||||||
const timeDiff = now - lastTime;
|
|
||||||
|
|
||||||
if (timeDiff > 0 && timeDiff < 120000) {
|
|
||||||
console.log('[Visibility] Recent notification detected, showing indicator');
|
|
||||||
const indicator = document.getElementById('notificationReceivedIndicator');
|
|
||||||
const timeSpan = document.getElementById('notificationReceivedTime');
|
|
||||||
|
|
||||||
if (indicator && timeSpan) {
|
|
||||||
indicator.style.display = 'block';
|
|
||||||
lastTime.setSeconds(0, 0);
|
|
||||||
timeSpan.textContent = `Received at ${lastTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true })}`;
|
|
||||||
|
|
||||||
// Hide after 30 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
indicator.style.display = 'none';
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last known next notification time
|
|
||||||
lastKnownNextNotificationTime = result.nextNotificationTime;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('[Visibility] Failed to get notification status:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 3000); // Wait 3 seconds for recovery to complete (force-stop recovery can take time)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
console.log('Functions attached to window:', {
|
console.log('Functions attached to window:', {
|
||||||
|
|||||||
Reference in New Issue
Block a user