# 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](./alarms/03-plugin-requirements.md#312-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](./alarms/03-plugin-requirements.md) - Requirements this phase implements
- [Platform Capability Reference](./alarms/01-platform-capability-reference.md) - OS-level facts
- [Full Implementation Directive](./android-implementation-directive.md) - Complete scope
- [Phase 2: Force Stop Recovery](./android-implementation-directive-phase2.md) - Next phase
- [Phase 3: Boot Receiver Enhancement](./android-implementation-directive-phase3.md) - Final phase

---

## 1. Acceptance Criteria

### 1.1 Definition of Done

**Phase 1 is complete when:**

1. ✅ **On cold start, missed notifications are detected**
   - Notifications with `scheduled_time < currentTime` and `delivery_status != 'delivered'` are identified
   - Detection runs automatically on app launch (via `DailyNotificationPlugin.load()`)
   - Detection completes within 2 seconds (non-blocking)

2. ✅ **Missed notifications are marked in database**
   - `delivery_status` updated to `'missed'`
   - `last_delivery_attempt` updated to current time
   - Status change logged in history table

3. ✅ **Future alarms are verified and rescheduled if missing**
   - All enabled `notify` schedules checked against AlarmManager
   - Missing alarms rescheduled using existing `NotifyReceiver.scheduleExactNotification()`
   - No duplicate alarms created (verified before rescheduling)

4. ✅ **Recovery never crashes the app**
   - All exceptions caught and logged
   - Database errors don't propagate
   - Invalid data handled gracefully

5. ✅ **Recovery is observable**
   - All recovery actions logged with `DNP-REACTIVATION` tag
   - Recovery metrics recorded in history table
   - Logs include counts: missed detected, rescheduled, errors

### 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

```kotlin
package org.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/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart) - Alarms can be restored after app restart

```kotlin
/**
 * 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

```kotlin
/**
 * 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**:
```kotlin
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**:
```kotlin
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**:
- ✅ `id` must not be blank
- ✅ `scheduled_time` must be valid timestamp
- ✅ `delivery_status` must be valid enum value

**Schedule Validation**:
- ✅ `id` must not be blank
- ✅ `kind` must be "notify" or "fetch"
- ✅ `nextRunAt` must be set for verification
- ✅ `enabled` must 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_time` doesn't match `Schedule.nextRunAt`, use `scheduled_time` for missed detection
- Log warning for data inconsistency

---

## 5. Rollback Safety

### 5.1 No-Crash Guarantee

**All recovery operations must:**

1. **Catch all exceptions** - Never propagate exceptions to app
2. **Log errors** - All failures logged with context
3. **Continue processing** - One failure doesn't stop recovery
4. **Timeout protection** - Recovery completes within 2 seconds or times out
5. **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 has `delivery_status` field
- `schedules` - Already has `nextRunAt` field
- `history` - 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**:
1. Schedule notification for 2 minutes in future
2. Kill app process: `adb shell am kill org.timesafari.dailynotification`
3. Wait 5 minutes (past scheduled time)
4. Launch app: `adb shell am start -n org.timesafari.dailynotification/.MainActivity`
5. Check logs: `adb logcat -d | grep DNP-REACTIVATION`

**Expected**:
- ✅ Log shows "Cold start recovery: checking for missed notifications"
- ✅ Log shows "Marked missed notification: <id>"
- ✅ 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**:
1. Schedule notification for 10 minutes in future
2. Manually cancel alarm: `adb shell dumpsys alarm | grep timesafari` (note request code)
3. Launch app
4. Check logs: `adb logcat -d | grep DNP-REACTIVATION`
5. Verify alarm rescheduled: `adb shell dumpsys alarm | grep timesafari`

**Expected**:
- ✅ Log shows "Rescheduled missing alarm: <id>"
- ✅ 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**:
1. Create large number of schedules (100+)
2. Launch app
3. 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**:
1. Manually insert invalid notification (empty ID) into database
2. Launch app
3. 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:

```bash
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>0` and `rescheduled>0` as PASS cases
* ✅ Treats `DELETE_FAILED_INTERNAL_ERROR` on uninstall as non-fatal

For regression testing, use `PHASE1-EMULATOR-TESTING.md` + `test-phase1.sh` as the canonical procedure.

---

## 9. Implementation Checklist

- [ ] Create `ReactivationManager.kt` file
- [ ] 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 92
- `NotifyReceiver.isAlarmScheduled()` - Line 279
- `BootReceiver.calculateNextRunTime()` - Line 103 (for Phase 2)
- `NotificationContentDao.getNotificationsReadyForDelivery()` - Line 99
- `ScheduleDao.getEnabled()` - Line 298

**New Code to Create**:
- `ReactivationManager.kt` - New file (Phase 1)

---

## 11. Success Criteria Summary

**Phase 1 is complete when:**

1. ✅ Missed notifications detected on cold start
2. ✅ Missed notifications marked in database
3. ✅ Future alarms verified and rescheduled if missing
4. ✅ Recovery never crashes app
5. ✅ Recovery completes within 2 seconds
6. ✅ All tests pass
7. ✅ No duplicate alarms created

---

## Related Documentation

- [Unified Alarm Directive](./alarms/000-UNIFIED-ALARM-DIRECTIVE.md) - Master coordination document
- [Plugin Requirements](./alarms/03-plugin-requirements.md) - Requirements this phase implements
- [Platform Capability Reference](./alarms/01-platform-capability-reference.md) - OS-level facts
- [Plugin Behavior Exploration](./alarms/02-plugin-behavior-exploration.md) - Test scenarios
- [Full Implementation Directive](./android-implementation-directive.md) - 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.

