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
713 lines
23 KiB
Markdown
713 lines
23 KiB
Markdown
# 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 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/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 com.timesafari.dailynotification`
|
||
3. Wait 5 minutes (past scheduled time)
|
||
4. Launch app: `adb shell am start -n com.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.
|
||
|