16 Commits

Author SHA1 Message Date
Matthew Raymer
839e167c98 chore: sync test app UI with improved recovery timing
- Update test app index.html with improved UI refresh timing after force-stop
- Includes immediate + delayed refresh pattern for better recovery detection
- Better error handling for database not ready scenarios
2025-12-30 10:26:24 +00:00
Matthew Raymer
f40562b68a fix: improve UI refresh timing after force-stop recovery
- Increase recovery delay from 1s to 3s (force-stop recovery can take time)
- Add immediate refresh + delayed refresh to catch recovery at different stages
- Better error handling for database not ready yet (shows 'Checking...' instead of error)
- Add logging to indicate when config not found is normal (after uninstall/reinstall)

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

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

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

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

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

This fixes Test 1 failure where alarms weren't appearing after scheduling.
2025-12-30 09:50:29 +00:00
Matthew Raymer
f38b06abed chore: sync test app UI with improved logging
- Update test app index.html with JSON.stringify improvements for better log readability
- This file is synced from www/index.html during build process
- Includes date formatting improvements for debugging UI refresh issues
2025-12-30 09:40:38 +00:00
Matthew Raymer
ea4bc88808 fix: cancel existing alarm before scheduling new one for same scheduleId
- Cancel existing alarm for scheduleId before scheduling new one in scheduleDailyNotification
- Add verification logging to cancelNotification to confirm cancellation worked
- Ensures 'one per day' semantics when updating schedule time

Previously, when updating a schedule time:
- cleanupExistingNotificationSchedules() only canceled OTHER schedules (excluded current scheduleId)
- Old alarm for same scheduleId with different trigger time was not canceled
- Result: 2 alarms existed (old + new) violating 'one per day' semantics

The fix:
- In ScheduleHelper.scheduleDailyNotification(), cancel existing alarm for scheduleId BEFORE scheduling new one
- This ensures when updating schedule time, old alarm is canceled first
- Enhanced logging with DNP-CANCEL tag to track cancellation and verify it worked

Test 2 should now pass: updating schedule time will cancel old alarm before scheduling new one.
2025-12-30 09:17:18 +00:00
Matthew Raymer
63e5b4535e fix: restore and display configuration status after force-stop
- Add loadConfigurationStatus() function to check if plugin/fetcher are configured
- Call loadConfigurationStatus() on page load and app resume (visibility change)
- Add logging to getConfig() to track configuration restoration from database
- UI now shows  Configured status after force-stop recovery

Previously, after force-stop recovery:
- Configuration was stored in database but UI didn't check for it
- configStatus and fetcherStatus remained as 'Not configured' even though config existed
- No logging to track when configuration was restored

The fix:
- loadConfigurationStatus() queries database for native_fetcher_config
- Updates UI status indicators (configStatus, fetcherStatus) based on database state
- Called automatically on page load and when app becomes visible
- Android logs 'DNP-CONFIG: Configuration restored from database' when config is loaded

This ensures the 'Ready to test...' UI block shows correct configuration status
after force-stop recovery, matching the actual database state.
2025-12-30 09:15:25 +00:00
Matthew Raymer
d913f03e23 fix: refresh UI on app resume after force-stop recovery
- Add visibility change handler to refresh UI when app becomes visible
- Check for recent notifications and show indicator if notification was received
- Update lastKnownNextNotificationTime on app resume
- Auto-enable fire verification in Test 1 if alarm is within 5 minutes

Previously, after force-stop recovery:
- UI only refreshed on page load (which doesn't fire again after force-stop)
- Notification received indicator didn't show if notification fired while app was stopped
- Test 1 didn't verify that restored alarms actually fire

The fix:
- Listen for visibilitychange event to detect app resume
- Refresh all status displays (plugin, permissions, channel) when app becomes visible
- Check for notifications received in last 2 minutes and show indicator
- Wait 1 second after visibility change to allow recovery to complete
- Test 1 now automatically verifies restored alarms fire if within 5 minutes

This ensures the UI stays in sync after force-stop recovery and shows
notification indicators for notifications that fired while the app was stopped.
2025-12-30 08:58:11 +00:00
Matthew Raymer
4c1281754e fix: update existing schedule instead of creating new one during rollover
- Find existing enabled notify schedule and update its nextRunAt
- Fallback to finding schedule by kind='notify' && enabled=true if not found by ID
- This ensures getNotificationStatus() finds the updated schedule, not a stale one

Previously, during rollover we were creating a new schedule with a different ID
(daily_rollover_...), but getNotificationStatus() was finding the original schedule
(with ID like notify_... or daily_notification) which still had the old nextRunAt.

The fix:
- First try to find schedule by the provided stableScheduleId
- If not found, find the existing enabled notify schedule (there should only be one)
- Update that schedule's nextRunAt instead of creating a new one
- This ensures getNotificationStatus() returns the correct nextNotificationTime

This matches the pattern used in getNotificationStatus() which finds schedules
with kind='notify' && enabled=true.
2025-12-30 08:28:00 +00:00
Matthew Raymer
9655fa10f8 fix: correct Schedule class reference in NotifyReceiver
- Change DatabaseSchema.Schedule to Schedule (same package, no prefix needed)
- Fixes compilation error: Unresolved reference: DatabaseSchema

Since both NotifyReceiver.kt and DatabaseSchema.kt are in the same
package (com.timesafari.dailynotification), Schedule can be referenced
directly without the DatabaseSchema prefix.
2025-12-30 08:18:36 +00:00
Matthew Raymer
6ac7b35566 fix: update database schedule nextRunAt after scheduling alarm in rollover
- Add database schedule update after successfully scheduling alarm
- Update existing schedule's nextRunAt or create new schedule entry
- Extract cron expression and clockTime from trigger time
- This ensures getNotificationStatus() returns correct nextNotificationTime after rollover

Previously, scheduleExactNotification() scheduled the alarm correctly but
didn't update the database schedule's nextRunAt. This caused getNotificationStatus()
to return stale data, making the UI show the old notification time even though
the alarm was correctly rescheduled for tomorrow.

The fix:
- After successfully scheduling the alarm, update or create the schedule in the database
- Set nextRunAt to the triggerAtMillis value
- Extract hour:minute from trigger time to populate cron and clockTime fields
- Use runBlocking to call suspend function from non-suspend context
- Log but don't fail if DB update fails (alarm is already scheduled)

This matches the pattern used in ReactivationManager for boot/app launch recovery.
2025-12-30 08:13:58 +00:00
Matthew Raymer
62559cd546 fix: improve UI logging to show actual nextNotificationTime values
- Use JSON.stringify() to log actual values instead of [object Object]
- Add ISO date strings for nextNotificationTime and lastNotificationTime
- Add raw timestamp values alongside formatted dates
- Log pending count and other status fields for debugging

This will help diagnose why the UI isn't updating after rollover
by showing the actual values returned from getNotificationStatus().
2025-12-30 08:05:37 +00:00
Matthew Raymer
7b1f1200bc fix: improve rollover date comparison logic in Test 0
- Fix date comparison to check alarm date against current date, not initial alarm date
- Remove false warning when alarm date unchanged but scheduled for future (correct behavior)
- Only warn if alarm date is in the past (actual failure case)
- Improve logic flow: date change confirms rollover, but rollover logs are primary indicator
- When dates match but alarm is scheduled for today/future, defer to rollover log check

This fixes the confusing warning message that appeared when:
- Initial alarm scheduled for tomorrow (2025-12-31)
- Notification fires and rollover reschedules for same tomorrow date
- Rollover logs confirm rollover occurred, but date comparison incorrectly warned

The test now correctly recognizes that an alarm scheduled for the future
is valid, and relies on rollover logs as the primary verification method.
2025-12-30 07:53:23 +00:00
Matthew Raymer
39eed856f5 Merge branch 'ios-2' 2025-12-30 06:28:23 +00:00
Matthew Raymer
f83e799254 Merge branch 'ios-2' 2025-12-30 03:03:09 +00:00
Jose Olarte III
7725f19387 Merge branch 'ios-2' 2025-12-19 10:54:18 +08:00
76b3fa8199 doc: add notes from overall discussions 2025-12-17 09:23:55 -07:00
7 changed files with 458 additions and 74 deletions

View File

@@ -38,6 +38,15 @@ The plugin has been optimized for **native-first deployment** with the following
## Implementation Status
### **Overview**
Dec 17
- test-apps
- android has been seen to work
- ios is being developed (Jose)
- after ios, will work on daily-notification-test (that includes Vue)
- need to test with real data in the API
### ✅ **Phase 2 Complete - Production Ready**
| Component | Status | Implementation |

View File

@@ -2072,6 +2072,8 @@ open class DailyNotificationPlugin : Plugin() {
val options = call.getObject("options")
val timesafariDid = options?.getString("timesafariDid")
Log.d(TAG, "DNP-CONFIG: Loading config from database: key=$key, timesafariDid=${timesafariDid?.take(20)}...")
val entity = if (timesafariDid != null) {
getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
} else {
@@ -2079,8 +2081,10 @@ open class DailyNotificationPlugin : Plugin() {
}
if (entity != null) {
Log.i(TAG, "DNP-CONFIG: Configuration restored from database: key=$key, configType=${entity.configType}, hasValue=${entity.configValue.isNotEmpty()}")
call.resolve(configToJson(entity))
} else {
Log.d(TAG, "DNP-CONFIG: Configuration not found in database: key=$key")
call.resolve(JSObject().apply { put("config", null) })
}
} catch (e: Exception) {
@@ -2631,6 +2635,12 @@ object ScheduleHelper {
return try {
val nextRunTime = calculateNextRunTime(config.schedule)
// CRITICAL: Cancel any existing alarm for this scheduleId BEFORE scheduling new one
// This ensures "one per day" semantics - when updating schedule time, old alarm is canceled
// The cleanupExistingNotificationSchedules() above only cancels OTHER schedules, not the current one
NotifyReceiver.cancelNotification(context, scheduleId)
Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
// Schedule AlarmManager notification as static reminder
// (doesn't require cached content)
NotifyReceiver.scheduleExactNotification(

View File

@@ -396,6 +396,61 @@ class NotifyReceiver : BroadcastReceiver() {
)
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
// Update database schedule with new nextRunAt so getNotificationStatus() returns correct value
// This is critical for rollover scenarios where the UI needs to show the updated time
// Strategy: Find existing enabled notify schedule and update it (there should only be one)
// This ensures getNotificationStatus() finds the updated schedule, not a stale one
try {
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
// First, try to find schedule by the provided stableScheduleId
var scheduleToUpdate = db.scheduleDao().getById(stableScheduleId)
// If not found by ID, find the existing enabled notify schedule (for rollover scenarios)
// getNotificationStatus() finds schedules with kind="notify" && enabled=true
if (scheduleToUpdate == null) {
val allSchedules = db.scheduleDao().getAll()
scheduleToUpdate = allSchedules.firstOrNull { it.kind == "notify" && it.enabled }
}
// Calculate cron expression from trigger time (HH:mm format)
val calendar = java.util.Calendar.getInstance().apply {
timeInMillis = triggerAtMillis
}
val hour = calendar.get(java.util.Calendar.HOUR_OF_DAY)
val minute = calendar.get(java.util.Calendar.MINUTE)
val cronExpression = "${minute} ${hour} * * *"
val clockTime = String.format("%02d:%02d", hour, minute)
if (scheduleToUpdate != null) {
// Update existing schedule with new nextRunAt
// Use the existing schedule's ID (not stableScheduleId) to ensure we update the right one
db.scheduleDao().updateRunTimes(scheduleToUpdate.id, scheduleToUpdate.lastRunAt, triggerAtMillis)
Log.d(SCHEDULE_TAG, "Updated schedule in database: id=${scheduleToUpdate.id}, nextRunAt=$triggerAtMillis (rollover)")
} else {
// No existing schedule found - create new one (shouldn't happen in normal flow)
val newSchedule = Schedule(
id = stableScheduleId,
kind = "notify",
cron = cronExpression,
clockTime = clockTime,
enabled = true,
lastRunAt = null,
nextRunAt = triggerAtMillis,
jitterMs = 0,
backoffPolicy = "exp",
stateJson = null
)
db.scheduleDao().upsert(newSchedule)
Log.d(SCHEDULE_TAG, "Created new schedule in database: id=$stableScheduleId, nextRunAt=$triggerAtMillis")
}
}
} catch (e: Exception) {
// Log but don't fail - alarm is already scheduled, DB update is best-effort
Log.w(SCHEDULE_TAG, "Failed to update schedule in database: $stableScheduleId (alarm still scheduled)", e)
}
}
/**
@@ -419,14 +474,38 @@ class NotifyReceiver : BroadcastReceiver() {
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,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.i(TAG, "Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
if (existingPendingIntent != null) {
// Cancel both the alarm in AlarmManager AND the PendingIntent itself
// This matches the pattern in scheduleExactNotification (lines 311-312)
alarmManager.cancel(existingPendingIntent)
existingPendingIntent.cancel()
Log.i(TAG, "DNP-CANCEL: Notification alarm cancelled: scheduleId=$scheduleId, triggerAt=$triggerAtMillis, requestCode=$requestCode")
// Verify cancellation by checking if alarm still exists
val verifyIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
if (verifyIntent == null) {
Log.d(TAG, "DNP-CANCEL: ✅ Cancellation verified - no PendingIntent found for requestCode=$requestCode")
} else {
Log.w(TAG, "DNP-CANCEL: ⚠️ Cancellation may have failed - PendingIntent still exists for requestCode=$requestCode")
}
} else {
Log.d(TAG, "DNP-CANCEL: No existing PendingIntent found to cancel: scheduleId=$scheduleId, requestCode=$requestCode")
}
}
/**

View File

@@ -4,6 +4,8 @@
Both test apps are configured to **automatically build the plugin** as part of their build process. The plugin is included as a Gradle project dependency, so Gradle handles building it automatically.
Note that Test App 1 `android-test-app` is used most frequently.
---
## Test App 1: `android-test-app` (Standalone Android)
@@ -59,7 +61,7 @@ avdmanager list avd
# Run one
emulator -avd AVD_NAME
# Check that one is running
# Simply see that one is running
adb devices
# Now install on the emulator

View File

@@ -196,22 +196,27 @@
console.log('[UI Refresh] Calling getNotificationStatus()...');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[UI Refresh] getNotificationStatus result:', {
console.log('[UI Refresh] getNotificationStatus result:', JSON.stringify({
nextNotificationTime: result.nextNotificationTime,
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
isEnabled: result.isEnabled,
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 hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
const statusIcon = hasSchedules ? '✅' : '⏸️';
console.log('[UI Refresh] Updating UI:', {
console.log('[UI Refresh] Updating UI:', JSON.stringify({
nextTime: nextTime,
nextNotificationTimeRaw: result.nextNotificationTime,
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
hasSchedules: hasSchedules,
statusIcon: statusIcon
});
statusIcon: statusIcon,
pending: result.pending
}, null, 2));
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
📅 Next Notification: ${nextTime}<br>
@@ -341,6 +346,60 @@
}
}
// Load configuration status (plugin settings and native fetcher)
function loadConfigurationStatus() {
console.log('[Config Check] Checking configuration status...');
const configStatus = document.getElementById('configStatus');
const fetcherStatus = document.getElementById('fetcherStatus');
if (!window.DailyNotification) {
console.warn('[Config Check] DailyNotification plugin not available');
configStatus.innerHTML = '❌ Plugin unavailable';
fetcherStatus.innerHTML = '❌ Plugin unavailable';
return;
}
// Check if plugin settings are configured
// Plugin settings are stored internally, so we check by trying to get status
// For now, we'll check if native fetcher config exists as a proxy
// TODO: Add explicit plugin settings check method
window.DailyNotification.getConfig({ key: 'native_fetcher_config' })
.then(result => {
console.log('[Config Check] Native fetcher config result:', JSON.stringify(result));
if (result && result.config && result.config.configValue) {
try {
const configValue = JSON.parse(result.config.configValue);
if (configValue.apiBaseUrl && configValue.apiBaseUrl.length > 0) {
console.log('[Config Check] ✅ Native fetcher is configured');
fetcherStatus.innerHTML = '✅ Configured';
} else {
console.log('[Config Check] ⚠️ Native fetcher config exists but is empty');
fetcherStatus.innerHTML = '❌ Not configured';
}
} catch (e) {
console.warn('[Config Check] Failed to parse config value:', e);
fetcherStatus.innerHTML = '❌ Error';
}
} else {
console.log('[Config Check] ❌ Native fetcher config not found');
fetcherStatus.innerHTML = '❌ Not configured';
}
// For plugin settings, we assume configured if native fetcher is configured
// This is a heuristic - in production, add explicit check
if (fetcherStatus.innerHTML.includes('✅')) {
configStatus.innerHTML = '✅ Configured';
} else {
configStatus.innerHTML = '❌ Not configured';
}
})
.catch(error => {
console.error('[Config Check] Failed to check configuration:', error);
configStatus.innerHTML = '❌ Error';
fetcherStatus.innerHTML = '❌ Error';
});
}
function loadChannelStatus() {
const channelStatus = document.getElementById('channelStatus');
@@ -472,11 +531,14 @@
console.log('[Poll] checkNotificationDelivery called');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[Poll] Status check result:', {
console.log('[Poll] Status check result:', JSON.stringify({
nextNotificationTime: result.nextNotificationTime,
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
lastNotificationTime: result.lastNotificationTime,
lastKnownNextNotificationTime: lastKnownNextNotificationTime
});
lastNotificationTimeDate: result.lastNotificationTime ? new Date(result.lastNotificationTime).toISOString() : null,
lastKnownNextNotificationTime: lastKnownNextNotificationTime,
lastKnownNextNotificationTimeDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null
}, null, 2));
// Check for notification delivery
if (result.lastNotificationTime) {
@@ -520,11 +582,13 @@
// Detect if nextNotificationTime changed (rollover occurred)
const currentNextTime = result.nextNotificationTime;
console.log('[Poll] Comparing nextNotificationTime:', {
console.log('[Poll] Comparing nextNotificationTime:', JSON.stringify({
current: currentNextTime,
currentDate: currentNextTime ? new Date(currentNextTime).toISOString() : null,
lastKnown: lastKnownNextNotificationTime,
lastKnownDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null,
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
});
}, null, 2));
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
if (lastKnownNextNotificationTime !== null) {
@@ -563,6 +627,7 @@
loadPluginStatus();
loadPermissionStatus();
loadChannelStatus();
loadConfigurationStatus();
// Initialize last known next notification time
if (window.DailyNotification) {
@@ -580,6 +645,57 @@
}, 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:', {

View File

@@ -824,19 +824,37 @@ main() {
info "Post-rollover alarm time: ${post_rollover_alarm_time} (normalized)"
# Verify alarm time changed (rollover occurred)
# Compare dates only (YYYY-MM-DD) to detect day change
if [ -n "${initial_alarm_time}" ] && [ -n "${post_rollover_alarm_time}" ]; then
local initial_date=$(echo "${initial_alarm_time}" | cut -d' ' -f1)
# Compare alarm date to current date - if alarm is scheduled for tomorrow or later, rollover worked
if [ -n "${post_rollover_alarm_time}" ]; then
local current_date=$(date +%Y-%m-%d)
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 we also have initial_alarm_time, check if it advanced
if [ -n "${initial_alarm_time}" ]; then
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 did NOT change: ${post_rollover_alarm_time} (same date as initial: ${initial_date})"
warn "This indicates the notification did not fire and rollover did not occur"
warn "Alarm date ${post_date} is in the past (current: ${current_date}) - rollover may have failed"
rollover_verified=false
fi
else
# No initial alarm time to compare, just check if it's scheduled for future
if [ "${post_date}" \> "${current_date}" ] || [ "${post_date}" = "${current_date}" ]; then
info "Alarm scheduled for ${post_date} (current: ${current_date}) - checking rollover logs for confirmation"
# 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
@@ -1203,28 +1221,44 @@ main() {
evidence_block "test1_force_stop_recovery"
# Optional: Verify alarm fires (controlled by VERIFY_FIRE flag)
if [ "${VERIFY_FIRE}" = "true" ] && [ -n "${alarm_trigger_ms}" ] && [ "${goto_test1_end}" != "true" ]; then
substep "Step 8: Verify alarm fires at scheduled time (optional)"
# Verify alarm fires if it's scheduled within reasonable time window (< 5 minutes)
# This ensures restored alarms actually work, not just that they were restored
if [ -n "${alarm_trigger_ms}" ] && [ "${goto_test1_end}" != "true" ]; then
local current_time_sec current_time_ms wait_ms wait_sec
current_time_sec=$(get_current_time)
current_time_ms=$((current_time_sec * 1000))
wait_ms=$((alarm_trigger_ms - current_time_ms))
# Auto-enable fire verification if alarm is within 5 minutes (300 seconds)
# Or if VERIFY_FIRE is explicitly set to true
local should_verify_fire=false
if [ "${VERIFY_FIRE}" = "true" ]; then
should_verify_fire=true
elif [ "${wait_ms}" -gt 0 ] && [ "${wait_ms}" -lt 300000 ]; then
# Alarm is in the future and within 5 minutes - auto-verify
should_verify_fire=true
fi
if [ "${should_verify_fire}" = "true" ]; then
substep "Step 8: Verify restored alarm fires at scheduled time"
set_test_context "phase1" "phase1_test1" "p1_t1_s6"
if [ "${wait_ms}" -lt 0 ]; then
warn "Alarm time already passed (${wait_ms} ms ago); skipping fire verification"
step_warn "p1_t1_s6" "Alarm time already passed"
warn "Alarm time already passed (${wait_ms} ms ago); cannot verify fire"
else
wait_sec=$((wait_ms / 1000))
# Clamp upper bound to prevent accidentally waiting 30+ minutes
# Clamp upper bound to prevent accidentally waiting too long
if [ "${wait_sec}" -gt 600 ]; then
step_warn "p1_t1_s6" "Alarm too far in future"
warn "Alarm is >10 minutes away (${wait_sec}s); skipping fire verification"
info "To test fire verification, schedule alarm closer to current time"
else
info "Alarm scheduled for: ${alarm_readable}"
step_start "p1_t1_s6" "Waiting for restored alarm to fire"
info "Restored 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..."
info "Waiting ~${wait_sec} seconds for restored alarm to fire..."
clear_logs
sleep ${wait_sec}
@@ -1235,15 +1269,18 @@ main() {
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:"
step_pass "p1_t1_s6" "Restored alarm fired successfully"
ok "✅ Restored alarm fired! Logs:"
echo "${alarm_fired}"
else
warn "No alarm fire logs found"
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 # End of "if goto_test1_end != true" block

View File

@@ -196,22 +196,27 @@
console.log('[UI Refresh] Calling getNotificationStatus()...');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[UI Refresh] getNotificationStatus result:', {
console.log('[UI Refresh] getNotificationStatus result:', JSON.stringify({
nextNotificationTime: result.nextNotificationTime,
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
isEnabled: result.isEnabled,
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 hasSchedules = result.isEnabled || (result.pending && result.pending > 0);
const statusIcon = hasSchedules ? '✅' : '⏸️';
console.log('[UI Refresh] Updating UI:', {
console.log('[UI Refresh] Updating UI:', JSON.stringify({
nextTime: nextTime,
nextNotificationTimeRaw: result.nextNotificationTime,
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
hasSchedules: hasSchedules,
statusIcon: statusIcon
});
statusIcon: statusIcon,
pending: result.pending
}, null, 2));
pluginStatusContent.innerHTML = `${statusIcon} Active Schedules: ${hasSchedules ? 'Yes' : 'No'}<br>
📅 Next Notification: ${nextTime}<br>
@@ -341,6 +346,68 @@
}
}
// 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() {
const channelStatus = document.getElementById('channelStatus');
@@ -472,11 +539,14 @@
console.log('[Poll] checkNotificationDelivery called');
window.DailyNotification.getNotificationStatus()
.then(result => {
console.log('[Poll] Status check result:', {
console.log('[Poll] Status check result:', JSON.stringify({
nextNotificationTime: result.nextNotificationTime,
nextNotificationTimeDate: result.nextNotificationTime ? new Date(result.nextNotificationTime).toISOString() : null,
lastNotificationTime: result.lastNotificationTime,
lastKnownNextNotificationTime: lastKnownNextNotificationTime
});
lastNotificationTimeDate: result.lastNotificationTime ? new Date(result.lastNotificationTime).toISOString() : null,
lastKnownNextNotificationTime: lastKnownNextNotificationTime,
lastKnownNextNotificationTimeDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null
}, null, 2));
// Check for notification delivery
if (result.lastNotificationTime) {
@@ -520,11 +590,13 @@
// Detect if nextNotificationTime changed (rollover occurred)
const currentNextTime = result.nextNotificationTime;
console.log('[Poll] Comparing nextNotificationTime:', {
console.log('[Poll] Comparing nextNotificationTime:', JSON.stringify({
current: currentNextTime,
currentDate: currentNextTime ? new Date(currentNextTime).toISOString() : null,
lastKnown: lastKnownNextNotificationTime,
lastKnownDate: lastKnownNextNotificationTime ? new Date(lastKnownNextNotificationTime).toISOString() : null,
changed: currentNextTime && currentNextTime !== lastKnownNextNotificationTime
});
}, null, 2));
if (currentNextTime && currentNextTime !== lastKnownNextNotificationTime) {
if (lastKnownNextNotificationTime !== null) {
@@ -563,6 +635,7 @@
loadPluginStatus();
loadPermissionStatus();
loadChannelStatus();
loadConfigurationStatus();
// Initialize last known next notification time
if (window.DailyNotification) {
@@ -580,6 +653,64 @@
}, 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:', {