Files
daily-notification-plugin/docs/exploration-findings-initial.md
Matthew Raymer 6aa9140f67 docs: add comprehensive alarm/notification behavior documentation
- Add platform capability reference (Android & iOS OS-level facts)
- Add plugin behavior exploration template (executable test matrices)
- Add plugin requirements & implementation directive
- Add Android-specific implementation directive with detailed test procedures
- Add exploration findings from code inspection
- Add improvement directive for refining documentation structure
- Add Android alarm persistence directive (OS capabilities)

All documents include:
- File locations, function references, and line numbers
- Detailed test procedures with ADB commands
- Cross-platform comparisons
- Implementation checklists and code examples
2025-11-21 07:30:25 +00:00

27 KiB

Plugin Behavior Exploration - Initial Findings

Author: Matthew Raymer
Date: November 2025
Status: Initial Code Review - In Progress

Purpose

This document contains initial findings from code-level inspection of the plugin. These findings should be verified through actual testing using Plugin Behavior Exploration Template.


0. Behavior Definitions & Investigation Scope

Before examining the code, we need to clearly define what behaviors we're investigating and what each scenario means.

0.1 App State Scenarios

Swipe from Recents (Recent Apps List)

What it is: User swipes the app away from the Android recent apps list (app switcher) or iOS app switcher.

What happens:

  • Android: The app's UI is removed from the recent apps list, but:
    • The app process may still be running in the background
    • The app may be killed by the OS later due to memory pressure
    • AlarmManager alarms remain scheduled and will fire even if the process is killed
    • The OS will recreate the app process when the alarm fires
  • iOS: The app is terminated, but:
    • UNUserNotificationCenter notifications remain scheduled and will fire
    • Calendar/time-based triggers persist across reboot
    • TimeInterval triggers also persist across reboot (UNLESS they were scheduled with repeats = false AND the reboot occurs before the elapsed interval)
    • The app does not run in the background (unless it has active background tasks)
    • Notifications fire even though the app is not running
    • No plugin code runs when notification fires unless the user interacts with the notification

Key Point: Swiping from recents does not cancel scheduled alarms/notifications. The OS maintains them separately from the app process.

Android Nuance - Swipe vs Kill:

  • "Swipe away" DOES NOT kill your process; the OS may kill it later due to memory pressure
  • AlarmManager remains unaffected by swipe - alarms stay scheduled
  • WorkManager tasks remain scheduled regardless of swipe
  • The app process may continue running in the background after swipe
  • Only Force Stop actually cancels alarms and prevents execution

Investigation Goal: Verify that alarms/notifications still fire after the app is swiped away.


Force Stop (Android Only)

What it is: User goes to Settings → Apps → [Your App] → Force Stop. This is a hard kill that is different from swiping from recents.

What happens:

  • All alarms are immediately cancelled by the OS
  • All WorkManager tasks are cancelled
  • All broadcast receivers are blocked (including BOOT_COMPLETED)
  • All JobScheduler jobs are cancelled
  • The app cannot run until the user manually opens it again
  • No background execution is possible

Key Point: Force Stop is a hard boundary that cannot be bypassed. It's more severe than swiping from recents.

Investigation Goal: Verify that alarms do NOT fire after force stop, and that the plugin can detect and recover when the app is opened again.

Difference from Swipe:

  • Swipe: Alarms remain scheduled, app may still run in background
  • Force Stop: Alarms are cancelled, app cannot run until manually opened

App Still Functioning When Not Visible

Android:

  • When an app is swiped from recents but not force-stopped:
    • The app process may continue running in the background
    • Background services can continue
    • WorkManager tasks continue
    • AlarmManager alarms remain scheduled
    • The app is just not visible in the recent apps list
  • The OS may kill the process later due to memory pressure, but alarms remain scheduled

iOS:

  • When an app is swiped from the app switcher:
    • The app process is terminated
    • Background tasks (BGTaskScheduler) may still execute (system-controlled, but opportunistic, not exact)
    • UNUserNotificationCenter notifications remain scheduled
    • The app does not run in the foreground or background (unless it has active background tasks)
    • No persistent background execution after user swipe
    • No alarm-like wake for plugins (unlike Android AlarmManager)
    • No background execution at notification time unless user interacts

iOS Limitations:

  • No background execution at notification fire time unless user interacts
  • No alarm-style wakeups exist on iOS
  • Background execution (BGTaskScheduler) cannot be used for precise timing
  • Notifications survive reboot but plugin code does not run automatically

Investigation Goal: Understand that "not visible" does not mean "not functioning" for alarms/notifications, but also understand iOS limitations on background execution.


0.2 App Launch Recovery - How It Should Work

App Launch Recovery is the mechanism by which the plugin detects and handles missed alarms/notifications when the app starts.

Recovery Scenarios

Cold Start

What it is: App is launched from a completely terminated state (process was killed or never started).

Recovery Process:

  1. Plugin's load() method is called
  2. Plugin initializes database/storage
  3. Plugin queries for missed alarms/notifications:
    • Find alarms with scheduled_time < now and delivery_status != 'delivered'
    • Find notifications that should have fired but didn't
  4. For each missed alarm/notification:
    • Generate a "missed alarm" event or notification
    • If repeating, reschedule the next occurrence
    • Update delivery status to "missed" or "delivered"
  5. Reschedule future alarms/notifications that are still valid
  6. Verify active alarms match stored alarms

Investigation Goal: Verify that the plugin detects missed alarms on cold start and handles them appropriately.

Android Force Stop Detection:

  • On cold start, query AlarmManager for active alarms
  • Query plugin DB schedules
  • If (DB.count > 0 && AlarmManager.count == 0): Force Stop detected
  • Recovery: Mark all past schedules as missed, reschedule all future schedules, emit missed notifications

Warm Start

What it is: App is returning from background (app was paused but process still running).

Recovery Process:

  1. Plugin's load() method may be called (or app resumes)
  2. Plugin checks for missed alarms/notifications (same as cold start)
  3. Plugin verifies that active alarms are still scheduled correctly
  4. Plugin reschedules if any alarms were cancelled (shouldn't happen, but verify)

Investigation Goal: Verify that the plugin checks for missed alarms on warm start and verifies active alarms.


Force Stop Recovery (Android)

What it is: App was force-stopped and user manually opens it again.

Recovery Process:

  1. App launches (this is the only way to recover from force stop)
  2. Plugin's load() method is called
  3. Plugin detects that alarms were cancelled (all alarms have scheduled_time < now or are missing from AlarmManager)
  4. Plugin queries database for all enabled alarms
  5. For each alarm:
    • If scheduled_time < now: Mark as missed, generate missed alarm event, reschedule if repeating
    • If scheduled_time >= now: Reschedule the alarm
  6. Plugin reschedules all future alarms

Investigation Goal: Verify that the plugin can detect force stop scenario and fully recover all alarms.

Key Difference: Force stop recovery is more comprehensive than normal app launch recovery because ALL alarms were cancelled, not just missed ones.


0.3 What We're Investigating

For each scenario, we want to know:

  1. Does the alarm/notification fire? (OS behavior)
  2. Does the plugin detect missed alarms? (Plugin behavior)
  3. Does the plugin recover/reschedule? (Plugin behavior)
  4. What happens on next app launch? (Recovery behavior)

Expected Behaviors:

Scenario Alarm Fires? Plugin Detects Missed? Plugin Recovers?
Swipe from recents Yes (OS) N/A (fired) N/A
Force stop No (OS cancels) Should detect Should recover
Device reboot (Android) No (OS cancels) Should detect Should recover
Device reboot (iOS) Yes (OS persists) ⚠️ May detect ⚠️ May recover
Cold start N/A Should detect Should recover
Warm start N/A Should detect Should verify

1. Android Findings

1.1 Boot Receiver Implementation

Status: IMPLEMENTED

Location: android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt

Findings:

  • Boot receiver exists and handles ACTION_BOOT_COMPLETED (line 24)
  • Reschedules alarms from database (line 38+)
  • Loads enabled schedules from Room database (line 40)
  • Reschedules both "fetch" and "notify" schedules (lines 46-81)

Gap Identified:

  • Missed Alarm Handling: Boot receiver only reschedules FUTURE alarms
    • Line 64: if (nextRunTime > System.currentTimeMillis())
    • This means if an alarm was scheduled for before the reboot time, it won't be rescheduled
    • No missed alarm detection or notification

Recommendation: Add missed alarm detection in rescheduleNotifications() method


1.2 Missed Alarm Detection

Status: ⚠️ PARTIAL

Location: android/src/main/java/com/timesafari/dailynotification/dao/NotificationContentDao.java

Findings:

  • DAO has query for missed alarms: getNotificationsReadyForDelivery() (line 98)
    • Query: SELECT * FROM notification_content WHERE scheduled_time <= :currentTime AND delivery_status != 'delivered'
    • This can identify notifications that should have fired but haven't

Gap Identified:

  • Not called on app launch: The DailyNotificationPlugin.load() method (line 91) only initializes the database
  • No recovery logic in load() method
  • Query exists but may not be used for missed alarm detection

Recommendation: Add missed alarm detection in load() method or create separate recovery method


1.3 App Launch Recovery

Status: NOT IMPLEMENTED

Location: android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt

Expected Behavior (as defined in Section 0.2):

Cold Start Recovery:

  1. Plugin load() method called
  2. Query database for missed alarms: scheduled_time < now AND delivery_status != 'delivered'
  3. For each missed alarm:
    • Generate missed alarm event/notification
    • Reschedule if repeating
    • Update delivery status
  4. Reschedule all future alarms from database
  5. Verify active alarms match stored alarms

Warm Start Recovery:

  1. Plugin checks for missed alarms (same as cold start)
  2. Verify active alarms are still scheduled
  3. Reschedule if any were cancelled

Force Stop Recovery:

  1. Detect that all alarms were cancelled (force stop scenario)
  2. Query database for ALL enabled alarms
  3. For each alarm:
    • If scheduled_time < now: Mark as missed, generate event, reschedule if repeating
    • If scheduled_time >= now: Reschedule immediately
  4. Fully restore alarm state

Current Implementation:

  • load() method (line 91) only initializes database
  • No recovery logic on app launch
  • No check for missed alarms
  • No rescheduling of future alarms
  • No distinction between cold/warm/force-stop scenarios

Gap Identified:

  • Plugin does not recover on app cold/warm start
  • Plugin does not recover from force stop
  • Only boot receiver handles recovery (and only for future alarms)
  • No missed alarm detection on app launch

Recommendation:

  1. Add recovery logic to load() method or create ReactivationManager
  2. Implement missed alarm detection using getNotificationsReadyForDelivery() query
  3. Implement force stop detection (all alarms cancelled)
  4. Implement rescheduling of future alarms from database

1.4 Persistence Completeness

Status: IMPLEMENTED

Findings:

  • Room database used for persistence
  • Schedule entity stores: id, kind, cron, clockTime, enabled, nextRunAt
  • NotificationContentEntity stores: id, title, body, scheduledTime, priority, etc.
  • ContentCache stores: fetched content with TTL

All Required Fields Present:

  • alarm_id (Schedule.id, NotificationContentEntity.id)
  • trigger_time (Schedule.nextRunAt, NotificationContentEntity.scheduledTime)
  • repeat_rule (Schedule.cron, Schedule.clockTime)
  • channel_id (NotificationContentEntity - implicit)
  • priority (NotificationContentEntity.priority)
  • title, body (NotificationContentEntity)
  • sound_enabled, vibration_enabled (NotificationContentEntity)
  • created_at, updated_at (NotificationContentEntity)
  • enabled (Schedule.enabled)

1.5 Force Stop Recovery

Status: NOT IMPLEMENTED

Expected Behavior (as defined in Section 0.1 and 0.2):

Force Stop Scenario:

  • User goes to Settings → Apps → [App] → Force Stop
  • All alarms are immediately cancelled by the OS
  • App cannot run until user manually opens it
  • When app is opened, it's a cold start scenario

Force Stop Recovery:

  1. Detect that alarms were cancelled (check AlarmManager for scheduled alarms)
  2. Compare with database: if database has alarms but AlarmManager has none → force stop occurred
  3. Query database for ALL enabled alarms
  4. For each alarm:
    • If scheduled_time < now: This alarm was missed during force stop
      • Generate missed alarm event/notification
      • Reschedule next occurrence if repeating
      • Update delivery status
    • If scheduled_time >= now: This alarm is still in the future
      • Reschedule immediately
  5. Fully restore alarm state

Current Implementation:

  • No specific force stop detection
  • No recovery logic for force stop scenario
  • App launch recovery (if implemented) would handle this, but app launch recovery is not implemented
  • Cannot distinguish between normal app launch and force stop recovery

Gap Identified:

  • Plugin cannot detect force stop scenario
  • Plugin cannot distinguish between normal app launch and force stop recovery
  • No special handling for force stop scenario
  • All alarms remain cancelled until user opens app, then plugin should recover them

Recommendation:

  1. Implement app launch recovery (which will handle force stop as a special case)
  2. Add force stop detection: compare AlarmManager scheduled alarms with database
  3. If force stop detected, recover ALL alarms (not just missed ones)

2. iOS Findings

2.1 Notification Persistence

Status: IMPLEMENTED

Location: ios/Plugin/DailyNotificationStorage.swift

Findings:

  • Plugin uses DailyNotificationStorage for separate persistence
  • Uses UserDefaults for quick access (line 40)
  • Uses CoreData for structured data (line 41)
  • Stores notifications separately from UNUserNotificationCenter

Storage Components:

  • UserDefaults: Settings, last fetch, BGTask tracking
  • CoreData: NotificationContent, Schedule entities
  • UNUserNotificationCenter: OS-managed notification scheduling

2.2 Missed Notification Detection

Status: ⚠️ PARTIAL

Location: ios/Plugin/DailyNotificationPlugin.swift

Findings:

  • checkForMissedBGTask() method exists (line 421)
  • Checks for missed background tasks (BGTaskScheduler)
  • Reschedules missed BGTask if needed

Gap Identified:

  • Only checks for missed BGTask, not missed notifications
  • UNUserNotificationCenter handles notification persistence, but plugin doesn't check for missed notifications
  • No comparison between plugin storage and UNUserNotificationCenter pending notifications

Recommendation: Add missed notification detection by comparing plugin storage with UNUserNotificationCenter pending requests


2.3 App Launch Recovery

Status: ⚠️ PARTIAL

Location: ios/Plugin/DailyNotificationPlugin.swift

Expected Behavior (iOS Missed Notification Recovery Architecture):

Required Steps for Missed Notification Detection:

  1. Query plugin storage (CoreData) for all scheduled notifications
  2. Query UNUserNotificationCenter.pendingNotificationRequests() for future notifications
  3. Query UNUserNotificationCenter.getDeliveredNotifications() for already-fired notifications
  4. Find CoreData entries where:
    • scheduled_time < now (should have fired)
    • NOT in deliveredNotifications list (didn't fire)
    • NOT in pendingNotificationRequests list (not scheduled for future)
  5. Generate "missed notification" events for each detected miss
  6. Reschedule repeating notifications
  7. Verify that scheduled notifications in UNUserNotificationCenter align with CoreData schedules

This must be placed in load() during cold start.

Current Implementation:

  • load() method exists (line 42)
  • setupBackgroundTasks() called (line 318)
  • checkForMissedBGTask() called on setup (line 330)
  • Only checks for missed BGTask, not missed notifications
  • No recovery of notification state
  • No rescheduling of notifications from plugin storage
  • No comparison between UNUserNotificationCenter and CoreData

Gap Identified:

  • Only checks for missed BGTask, not missed notifications
  • No recovery of notification state
  • No rescheduling of notifications from plugin storage
  • No cross-checking between UNUserNotificationCenter and CoreData
  • iOS cannot detect missed notifications unless plugin compares storage vs UNUserNotificationCenter.getDeliveredNotifications() or infers from plugin timestamps

Recommendation:

  1. Add notification recovery logic in load() or setupBackgroundTasks()
  2. Implement three-way comparison: CoreData vs pending vs delivered notifications
  3. Add missed notification detection using the architecture above
  4. Note: iOS does NOT allow arbitrary code execution at notification fire time unless user interacts or Notification Service Extensions are used (not currently used)

2.4 Background Execution Limits

Status: DOCUMENTED IN CODE

Findings:

  • BGTaskScheduler used for background fetch
  • Time budget limitations understood (30 seconds typical)
  • System-controlled execution acknowledged
  • Rescheduling logic handles missed tasks

Code Evidence:

  • checkForMissedBGTask() handles missed BGTask (line 421)
  • 15-minute miss window used (line 448)
  • Reschedules if missed (line 462)

3. Cross-Platform Gaps Summary

Gap Android iOS Severity Recommendation
Missed alarm/notification detection ⚠️ Partial ⚠️ Partial High Implement on app launch
App launch recovery Missing ⚠️ Partial High MUST implement for both platforms
Force stop recovery Missing N/A Medium Android: Implement app launch recovery with force stop detection
Boot recovery missed alarms ⚠️ Only future N/A Medium Android: Add missed alarm handling in boot receiver
Cross-check mechanism (DB vs OS) Missing ⚠️ Partial High Android: AlarmManager vs DB; iOS: UNUserNotificationCenter vs CoreData

Critical Requirement: App Launch Recovery must be implemented on BOTH platforms:

  • Plugin must execute recovery logic during load() OR equivalent
  • Distinguish cold vs warm start
  • Use timestamps in storage to verify last known state
  • Reconcile DB entries with OS scheduling APIs
  • Android: Cross-check AlarmManager scheduled alarms with DB
  • iOS: Cross-check UNUserNotificationCenter with CoreData schedules

4. Test Validation Outputs

For each scenario, the exploration should produce explicit outputs:

Scenario OS Expected Plugin Expected Observed Result Pass/Fail Notes
Swipe from recents Alarm fires Alarm fires
Force stop Alarm does NOT fire Plugin detects on launch
Device reboot (Android) Alarm does NOT fire Plugin reschedules on boot
Device reboot (iOS) Notification fires Notification fires
Cold start N/A Missed alarms detected
Warm start N/A Missed alarms detected
Force stop recovery N/A All alarms recovered

This creates alignment with the Exploration Template.


5. Next Steps

  1. Verify findings through testing using Plugin Behavior Exploration Template
  2. Test boot receiver on actual device reboot
  3. Test app launch recovery on cold/warm start
  4. Test force stop recovery on Android (with cross-check mechanism)
  5. Test missed notification detection on iOS (with three-way comparison)
  6. Inspect UNUserNotificationCenter.getPendingNotificationRequests() vs CoreData to detect "lost" iOS notifications
  7. Update Plugin Requirements document with verified gaps
  8. Generate Test Validation Outputs table with actual test results

6. Code References for Implementation

Android - Add Missed Alarm Detection with Force Stop Detection

Location: DailyNotificationPlugin.kt - load() method (line 91)

Suggested Implementation (with Force Stop Detection):

override fun load() {
    super.load()
    // ... existing initialization ...
    
    // Check for missed alarms on app launch
    CoroutineScope(Dispatchers.IO).launch {
        detectAndHandleMissedAlarms()
    }
}

private suspend fun detectAndHandleMissedAlarms() {
    val db = DailyNotificationDatabase.getDatabase(context)
    val currentTime = System.currentTimeMillis()
    
    // Cross-check: Query AlarmManager for active alarms
    val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    val activeAlarmCount = getActiveAlarmCount(alarmManager) // Helper method needed
    
    // Query database for all enabled schedules
    val dbSchedules = db.scheduleDao().getEnabled()
    
    // Force Stop Detection: If DB has schedules but AlarmManager has zero
    val forceStopDetected = dbSchedules.isNotEmpty() && activeAlarmCount == 0
    
    if (forceStopDetected) {
        Log.i(TAG, "Force stop detected - all alarms were cancelled")
        // Recover ALL alarms (not just missed ones)
        recoverAllAlarmsAfterForceStop(db, dbSchedules, currentTime)
    } else {
        // Normal recovery: only check for missed alarms
        val missedNotifications = db.notificationContentDao()
            .getNotificationsReadyForDelivery(currentTime)
        
        missedNotifications.forEach { notification ->
            // Generate missed alarm event/notification
            // Reschedule if repeating
            // Update delivery status
        }
        
        // Reschedule future alarms from database
        rescheduleFutureAlarms(db, dbSchedules, currentTime)
    }
}

private suspend fun recoverAllAlarmsAfterForceStop(
    db: DailyNotificationDatabase,
    schedules: List<Schedule>,
    currentTime: Long
) {
    schedules.forEach { schedule ->
        val nextRunTime = calculateNextRunTime(schedule)
        if (nextRunTime < currentTime) {
            // Past alarm - mark as missed
            // Generate missed alarm notification
            // Reschedule if repeating
        } else {
            // Future alarm - reschedule immediately
            rescheduleAlarm(schedule, nextRunTime)
        }
    }
}

Android - Add Missed Alarm Handling in Boot Receiver

Location: BootReceiver.kt - rescheduleNotifications() method (line 38)

Suggested Implementation:

// After rescheduling future alarms, check for missed ones
val missedNotifications = db.notificationContentDao()
    .getNotificationsReadyForDelivery(System.currentTimeMillis())

missedNotifications.forEach { notification ->
    // Generate missed alarm notification
    // Reschedule if repeating
}

iOS - Add Missed Notification Detection

Location: DailyNotificationPlugin.swift - setupBackgroundTasks() or load() method

Suggested Implementation (Three-Way Comparison):

private func checkForMissedNotifications() async {
    // Step 1: Get pending notifications (future) from UNUserNotificationCenter
    let pendingRequests = await notificationCenter.pendingNotificationRequests()
    let pendingIds = Set(pendingRequests.map { $0.identifier })
    
    // Step 2: Get delivered notifications (already fired) from UNUserNotificationCenter
    let deliveredNotifications = await notificationCenter.getDeliveredNotifications()
    let deliveredIds = Set(deliveredNotifications.map { $0.request.identifier })
    
    // Step 3: Get notifications from plugin storage (CoreData)
    let storedNotifications = storage?.getAllNotifications() ?? []
    let currentTime = Date().timeIntervalSince1970
    
    // Step 4: Find missed notifications
    // Missed = scheduled_time < now AND not in delivered AND not in pending
    for notification in storedNotifications {
        let scheduledTime = notification.scheduledTime
        let notificationId = notification.id
        
        if scheduledTime < currentTime {
            // Should have fired by now
            if !deliveredIds.contains(notificationId) && !pendingIds.contains(notificationId) {
                // This notification was missed
                // Generate missed notification event
                // Reschedule if repeating
                // Update delivery status
            }
        }
    }
    
    // Step 5: Verify alignment - check if CoreData schedules match UNUserNotificationCenter
    // Reschedule any missing notifications from CoreData
}


7. Document Separation Directive

After improvements are complete, separate documents by purpose:

This avoids future redundancy and maintains clear separation of concerns.


Notes

  • These findings are from code inspection only
  • Actual testing required to verify behavior
  • Findings should be updated after testing with Plugin Behavior Exploration Template
  • iOS missed notification detection requires three-way comparison: CoreData vs pending vs delivered
  • Android force stop detection requires cross-check: AlarmManager vs database