2 Commits

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

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

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

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

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

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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
- Document clean-build.sh script in Build Scripts section - Add check_command_line_tools() function
- Basic usage: ./scripts/clean-build.sh - Verifies Xcode Command Line Tools are installed and configured
- All options: --all, --clean-gradle-cache, --clean-derived-data, - Checks for xcodebuild availability
--reinstall-node - Verifies sqlite3 is available (part of Command Line Tools)
- Explain when to use clean builds - Provides clear error messages with installation instructions
- Enhance iOS Native Build Process section - Enhance pkgx detection in iOS build functions
- Add prerequisite note about Command Line Tools - Specifically searches for pkgx SQLite dylibs
- Include troubleshooting commands for pod install issues - Automatically removes pkgx paths from PATH environment variable
- Reference prerequisites section for details - Provides detailed warnings about detected conflicts
- Cleans all problematic environment variables before building
- Add comprehensive troubleshooting sections - Integrate checks into environment validation
- Clean Build section at start of Troubleshooting - Runs automatically when building for iOS
- Recommends clean-build as first step for many issues - Provides early warnings before build starts
- Lists when to use clean builds - Fails fast with clear error messages if tools are missing
- 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: This fixes the linker error:
- Xcode Command Line Tools as required iOS prerequisite "ld: building for 'iOS-simulator', but linking in dylib
- clean-build.sh as available build tool (/Users/trent/.pkgx/sqlite.org/v3.44.2/lib/libsqlite3.0.dylib)
- Complete iOS troubleshooting workflow built for 'macOS'"
The build script now:
- Detects pkgx SQLite conflicts before building
- Automatically fixes environment variables
- Verifies Command Line Tools are installed
- Provides clear guidance for manual fixes if needed
Files modified: Files modified:
- BUILDING.md - scripts/build-native.sh

View File

@@ -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

View File

@@ -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"

View File

@@ -76,13 +76,11 @@ 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"
@@ -92,8 +90,7 @@ class BootReceiver : BroadcastReceiver() {
nextRunTime, nextRunTime,
config, config,
scheduleId = schedule.id, scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY, source = ScheduleSource.BOOT_RECOVERY
skipPendingIntentIdempotence = true
) )
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}") Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
} }

View File

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

View File

@@ -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,15 +2631,8 @@ 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,
@@ -2679,15 +2640,50 @@ object ScheduleHelper {
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
} }

View File

@@ -109,9 +109,7 @@ public class DailyNotificationReceiver extends BroadcastReceiver {
String workName = "display_" + notificationId; String workName = "display_" + notificationId;
// Extract static reminder extras from intent if present // Extract static reminder extras from intent if present
// Static reminders have title/body in Intent extras, not in storage. // Static reminders have title/body in Intent extras, not in storage
// Do NOT access DB on main thread here (Room disallows it); Worker will resolve
// missing title/body by schedule_id on a background thread (see plugin-feedback-android-rollover-double-fire).
boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false); boolean isStaticReminder = intent.getBooleanExtra("is_static_reminder", false);
String title = intent.getStringExtra("title"); String title = intent.getStringExtra("title");
String body = intent.getStringExtra("body"); String body = intent.getStringExtra("body");
@@ -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 when present (from Intent; Worker resolves from DB by schedule_id if missing) // Add static reminder data if present
if (isStaticReminder && title != null && body != null) { if (isStaticReminder && title != null && body != null) {
dataBuilder.putString("title", title) dataBuilder.putString("title", title)
.putString("body", body) .putString("body", body)
@@ -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);

View File

@@ -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

View File

@@ -133,7 +133,7 @@ public class DailyNotificationWorker extends Worker {
NotificationContent content; NotificationContent content;
if (isStaticReminder) { if (isStaticReminder) {
// Static reminder: create NotificationContent from input data (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 {

View File

@@ -17,7 +17,7 @@ import org.json.JSONObject
* Implements exponential backoff and network constraints * Implements exponential backoff and network constraints
* *
* @author Matthew Raymer * @author Matthew Raymer
* @version 1.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()

View File

@@ -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,10 +123,6 @@ 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(
@@ -135,8 +132,7 @@ class NotifyReceiver : BroadcastReceiver() {
isStaticReminder: Boolean = false, isStaticReminder: Boolean = false,
reminderId: String? = null, reminderId: String? = null,
scheduleId: String? = null, scheduleId: String? = null,
source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE, source: ScheduleSource = ScheduleSource.MANUAL_RESCHEDULE
skipPendingIntentIdempotence: Boolean = false
) { ) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
@@ -147,88 +143,90 @@ class NotifyReceiver : BroadcastReceiver() {
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time) // Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
val notificationId = reminderId ?: "notify_${triggerAtMillis}" val notificationId = reminderId ?: "notify_${triggerAtMillis}"
// IDEMPOTENCE CHECK: Verify no existing alarm for this trigger time before scheduling
// This prevents duplicate alarms when multiple scheduling paths race
// Strategy: Check both by scheduleId (stable) and by trigger time (catches different scheduleIds for same time)
val requestCode = getRequestCode(stableScheduleId) val requestCode = getRequestCode(stableScheduleId)
val checkIntent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply { // 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) // Check 3: Also check if AlarmManager already has an alarm for this exact time
if (existingPendingIntent == null) { // This is a fallback for when PendingIntent checks fail but alarm still exists
val timeBasedRequestCode = getRequestCodeFromTime(triggerAtMillis) // We check the next alarm clock time (Android 5.0+)
existingPendingIntent = PendingIntent.getBroadcast( if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
context, val nextAlarm = alarmManager.nextAlarmClock
timeBasedRequestCode, if (nextAlarm != null) {
checkIntent, val nextAlarmTime = nextAlarm.triggerTime
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE 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)
// Check 3: AlarmManager next alarm (Android 5.0+) .format(java.util.Date(triggerAtMillis))
if (existingPendingIntent == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
val nextAlarm = alarmManager.nextAlarmClock Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
if (nextAlarm != null) { return
val nextAlarmTime = nextAlarm.triggerTime
val timeDiff = Math.abs(nextAlarmTime - triggerAtMillis)
if (timeDiff < 60000) {
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.w(SCHEDULE_TAG, "Skipping duplicate schedule: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing alarm found in AlarmManager at $nextAlarmTime (diff=${timeDiff}ms) - alarm already scheduled")
return
}
} }
} }
}
if (existingPendingIntent != null) { if (existingPendingIntent != null) {
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: id=$stableScheduleId, nextRun=$triggerTimeStr, source=$source")
Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled") Log.w(SCHEDULE_TAG, "Existing PendingIntent found for requestCode=$requestCode - alarm already scheduled")
return return
}
} else {
Log.d(SCHEDULE_TAG, "Skipping PendingIntent idempotence (caller just cancelled scheduleId=$stableScheduleId)")
} }
// DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun // DB-LEVEL IDEMPOTENCE CHECK: Verify no existing schedule for this scheduleId and nextRun
// When skipPendingIntentIdempotence is true (e.g. "re-set" flow), skip this check so we don't // This prevents logical duplicates before even hitting AlarmManager
// cancel the alarm and then skip re-scheduling, resulting in no alarm. try {
if (!skipPendingIntentIdempotence) { runBlocking {
try { val db = DailyNotificationDatabase.getDatabase(context)
runBlocking { val existingSchedule = db.scheduleDao().getById(stableScheduleId)
val db = DailyNotificationDatabase.getDatabase(context)
val existingSchedule = db.scheduleDao().getById(stableScheduleId)
if (existingSchedule != null && existingSchedule.nextRunAt != null) { if (existingSchedule != null && existingSchedule.nextRunAt != null) {
val timeDiff = Math.abs(existingSchedule.nextRunAt - triggerAtMillis) 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 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 for id=$stableScheduleId at $triggerTimeStr from source=$source") 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") Log.w(SCHEDULE_TAG, "Existing schedule found in DB: nextRunAt=${existingSchedule.nextRunAt}, diff=${timeDiff}ms")
return@runBlocking return@runBlocking
}
} }
} }
} catch (e: Exception) {
Log.w(SCHEDULE_TAG, "DB idempotence check failed, continuing with schedule: $stableScheduleId", e)
} }
} else { } catch (e: Exception) {
Log.d(SCHEDULE_TAG, "Skipping DB idempotence (skipPendingIntentIdempotence=true) for scheduleId=$stableScheduleId") 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) 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)

View File

@@ -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
@@ -42,26 +43,6 @@ class ReactivationManager(private val context: Context) {
private const val TAG = "DNP-REACTIVATION" private const val TAG = "DNP-REACTIVATION"
private const val RECOVERY_TIMEOUT_SECONDS = 2L private const val RECOVERY_TIMEOUT_SECONDS = 2L
/**
* Load persisted title/body for a schedule from NotificationContentEntity (post-reboot recovery).
* Tries schedule.id then "daily_${schedule.id}" to match NotifyReceiver/ScheduleHelper id convention.
* Internal so BootReceiver can use when rescheduling after boot.
*/
internal fun getTitleBodyForSchedule(db: DailyNotificationDatabase, schedule: Schedule): Pair<String, String>? {
val entity = try {
db.notificationContentDao().getNotificationById(schedule.id)
} catch (_: Exception) {
null
} ?: try {
db.notificationContentDao().getNotificationById("daily_${schedule.id}")
} catch (_: Exception) {
null
} ?: return null
val t = entity.title?.takeIf { it.isNotBlank() } ?: return null
val b = entity.body?.takeIf { it.isNotBlank() } ?: return null
return Pair(t, b)
}
/** /**
* Run boot-time recovery * Run boot-time recovery
* *
@@ -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,13 +276,11 @@ 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"
@@ -312,8 +291,7 @@ class ReactivationManager(private val context: Context) {
nextRunTime, nextRunTime,
config, config,
scheduleId = schedule.id, scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY, source = ScheduleSource.BOOT_RECOVERY
skipPendingIntentIdempotence = true
) )
// Update schedule in database (best effort) // Update schedule in database (best effort)
@@ -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,13 +1052,11 @@ 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"
@@ -1085,8 +1067,7 @@ class ReactivationManager(private val context: Context) {
nextRunTime, nextRunTime,
config, config,
scheduleId = schedule.id, scheduleId = schedule.id,
source = ScheduleSource.BOOT_RECOVERY, source = ScheduleSource.BOOT_RECOVERY
skipPendingIntentIdempotence = true
) )
// Update schedule in database (best effort) // Update schedule in database (best effort)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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.

View File

@@ -1,8 +1,8 @@
# Running Android App in Standalone Emulator (Without Android Studio) # Running Android App in Standalone Emulator (Without Android Studio)
**Author**: Matthew Raymer **Author**: Matthew Raymer
**Last Updated**: 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 apps `android` directory. No separate Gradle install is needed.
After installing, run the checks again to confirm `adb`, `emulator`, and `emulator -list-avds` work.
## Step-by-Step Process ## Step-by-Step Process
### 1. Check Available Emulators ### 1. Check Available Emulators
@@ -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

View File

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

View File

@@ -1,6 +1,6 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'DailyNotificationPlugin' s.name = 'DailyNotificationPlugin'
s.version = '1.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'

View File

@@ -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/",

View File

@@ -5,7 +5,7 @@
* Aligned with Android implementation and test requirements * Aligned with Android implementation and test requirements
* *
* @author Matthew Raymer * @author Matthew Raymer
* @version 1.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

View File

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

View File

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

View File

@@ -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

View File

@@ -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:', {

View File

@@ -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 # Clamp upper bound to prevent accidentally waiting 30+ minutes
substep "Step 8: Verify restored alarm fires at scheduled time" if [ "${wait_sec}" -gt 600 ]; then
set_test_context "phase1" "phase1_test1" "p1_t1_s6" warn "Alarm is >10 minutes away (${wait_sec}s); skipping fire verification"
info "To test fire verification, schedule alarm closer to current time"
if [ "${wait_ms}" -lt 0 ]; then
step_warn "p1_t1_s6" "Alarm time already passed"
warn "Alarm time already passed (${wait_ms} ms ago); cannot verify fire"
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

View File

@@ -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"

View File

@@ -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"
# Build APK cd "$PROJECT_DIR/android"
if ./gradlew :app:assembleDebug; then
log_info "Android APK built successfully"
APK_PATH="$PROJECT_DIR/android/app/build/outputs/apk/debug/app-debug.apk" # Build APK (Gradle doesn't require adb for building)
if ./gradlew :app:assembleDebug; then
log_info "Android APK built successfully"
if [ -f "$APK_PATH" ]; then APK_PATH="$PROJECT_DIR/android/app/build/outputs/apk/debug/app-debug.apk"
log_info "APK location: $APK_PATH"
# Run on emulator if requested if [ -f "$APK_PATH" ]; then
if [ "$RUN_ALL" = true ] || [ "$RUN_ANDROID" = true ]; then log_info "APK location: $APK_PATH"
# Run on emulator if requested (requires adb)
if [ "$RUN_ALL" = true ] || [ "$RUN_ANDROID" = true ]; then
# Check for Android SDK tools (adb)
if ! command -v adb &> /dev/null; then
log_warn "adb not found in PATH. Cannot install/launch app."
log_warn "APK built successfully, but install/launch requires Android SDK."
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

View File

@@ -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:', {