Files
daily-notification-plugin/docs/android-implementation-directive-phase1.md
Matthew Raymer 3151a1cc31 feat(android): implement Phase 1 cold start recovery
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
2025-11-27 10:01:34 +00:00

713 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.