Files
daily-notification-plugin/docs/android-implementation-directive.md
2025-11-25 08:04:53 +00:00

44 KiB
Raw Blame History

Android Implementation Directive: App Launch Recovery & Missed Alarm Detection

Author: Matthew Raymer
Date: November 2025
Status: Active Implementation Directive - Android Only

Purpose

This directive provides descriptive overview and integration guidance for Android-specific gaps identified in the exploration:

  1. App Launch Recovery (cold/warm/force-stop)
  2. Missed Alarm Detection
  3. Force Stop Detection
  4. Boot Receiver Missed Alarm Handling

⚠️ CRITICAL: This document is descriptive and integrative. The normative implementation instructions are in the Phase 13 directives below. If any code or behavior in this file conflicts with a Phase directive, the Phase directive wins.

Reference: See Exploration Findings for gap analysis.

⚠️ IMPORTANT: For implementation, use the phase-specific directives (these are the canonical source of truth):


1. Implementation Overview

1.1 What Needs to Be Implemented

Feature Status Priority Location
App Launch Recovery Missing High DailyNotificationPlugin.kt - load() method
Missed Alarm Detection ⚠️ Partial High DailyNotificationPlugin.kt - new method
Force Stop Detection Missing High DailyNotificationPlugin.kt - recovery logic
Boot Receiver Missed Alarms ⚠️ Partial Medium BootReceiver.kt - rescheduleNotifications()

1.2 Implementation Strategy

Phase 1 Cold start recovery only

  • Missed notification detection + future alarm verification
  • No force-stop detection, no boot handling
  • See Phase 1 directive for implementation

Phase 2 Force stop detection & full recovery

  • Force stop detection via AlarmManager state comparison
  • Comprehensive recovery of all schedules (notify + fetch)
  • Past alarms marked as missed, future alarms rescheduled
  • See Phase 2 directive for implementation

Phase 3 Boot receiver missed alarm detection & rescheduling

  • Boot receiver detects missed alarms during device reboot
  • Next occurrence rescheduled for repeating schedules
  • See Phase 3 directive for implementation

2. Implementation: ReactivationManager

⚠️ Illustrative only See Phase 1 and Phase 2 directives for canonical implementation.

ReactivationManager Responsibilities by Phase:

Phase Responsibilities
1 Cold start only (missed detection + verify/reschedule future)
2 Adds force stop detection & recovery
3 Warm start optimizations (future)

For implementation details, see:

2.1 Create New File

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

Purpose: Centralized recovery logic for app launch scenarios

2.2 Class Structure

⚠️ Illustrative only See Phase 1 for canonical implementation.

package com.timesafari.dailynotification

import android.app.AlarmManager
import android.content.Context
import android.os.Build
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

/**
 * Manages recovery of alarms and notifications on app launch
 * Handles cold start, warm start, and force stop recovery scenarios
 * 
 * @author Matthew Raymer
 * @version 1.0.0
 */
class ReactivationManager(private val context: Context) {
    
    companion object {
        private const val TAG = "DNP-REACTIVATION"
    }
    
    /**
     * Perform recovery on app launch
     * Detects scenario (cold/warm/force-stop) and handles accordingly
     */
    fun performRecovery() {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                Log.i(TAG, "Starting app launch recovery")
                
                // Step 1: Detect scenario
                val scenario = detectScenario()
                Log.i(TAG, "Detected scenario: $scenario")
                
                // Step 2: Handle based on scenario
                when (scenario) {
                    RecoveryScenario.FORCE_STOP -> handleForceStopRecovery()
                    RecoveryScenario.COLD_START -> handleColdStartRecovery()
                    RecoveryScenario.WARM_START -> handleWarmStartRecovery()
                }
                
                Log.i(TAG, "App launch recovery completed")
            } catch (e: Exception) {
                Log.e(TAG, "Error during app launch recovery", e)
            }
        }
    }
    
    // ... implementation methods below ...
}

2.3 Scenario Detection

⚠️ Illustrative only See Phase 2 for canonical scenario detection implementation.

/**
 * Detect recovery scenario based on AlarmManager state vs database
 */
private suspend fun detectScenario(): RecoveryScenario {
    val db = DailyNotificationDatabase.getDatabase(context)
    val dbSchedules = db.scheduleDao().getEnabled()
    
    if (dbSchedules.isEmpty()) {
        // No schedules in database - normal first launch
        return RecoveryScenario.COLD_START
    }
    
    // Check AlarmManager for active alarms
    val activeAlarmCount = getActiveAlarmCount()
    
    // Force Stop Detection: DB has schedules but AlarmManager has zero
    if (dbSchedules.isNotEmpty() && activeAlarmCount == 0) {
        return RecoveryScenario.FORCE_STOP
    }
    
    // Check if this is warm start (app was in background)
    // For now, treat as cold start - can be enhanced later
    return RecoveryScenario.COLD_START
}

/**
 * Get count of active alarms in AlarmManager
 */
private fun getActiveAlarmCount(): Int {
    return try {
        val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
        
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            // Use nextAlarmClock as indicator (not perfect, but best available)
            val nextAlarm = alarmManager.nextAlarmClock
            if (nextAlarm != null) {
                // At least one alarm exists
                // Note: This doesn't give exact count, but indicates alarms exist
                return 1 // Assume at least one
            } else {
                return 0
            }
        } else {
            // Pre-Lollipop: Cannot query AlarmManager directly
            // Assume alarms exist if we can't check
            return 1
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error checking active alarm count", e)
        return 0
    }
}

enum class RecoveryScenario {
    COLD_START,
    WARM_START,
    FORCE_STOP
}

2.4 Force Stop Recovery

⚠️ Illustrative only See Phase 2 for canonical force stop recovery implementation.

/**
 * Handle force stop recovery
 * All alarms were cancelled, need to restore everything
 */
private suspend fun handleForceStopRecovery() {
    Log.i(TAG, "Handling force stop recovery")
    
    val db = DailyNotificationDatabase.getDatabase(context)
    val dbSchedules = db.scheduleDao().getEnabled()
    val currentTime = System.currentTimeMillis()
    
    var missedCount = 0
    var rescheduledCount = 0
    
    dbSchedules.forEach { schedule ->
        try {
            when (schedule.kind) {
                "notify" -> {
                    val nextRunTime = calculateNextRunTime(schedule)
                    
                    if (nextRunTime < currentTime) {
                        // Past alarm - was missed during force stop
                        missedCount++
                        handleMissedAlarm(schedule, nextRunTime)
                        
                        // Reschedule next occurrence if repeating
                        if (isRepeating(schedule)) {
                            val nextOccurrence = calculateNextOccurrence(schedule, currentTime)
                            rescheduleAlarm(schedule, nextOccurrence)
                            rescheduledCount++
                        }
                    } else {
                        // Future alarm - reschedule immediately
                        rescheduleAlarm(schedule, nextRunTime)
                        rescheduledCount++
                    }
                }
                "fetch" -> {
                    // Reschedule fetch work
                    rescheduleFetch(schedule)
                    rescheduledCount++
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error recovering schedule ${schedule.id}", e)
        }
    }
    
    // Record recovery in history
    recordRecoveryHistory(db, "force_stop", missedCount, rescheduledCount)
    
    Log.i(TAG, "Force stop recovery complete: $missedCount missed, $rescheduledCount rescheduled")
}

2.5 Cold Start Recovery

⚠️ Illustrative only See Phase 1 for canonical cold start recovery implementation.

/**
 * Handle cold start recovery
 * Check for missed alarms and reschedule future ones
 */
private suspend fun handleColdStartRecovery() {
    Log.i(TAG, "Handling cold start recovery")
    
    val db = DailyNotificationDatabase.getDatabase(context)
    val currentTime = System.currentTimeMillis()
    
    // Step 1: Detect missed alarms
    val missedNotifications = db.notificationContentDao()
        .getNotificationsReadyForDelivery(currentTime)
    
    var missedCount = 0
    missedNotifications.forEach { notification ->
        try {
            handleMissedNotification(notification)
            missedCount++
        } catch (e: Exception) {
            Log.e(TAG, "Error handling missed notification ${notification.id}", e)
        }
    }
    
    // Step 2: Reschedule future alarms from database
    val dbSchedules = db.scheduleDao().getEnabled()
    var rescheduledCount = 0
    
    dbSchedules.forEach { schedule ->
        try {
            val nextRunTime = calculateNextRunTime(schedule)
            
            if (nextRunTime >= currentTime) {
                // Future alarm - verify it's scheduled
                if (!isAlarmScheduled(schedule, nextRunTime)) {
                    rescheduleAlarm(schedule, nextRunTime)
                    rescheduledCount++
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error rescheduling schedule ${schedule.id}", e)
        }
    }
    
    // Record recovery in history
    recordRecoveryHistory(db, "cold_start", missedCount, rescheduledCount)
    
    Log.i(TAG, "Cold start recovery complete: $missedCount missed, $rescheduledCount rescheduled")
}

2.6 Warm Start Recovery

/**
 * Handle warm start recovery
 * Verify active alarms and check for missed ones
 */
private suspend fun handleWarmStartRecovery() {
    Log.i(TAG, "Handling warm start recovery")
    
    // Similar to cold start, but lighter weight
    // Just verify alarms are still scheduled
    val db = DailyNotificationDatabase.getDatabase(context)
    val currentTime = System.currentTimeMillis()
    
    // Check for missed alarms (same as cold start)
    val missedNotifications = db.notificationContentDao()
        .getNotificationsReadyForDelivery(currentTime)
    
    var missedCount = 0
    missedNotifications.forEach { notification ->
        try {
            handleMissedNotification(notification)
            missedCount++
        } catch (e: Exception) {
            Log.e(TAG, "Error handling missed notification ${notification.id}", e)
        }
    }
    
    // Verify active alarms (lighter check than cold start)
    val dbSchedules = db.scheduleDao().getEnabled()
    var verifiedCount = 0
    
    dbSchedules.forEach { schedule ->
        try {
            val nextRunTime = calculateNextRunTime(schedule)
            if (nextRunTime >= currentTime && isAlarmScheduled(schedule, nextRunTime)) {
                verifiedCount++
            }
        } catch (e: Exception) {
            Log.e(TAG, "Error verifying schedule ${schedule.id}", e)
        }
    }
    
    Log.i(TAG, "Warm start recovery complete: $missedCount missed, $verifiedCount verified")
}

2.7 Helper Methods

/**
 * Handle a missed alarm/notification
 */
private suspend fun handleMissedAlarm(schedule: Schedule, scheduledTime: Long) {
    val db = DailyNotificationDatabase.getDatabase(context)
    
    // Update delivery status
    // Note: This assumes NotificationContentEntity exists for this schedule
    // May need to create if it doesn't exist
    
    // Generate missed alarm event
    // Option 1: Fire callback
    fireMissedAlarmCallback(schedule, scheduledTime)
    
    // Option 2: Generate missed alarm notification
    // generateMissedAlarmNotification(schedule, scheduledTime)
    
    Log.i(TAG, "Handled missed alarm: ${schedule.id} scheduled for $scheduledTime")
}

/**
 * Handle a missed notification (from NotificationContentEntity)
 */
private suspend fun handleMissedNotification(notification: NotificationContentEntity) {
    val db = DailyNotificationDatabase.getDatabase(context)
    
    // Update delivery status
    notification.deliveryStatus = "missed"
    db.notificationContentDao().update(notification)
    
    // Generate missed notification event
    fireMissedNotificationCallback(notification)
    
    Log.i(TAG, "Handled missed notification: ${notification.id}")
}

/**
 * Reschedule an alarm
 */
private suspend fun rescheduleAlarm(schedule: Schedule, nextRunTime: Long) {
    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)
    
    // Update schedule in database
    schedule.nextRunAt = nextRunTime
    val db = DailyNotificationDatabase.getDatabase(context)
    db.scheduleDao().update(schedule)
    
    Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextRunTime")
}

/**
 * Check if alarm is scheduled in AlarmManager
 */
private fun isAlarmScheduled(schedule: Schedule, triggerTime: Long): Boolean {
    return NotifyReceiver.isAlarmScheduled(context, triggerTime)
}

/**
 * Calculate next run time from schedule
 */
private fun calculateNextRunTime(schedule: Schedule): Long {
    // Use existing logic from BootReceiver or create new
    // For now, simplified version
    val now = System.currentTimeMillis()
    
    return when {
        schedule.cron != null -> {
            // Parse cron and calculate next run
            // For now, return next day at 9 AM
            now + (24 * 60 * 60 * 1000L)
        }
        schedule.clockTime != null -> {
            // Parse HH:mm and calculate next run
            // For now, return next day at specified time
            now + (24 * 60 * 60 * 1000L)
        }
        schedule.nextRunAt != null -> {
            schedule.nextRunAt
        }
        else -> {
            // Default to next day at 9 AM
            now + (24 * 60 * 60 * 1000L)
        }
    }
}

/**
 * Check if schedule is repeating
 */
private fun isRepeating(schedule: Schedule): Boolean {
    // Check cron or clockTime pattern to determine if repeating
    // For now, assume daily schedules are repeating
    return schedule.cron != null || schedule.clockTime != null
}

/**
 * Calculate next occurrence for repeating schedule
 */
private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long {
    // Calculate next occurrence after fromTime
    // For daily: add 24 hours
    // For weekly: add 7 days
    // etc.
    return fromTime + (24 * 60 * 60 * 1000L) // Simplified: daily
}

/**
 * Reschedule fetch work
 */
private suspend fun rescheduleFetch(schedule: Schedule) {
    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: ${schedule.id}")
}

/**
 * Record recovery in history
 */
private suspend fun recordRecoveryHistory(
    db: DailyNotificationDatabase,
    scenario: String,
    missedCount: Int,
    rescheduledCount: Int
) {
    try {
        db.historyDao().insert(
            History(
                refId = "recovery_${System.currentTimeMillis()}",
                kind = "recovery",
                occurredAt = System.currentTimeMillis(),
                outcome = "success",
                diagJson = """
                    {
                        "scenario": "$scenario",
                        "missed_count": $missedCount,
                        "rescheduled_count": $rescheduledCount
                    }
                """.trimIndent()
            )
        )
    } catch (e: Exception) {
        Log.e(TAG, "Failed to record recovery history", e)
    }
}

/**
 * Fire missed alarm callback
 */
private suspend fun fireMissedAlarmCallback(schedule: Schedule, scheduledTime: Long) {
    // TODO: Implement callback mechanism
    // This could fire a Capacitor event or call a registered callback
    Log.d(TAG, "Missed alarm callback: ${schedule.id} at $scheduledTime")
}

/**
 * Fire missed notification callback
 */
private suspend fun fireMissedNotificationCallback(notification: NotificationContentEntity) {
    // TODO: Implement callback mechanism
    Log.d(TAG, "Missed notification callback: ${notification.id}")
}

3. Integration: DailyNotificationPlugin

3.1 Update load() Method

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

Location: Line 91

Current Code:

override fun load() {
    super.load()
    try {
        if (context == null) {
            Log.e(TAG, "Context is null, cannot initialize database")
            return
        }
        db = DailyNotificationDatabase.getDatabase(context)
        Log.i(TAG, "Daily Notification Plugin loaded successfully")
    } catch (e: Exception) {
        Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
    }
}

Updated Code:

override fun load() {
    super.load()
    try {
        if (context == null) {
            Log.e(TAG, "Context is null, cannot initialize database")
            return
        }
        db = DailyNotificationDatabase.getDatabase(context)
        Log.i(TAG, "Daily Notification Plugin loaded successfully")
        
        // Perform app launch recovery
        val reactivationManager = ReactivationManager(context)
        reactivationManager.performRecovery()
    } catch (e: Exception) {
        Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
    }
}

4. Enhancement: BootReceiver

⚠️ Illustrative only See Phase 3 directive for canonical boot receiver implementation.

For implementation details, see Phase 3: Boot Receiver Enhancement

4.1 Update rescheduleNotifications() Method

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

Location: Line 38

Current Issue: Only reschedules future alarms (line 64: if (nextRunTime > System.currentTimeMillis()))

Updated Code:

private suspend fun rescheduleNotifications(context: Context) {
    val db = DailyNotificationDatabase.getDatabase(context)
    val enabledSchedules = db.scheduleDao().getEnabled()
    val currentTime = System.currentTimeMillis()
    
    Log.i(TAG, "Found ${enabledSchedules.size} enabled schedules to reschedule")
    
    var futureRescheduled = 0
    var missedDetected = 0
    
    enabledSchedules.forEach { schedule ->
        try {
            when (schedule.kind) {
                "fetch" -> {
                    // Reschedule WorkManager fetch
                    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" -> {
                    // Reschedule AlarmManager notification
                    val nextRunTime = calculateNextRunTime(schedule)
                    
                    if (nextRunTime > currentTime) {
                        // Future alarm - reschedule
                        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 notification for schedule: ${schedule.id}")
                        futureRescheduled++
                    } else {
                        // Past alarm - was missed during reboot
                        missedDetected++
                        handleMissedAlarmOnBoot(context, schedule, nextRunTime)
                        
                        // Reschedule next occurrence if repeating
                        if (isRepeating(schedule)) {
                            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}")
                            futureRescheduled++
                        }
                    }
                }
                else -> {
                    Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
                }
            }
        } catch (e: Exception) {
            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 = "success",
                diagJson = """
                    {
                        "schedules_rescheduled": $futureRescheduled,
                        "missed_detected": $missedDetected
                    }
                """.trimIndent()
            )
        )
    } catch (e: Exception) {
        Log.e(TAG, "Failed to record boot recovery", e)
    }
    
    Log.i(TAG, "Boot recovery complete: $futureRescheduled rescheduled, $missedDetected missed")
}

/**
 * Handle missed alarm detected during boot recovery
 */
private suspend fun handleMissedAlarmOnBoot(
    context: Context,
    schedule: Schedule,
    scheduledTime: Long
) {
    // Option 1: Generate missed alarm notification
    // Option 2: Fire callback
    // Option 3: Update delivery status in database
    
    Log.i(TAG, "Missed alarm detected on boot: ${schedule.id} scheduled for $scheduledTime")
    
    // TODO: Implement missed alarm handling
    // This could generate a notification or fire a callback
}

5. Testing Requirements

⚠️ Illustrative only See Phase 1, Phase 2, and Phase 3 directives for canonical testing procedures.

For testing details, see:

5.1 Test Setup

Package Name: com.timesafari.dailynotification

Test App Location: test-apps/android-test-app/

Prerequisites:

  • Android device or emulator connected via ADB
  • Test app built and installed
  • ADB access enabled
  • Logcat monitoring capability

Useful ADB Commands:

# Check if device is connected
adb devices

# Install test app
cd test-apps/android-test-app
./gradlew installDebug

# Monitor logs (filter for plugin)
adb logcat | grep -E "DNP|DailyNotification|REACTIVATION|BOOT"

# Clear logs before test
adb logcat -c

# Check scheduled alarms
adb shell dumpsys alarm | grep -i timesafari

# Check WorkManager tasks
adb shell dumpsys jobscheduler | grep -i timesafari

5.2 Test 1: Cold Start Recovery

Purpose: Verify plugin detects and handles missed alarms when app is launched after process kill.

Test Procedure:

Step 1: Schedule Alarm

Via Test App UI:

  1. Launch test app: adb shell am start -n com.timesafari.dailynotification/.MainActivity
  2. Click "Test Notification" button
  3. Schedule alarm for 4 minutes in future (test app default)
  4. Note the scheduled time

Via ADB (Alternative):

# Launch app
adb shell am start -n com.timesafari.dailynotification/.MainActivity

# Wait for app to load, then use UI to schedule
# Or use monkey to click button (if button ID known)

Verify Alarm Scheduled:

# Check AlarmManager
adb shell dumpsys alarm | grep -A 5 -B 5 timesafari

# Check logs for scheduling confirmation
adb logcat -d | grep "DN|SCHEDULE\|DN|ALARM"

Step 2: Kill App Process (Not Force Stop)

Important: Use am kill NOT am force-stop. This simulates OS memory pressure kill.

# Kill app process (simulates OS kill)
adb shell am kill com.timesafari.dailynotification

# Verify app is killed
adb shell ps | grep timesafari
# Should return nothing (process killed)

# Verify alarm is still scheduled (should be)
adb shell dumpsys alarm | grep -i timesafari
# Should still show scheduled alarm

Step 3: Wait for Alarm Time to Pass

# Wait 5-10 minutes (longer than scheduled alarm time)
# Monitor system time
adb shell date

# Or set a timer and wait
# Alarm was scheduled for 4 minutes, wait 10 minutes total

During Wait Period:

  • Alarm should NOT fire (app process is killed)
  • AlarmManager still has the alarm scheduled
  • No notification should appear

Step 4: Launch App (Cold Start)

# Launch app (triggers cold start)
adb shell am start -n com.timesafari.dailynotification/.MainActivity

# Monitor logs for recovery
adb logcat -c  # Clear logs first
adb logcat | grep -E "DNP-REACTIVATION|DN|RECOVERY|missed"

Step 5: Verify Recovery

Check Logs:

# Look for recovery logs
adb logcat -d | grep -E "DNP-REACTIVATION|COLD_START|missed"

# Expected log entries:
# - "Starting app launch recovery"
# - "Detected scenario: COLD_START"
# - "Handling cold start recovery"
# - "Handled missed notification: <id>"
# - "Cold start recovery complete: X missed, Y rescheduled"

Check Database:

# Query database for missed notifications (requires root or debug build)
# Or check via test app UI if status display shows missed alarms

Expected Results:

  • Plugin detects missed alarm
  • Missed alarm event/notification generated
  • Future alarms rescheduled
  • Recovery logged in history

Pass/Fail Criteria:

  • Pass: Logs show "missed" detection and recovery completion
  • Fail: No recovery logs, or recovery doesn't detect missed alarm

5.3 Test 2: Force Stop Recovery

Purpose: Verify plugin detects force stop scenario and recovers ALL alarms (missed and future).

Test Procedure:

Step 1: Schedule Multiple Alarms

Via Test App UI:

  1. Launch app: adb shell am start -n com.timesafari.dailynotification/.MainActivity
  2. Schedule alarm #1 for 2 minutes in future
  3. Schedule alarm #2 for 5 minutes in future
  4. Schedule alarm #3 for 10 minutes in future
  5. Note all scheduled times

Verify Alarms Scheduled:

# Check AlarmManager
adb shell dumpsys alarm | grep -A 10 timesafari

# Check logs
adb logcat -d | grep "DN|SCHEDULE"

Step 2: Force Stop App

Important: Use am force-stop to trigger hard kill.

# Force stop app (hard kill)
adb shell am force-stop com.timesafari.dailynotification

# Verify app is force-stopped
adb shell ps | grep timesafari
# Should return nothing

# Verify alarms are cancelled
adb shell dumpsys alarm | grep -i timesafari
# Should return nothing (all alarms cancelled)

Expected: All alarms immediately cancelled by OS.

Step 3: Wait Past Scheduled Times

# Wait 15 minutes (past all scheduled times)
# Monitor time
adb shell date

# During wait: No alarms should fire (all cancelled)

Step 4: Launch App (Force Stop Recovery)

# Launch app (triggers force stop recovery)
adb shell am start -n com.timesafari.dailynotification/.MainActivity

# Monitor logs immediately
adb logcat -c
adb logcat | grep -E "DNP-REACTIVATION|FORCE_STOP|missed|rescheduled"

Step 5: Verify Force Stop Recovery

Check Logs:

# Look for force stop detection
adb logcat -d | grep -E "DNP-REACTIVATION|FORCE_STOP|force_stop"

# Expected log entries:
# - "Starting app launch recovery"
# - "Detected scenario: FORCE_STOP"
# - "Handling force stop recovery"
# - "Missed alarm detected: <id>"
# - "Rescheduled alarm: <id>"
# - "Force stop recovery complete: X missed, Y rescheduled"

Check AlarmManager:

# Verify alarms are rescheduled
adb shell dumpsys alarm | grep -A 10 timesafari
# Should show rescheduled alarms

Expected Results:

  • Plugin detects force stop scenario (DB has alarms, AlarmManager has zero)
  • All past alarms marked as missed
  • All future alarms rescheduled
  • Recovery logged in history

Pass/Fail Criteria:

  • Pass: Logs show "FORCE_STOP" detection and all alarms recovered
  • Fail: No force stop detection, or alarms not recovered

5.4 Test 3: Boot Recovery Missed Alarms

Purpose: Verify boot receiver detects and handles missed alarms during device reboot.

Test Procedure:

Step 1: Schedule Alarm Before Reboot

Via Test App UI:

  1. Launch app: adb shell am start -n com.timesafari.dailynotification/.MainActivity
  2. Schedule alarm for 5 minutes in future
  3. Note scheduled time

Verify Alarm Scheduled:

adb shell dumpsys alarm | grep -A 5 timesafari

Step 2: Reboot Device

Emulator:

# Reboot emulator
adb reboot

# Wait for reboot to complete (30-60 seconds)
# Monitor with:
adb wait-for-device
adb shell getprop sys.boot_completed
# Wait until returns "1"

Physical Device:

  • Manually reboot device
  • Wait for boot to complete
  • Reconnect ADB: adb devices

Step 3: Wait for Alarm Time to Pass

# After reboot, wait 10 minutes (past scheduled alarm time)
# Do NOT open app during this time

# Monitor system time
adb shell date

During Wait:

  • Boot receiver should have run (check logs after reboot)
  • Alarm should NOT fire (was wiped on reboot)
  • No notification should appear

Step 4: Check Boot Receiver Logs

# Check logs from boot
adb logcat -d | grep -E "DNP-BOOT|BOOT_COMPLETED|reschedule"

# Expected log entries:
# - "Boot completed, rescheduling notifications"
# - "Found X enabled schedules to reschedule"
# - "Rescheduled notification for schedule: <id>"
# - "Missed alarm detected on boot: <id>"
# - "Boot recovery complete: X rescheduled, Y missed"

Step 5: Launch App and Verify

# Launch app to verify state
adb shell am start -n com.timesafari.dailynotification/.MainActivity

# Check if missed alarms were handled
adb logcat -d | grep -E "missed|boot_recovery"

Expected Results:

  • Boot receiver runs on reboot
  • Boot receiver detects missed alarms (past scheduled time)
  • Missed alarms handled (notification generated or callback fired)
  • Next occurrence rescheduled if repeating
  • Future alarms rescheduled

Pass/Fail Criteria:

  • Pass: Boot receiver logs show missed alarm detection and handling
  • Fail: Boot receiver doesn't run, or doesn't detect missed alarms

5.5 Test 4: Warm Start Verification

Purpose: Verify plugin checks for missed alarms when app returns from background.

Test Procedure:

Step 1: Schedule Alarm

Via Test App UI:

  1. Launch app: adb shell am start -n com.timesafari.dailynotification/.MainActivity
  2. Schedule alarm for 10 minutes in future
  3. Note scheduled time

Verify Alarm Scheduled:

adb shell dumpsys alarm | grep -A 5 timesafari

Step 2: Put App in Background

# Press home button (puts app in background)
adb shell input keyevent KEYCODE_HOME

# Verify app is in background
adb shell dumpsys activity activities | grep -A 5 timesafari
# App should be in "stopped" or "paused" state

Alternative: Use app switcher on device/emulator to swipe away (but don't force stop).

Step 3: Wait and Verify Alarm Still Scheduled

# Wait 2-3 minutes (before alarm time)
# Verify alarm is still scheduled
adb shell dumpsys alarm | grep -A 5 timesafari
# Should still show scheduled alarm

# Verify app process status
adb shell ps | grep timesafari
# Process may still be running (warm start scenario)

Step 4: Bring App to Foreground

# Bring app to foreground
adb shell am start -n com.timesafari.dailynotification/.MainActivity

# Monitor logs for warm start recovery
adb logcat -c
adb logcat | grep -E "DNP-REACTIVATION|WARM_START|verified"

Step 5: Verify Warm Start Recovery

Check Logs:

# Look for warm start recovery
adb logcat -d | grep -E "DNP-REACTIVATION|WARM_START|verified"

# Expected log entries:
# - "Starting app launch recovery"
# - "Detected scenario: WARM_START" (or COLD_START if process was killed)
# - "Handling warm start recovery"
# - "Warm start recovery complete: X missed, Y verified"

Check Alarm Status:

# Verify alarm is still scheduled
adb shell dumpsys alarm | grep -A 5 timesafari
# Should still show scheduled alarm

Expected Results:

  • Plugin checks for missed alarms on warm start
  • Plugin verifies active alarms are still scheduled
  • No false positives (alarm still valid, not marked as missed)

Pass/Fail Criteria:

  • Pass: Logs show verification, alarm still scheduled
  • Fail: Alarm incorrectly marked as missed, or verification fails

5.6 Test 5: Swipe from Recents (Alarm Persistence)

Purpose: Verify alarms persist and fire even after app is swiped from recents.

Test Procedure:

Step 1: Schedule Alarm

# Launch app and schedule alarm for 4 minutes
adb shell am start -n com.timesafari.dailynotification/.MainActivity
# Use UI to schedule alarm

Step 2: Swipe App from Recents

Emulator:

# Open recent apps
adb shell input keyevent KEYCODE_APP_SWITCH

# Swipe app away (requires manual interaction or automation)
# Or use:
adb shell input swipe 500 1000 500 500  # Swipe up gesture

Physical Device: Manually swipe app from recent apps list.

Step 3: Verify App Process Status

# Check if process is still running
adb shell ps | grep timesafari
# Process may be killed or still running (OS decision)

# Verify alarm is still scheduled (CRITICAL)
adb shell dumpsys alarm | grep -A 5 timesafari
# Should still show scheduled alarm

Step 4: Wait for Alarm Time

# Wait for scheduled alarm time
# Monitor logs
adb logcat -c
adb logcat | grep -E "DN|RECEIVE|DN|NOTIFICATION|DN|WORK"

Step 5: Verify Alarm Fires

Check Logs:

# Look for alarm firing
adb logcat -d | grep -E "DN|RECEIVE_START|DN|WORK_START|DN|DISPLAY"

# Expected log entries:
# - "DN|RECEIVE_START" (DailyNotificationReceiver triggered)
# - "DN|WORK_START" (WorkManager worker started)
# - "DN|DISPLAY_NOTIF_START" (Notification displayed)

Check Notification:

  • Notification should appear in system notification tray
  • App process should be recreated by OS to deliver notification

Expected Results:

  • Alarm fires even though app was swiped away
  • App process recreated by OS
  • Notification displayed

Pass/Fail Criteria:

  • Pass: Notification appears, logs show alarm fired
  • Fail: No notification, or alarm doesn't fire

5.7 Test 6: Exact Alarm Permission (Android 12+)

Purpose: Verify exact alarm permission handling on Android 12+.

Test Procedure:

Step 1: Revoke Exact Alarm Permission

Android 12+ (API 31+):

# Check current permission status
adb shell dumpsys package com.timesafari.dailynotification | grep -i "schedule_exact_alarm"

# Revoke permission (requires root or system app)
# Or use Settings UI:
adb shell am start -a android.settings.REQUEST_SCHEDULE_EXACT_ALARM
# Manually revoke in settings

Alternative: Use test app UI to check permission status.

Step 2: Attempt to Schedule Alarm

Via Test App UI:

  1. Launch app
  2. Click "Test Notification"
  3. Should trigger permission request flow

Check Logs:

adb logcat -d | grep -E "EXACT_ALARM|permission|SCHEDULE_EXACT"

Step 3: Grant Permission

Via Settings:

# Open exact alarm settings
adb shell am start -a android.settings.REQUEST_SCHEDULE_EXACT_ALARM

# Or navigate manually:
adb shell am start -a android.settings.APPLICATION_DETAILS_SETTINGS -d package:com.timesafari.dailynotification

Step 4: Verify Alarm Scheduling

# Schedule alarm again
# Check if it succeeds
adb logcat -d | grep "DN|SCHEDULE"

# Verify alarm is scheduled
adb shell dumpsys alarm | grep -A 5 timesafari

Expected Results:

  • Plugin detects missing permission
  • Settings opened automatically
  • After granting, alarm schedules successfully

5.8 Test Validation Matrix

Test Scenario Expected OS Behavior Expected Plugin Behavior Verification Method Pass Criteria
1 Cold Start N/A Detect missed alarms Logs show recovery Recovery logs present
2 Force Stop Alarms cancelled Detect force stop, recover all Logs + AlarmManager check All alarms recovered
3 Boot Recovery Alarms wiped Boot receiver reschedules + detects missed Boot logs Boot receiver handles missed
4 Warm Start Alarms persist Verify alarms still scheduled Logs + AlarmManager check Verification logs present
5 Swipe from Recents Alarm fires Alarm fires (OS handles) Notification appears Notification displayed
6 Exact Alarm Permission Permission required Request permission, schedule after grant Logs + AlarmManager Permission flow works

5.9 Log Monitoring Commands

Comprehensive Log Monitoring:

# Monitor all plugin-related logs
adb logcat | grep -E "DNP|DailyNotification|REACTIVATION|BOOT|DN\|"

# Monitor recovery specifically
adb logcat | grep -E "REACTIVATION|recovery|missed|reschedule"

# Monitor alarm scheduling
adb logcat | grep -E "DN\|SCHEDULE|DN\|ALARM|setAlarmClock|setExact"

# Monitor notification delivery
adb logcat | grep -E "DN\|RECEIVE|DN\|WORK|DN\|DISPLAY|Notification"

# Save logs to file for analysis
adb logcat -d > recovery_test.log

Filter by Tag:

# DNP-REACTIVATION (recovery manager)
adb logcat -s DNP-REACTIVATION

# DNP-BOOT (boot receiver)
adb logcat -s DNP-BOOT

# DailyNotificationWorker
adb logcat -s DailyNotificationWorker

# DailyNotificationReceiver
adb logcat -s DailyNotificationReceiver

5.10 Troubleshooting Test Failures

If Recovery Doesn't Run:

# Check if plugin loaded
adb logcat -d | grep "Daily Notification Plugin loaded"

# Check for errors
adb logcat -d | grep -E "ERROR|Exception|Failed"

# Verify database initialized
adb logcat -d | grep "DailyNotificationDatabase"

If Force Stop Not Detected:

# Check AlarmManager state
adb shell dumpsys alarm | grep -c timesafari
# Count should be 0 after force stop

# Check database state
# (Requires database inspection tool or test app UI)

If Missed Alarms Not Detected:

# Check database query
adb logcat -d | grep "getNotificationsReadyForDelivery"

# Verify scheduled_time < currentTime
adb shell date +%s
# Compare with alarm scheduled times

5.11 Automated Test Script

Basic Test Script (save as test_recovery.sh):

#!/bin/bash

PACKAGE="com.timesafari.dailynotification"
ACTIVITY="${PACKAGE}/.MainActivity"

echo "=== Test 1: Cold Start Recovery ==="
echo "1. Schedule alarm via UI, then run:"
echo "   adb shell am kill $PACKAGE"
echo "2. Wait 10 minutes"
echo "3. Launch app:"
echo "   adb shell am start -n $ACTIVITY"
echo "4. Check logs:"
echo "   adb logcat -d | grep DNP-REACTIVATION"

echo ""
echo "=== Test 2: Force Stop Recovery ==="
echo "1. Schedule alarms via UI"
echo "2. Force stop:"
echo "   adb shell am force-stop $PACKAGE"
echo "3. Wait 15 minutes"
echo "4. Launch app:"
echo "   adb shell am start -n $ACTIVITY"
echo "5. Check logs:"
echo "   adb logcat -d | grep FORCE_STOP"

echo ""
echo "=== Test 3: Boot Recovery ==="
echo "1. Schedule alarm via UI"
echo "2. Reboot:"
echo "   adb reboot"
echo "3. Wait for boot, then 10 minutes"
echo "4. Check boot logs:"
echo "   adb logcat -d | grep DNP-BOOT"

Make executable: chmod +x test_recovery.sh


6. Implementation Checklist

  • Create ReactivationManager.kt file
  • Implement scenario detection
  • Implement force stop recovery
  • Implement cold start recovery
  • Implement warm start recovery
  • Implement missed alarm handling
  • Implement missed notification handling
  • Update DailyNotificationPlugin.load() to call recovery
  • Update BootReceiver.rescheduleNotifications() to handle missed alarms
  • Add helper methods (calculateNextRunTime, isRepeating, etc.)
  • Add logging for debugging
  • Test cold start recovery
  • Test force stop recovery
  • Test boot recovery missed alarms
  • Test warm start verification

7. Code References

Existing Code to Reuse:

  • NotifyReceiver.scheduleExactNotification() - Line 92
  • NotifyReceiver.isAlarmScheduled() - Line 279
  • NotifyReceiver.getNextAlarmTime() - Line 305
  • FetchWorker.scheduleFetch() - Line 31
  • NotificationContentDao.getNotificationsReadyForDelivery() - Line 98
  • BootReceiver.calculateNextRunTime() - Line 103

New Code to Create:

  • ReactivationManager.kt - New file
  • Helper methods in BootReceiver.kt - Add to existing file


Notes

  • Implementation should be done incrementally
  • Test each scenario after implementation
  • Log extensively for debugging
  • Handle errors gracefully (don't crash on recovery failure)
  • Consider performance (recovery should not block app launch)