Implements cold start recovery for missed notifications and future alarm verification/rescheduling as specified in Phase 1 directive. Changes: - Add ReactivationManager.kt with cold start recovery logic - Integrate recovery into DailyNotificationPlugin.load() - Fix NotifyReceiver to always store NotificationContentEntity for recovery - Add Phase 1 emulator testing guide and verification doc - Add test-phase1.sh automated test harness Recovery behavior: - Detects missed notifications on app launch - Marks missed notifications in database - Verifies future alarms are scheduled in AlarmManager - Reschedules missing future alarms - Completes within 2-second timeout (non-blocking) Test harness: - Automated script with 4 test cases - UI prompts for plugin configuration - Log parsing for recovery results - Verified on Pixel 8 API 34 emulator Related: - Implements: android-implementation-directive-phase1.md - Requirements: docs/alarms/03-plugin-requirements.md §3.1.2 - Testing: docs/alarms/PHASE1-EMULATOR-TESTING.md - Verification: docs/alarms/PHASE1-VERIFICATION.md
23 KiB
Android Implementation Directive: Phase 1 - Cold Start Recovery
Author: Matthew Raymer
Date: November 2025
Status: Phase 1 - Minimal Viable Recovery
Version: 1.0.0
Last Synced With Plugin Version: v1.1.0
Implements: Plugin Requirements §3.1.2 - App Cold Start
Purpose
Phase 1 implements minimal viable app launch recovery for cold start scenarios. This focuses on detecting and handling missed notifications when the app launches after the process was killed.
Scope: Phase 1 implements cold start recovery only. Force stop detection, warm start optimization, and boot receiver enhancements are out of scope for this phase and deferred to later phases.
Reference:
- Plugin Requirements - Requirements this phase implements
- Platform Capability Reference - OS-level facts
- Full Implementation Directive - Complete scope
- Phase 2: Force Stop Recovery - Next phase
- Phase 3: Boot Receiver Enhancement - Final phase
1. Acceptance Criteria
1.1 Definition of Done
Phase 1 is complete when:
-
✅ On cold start, missed notifications are detected
- Notifications with
scheduled_time < currentTimeanddelivery_status != 'delivered'are identified - Detection runs automatically on app launch (via
DailyNotificationPlugin.load()) - Detection completes within 2 seconds (non-blocking)
- Notifications with
-
✅ Missed notifications are marked in database
delivery_statusupdated to'missed'last_delivery_attemptupdated to current time- Status change logged in history table
-
✅ Future alarms are verified and rescheduled if missing
- All enabled
notifyschedules checked against AlarmManager - Missing alarms rescheduled using existing
NotifyReceiver.scheduleExactNotification() - No duplicate alarms created (verified before rescheduling)
- All enabled
-
✅ Recovery never crashes the app
- All exceptions caught and logged
- Database errors don't propagate
- Invalid data handled gracefully
-
✅ Recovery is observable
- All recovery actions logged with
DNP-REACTIVATIONtag - Recovery metrics recorded in history table
- Logs include counts: missed detected, rescheduled, errors
- All recovery actions logged with
1.2 Success Metrics
| Metric | Target | Measurement |
|---|---|---|
| Recovery execution time | < 2 seconds | Log timestamp difference |
| Missed detection accuracy | 100% | Manual verification via logs |
| Reschedule success rate | > 95% | History table outcome field |
| Crash rate | 0% | No exceptions propagate to app |
1.3 Out of Scope (Phase 1)
- ❌ Force stop detection (Phase 2)
- ❌ Warm start optimization (Phase 2)
- ❌ Boot receiver missed alarm handling (Phase 2)
- ❌ Callback event emission (Phase 2)
- ❌ Fetch work recovery (Phase 2)
2. Implementation: ReactivationManager
2.1 Create New File
File: android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt
Purpose: Centralized cold start recovery logic
2.2 Class Structure
package com.timesafari.dailynotification
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.concurrent.TimeUnit
/**
* Manages recovery of alarms and notifications on app launch
* Phase 1: Cold start recovery only
*
* @author Matthew Raymer
* @version 1.0.0
*/
class ReactivationManager(private val context: Context) {
companion object {
private const val TAG = "DNP-REACTIVATION"
private const val RECOVERY_TIMEOUT_SECONDS = 2L
}
/**
* Perform recovery on app launch
* Phase 1: Calls only performColdStartRecovery() when DB is non-empty
*
* Scenario detection is not implemented in Phase 1 - all app launches
* with non-empty DB are treated as cold start. Force stop, boot, and
* warm start handling are deferred to Phase 2.
*
* **Correction**: Must not run when DB is empty (first launch).
*
* Runs asynchronously with timeout to avoid blocking app startup
*
* Rollback Safety: If recovery fails, app continues normally
*/
fun performRecovery() {
CoroutineScope(Dispatchers.IO).launch {
try {
withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) {
Log.i(TAG, "Starting app launch recovery (Phase 1: cold start only)")
// Correction: Short-circuit if DB is empty (first launch)
val db = DailyNotificationDatabase.getDatabase(context)
val dbSchedules = db.scheduleDao().getEnabled()
if (dbSchedules.isEmpty()) {
Log.i(TAG, "No schedules present — skipping recovery (first launch)")
return@withTimeout
}
val result = performColdStartRecovery()
Log.i(TAG, "App launch recovery completed: $result")
}
} catch (e: Exception) {
// Rollback: Log error but don't crash
Log.e(TAG, "Recovery failed (non-fatal): ${e.message}", e)
// Record failure in history (best effort, don't fail if this fails)
try {
recordRecoveryFailure(e)
} catch (historyError: Exception) {
Log.w(TAG, "Failed to record recovery failure in history", historyError)
}
}
}
}
// ... implementation methods below ...
}
2.3 Cold Start Recovery
Platform Reference: Android §2.1.4 - Alarms can be restored after app restart
/**
* Perform cold start recovery
*
* Steps:
* 1. Detect missed notifications (scheduled_time < now, not delivered)
* 2. Mark missed notifications in database
* 3. Verify future alarms are scheduled
* 4. Reschedule missing future alarms
*
* @return RecoveryResult with counts
*/
private suspend fun performColdStartRecovery(): RecoveryResult {
val db = DailyNotificationDatabase.getDatabase(context)
val currentTime = System.currentTimeMillis()
Log.i(TAG, "Cold start recovery: checking for missed notifications")
// Step 1: Detect missed notifications
val missedNotifications = try {
db.notificationContentDao().getNotificationsReadyForDelivery(currentTime)
.filter { it.deliveryStatus != "delivered" }
} catch (e: Exception) {
Log.e(TAG, "Failed to query missed notifications", e)
emptyList()
}
var missedCount = 0
var missedErrors = 0
// Step 2: Mark missed notifications
missedNotifications.forEach { notification ->
try {
// Data integrity check: verify notification is valid
if (notification.id.isBlank()) {
Log.w(TAG, "Skipping invalid notification: empty ID")
return@forEach
}
// Update delivery status
notification.deliveryStatus = "missed"
notification.lastDeliveryAttempt = currentTime
notification.deliveryAttempts = (notification.deliveryAttempts ?: 0) + 1
db.notificationContentDao().updateNotification(notification)
missedCount++
Log.d(TAG, "Marked missed notification: ${notification.id}")
} catch (e: Exception) {
missedErrors++
Log.e(TAG, "Failed to mark missed notification: ${notification.id}", e)
// Continue processing other notifications
}
}
// Step 3: Verify and reschedule future alarms
val schedules = try {
db.scheduleDao().getEnabled()
.filter { it.kind == "notify" }
} catch (e: Exception) {
Log.e(TAG, "Failed to query schedules", e)
emptyList()
}
var rescheduledCount = 0
var verifiedCount = 0
var rescheduleErrors = 0
schedules.forEach { schedule ->
try {
// Data integrity check: verify schedule is valid
if (schedule.id.isBlank() || schedule.nextRunAt == null) {
Log.w(TAG, "Skipping invalid schedule: ${schedule.id}")
return@forEach
}
val nextRunTime = schedule.nextRunAt!!
// Only check future alarms
if (nextRunTime >= currentTime) {
// Verify alarm is scheduled
val isScheduled = NotifyReceiver.isAlarmScheduled(context, nextRunTime)
if (isScheduled) {
verifiedCount++
Log.d(TAG, "Verified scheduled alarm: ${schedule.id} at $nextRunTime")
} else {
// Reschedule missing alarm
rescheduleAlarm(schedule, nextRunTime, db)
rescheduledCount++
Log.i(TAG, "Rescheduled missing alarm: ${schedule.id} at $nextRunTime")
}
}
} catch (e: Exception) {
rescheduleErrors++
Log.e(TAG, "Failed to verify/reschedule: ${schedule.id}", e)
// Continue processing other schedules
}
}
// Step 4: Record recovery in history
val result = RecoveryResult(
missedCount = missedCount,
rescheduledCount = rescheduledCount,
verifiedCount = verifiedCount,
errors = missedErrors + rescheduleErrors
)
recordRecoveryHistory(db, "cold_start", result)
Log.i(TAG, "Cold start recovery complete: $result")
return result
}
/**
* Data class for recovery results
*/
private data class RecoveryResult(
val missedCount: Int,
val rescheduledCount: Int,
val verifiedCount: Int,
val errors: Int
) {
override fun toString(): String {
return "missed=$missedCount, rescheduled=$rescheduledCount, verified=$verifiedCount, errors=$errors"
}
}
2.4 Helper Methods
/**
* Reschedule an alarm
*
* Data integrity: Validates schedule before rescheduling
*/
private suspend fun rescheduleAlarm(
schedule: Schedule,
nextRunTime: Long,
db: DailyNotificationDatabase
) {
try {
// Use existing BootReceiver logic for calculating next run time
// For now, use schedule.nextRunAt directly
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 (best effort)
try {
db.scheduleDao().updateRunTimes(schedule.id, schedule.lastRunAt, nextRunTime)
} catch (e: Exception) {
Log.w(TAG, "Failed to update schedule in database: ${schedule.id}", e)
// Don't fail rescheduling if DB update fails
}
Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextRunTime")
} catch (e: Exception) {
Log.e(TAG, "Failed to reschedule alarm: ${schedule.id}", e)
throw e // Re-throw to be caught by caller
}
}
/**
* Record recovery in history
*
* Rollback safety: If history recording fails, log warning but don't fail recovery
*/
private suspend fun recordRecoveryHistory(
db: DailyNotificationDatabase,
scenario: String,
result: RecoveryResult
) {
try {
db.historyDao().insert(
History(
refId = "recovery_${System.currentTimeMillis()}",
kind = "recovery",
occurredAt = System.currentTimeMillis(),
outcome = if (result.errors == 0) "success" else "partial",
diagJson = """
{
"scenario": "$scenario",
"missed_count": ${result.missedCount},
"rescheduled_count": ${result.rescheduledCount},
"verified_count": ${result.verifiedCount},
"errors": ${result.errors}
}
""".trimIndent()
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to record recovery history (non-fatal)", e)
// Don't throw - history recording failure shouldn't fail recovery
}
}
/**
* Record recovery failure in history
*/
private suspend fun recordRecoveryFailure(e: Exception) {
try {
val db = DailyNotificationDatabase.getDatabase(context)
db.historyDao().insert(
History(
refId = "recovery_failure_${System.currentTimeMillis()}",
kind = "recovery",
occurredAt = System.currentTimeMillis(),
outcome = "failure",
diagJson = """
{
"error": "${e.message}",
"error_type": "${e.javaClass.simpleName}"
}
""".trimIndent()
)
)
} catch (historyError: Exception) {
// Silently fail - we're already in error handling
Log.w(TAG, "Failed to record recovery failure", historyError)
}
}
3. Integration: DailyNotificationPlugin
3.1 Update load() Method
File: android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
Location: After database initialization (line 98)
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")
// Phase 1: Perform app launch recovery (cold start only)
// Runs asynchronously, non-blocking, with timeout
val reactivationManager = ReactivationManager(context)
reactivationManager.performRecovery()
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
// Don't throw - allow plugin to load even if recovery fails
}
}
4. Data Integrity Checks
4.1 Validation Rules
Notification Validation:
- ✅
idmust not be blank - ✅
scheduled_timemust be valid timestamp - ✅
delivery_statusmust be valid enum value
Schedule Validation:
- ✅
idmust not be blank - ✅
kindmust be "notify" or "fetch" - ✅
nextRunAtmust be set for verification - ✅
enabledmust be true (filtered by DAO)
4.2 Orphaned Data Handling
Orphaned Notifications (no matching schedule):
- Log warning but don't fail recovery
- Mark as missed if past scheduled time
Orphaned Schedules (no matching notification content):
- Log warning but don't fail recovery
- Reschedule if future alarm is missing
Mismatched Data:
- If
NotificationContentEntity.scheduled_timedoesn't matchSchedule.nextRunAt, usescheduled_timefor missed detection - Log warning for data inconsistency
5. Rollback Safety
5.1 No-Crash Guarantee
All recovery operations must:
- Catch all exceptions - Never propagate exceptions to app
- Log errors - All failures logged with context
- Continue processing - One failure doesn't stop recovery
- Timeout protection - Recovery completes within 2 seconds or times out
- Best-effort updates - Database failures don't prevent alarm rescheduling
5.2 Error Handling Strategy
| Error Type | Handling | Log Level |
|---|---|---|
| Database query failure | Return empty list, continue | ERROR |
| Invalid notification data | Skip notification, continue | WARN |
| Alarm reschedule failure | Log error, continue to next | ERROR |
| History recording failure | Log warning, don't fail | WARN |
| Timeout | Log timeout, abort recovery | WARN |
5.3 Fallback Behavior
If recovery fails completely:
- App continues normally
- No alarms are lost (existing alarms remain scheduled)
- User can manually trigger recovery via app restart
- Error logged in history table (if possible)
6. Callback Behavior (Phase 1 - Deferred)
Phase 1 does NOT emit callbacks. Callback behavior is deferred to Phase 2.
Future callback contract (for Phase 2):
| Event | Fired When | Payload | Guarantees |
|---|---|---|---|
missed_notification |
Missed notification detected | {notificationId, scheduledTime, detectedAt} |
Fired once per missed notification |
recovery_complete |
Recovery finished | {scenario, missedCount, rescheduledCount, errors} |
Fired once per recovery run |
Implementation notes:
- Callbacks will use Capacitor event system
- Events batched if multiple missed notifications detected
- Callbacks fire after database updates complete
7. Versioning & Migration
7.1 Version Bump
Plugin Version: Increment patch version (e.g., 1.1.0 → 1.1.1)
Reason: New feature (recovery), no breaking changes
7.2 Database Migration
No database migration required for Phase 1.
Existing tables used:
notification_content- Already hasdelivery_statusfieldschedules- Already hasnextRunAtfieldhistory- Already supports recovery events
7.3 Backward Compatibility
Phase 1 is backward compatible:
- Existing alarms continue to work
- No schema changes
- Recovery is additive (doesn't break existing functionality)
8. Testing Requirements
8.1 Test 1: Cold Start Missed Detection
Purpose: Verify missed notifications are detected and marked.
Steps:
- Schedule notification for 2 minutes in future
- Kill app process:
adb shell am kill com.timesafari.dailynotification - Wait 5 minutes (past scheduled time)
- Launch app:
adb shell am start -n com.timesafari.dailynotification/.MainActivity - Check logs:
adb logcat -d | grep DNP-REACTIVATION
Expected:
- ✅ Log shows "Cold start recovery: checking for missed notifications"
- ✅ Log shows "Marked missed notification: "
- ✅ Database shows
delivery_status = 'missed' - ✅ History table has recovery entry
Pass Criteria: Missed notification detected and marked in database.
8.2 Test 2: Future Alarm Rescheduling
Purpose: Verify missing future alarms are rescheduled.
Steps:
- Schedule notification for 10 minutes in future
- Manually cancel alarm:
adb shell dumpsys alarm | grep timesafari(note request code) - Launch app
- Check logs:
adb logcat -d | grep DNP-REACTIVATION - Verify alarm rescheduled:
adb shell dumpsys alarm | grep timesafari
Expected:
- ✅ Log shows "Rescheduled missing alarm: "
- ✅ AlarmManager shows rescheduled alarm
- ✅ No duplicate alarms created
Pass Criteria: Missing alarm rescheduled, no duplicates.
8.3 Test 3: Recovery Timeout
Purpose: Verify recovery times out gracefully.
Steps:
- Create large number of schedules (100+)
- Launch app
- Check logs for timeout
Expected:
- ✅ Recovery completes within 2 seconds OR times out
- ✅ App doesn't crash
- ✅ Partial recovery logged if timeout occurs
Pass Criteria: Recovery doesn't block app launch.
8.4 Test 4: Invalid Data Handling
Purpose: Verify invalid data doesn't crash recovery.
Steps:
- Manually insert invalid notification (empty ID) into database
- Launch app
- Check logs
Expected:
- ✅ Invalid notification skipped
- ✅ Warning logged
- ✅ Recovery continues normally
Pass Criteria: Invalid data handled gracefully.
8.4 Emulator Test Harness
The manual tests in §8.1–§8.3 are codified in the script test-phase1.sh in:
test-apps/android-test-app/test-phase1.sh
Status:
- ✅ Script implemented and polished
- ✅ Verified on Android Emulator (Pixel 8 API 34) on 27 November 2025
- ✅ Correctly recognizes both
verified>0andrescheduled>0as PASS cases - ✅ Treats
DELETE_FAILED_INTERNAL_ERRORon uninstall as non-fatal
For regression testing, use PHASE1-EMULATOR-TESTING.md + test-phase1.sh as the canonical procedure.
9. Implementation Checklist
- Create
ReactivationManager.ktfile - Implement
performRecovery()with timeout - Implement
performColdStartRecovery() - Implement missed notification detection
- Implement missed notification marking
- Implement future alarm verification
- Implement missing alarm rescheduling
- Add data integrity checks
- Add error handling (no-crash guarantee)
- Add recovery history recording
- Update
DailyNotificationPlugin.load()to call recovery - Test cold start missed detection
- Test future alarm rescheduling
- Test recovery timeout
- Test invalid data handling
- Verify no duplicate alarms
- Verify recovery doesn't block app launch
10. Code References
Existing Code to Reuse:
NotifyReceiver.scheduleExactNotification()- Line 92NotifyReceiver.isAlarmScheduled()- Line 279BootReceiver.calculateNextRunTime()- Line 103 (for Phase 2)NotificationContentDao.getNotificationsReadyForDelivery()- Line 99ScheduleDao.getEnabled()- Line 298
New Code to Create:
ReactivationManager.kt- New file (Phase 1)
11. Success Criteria Summary
Phase 1 is complete when:
- ✅ Missed notifications detected on cold start
- ✅ Missed notifications marked in database
- ✅ Future alarms verified and rescheduled if missing
- ✅ Recovery never crashes app
- ✅ Recovery completes within 2 seconds
- ✅ All tests pass
- ✅ No duplicate alarms created
Related Documentation
- Unified Alarm Directive - Master coordination document
- Plugin Requirements - Requirements this phase implements
- Platform Capability Reference - OS-level facts
- Plugin Behavior Exploration - Test scenarios
- Full Implementation Directive - Complete scope (all phases)
Notes
- Incremental approach: Phase 1 focuses on cold start only. Force stop and boot recovery in Phase 2.
- Safety first: All recovery operations are non-blocking and non-fatal.
- Observability: Extensive logging for debugging and monitoring.
- Data integrity: Validation prevents invalid data from causing failures.