Files
daily-notification-plugin/docs/android-implementation-directive-phase3.md
Matthew Raymer 35babb3126 docs(alarms): unify and enhance alarm directive documentation stack
Create unified alarm documentation system with strict role separation:
- Doc A: Platform capability reference (canonical OS facts)
- Doc B: Plugin behavior exploration (executable test harness)
- Doc C: Plugin requirements (guarantees, JS/TS contract, traceability)

Changes:
- Add canonical rule to Doc A preventing platform fact duplication
- Convert Doc B to pure executable test spec with scenario tables
- Complete Doc C with guarantees matrix, JS/TS API contract, recovery
  contract, unsupported behaviors, and traceability matrix
- Remove implementation details from unified directive
- Add compliance milestone tracking and iOS parity gates
- Add deprecation banners to legacy platform docs

All documents now enforce strict role separation with cross-references
to prevent duplication and ensure single source of truth.
2025-11-25 10:09:46 +00:00

24 KiB
Raw Blame History

Android Implementation Directive: Phase 3 - Boot Receiver Missed Alarm Handling

Author: Matthew Raymer
Date: November 2025
Status: Phase 3 - Boot Recovery Enhancement
Version: 1.0.0
Last Synced With Plugin Version: v1.1.0

Implements: Plugin Requirements §3.1.1 - Boot Event

Purpose

Phase 3 enhances the boot receiver to detect and handle missed alarms during device reboot. This handles alarms that were scheduled before reboot but were missed because alarms are wiped on reboot.

Prerequisites: Phase 1 and Phase 2 must be complete.

Scope: Boot receiver missed alarm detection and handling.

Dependencies: Boot receiver behavior assumes that Phase 1 and Phase 2 definitions of 'missed alarm', 'next occurrence', and Schedule/NotificationContentEntity semantics are already in place.

Reference:

Boot vs App Launch Recovery:

Scenario Entry point Directive Responsibility
App launch after kill/force-stop ReactivationManager.performRecovery() Phase 12 Detect & recover missed
Device reboot BootReceiverReactivationManager Phase 3 Queue recovery, ReactivationManager handles

User-Facing Behavior: In Phase 3, missed alarms are recorded and rescheduled, but not yet surfaced to the user with explicit "you missed this" UX (that's a future concern).


1. Acceptance Criteria

1.1 Definition of Done

Phase 3 is complete when:

  1. Boot receiver detects missed alarms

    • Alarms with nextRunAt < currentTime detected during boot recovery
    • Detection runs automatically on BOOT_COMPLETED intent
    • Detection completes within 5 seconds (boot receiver timeout)
  2. Missed alarms are marked in database

    • delivery_status updated to 'missed'
    • last_delivery_attempt updated to current time
    • Status change logged in history table
  3. Next occurrence is rescheduled for repeating schedules

    • Repeating schedules calculate next occurrence after missed time
    • Next occurrence scheduled via AlarmManager
    • Non-repeating schedules not rescheduled
  4. Future alarms are rescheduled

    • All future alarms (not missed) rescheduled normally
    • Existing boot receiver logic enhanced, not replaced
  5. Boot recovery never crashes

    • All exceptions caught and logged
    • Database errors don't propagate
    • Invalid data handled gracefully

1.2 Success Metrics

Metric Target Measurement
Boot receiver execution time < 5 seconds Log timestamp difference
Missed detection accuracy 100% Manual verification via logs
Next occurrence calculation accuracy 100% Verify scheduled time matches expected
Recovery success rate > 95% History table outcome field
Crash rate 0% No exceptions propagate

1.3 Out of Scope (Phase 3)

  • Warm start optimization (future phase)
  • Callback event emission (future phase)
  • User notification of missed alarms (future phase)
  • Boot receiver performance optimization (future phase)

2. Implementation: BootReceiver Enhancement

2.1 Canonical Source of Truth

⚠️ CRITICAL CORRECTION: BootReceiver must NOT implement recovery logic directly. It must only queue ReactivationManager.performRecovery() with a BOOT flag.

ReactivationManager.kt is the only file allowed to:

  • Perform scenario detection
  • Initiate recovery logic
  • Branch execution per phase

2.2 Update BootReceiver

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

Location: onReceive() method

2.3 Corrected Implementation

Corrected Code (BootReceiver only queues recovery):

package com.timesafari.dailynotification

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/**
 * Boot recovery receiver to trigger recovery after device reboot
 * Phase 3: Only queues ReactivationManager, does not implement recovery directly
 * 
 * @author Matthew Raymer
 * @version 2.0.0 - Corrected to queue ReactivationManager only
 */
class BootReceiver : BroadcastReceiver() {
    
    companion object {
        private const val TAG = "DNP-BOOT"
        private const val PREFS_NAME = "dailynotification_recovery"
        private const val KEY_LAST_BOOT_AT = "last_boot_at"
    }

    override fun onReceive(context: Context, intent: Intent?) {
        if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
            Log.i(TAG, "Boot completed, queuing ReactivationManager recovery")
            
            // Set boot flag in SharedPreferences
            // ReactivationManager will detect this and handle recovery
            val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            prefs.edit().putLong(KEY_LAST_BOOT_AT, System.currentTimeMillis()).apply()
            
            // Queue ReactivationManager recovery
            // Recovery will run when app launches or can be triggered immediately
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    val reactivationManager = ReactivationManager(context)
                    reactivationManager.performRecovery()
                    Log.i(TAG, "Boot recovery queued to ReactivationManager")
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to queue boot recovery", e)
                }
            }
        }
    }
}

⚠️ REMOVED: All direct rescheduling logic from BootReceiver. Recovery is now handled entirely by ReactivationManager.

2.4 How Boot Recovery Works

Flow:

  1. Device reboots → BootReceiver.onReceive() called
  2. BootReceiver sets last_boot_at flag in SharedPreferences
  3. BootReceiver queues ReactivationManager.performRecovery()
  4. ReactivationManager detects BOOT scenario via isBootRecovery()
  5. ReactivationManager handles recovery (same logic as force stop - all alarms wiped)

Key Points:

  • BootReceiver never implements recovery directly
  • All recovery logic is in ReactivationManager
  • Boot recovery uses same recovery path as force stop (all alarms wiped on reboot)

3. Data Integrity Checks

/**
 * Reschedule notifications after device reboot
 * Phase 3: Adds missed alarm detection and handling
 * 
 * @param context Application context
 */
private suspend fun rescheduleNotifications(context: Context) {
    val db = DailyNotificationDatabase.getDatabase(context)
    val enabledSchedules = db.scheduleDao().getEnabled()
    val currentTime = System.currentTimeMillis()
    
    Log.i(TAG, "Boot recovery: Found ${enabledSchedules.size} enabled schedules to reschedule")
    
    var futureRescheduled = 0
    var missedDetected = 0
    var missedRescheduled = 0
    var errors = 0
    
    enabledSchedules.forEach { schedule ->
        try {
            when (schedule.kind) {
                "fetch" -> {
                    // Reschedule WorkManager fetch (unchanged)
                    val config = ContentFetchConfig(
                        enabled = schedule.enabled,
                        schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
                        url = null,
                        timeout = 30000,
                        retryAttempts = 3,
                        retryDelay = 1000,
                        callbacks = CallbackConfig()
                    )
                    FetchWorker.scheduleFetch(context, config)
                    Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}")
                    futureRescheduled++
                }
                "notify" -> {
                    // Phase 3: Handle both past and future alarms
                    val nextRunTime = calculateNextRunTime(schedule)
                    
                    if (nextRunTime > currentTime) {
                        // Future alarm - reschedule normally
                        val config = UserNotificationConfig(
                            enabled = schedule.enabled,
                            schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
                            title = "Daily Notification",
                            body = "Your daily update is ready",
                            sound = true,
                            vibration = true,
                            priority = "normal"
                        )
                        NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
                        Log.i(TAG, "Rescheduled future notification: ${schedule.id} for $nextRunTime")
                        futureRescheduled++
                    } else {
                        // Past alarm - was missed during reboot
                        missedDetected++
                        Log.i(TAG, "Missed alarm detected on boot: ${schedule.id} scheduled for $nextRunTime")
                        
                        // Mark as missed
                        handleMissedAlarmOnBoot(context, schedule, nextRunTime, db)
                        
                        // Reschedule next occurrence if repeating
                        if (isRepeating(schedule)) {
                            try {
                                val nextOccurrence = calculateNextOccurrence(schedule, currentTime)
                                val config = UserNotificationConfig(
                                    enabled = schedule.enabled,
                                    schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
                                    title = "Daily Notification",
                                    body = "Your daily update is ready",
                                    sound = true,
                                    vibration = true,
                                    priority = "normal"
                                )
                                NotifyReceiver.scheduleExactNotification(context, nextOccurrence, config)
                                Log.i(TAG, "Rescheduled next occurrence for missed alarm: ${schedule.id} for $nextOccurrence")
                                missedRescheduled++
                            } catch (e: Exception) {
                                errors++
                                Log.e(TAG, "Failed to reschedule next occurrence: ${schedule.id}", e)
                            }
                        }
                    }
                }
                else -> {
                    Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
                }
            }
        } catch (e: Exception) {
            errors++
            Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e)
        }
    }
    
    // Record boot recovery in history
    try {
        db.historyDao().insert(
            History(
                refId = "boot_recovery_${System.currentTimeMillis()}",
                kind = "boot_recovery",
                occurredAt = System.currentTimeMillis(),
                outcome = if (errors == 0) "success" else "partial",
                diagJson = """
                    {
                        "schedules_rescheduled": $futureRescheduled,
                        "missed_detected": $missedDetected,
                        "missed_rescheduled": $missedRescheduled,
                        "errors": $errors
                    }
                """.trimIndent()
            )
        )
    } catch (e: Exception) {
        Log.e(TAG, "Failed to record boot recovery history", e)
        // Don't fail boot recovery if history recording fails
    }
    
    Log.i(TAG, "Boot recovery complete: $futureRescheduled future, $missedDetected missed, $missedRescheduled next occurrences, $errors errors")
}

Note: All data integrity checks are handled by ReactivationManager (Phase 2). BootReceiver does not perform any data operations directly.

3.1 Missed Alarm Detection Validation

/**
 * Handle missed alarm detected during boot recovery
 * Phase 3: Marks missed alarm in database
 * 
 * @param context Application context
 * @param schedule Schedule that was missed
 * @param scheduledTime When the alarm was scheduled
 * @param db Database instance
 */
private suspend fun handleMissedAlarmOnBoot(
    context: Context,
    schedule: Schedule,
    scheduledTime: Long,
    db: DailyNotificationDatabase
) {
    try {
        // Data integrity check
        if (schedule.id.isBlank()) {
            Log.w(TAG, "Skipping invalid schedule: empty ID")
            return
        }
        
        // Try to find existing NotificationContentEntity
        val notificationId = schedule.id
        val existingNotification = db.notificationContentDao().getNotificationById(notificationId)
        
        if (existingNotification != null) {
            // Update existing notification
            existingNotification.deliveryStatus = "missed"
            existingNotification.lastDeliveryAttempt = System.currentTimeMillis()
            existingNotification.deliveryAttempts = (existingNotification.deliveryAttempts ?: 0) + 1
            db.notificationContentDao().updateNotification(existingNotification)
            Log.d(TAG, "Marked missed notification on boot: $notificationId")
        } else {
            // No NotificationContentEntity found - this is okay for boot recovery
            // The schedule exists but content may not have been fetched yet
            Log.d(TAG, "No NotificationContentEntity found for missed schedule: $notificationId (expected for boot recovery)")
        }
        
        // Record missed alarm in history
        try {
            db.historyDao().insert(
                History(
                    refId = "missed_boot_${schedule.id}_${System.currentTimeMillis()}",
                    kind = "missed_alarm",
                    occurredAt = System.currentTimeMillis(),
                    outcome = "missed",
                    diagJson = """
                        {
                            "schedule_id": "${schedule.id}",
                            "scheduled_time": $scheduledTime,
                            "detected_at": ${System.currentTimeMillis()},
                            "scenario": "boot_recovery"
                        }
                    """.trimIndent()
                )
            )
        } catch (e: Exception) {
            Log.w(TAG, "Failed to record missed alarm history", e)
            // Don't fail if history recording fails
        }
        
    } catch (e: Exception) {
        Log.e(TAG, "Failed to handle missed alarm on boot: ${schedule.id}", e)
        // Don't throw - continue with boot recovery
    }
}

2.5 Helper Methods

⚠️ Implementation Consistency: These helpers must match the implementation used in ReactivationManager (Phase 2). Treat ReactivationManager as canonical and keep these in sync.

/**
 * Check if schedule is repeating
 * 
 * **Implementation Note**: Must match `isRepeating()` in ReactivationManager (Phase 2).
 * This is a duplication for now; treat ReactivationManager as canonical.
 * 
 * @param schedule Schedule to check
 * @return true if repeating, false if one-time
 */
private fun isRepeating(schedule: Schedule): Boolean {
    // Schedules with cron or clockTime are repeating
    return schedule.cron != null || schedule.clockTime != null
}

/**
 * Calculate next occurrence for repeating schedule
 * 
 * **Implementation Note**: Must match `calculateNextOccurrence()` in ReactivationManager (Phase 2).
 * This is a duplication for now; treat ReactivationManager as canonical.
 * 
 * @param schedule Schedule to calculate for
 * @param fromTime Calculate next occurrence after this time
 * @return Next occurrence time in milliseconds
 */
private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long {
    // TODO: Implement proper calculation based on cron/clockTime
    // For now, simplified: daily schedules add 24 hours
    // This should match the logic in ReactivationManager (Phase 2)
    return when {
        schedule.cron != null -> {
            // Parse cron and calculate next occurrence
            // For now, simplified: daily schedules
            fromTime + (24 * 60 * 60 * 1000L)
        }
        schedule.clockTime != null -> {
            // Parse HH:mm and calculate next occurrence
            // For now, simplified: daily schedules
            fromTime + (24 * 60 * 60 * 1000L)
        }
        else -> {
            // Not repeating
            fromTime
        }
    }
}

3. Data Integrity Checks

3.1 Missed Alarm Detection Validation

Boot Flag Rules:

  • BootReceiver sets flag immediately on BOOT_COMPLETED
  • Flag is valid for 60 seconds after boot
  • ReactivationManager clears flag after reading
  • Stale flags are ignored (prevents false positives)

Edge Cases:

  • Multiple boot broadcasts: Flag is overwritten (last one wins)
  • App not launched after boot: Flag expires after 60 seconds
  • SharedPreferences errors: Log error, recovery continues

4. Rollback Safety

4.1 No-Crash Guarantee

All boot recovery operations must:

  1. Catch all exceptions - Never propagate exceptions
  2. Continue processing - One schedule failure doesn't stop recovery
  3. Log errors - All failures logged with context
  4. Timeout protection - Boot receiver has 10-second timeout (Android limit)

4.2 Error Handling Strategy

Error Type Handling Log Level
Schedule query failure Return empty list, log error ERROR
Invalid schedule data Skip schedule, continue WARN
Missed alarm marking failure Log error, continue ERROR
Next occurrence calculation failure Log error, don't reschedule ERROR
Alarm reschedule failure Log error, continue ERROR
History recording failure Log warning, don't fail WARN

5. Testing Requirements

5.1 Test 1: Boot Recovery Missed Detection

Purpose: Verify boot receiver detects missed alarms.

Steps:

  1. Schedule notification for 5 minutes in future
  2. Verify alarm scheduled: adb shell dumpsys alarm | grep timesafari
  3. Reboot device: adb reboot
  4. Wait for boot: adb wait-for-device && adb shell getprop sys.boot_completed (wait for "1")
  5. Wait 10 minutes (past scheduled time)
  6. Check boot logs: adb logcat -d | grep DNP-BOOT

Expected:

  • Log shows "Boot recovery: Found X enabled schedules"
  • Log shows "Missed alarm detected on boot: "
  • Database shows delivery_status = 'missed'

Pass Criteria: Missed alarm detected during boot recovery.

5.2 Test 2: Next Occurrence Rescheduling

Purpose: Verify repeating schedules calculate next occurrence correctly.

Steps:

  1. Schedule daily notification (cron: "0 9 * * *") for today at 9 AM
  2. Reboot device
  3. Wait until 10 AM (past scheduled time)
  4. Check boot logs
  5. Verify next occurrence scheduled: adb shell dumpsys alarm | grep timesafari

Expected:

  • Log shows "Missed alarm detected on boot"
  • Log shows "Rescheduled next occurrence for missed alarm"
  • AlarmManager shows alarm scheduled for tomorrow 9 AM

Pass Criteria: Next occurrence correctly calculated and scheduled.

5.3 Test 3: Future Alarm Rescheduling

Purpose: Verify future alarms are still rescheduled normally.

Steps:

  1. Schedule notification for 1 hour in future
  2. Reboot device
  3. Wait for boot
  4. Verify alarm rescheduled: adb shell dumpsys alarm | grep timesafari

Expected:

  • Log shows "Rescheduled future notification"
  • AlarmManager shows alarm scheduled for original time
  • No missed alarm detection for future alarms

Pass Criteria: Future alarms rescheduled normally.

5.4 Test 4: Non-Repeating Schedule Handling

Purpose: Verify non-repeating schedules don't reschedule next occurrence.

Steps:

  1. Schedule one-time notification (no cron/clockTime) for 5 minutes in future
  2. Reboot device
  3. Wait 10 minutes (past scheduled time)
  4. Check boot logs
  5. Verify no next occurrence scheduled

Expected:

  • Log shows "Missed alarm detected on boot"
  • Log does NOT show "Rescheduled next occurrence"
  • AlarmManager does NOT show new alarm

Pass Criteria: Non-repeating schedules don't reschedule.

5.5 Test 5: Boot Recovery Error Handling

Purpose: Verify boot recovery handles errors gracefully.

Steps:

  1. Manually corrupt database (insert invalid schedule)
  2. Reboot device
  3. Check boot logs

Expected:

  • Invalid schedule skipped with warning
  • Boot recovery continues normally
  • Valid schedules still recovered
  • No crash or exception

Pass Criteria: Errors handled gracefully, recovery continues.


6. Implementation Checklist

  • Update BootReceiver.onReceive() to set boot flag
  • Update BootReceiver.onReceive() to queue ReactivationManager
  • Remove all direct rescheduling logic from BootReceiver
  • Verify ReactivationManager detects BOOT scenario correctly
  • Update history recording to include missed alarm counts
  • Add data integrity checks
  • Add error handling
  • Test boot recovery missed detection
  • Test next occurrence rescheduling
  • Test future alarm rescheduling
  • Test non-repeating schedule handling
  • Test error handling
  • Verify no duplicate alarms

7. Code References

Existing Code to Reuse:

  • BootReceiver.rescheduleNotifications() - Line 38 (update existing)
  • BootReceiver.calculateNextRunTime() - Line 103 (already exists)
  • NotifyReceiver.scheduleExactNotification() - Line 92
  • ScheduleDao.getEnabled() - Line 298
  • NotificationContentDao.getNotificationById() - Line 69

New Code to Create:

  • handleMissedAlarmOnBoot() - Add to BootReceiver
  • isRepeating() - Add to BootReceiver (or reuse from ReactivationManager)
  • calculateNextOccurrence() - Add to BootReceiver (or reuse from ReactivationManager)

8. Success Criteria Summary

Phase 3 is complete when:

  1. Boot receiver detects missed alarms
  2. Missed alarms marked in database
  3. Next occurrence rescheduled for repeating schedules
  4. Future alarms rescheduled normally
  5. Boot recovery never crashes
  6. All tests pass


Notes

  • Prerequisites: Phase 1 and Phase 2 must be complete before starting Phase 3
  • Boot receiver timeout: Android limits boot receiver execution to 10 seconds
  • Comprehensive recovery: Boot recovery handles both missed and future alarms
  • Safety first: All recovery operations are non-blocking and non-fatal
  • Code reuse: Consider extracting helper methods to shared utility class