chore: synch this plan

This commit is contained in:
Matthew Raymer
2025-11-25 08:04:53 +00:00
parent 6aa9140f67
commit afbc98f7dc
5 changed files with 2629 additions and 12 deletions

View File

@@ -0,0 +1,483 @@
# 🔧 UNIFIED DIRECTIVE: Alarm / Schedule / Notification Documentation & Implementation Stack
**Author:** Matthew Raymer
**Status:** Active Master Coordination Directive
**Scope:** Android & iOS, Capacitor plugin, alarms/schedules/notifications
**Version:** 1.0.0
**Last Updated:** November 2025
---
## 0. Purpose
Unify all existing alarm/notification documents into a **coherent, layered system** that:
1. **Eliminates duplication** (especially Android alarm behavior repeated across docs)
2. **Separates concerns** into:
* Platform facts
* Exploration/testing
* Plugin requirements
* Implementation directives (Phases 13)
3. **Provides iOS parity** to the existing Android-heavy material
4. **Maps OS-level capabilities → plugin guarantees → JS/TS API contract**
5. **Standardizes testing** into executable matrices for exploration and regression
6. **Connects exploration → design → implementation** and tracks status across that pipeline
**This directive is the top of the stack.** If any lower-level document conflicts with this one, **this directive wins** unless explicitly noted.
**⚠️ ENFORCEMENT RULE**: No new alarm-related documentation may be created outside Documents A, B, C, or P1P3. All future changes must modify these documents, not create additional standalone files.
---
## 1. Inputs (Existing Documents)
### 1.1 Platform & Reference Layer
* `platform-capability-reference.md` OS-level facts (Android & iOS)
* `android-alarm-persistence-directive.md` Android abilities & limitations, engineering-grade
### 1.2 Exploration & Findings
* `plugin-behavior-exploration-template.md` Structured template for exploring behavior
* `explore-alarm-behavior-directive.md` Exploration directive for behavior & persistence
* `exploration-findings-initial.md` Initial code-level discovery
### 1.3 Requirements & Implementation
* `plugin-requirements-implementation.md` Plugin behavior rules & guarantees
* `android-implementation-directive.md` Umbrella Android implementation overview (app launch recovery, missed alarms, force stop, boot)
* Phase directives (normative):
* `android-implementation-directive-phase1.md` Cold start recovery (minimal viable)
* `android-implementation-directive-phase2.md` Force stop detection & recovery
* `android-implementation-directive-phase3.md` Boot receiver missed alarm handling
### 1.4 Improvement Directive
* `improve-alarm-directives.md` Current plan for structural and content improvements across the stack
---
## 2. Target Structure (What We're Building)
We standardize around **three primary doc roles**, plus implementation phases:
### A. **Platform Reference (Document A)**
**Goal:** Single, stable, normative OS facts.
* Merge:
* Android section from `android-alarm-persistence-directive.md`
* Android & iOS matrices from `platform-capability-reference.md`
* Content rules:
* NO plugin-specific rules
* Just: "what Android/iOS can and cannot do"
* Use matrices & short prose only
* Label each item as:
* **OS-guaranteed**, **Plugin-required**, or **Forbidden** (where relevant)
* Output path (recommended):
`docs/alarms/01-platform-capability-reference.md`
### B. **Plugin Behavior Exploration (Document B)**
**Goal:** Executable exploration & testing spec.
* Baseline: `plugin-behavior-exploration-template.md` + `explore-alarm-behavior-directive.md`
* Remove duplicated platform explanations (refer to Document A instead)
* For each scenario (swipe, reboot, force stop, etc.):
* Link to source files & functions (line or section refs)
* Provide **Expected (OS)** & **Expected (Plugin)** from Docs A + C
* Add columns for **Actual Result** and **Notes** (checkbox matrix)
* Output path:
`docs/alarms/02-plugin-behavior-exploration.md`
### C. **Plugin Requirements & Implementation Rules (Document C)**
**Goal:** What the plugin MUST guarantee & how.
* Baseline: `plugin-requirements-implementation.md` + `improve-alarm-directives.md`
* Must include:
1. **Plugin Behavior Guarantees & Limitations** by platform (table)
2. **Persistence Requirements** (fields, storage, validation, failure modes)
3. **Recovery Requirements**:
* Boot (Android)
* Cold start
* Warm start
* Force stop (Android)
* User-tap recovery
4. **Missed alarm handling contract** (definition, triggers, required actions)
5. **JS/TS API Contract**:
* What JS devs can rely on
* Platform caveats (e.g., Force Stop, iOS background limits)
6. **Unsupported features / limitations** clearly called out
* Output path:
`docs/alarms/03-plugin-requirements.md`
### D. **Implementation Directives (Phase 13)**
Already exist; this directive standardizes their role:
* **Phase docs are normative for code.**
If they conflict with Document C, update the phase docs and then C.
**⚠️ STRUCTURE FREEZE**: The structure defined in this directive is FINAL. No new document categories may be introduced without updating this master directive first.
---
## 3. Ownership, Versioning & Status
### 3.1 Ownership
**Assignable Ownership** (replace abstract roles with concrete owners):
| Layer | Owner | Responsibility |
| ---------------------- | ------------------- | ------------------------------------------------- |
| Doc A Platform Facts | Engineering Lead | Long-lived reference, rare changes |
| Doc B Exploration | QA / Testing Lead | Living document, updated with test results |
| Doc C Requirements | Plugin Architect | Versioned with plugin, defines guarantees |
| Phases P1P3 | Implementation Lead | Normative code specs, tied to Doc C requirements |
**Note**: If owners are not yet assigned, use role names above as placeholders until assignment.
### 3.2 Versioning Rules
* Any change that alters **JS/TS-visible behavior** → bump plugin **MINOR** at least
* Any change that breaks prior guarantees or adds new required migration → **MAJOR bump**
* Each doc should include:
* `Version: x.y.z`
* `Last Synced With Plugin Version: vX.Y.Z`
### 3.3 Status Matrix
Add this table to the end of *this* directive and keep it updated:
| Doc | Path | Role | Drafted? | Cleaned? | In Use? |
| --- | ------------------------------------- | ----------------- | -------- | -------- | ------- |
| A | `01-platform-capability-reference.md` | Platform facts | ☐ | ☐ | ☐ |
| B | `02-plugin-behavior-exploration.md` | Exploration | ☐ | ☐ | ☐ |
| C | `03-plugin-requirements.md` | Requirements | ☐ | ☐ | ☐ |
| P1 | `android-implementation-phase1.md` | Impl Cold start | ✅ | ☐ | ☐ |
| P2 | `android-implementation-phase2.md` | Impl Force stop | ✅ | ☐ | ☐ |
| P3 | `android-implementation-phase3.md` | Impl Boot | ✅ | ☐ | ☐ |
(You can edit this directly in git as you go.)
---
## 4. Work Plan "Do All of the Above"
This is the **concrete to-do list** that satisfies:
* Consolidation
* Versioning & ownership
* Status tracking
* Single-source master structure
* Next-phase readiness
* Improvements from `improve-alarm-directives.md`
### Step 1 Normalize Platform Reference (Doc A)
1. Start from `platform-capability-reference.md` + `android-alarm-persistence-directive.md`
2. Merge into a single doc:
* Android matrix
* iOS matrix
* Core principles (no plugin rules)
3. Ensure each line is labeled:
* **OS-guaranteed**, **Plugin-required**, or **Forbidden** (where relevant)
4. Mark the old Android alarm persistence doc as:
* **Deprecated in favor of Doc A** (leave file but add a banner)
### Step 2 Rewrite Exploration (Doc B)
1. Use `plugin-behavior-exploration-template.md` as the skeleton
2. Integrate concrete scenario descriptions and code paths from `explore-alarm-behavior-directive.md`
3. For **each scenario**:
* App swipe, OS kill, reboot, force stop, cold start, warm start, notification tap:
* Add row: Step-by-step actions + Expected (OS) + Expected (Plugin) + Actual + Notes
4. Remove any explanation that duplicates Doc A; replace with "See Doc A, section X"
### Step 3 Rewrite Plugin Requirements (Doc C)
1. Start from `plugin-requirements-implementation.md` & improvement goals
2. Add / enforce sections:
* **Plugin Behavior Guarantees & Limitations**
* **Persistence Spec** (fields, mandatory vs optional)
* **Recovery Points** (boot, cold, warm, force stop, user tap)
* **Missed Alarm Contract**
* **JS/TS API Contract**
* **Unsupported / impossible guarantees** (Force Stop, iOS background, etc.)
3. Everywhere it relies on platform behavior:
* Link back to Doc A using short cross-reference ("See A §2.1")
4. Add a **"Traceability"** mini-table:
* Row per behavior → Platform fact in A → Tested scenario in B → Implemented in Phase N
### Step 4 Align Implementation Directives with Doc C
1. Treat Phase docs as **canonical code spec**: P1, P2, P3
2. For each behavior required in Doc C:
* Identify which Phase implements it (or will implement it)
3. Update:
* `android-implementation-directive.md` to be explicitly **descriptive/integrative**, not normative, pointing to Phases 13 & Doc C
* Ensure scenario model, alarm existence checks, and boot handling match the **corrected model** already defined in Phase 2 & 3
4. Add acceptance criteria per phase that directly reference:
* Requirements in Doc C
* Platform constraints in Doc A
5. **Phase 13 must REMOVE**:
* Any scenario logic (moved to Doc C)
* Any platform behavioral claims (moved to Doc A)
* Any recovery responsibilities not assigned to that phase
### Step 5 Versioning & Status
1. At the top of Docs A, B, C, and each Phase doc, add:
* `Version`, `Last Updated`, `Sync'd with Plugin vX.Y.Z`
2. Maintain the status matrix (Section 3.3) as the **single source of truth** for doc maturity
3. When a Phase is fully implemented and deployed:
* Mark "In Use?" = ✅
* Add link to code tags/commit hash
### Step 6 Readiness Check for Next Work Phase
Before starting any *new* implementation work on alarms / schedules:
1. **Confirm:**
* Doc A exists and is stable enough (no "TODO" in core matrices)
* Doc B has at least the base scenarios scaffolded
* Doc C clearly defines:
* What we guarantee on each platform
* What we cannot do (e.g., Force Stop auto-resume)
2. Only then:
* Modify or extend Phase 13
* Or add new phases (e.g., warm-start optimizations, iOS parity work)
---
## 5. Acceptance Criteria for THIS Directive (Revised and Final)
This directive is complete **ONLY** when:
1. **Doc A exists, is referenced, and no other doc contains platform facts**
* File exists: `docs/alarms/01-platform-capability-reference.md`
* All old platform docs marked as deprecated with banner
* No platform facts duplicated in other docs
2. **Doc B contains**:
* At least 6 scenarios (swipe, reboot, force stop, cold start, warm start, notification tap)
* Expected OS vs Plugin behavior columns
* Space for Actual Result and Notes
* Links to source files/functions
3. **Doc C contains**:
* Guarantees by platform (table format)
* Recovery matrix (boot, cold, warm, force stop, user tap)
* JS/TS API contract
* Unsupported behaviors clearly called out
4. **Phase docs**:
* Contain NO duplication of Doc A (platform facts)
* Reference Doc C explicitly (requirements)
* Have acceptance criteria tied to Doc C requirements
5. **Deprecated files have**:
* Banner: "⚠️ **DEPRECATED**: Superseded by [000-UNIFIED-ALARM-DIRECTIVE](./000-UNIFIED-ALARM-DIRECTIVE.md)"
* Link to replacement document
6. **Status matrix fields are no longer empty** (Section 10)
* All docs marked as Drafted at minimum
---
## 6. Document Relationships & Cross-References
### 6.1 Reference Flow
```
Unified Directive (this doc)
├─→ Doc A (Platform Reference)
│ └─→ Referenced by: B, C, P1-P3
├─→ Doc B (Exploration)
│ └─→ References: A (platform facts), C (expected behavior)
│ └─→ Feeds into: P1-P3 (test results inform implementation)
├─→ Doc C (Requirements)
│ └─→ References: A (platform constraints)
│ └─→ Referenced by: P1-P3 (implementation must satisfy)
└─→ P1-P3 (Implementation)
└─→ References: A (platform facts), C (requirements)
└─→ Validated by: B (exploration results)
```
### 6.2 Cross-Reference Format
When referencing between documents, use this format:
* **Doc A**: `[Platform Reference §2.1](../alarms/01-platform-capability-reference.md#21-android-alarm-persistence)`
* **Doc B**: `[Exploration §3.2](../alarms/02-plugin-behavior-exploration.md#32-cold-start-scenario)`
* **Doc C**: `[Requirements §4.3](../alarms/03-plugin-requirements.md#43-recovery-requirements)`
* **Phase docs**: `[Phase 1 §2.3](../android-implementation-directive-phase1.md#23-cold-start-recovery)`
---
## 7. Canonical Source of Truth Rules
### 7.1 Scenario Detection
**Only ReactivationManager.kt** performs scenario detection. No other component (BootReceiver, DailyNotificationPlugin, etc.) implements detection logic.
**Corrected Scenario Model** (from Phase 2):
```kotlin
enum class RecoveryScenario {
COLD_START, // Process killed, alarms may or may not exist
FORCE_STOP, // Alarms cleared, DB still populated
BOOT, // Device reboot
NONE // No recovery required (warm resume or first launch)
}
```
### 7.2 Recovery Logic
**Only ReactivationManager.kt** implements recovery logic. BootReceiver only sets flags and queues ReactivationManager.
### 7.3 Platform Facts
**Only Doc A** contains platform facts. All other docs reference Doc A, never duplicate platform behavior.
### 7.4 Requirements
**Only Doc C** defines plugin requirements. Phase docs implement Doc C requirements.
---
## 8. Conflict Resolution
If conflicts arise between documents:
1. **This directive (000)** wins for structure and organization
2. **Doc A** wins for platform facts
3. **Doc C** wins for requirements
4. **Phase docs (P1-P3)** win for implementation details
5. **Doc B** is observational (actual test results) and may reveal conflicts
When a conflict is found:
1. Document it in this section
2. Resolve by updating the lower-priority document
3. Update the status matrix
---
## 9. Next Steps
### ⚠️ Time-Based Triggers (Enforcement)
**Doc A must be created BEFORE any further implementation work.**
- No Phase 2 or Phase 3 changes until Doc A exists
- No new platform behavior claims in any doc until Doc A is canonical
**Doc B must be baseline-complete BEFORE Phase 2 changes.**
- At least 6 core scenarios scaffolded
- Expected behavior columns populated from Doc A + Doc C
**Doc C must be finalized BEFORE any JS/TS API changes.**
- All guarantees documented
- API contract defined
- No breaking changes to JS/TS API without Doc C update first
### Immediate (Before New Implementation)
1. Create stub documents A, B, C with structure
2. Migrate content from existing docs into new structure
3. Update all cross-references
4. Mark old docs as deprecated (with pointers to new docs)
**Deliverables Required**:
- Doc A exists as a file in repo
- Doc B exists with scenario tables scaffolded
- Doc C exists with required sections
- Status matrix updated in this document
- Deprecated docs marked with header banner
### Short-term (During Implementation)
1. Keep Doc B updated with test results
2. Update Phase docs as implementation progresses
3. Maintain status matrix
### Long-term (Post-Implementation)
1. Add iOS parity documentation
2. Expand exploration scenarios
3. Create regression test suite based on Doc B
---
## 10. Change-Control Rules
Any change to Docs AC requires:
1. **Update version header** in the document
2. **Update status matrix** (Section 10) in this directive
3. **Commit message tag**: `[ALARM-DOCS]` prefix
4. **Notification in CHANGELOG** if JS/TS-visible behavior changes
**Phase docs may not be modified unless Doc C changes first** (or explicit exception documented).
**Example commit message**:
```
[ALARM-DOCS] Update Doc C with force stop recovery guarantee
- Added force stop detection requirement
- Updated recovery matrix
- Version bumped to 1.1.0
```
---
## 11. Status Matrix
| Doc | Path | Role | Drafted? | Cleaned? | In Use? | Notes |
| --- | ------------------------------------- | ----------------- | -------- | -------- | ------- | ---------------------------------------- |
| A | `01-platform-capability-reference.md` | Platform facts | ☐ | ☐ | ☐ | Merge from existing platform docs |
| B | `02-plugin-behavior-exploration.md` | Exploration | ☐ | ☐ | ☐ | Based on exploration template |
| C | `03-plugin-requirements.md` | Requirements | ☐ | ☐ | ☐ | Consolidate from requirements doc |
| P1 | `android-implementation-phase1.md` | Impl Cold start | ✅ | ☐ | ☐ | Exists, needs alignment with Doc C |
| P2 | `android-implementation-phase2.md` | Impl Force stop | ✅ | ☐ | ☐ | Exists, needs alignment with Doc C |
| P3 | `android-implementation-phase3.md` | Impl Boot | ✅ | ☐ | ☐ | Exists, needs alignment with Doc C |
**Legend:**
- ✅ = Complete
- ☐ = Not started / In progress
- ⚠️ = Needs attention
---
## Related Documentation
* [Android Implementation Directive](../android-implementation-directive.md) Umbrella overview
* [Phase 1: Cold Start Recovery](../android-implementation-directive-phase1.md) Minimal viable recovery
* [Phase 2: Force Stop Recovery](../android-implementation-directive-phase2.md) Force stop detection
* [Phase 3: Boot Recovery](../android-implementation-directive-phase3.md) Boot receiver enhancement
* [Exploration Findings](../exploration-findings-initial.md) Initial code discovery
---
**Status**: Active master coordination directive
**Last Updated**: November 2025
**Next Review**: After Docs A, B, C are created
---
## 12. Single Instruction for Team
**⚠️ BLOCKING RULE**: No engineering or documentation work on alarms/schedules/notifications may continue until Steps 13 in §9 ("Immediate") are complete and committed.
**Specifically**:
- Doc A must exist as a file
- Doc B must have scenario tables scaffolded
- Doc C must have required sections
- Status matrix must be updated
- Deprecated files must be marked
**Exception**: Only emergency bug fixes may proceed, but must be documented retroactively in the appropriate Doc A/B/C structure.

View File

@@ -0,0 +1,686 @@
# Android Implementation Directive: Phase 1 - Cold Start Recovery
**Author**: Matthew Raymer
**Date**: November 2025
**Status**: Phase 1 - Minimal Viable Recovery
**Version**: 1.0.0
## 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**:
- [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
```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.
---
## 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
- [Full Implementation Directive](./android-implementation-directive.md) - Complete scope (all phases)
- [Exploration Findings](./exploration-findings-initial.md) - Gap analysis
- [Plugin Requirements](./plugin-requirements-implementation.md) - Requirements
---
## 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.

View File

@@ -0,0 +1,780 @@
# Android Implementation Directive: Phase 2 - Force Stop Detection & Recovery
**Author**: Matthew Raymer
**Date**: November 2025
**Status**: Phase 2 - Force Stop Recovery
**Version**: 1.0.0
## Purpose
Phase 2 implements **force stop detection and comprehensive recovery**. This handles the scenario where the user force-stops the app, causing all alarms to be cancelled by the OS.
**⚠️ IMPORTANT**: This phase **modifies and extends** the `ReactivationManager` introduced in Phase 1. Do not create a second copy; update the existing class.
**Prerequisites**: Phase 1 must be complete (cold start recovery implemented).
**Scope**: Force stop detection, scenario differentiation, and full alarm recovery.
**Reference**: See [Phase 1](./android-implementation-directive-phase1.md) for cold start recovery, [Full Implementation Directive](./android-implementation-directive.md) for complete scope.
---
## 1. Acceptance Criteria
### 1.1 Definition of Done
**Phase 2 is complete when:**
1.**Force stop scenario is detected correctly**
- Detection: `(DB schedules count > 0) && (AlarmManager alarms count == 0)`
- Detection runs on app launch (via `ReactivationManager`)
- False positives avoided (distinguishes from first launch)
2.**All past alarms are marked as missed**
- All schedules with `nextRunAt < currentTime` marked as missed
- Missed notifications created/updated in database
- History records created for each missed alarm
3.**All future alarms are rescheduled**
- All schedules with `nextRunAt >= currentTime` rescheduled
- Repeating schedules calculate next occurrence correctly
- No duplicate alarms created
4.**Recovery handles both notify and fetch schedules**
- `notify` schedules rescheduled via AlarmManager
- `fetch` schedules rescheduled via WorkManager
- Both types recovered completely
5.**Recovery never crashes the app**
- All exceptions caught and logged
- Partial recovery logged if some schedules fail
- App continues normally even if recovery fails
### 1.2 Success Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Force stop detection accuracy | 100% | Manual verification via logs |
| Past alarm recovery rate | 100% | All past alarms marked as missed |
| Future alarm recovery rate | > 95% | History table outcome field |
| Recovery execution time | < 3 seconds | Log timestamp difference |
| Crash rate | 0% | No exceptions propagate to app |
### 1.3 Out of Scope (Phase 2)
- ❌ Warm start optimization (Phase 3)
- ❌ Boot receiver missed alarm handling (Phase 3)
- ❌ Callback event emission (Phase 3)
- ❌ User notification of missed alarms (Phase 3)
---
## 2. Implementation: Force Stop Detection
### 2.1 Update ReactivationManager
**File**: `android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt`
**Location**: Add scenario detection after Phase 1 implementation
### 2.2 Scenario Detection
**⚠️ Canonical Source**: This method supersedes any earlier scenario detection code shown in the full directive.
```kotlin
/**
* Detect recovery scenario based on AlarmManager state vs database
*
* Phase 2: Adds force stop detection
*
* This is the normative implementation of scenario detection.
*
* @return RecoveryScenario enum value
*/
private suspend fun detectScenario(): RecoveryScenario {
val db = DailyNotificationDatabase.getDatabase(context)
val dbSchedules = db.scheduleDao().getEnabled()
// Check for first launch (empty DB)
if (dbSchedules.isEmpty()) {
Log.d(TAG, "No schedules in database - first launch (NONE)")
return RecoveryScenario.NONE
}
// Check for boot recovery (set by BootReceiver)
if (isBootRecovery()) {
Log.i(TAG, "Boot recovery detected")
return RecoveryScenario.BOOT
}
// Check for force stop: DB has schedules but no alarms exist
if (!alarmsExist()) {
Log.i(TAG, "Force stop detected: DB has ${dbSchedules.size} schedules, but no alarms exist")
return RecoveryScenario.FORCE_STOP
}
// Normal cold start: DB has schedules and alarms exist
// (Alarms may have fired or may be future alarms - need to verify/resync)
Log.d(TAG, "Cold start: DB has ${dbSchedules.size} schedules, alarms exist")
return RecoveryScenario.COLD_START
}
/**
* Check if this is a boot recovery scenario
*
* BootReceiver sets a flag in SharedPreferences when boot completes.
* This allows ReactivationManager to detect boot scenario.
*
* @return true if boot recovery, false otherwise
*/
private fun isBootRecovery(): Boolean {
val prefs = context.getSharedPreferences("dailynotification_recovery", Context.MODE_PRIVATE)
val lastBootAt = prefs.getLong("last_boot_at", 0)
val currentTime = System.currentTimeMillis()
// Boot flag is valid for 60 seconds after boot
// This prevents false positives from stale flags
if (lastBootAt > 0 && (currentTime - lastBootAt) < 60000) {
// Clear the flag after reading
prefs.edit().remove("last_boot_at").apply()
return true
}
return false
}
/**
* Check if alarms exist in AlarmManager
*
* **Correction**: Replaces unreliable nextAlarmClock check with PendingIntent check.
* This eliminates false positives from nextAlarmClock.
*
* @return true if at least one alarm exists, false otherwise
*/
private fun alarmsExist(): Boolean {
return try {
// Check if any PendingIntent for our receiver exists
// This is more reliable than nextAlarmClock
val intent = Intent(context, DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
}
val pendingIntent = PendingIntent.getBroadcast(
context,
0, // Use 0 to check for any alarm
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
val exists = pendingIntent != null
Log.d(TAG, "Alarm check: alarms exist = $exists")
exists
} catch (e: Exception) {
Log.e(TAG, "Error checking if alarms exist", e)
// On error, assume no alarms (conservative for force stop detection)
false
}
}
/**
* Recovery scenario enum
*
* **Corrected Model**: Only these four scenarios are supported
*/
enum class RecoveryScenario {
COLD_START, // Process killed, alarms may or may not exist
FORCE_STOP, // Alarms cleared, DB still populated
BOOT, // Device reboot
NONE // No recovery required (warm resume or first launch)
}
```
### 2.3 Update performRecovery()
```kotlin
/**
* Perform recovery on app launch
* Phase 2: Adds force stop handling
*/
fun performRecovery() {
CoroutineScope(Dispatchers.IO).launch {
try {
withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) {
Log.i(TAG, "Starting app launch recovery (Phase 2)")
// Step 1: Detect scenario
val scenario = detectScenario()
Log.i(TAG, "Detected scenario: $scenario")
// Step 2: Handle based on scenario
when (scenario) {
RecoveryScenario.FORCE_STOP -> {
// Phase 2: Force stop recovery (new in this phase)
val result = performForceStopRecovery()
Log.i(TAG, "Force stop recovery completed: $result")
}
RecoveryScenario.COLD_START -> {
// Phase 1: Cold start recovery (reuse existing implementation)
val result = performColdStartRecovery()
Log.i(TAG, "Cold start recovery completed: $result")
}
RecoveryScenario.BOOT -> {
// Phase 3: Boot recovery (handled via ReactivationManager)
// Boot recovery uses same logic as force stop (all alarms wiped)
val result = performForceStopRecovery()
Log.i(TAG, "Boot recovery completed: $result")
}
RecoveryScenario.NONE -> {
// No recovery needed (warm resume or first launch)
Log.d(TAG, "No recovery needed (NONE scenario)")
}
}
Log.i(TAG, "App launch recovery completed")
}
} catch (e: Exception) {
Log.e(TAG, "Recovery failed (non-fatal): ${e.message}", e)
try {
recordRecoveryFailure(e)
} catch (historyError: Exception) {
Log.w(TAG, "Failed to record recovery failure in history", historyError)
}
}
}
}
```
---
## 3. Implementation: Force Stop Recovery
### 3.1 Force Stop Recovery Method
```kotlin
/**
* Perform force stop recovery
*
* Force stop scenario: ALL alarms were cancelled by OS
* Need to:
* 1. Mark all past alarms as missed
* 2. Reschedule all future alarms
* 3. Handle both notify and fetch schedules
*
* @return RecoveryResult with counts
*/
private suspend fun performForceStopRecovery(): RecoveryResult {
val db = DailyNotificationDatabase.getDatabase(context)
val currentTime = System.currentTimeMillis()
Log.i(TAG, "Force stop recovery: recovering all schedules")
val dbSchedules = try {
db.scheduleDao().getEnabled()
} catch (e: Exception) {
Log.e(TAG, "Failed to query schedules", e)
return RecoveryResult(0, 0, 0, 1)
}
var missedCount = 0
var rescheduledCount = 0
var errors = 0
dbSchedules.forEach { schedule ->
try {
when (schedule.kind) {
"notify" -> {
val result = recoverNotifySchedule(schedule, currentTime, db)
missedCount += result.missedCount
rescheduledCount += result.rescheduledCount
errors += result.errors
}
"fetch" -> {
val result = recoverFetchSchedule(schedule, currentTime, db)
rescheduledCount += result.rescheduledCount
errors += result.errors
}
else -> {
Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
}
}
} catch (e: Exception) {
errors++
Log.e(TAG, "Failed to recover schedule: ${schedule.id}", e)
}
}
val result = RecoveryResult(
missedCount = missedCount,
rescheduledCount = rescheduledCount,
verifiedCount = 0, // Not applicable for force stop
errors = errors
)
recordRecoveryHistory(db, "force_stop", result)
Log.i(TAG, "Force stop recovery complete: $result")
return result
}
/**
* Data class for schedule recovery results
*/
private data class ScheduleRecoveryResult(
val missedCount: Int = 0,
val rescheduledCount: Int = 0,
val errors: Int = 0
)
```
### 3.2 Recover Notify Schedule
**Behavior**: Handles `kind == "notify"` schedules. Reschedules via AlarmManager.
```kotlin
/**
* Recover a notify schedule after force stop
*
* Handles notify schedules (kind == "notify")
*
* @param schedule Schedule to recover
* @param currentTime Current time in milliseconds
* @param db Database instance
* @return ScheduleRecoveryResult
*/
private suspend fun recoverNotifySchedule(
schedule: Schedule,
currentTime: Long,
db: DailyNotificationDatabase
): ScheduleRecoveryResult {
// Data integrity check
if (schedule.id.isBlank()) {
Log.w(TAG, "Skipping invalid schedule: empty ID")
return ScheduleRecoveryResult(errors = 1)
}
var missedCount = 0
var rescheduledCount = 0
var errors = 0
// Calculate next run time
val nextRunTime = calculateNextRunTime(schedule, currentTime)
if (nextRunTime < currentTime) {
// Past alarm - was missed during force stop
Log.i(TAG, "Past alarm detected: ${schedule.id} scheduled for $nextRunTime")
try {
// Mark as missed
markMissedNotification(schedule, nextRunTime, db)
missedCount++
} catch (e: Exception) {
errors++
Log.e(TAG, "Failed to mark missed notification: ${schedule.id}", e)
}
// Reschedule next occurrence if repeating
if (isRepeating(schedule)) {
try {
val nextOccurrence = calculateNextOccurrence(schedule, currentTime)
rescheduleAlarm(schedule, nextOccurrence, db)
rescheduledCount++
Log.i(TAG, "Rescheduled next occurrence: ${schedule.id} for $nextOccurrence")
} catch (e: Exception) {
errors++
Log.e(TAG, "Failed to reschedule next occurrence: ${schedule.id}", e)
}
}
} else {
// Future alarm - reschedule immediately
Log.i(TAG, "Future alarm detected: ${schedule.id} scheduled for $nextRunTime")
try {
rescheduleAlarm(schedule, nextRunTime, db)
rescheduledCount++
} catch (e: Exception) {
errors++
Log.e(TAG, "Failed to reschedule future alarm: ${schedule.id}", e)
}
}
return ScheduleRecoveryResult(missedCount, rescheduledCount, errors)
}
```
### 3.3 Recover Fetch Schedule
**Behavior**: Handles `kind == "fetch"` schedules. Reschedules via WorkManager.
```kotlin
/**
* Recover a fetch schedule after force stop
*
* Handles fetch schedules (kind == "fetch")
*
* @param schedule Schedule to recover
* @param currentTime Current time in milliseconds
* @param db Database instance
* @return ScheduleRecoveryResult
*/
private suspend fun recoverFetchSchedule(
schedule: Schedule,
currentTime: Long,
db: DailyNotificationDatabase
): ScheduleRecoveryResult {
// Data integrity check
if (schedule.id.isBlank()) {
Log.w(TAG, "Skipping invalid schedule: empty ID")
return ScheduleRecoveryResult(errors = 1)
}
var rescheduledCount = 0
var errors = 0
try {
// Reschedule fetch work via WorkManager
val config = ContentFetchConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
url = null, // Will use registered native fetcher
timeout = 30000,
retryAttempts = 3,
retryDelay = 1000,
callbacks = CallbackConfig()
)
FetchWorker.scheduleFetch(context, config)
rescheduledCount++
Log.i(TAG, "Rescheduled fetch: ${schedule.id}")
} catch (e: Exception) {
errors++
Log.e(TAG, "Failed to reschedule fetch: ${schedule.id}", e)
}
return ScheduleRecoveryResult(rescheduledCount = rescheduledCount, errors = errors)
}
```
### 3.4 Helper Methods
```kotlin
/**
* Mark a notification as missed
*
* @param schedule Schedule that was missed
* @param scheduledTime When the notification was scheduled
* @param db Database instance
*/
private suspend fun markMissedNotification(
schedule: Schedule,
scheduledTime: Long,
db: DailyNotificationDatabase
) {
try {
// Try to find existing NotificationContentEntity
val notificationId = schedule.id
val existingNotification = db.notificationContentDao().getNotificationById(notificationId)
if (existingNotification != null) {
// Update existing notification
existingNotification.deliveryStatus = "missed"
existingNotification.lastDeliveryAttempt = System.currentTimeMillis()
existingNotification.deliveryAttempts = (existingNotification.deliveryAttempts ?: 0) + 1
db.notificationContentDao().updateNotification(existingNotification)
Log.d(TAG, "Updated existing notification as missed: $notificationId")
} else {
// Create missed notification entry
// Note: This may not have full content, but marks the missed event
Log.w(TAG, "No NotificationContentEntity found for schedule: $notificationId")
// Could create a minimal entry here if needed
}
} catch (e: Exception) {
Log.e(TAG, "Failed to mark missed notification: ${schedule.id}", e)
throw e
}
}
/**
* Calculate next run time from schedule
* Uses existing BootReceiver logic if available
*
* @param schedule Schedule to calculate for
* @param currentTime Current time in milliseconds
* @return Next run time in milliseconds
*/
private fun calculateNextRunTime(schedule: Schedule, currentTime: Long): Long {
// Prefer nextRunAt if set
if (schedule.nextRunAt != null) {
return schedule.nextRunAt!!
}
// Calculate from cron or clockTime
// For now, simplified: use BootReceiver logic if available
// Otherwise, default to next day at 9 AM
return when {
schedule.cron != null -> {
// TODO: Parse cron and calculate next run
// For now, return next day at 9 AM
currentTime + (24 * 60 * 60 * 1000L)
}
schedule.clockTime != null -> {
// TODO: Parse HH:mm and calculate next run
// For now, return next day at specified time
currentTime + (24 * 60 * 60 * 1000L)
}
else -> {
// Default to next day at 9 AM
currentTime + (24 * 60 * 60 * 1000L)
}
}
}
/**
* Check if schedule is repeating
*
* **Helper Consistency Note**: This helper must remain consistent with any
* equivalent methods used in `BootReceiver` (Phase 3). If updated, update both places.
*
* @param schedule Schedule to check
* @return true if repeating, false if one-time
*/
private fun isRepeating(schedule: Schedule): Boolean {
// Schedules with cron or clockTime are repeating
return schedule.cron != null || schedule.clockTime != null
}
/**
* Calculate next occurrence for repeating schedule
*
* **Helper Consistency Note**: This helper must remain consistent with any
* equivalent methods used in `BootReceiver` (Phase 3). If updated, update both places.
*
* @param schedule Schedule to calculate for
* @param fromTime Calculate next occurrence after this time
* @return Next occurrence time in milliseconds
*/
private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long {
// TODO: Implement proper calculation based on cron/clockTime
// For now, simplified: daily schedules add 24 hours
return fromTime + (24 * 60 * 60 * 1000L)
}
```
---
## 4. Data Integrity Checks
### 4.1 Force Stop Detection Validation
**False Positive Prevention**:
- ✅ First launch: `DB schedules count == 0` → Not force stop
- ✅ Normal cold start: `AlarmManager has alarms` → Not force stop
- ✅ Only detect force stop when: `DB schedules > 0 && AlarmManager alarms == 0`
**Edge Cases**:
- ✅ All alarms already fired: Still detect as force stop if AlarmManager is empty
- ✅ Partial alarm cancellation: Not detected as force stop (handled by cold start recovery)
### 4.2 Schedule Validation
**Notify Schedule Validation**:
-`id` must not be blank
-`kind` must be "notify"
-`nextRunAt` or `cron`/`clockTime` must be set
**Fetch Schedule Validation**:
-`id` must not be blank
-`kind` must be "fetch"
-`cron` or `clockTime` must be set
---
## 5. Rollback Safety
### 5.1 No-Crash Guarantee
**All force stop recovery operations must:**
1. **Catch all exceptions** - Never propagate exceptions to app
2. **Continue processing** - One schedule failure doesn't stop recovery
3. **Log errors** - All failures logged with context
4. **Partial recovery** - Some schedules can recover even if others fail
### 5.2 Error Handling Strategy
| Error Type | Handling | Log Level |
|------------|----------|-----------|
| Schedule query failure | Return empty result, log error | ERROR |
| Invalid schedule data | Skip schedule, continue | WARN |
| Alarm reschedule failure | Log error, continue to next | ERROR |
| Fetch reschedule failure | Log error, continue to next | ERROR |
| Missed notification marking failure | Log error, continue | ERROR |
| History recording failure | Log warning, don't fail | WARN |
---
## 6. Testing Requirements
### 6.1 Test 1: Force Stop Detection
**Purpose**: Verify force stop scenario is detected correctly.
**Steps**:
1. Schedule 3 notifications (2 minutes, 5 minutes, 10 minutes in future)
2. Verify alarms scheduled: `adb shell dumpsys alarm | grep timesafari`
3. Force stop app: `adb shell am force-stop com.timesafari.dailynotification`
4. Verify alarms cancelled: `adb shell dumpsys alarm | grep timesafari` (should be empty)
5. Launch app: `adb shell am start -n com.timesafari.dailynotification/.MainActivity`
6. Check logs: `adb logcat -d | grep DNP-REACTIVATION`
**Expected**:
- ✅ Log shows "Force stop detected: DB has X schedules, AlarmManager has 0 alarms"
- ✅ Log shows "Detected scenario: FORCE_STOP"
- ✅ Log shows "Force stop recovery: recovering all schedules"
**Pass Criteria**: Force stop correctly detected.
### 6.2 Test 2: Past Alarm Recovery
**Purpose**: Verify past alarms are marked as missed.
**Steps**:
1. Schedule notification for 2 minutes in future
2. Force stop app
3. Wait 5 minutes (past scheduled time)
4. Launch app
5. Check database: `delivery_status = 'missed'` for past alarm
**Expected**:
- ✅ Past alarm marked as missed in database
- ✅ History entry created
- ✅ Log shows "Past alarm detected" and "Marked missed notification"
**Pass Criteria**: Past alarms correctly marked as missed.
### 6.3 Test 3: Future Alarm Recovery
**Purpose**: Verify future alarms are rescheduled.
**Steps**:
1. Schedule 3 notifications (5, 10, 15 minutes in future)
2. Force stop app
3. Launch app immediately
4. Verify alarms rescheduled: `adb shell dumpsys alarm | grep timesafari`
**Expected**:
- ✅ All 3 alarms rescheduled in AlarmManager
- ✅ Log shows "Future alarm detected" and "Rescheduled alarm"
- ✅ No duplicate alarms created
**Pass Criteria**: Future alarms correctly rescheduled.
### 6.4 Test 4: Repeating Schedule Recovery
**Purpose**: Verify repeating schedules calculate next occurrence correctly.
**Steps**:
1. Schedule daily notification (cron: "0 9 * * *")
2. Force stop app
3. Wait past scheduled time (e.g., wait until 10 AM)
4. Launch app
5. Verify next occurrence scheduled for tomorrow 9 AM
**Expected**:
- ✅ Past occurrence marked as missed
- ✅ Next occurrence scheduled for tomorrow
- ✅ Log shows "Rescheduled next occurrence"
**Pass Criteria**: Repeating schedules correctly calculate next occurrence.
### 6.5 Test 5: Fetch Schedule Recovery
**Purpose**: Verify fetch schedules are recovered.
**Steps**:
1. Schedule fetch work (cron: "0 9 * * *")
2. Force stop app
3. Launch app
4. Check WorkManager: `adb shell dumpsys jobscheduler | grep timesafari`
**Expected**:
- ✅ Fetch work rescheduled in WorkManager
- ✅ Log shows "Rescheduled fetch"
**Pass Criteria**: Fetch schedules correctly recovered.
---
## 7. Implementation Checklist
- [ ] Add `detectScenario()` method to ReactivationManager
- [ ] Add `alarmsExist()` method (replaces getActiveAlarmCount)
- [ ] Add `isBootRecovery()` method
- [ ] Add `RecoveryScenario` enum
- [ ] Update `performRecovery()` to handle force stop
- [ ] Implement `performForceStopRecovery()`
- [ ] Implement `recoverNotifySchedule()`
- [ ] Implement `recoverFetchSchedule()`
- [ ] Implement `markMissedNotification()`
- [ ] Implement `calculateNextRunTime()` (or reuse BootReceiver logic)
- [ ] Implement `isRepeating()`
- [ ] Implement `calculateNextOccurrence()`
- [ ] Add data integrity checks
- [ ] Add error handling
- [ ] Test force stop detection
- [ ] Test past alarm recovery
- [ ] Test future alarm recovery
- [ ] Test repeating schedule recovery
- [ ] Test fetch schedule recovery
- [ ] Verify no duplicate alarms
---
## 8. Code References
**Existing Code to Reuse**:
- `NotifyReceiver.scheduleExactNotification()` - Line 92
- `FetchWorker.scheduleFetch()` - Line 31
- `BootReceiver.calculateNextRunTime()` - Line 103 (for next run calculation)
- `ScheduleDao.getEnabled()` - Line 298
- `NotificationContentDao.getNotificationById()` - Line 69
**New Code to Create**:
- `detectScenario()` - Add to ReactivationManager
- `alarmsExist()` - Add to ReactivationManager (replaces getActiveAlarmCount)
- `isBootRecovery()` - Add to ReactivationManager
- `performForceStopRecovery()` - Add to ReactivationManager
- `recoverNotifySchedule()` - Add to ReactivationManager
- `recoverFetchSchedule()` - Add to ReactivationManager
---
## 9. Success Criteria Summary
**Phase 2 is complete when:**
1. ✅ Force stop scenario detected correctly
2. ✅ All past alarms marked as missed
3. ✅ All future alarms rescheduled
4. ✅ Both notify and fetch schedules recovered
5. ✅ Repeating schedules calculate next occurrence correctly
6. ✅ Recovery never crashes app
7. ✅ All tests pass
---
## Related Documentation
- [Phase 1: Cold Start Recovery](./android-implementation-directive-phase1.md) - Prerequisite
- [Full Implementation Directive](./android-implementation-directive.md) - Complete scope
- [Exploration Findings](./exploration-findings-initial.md) - Gap analysis
---
## Notes
- **Prerequisite**: Phase 1 must be complete before starting Phase 2
- **Detection accuracy**: Force stop detection uses best available method (nextAlarmClock)
- **Comprehensive recovery**: Force stop recovery handles ALL schedules (past and future)
- **Safety first**: All recovery operations are non-blocking and non-fatal

View File

@@ -0,0 +1,619 @@
# Android Implementation Directive: Phase 3 - Boot Receiver Missed Alarm Handling
**Author**: Matthew Raymer
**Date**: November 2025
**Status**: Phase 3 - Boot Recovery Enhancement
**Version**: 1.0.0
## Purpose
Phase 3 enhances the **boot receiver** to detect and handle missed alarms during device reboot. This handles alarms that were scheduled before reboot but were missed because alarms are wiped on reboot.
**Prerequisites**: Phase 1 and Phase 2 must be complete.
**Scope**: Boot receiver missed alarm detection and handling.
**Dependencies**: Boot receiver behavior assumes that Phase 1 and Phase 2 definitions of 'missed alarm', 'next occurrence', and `Schedule`/`NotificationContentEntity` semantics are already in place.
**Reference**: See [Phase 1](./android-implementation-directive-phase1.md) and [Phase 2](./android-implementation-directive-phase2.md) for app launch recovery, [Full Implementation Directive](./android-implementation-directive.md) for complete scope.
**Boot vs App Launch Recovery**:
| Scenario | Entry point | Directive | Responsibility |
| -------------------------------- | --------------------------------------- | --------- | ---------------------------------------- |
| App launch after kill/force-stop | `ReactivationManager.performRecovery()` | Phase 12 | Detect & recover missed |
| Device reboot | `BootReceiver``ReactivationManager` | Phase 3 | Queue recovery, ReactivationManager handles |
**User-Facing Behavior**: In Phase 3, missed alarms are **recorded** and **rescheduled**, but not yet surfaced to the user with explicit "you missed this" UX (that's a future concern).
---
## 1. Acceptance Criteria
### 1.1 Definition of Done
**Phase 3 is complete when:**
1.**Boot receiver detects missed alarms**
- Alarms with `nextRunAt < currentTime` detected during boot recovery
- Detection runs automatically on `BOOT_COMPLETED` intent
- Detection completes within 5 seconds (boot receiver timeout)
2.**Missed alarms are marked in database**
- `delivery_status` updated to `'missed'`
- `last_delivery_attempt` updated to current time
- Status change logged in history table
3.**Next occurrence is rescheduled for repeating schedules**
- Repeating schedules calculate next occurrence after missed time
- Next occurrence scheduled via AlarmManager
- Non-repeating schedules not rescheduled
4.**Future alarms are rescheduled**
- All future alarms (not missed) rescheduled normally
- Existing boot receiver logic enhanced, not replaced
5.**Boot recovery never crashes**
- All exceptions caught and logged
- Database errors don't propagate
- Invalid data handled gracefully
### 1.2 Success Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Boot receiver execution time | < 5 seconds | Log timestamp difference |
| Missed detection accuracy | 100% | Manual verification via logs |
| Next occurrence calculation accuracy | 100% | Verify scheduled time matches expected |
| Recovery success rate | > 95% | History table outcome field |
| Crash rate | 0% | No exceptions propagate |
### 1.3 Out of Scope (Phase 3)
- ❌ Warm start optimization (future phase)
- ❌ Callback event emission (future phase)
- ❌ User notification of missed alarms (future phase)
- ❌ Boot receiver performance optimization (future phase)
---
## 2. Implementation: BootReceiver Enhancement
### 2.1 Canonical Source of Truth
**⚠️ CRITICAL CORRECTION**: BootReceiver must **NOT** implement recovery logic directly. It must **only queue** ReactivationManager.performRecovery() with a BOOT flag.
**ReactivationManager.kt** is the **only** file allowed to:
- Perform scenario detection
- Initiate recovery logic
- Branch execution per phase
### 2.2 Update BootReceiver
**File**: `android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt`
**Location**: `onReceive()` method
### 2.3 Corrected Implementation
**Corrected Code** (BootReceiver only queues recovery):
```kotlin
package com.timesafari.dailynotification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Boot recovery receiver to trigger recovery after device reboot
* Phase 3: Only queues ReactivationManager, does not implement recovery directly
*
* @author Matthew Raymer
* @version 2.0.0 - Corrected to queue ReactivationManager only
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "DNP-BOOT"
private const val PREFS_NAME = "dailynotification_recovery"
private const val KEY_LAST_BOOT_AT = "last_boot_at"
}
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
Log.i(TAG, "Boot completed, queuing ReactivationManager recovery")
// Set boot flag in SharedPreferences
// ReactivationManager will detect this and handle recovery
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putLong(KEY_LAST_BOOT_AT, System.currentTimeMillis()).apply()
// Queue ReactivationManager recovery
// Recovery will run when app launches or can be triggered immediately
CoroutineScope(Dispatchers.IO).launch {
try {
val reactivationManager = ReactivationManager(context)
reactivationManager.performRecovery()
Log.i(TAG, "Boot recovery queued to ReactivationManager")
} catch (e: Exception) {
Log.e(TAG, "Failed to queue boot recovery", e)
}
}
}
}
}
```
**⚠️ REMOVED**: All direct rescheduling logic from BootReceiver. Recovery is now handled entirely by ReactivationManager.
### 2.4 How Boot Recovery Works
**Flow**:
1. Device reboots → `BootReceiver.onReceive()` called
2. BootReceiver sets `last_boot_at` flag in SharedPreferences
3. BootReceiver queues `ReactivationManager.performRecovery()`
4. ReactivationManager detects BOOT scenario via `isBootRecovery()`
5. ReactivationManager handles recovery (same logic as force stop - all alarms wiped)
**Key Points**:
- BootReceiver **never** implements recovery directly
- All recovery logic is in ReactivationManager
- Boot recovery uses same recovery path as force stop (all alarms wiped on reboot)
---
## 3. Data Integrity Checks
```kotlin
/**
* Reschedule notifications after device reboot
* Phase 3: Adds missed alarm detection and handling
*
* @param context Application context
*/
private suspend fun rescheduleNotifications(context: Context) {
val db = DailyNotificationDatabase.getDatabase(context)
val enabledSchedules = db.scheduleDao().getEnabled()
val currentTime = System.currentTimeMillis()
Log.i(TAG, "Boot recovery: Found ${enabledSchedules.size} enabled schedules to reschedule")
var futureRescheduled = 0
var missedDetected = 0
var missedRescheduled = 0
var errors = 0
enabledSchedules.forEach { schedule ->
try {
when (schedule.kind) {
"fetch" -> {
// Reschedule WorkManager fetch (unchanged)
val config = ContentFetchConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
url = null,
timeout = 30000,
retryAttempts = 3,
retryDelay = 1000,
callbacks = CallbackConfig()
)
FetchWorker.scheduleFetch(context, config)
Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}")
futureRescheduled++
}
"notify" -> {
// Phase 3: Handle both past and future alarms
val nextRunTime = calculateNextRunTime(schedule)
if (nextRunTime > currentTime) {
// Future alarm - reschedule normally
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
Log.i(TAG, "Rescheduled future notification: ${schedule.id} for $nextRunTime")
futureRescheduled++
} else {
// Past alarm - was missed during reboot
missedDetected++
Log.i(TAG, "Missed alarm detected on boot: ${schedule.id} scheduled for $nextRunTime")
// Mark as missed
handleMissedAlarmOnBoot(context, schedule, nextRunTime, db)
// Reschedule next occurrence if repeating
if (isRepeating(schedule)) {
try {
val nextOccurrence = calculateNextOccurrence(schedule, currentTime)
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextOccurrence, config)
Log.i(TAG, "Rescheduled next occurrence for missed alarm: ${schedule.id} for $nextOccurrence")
missedRescheduled++
} catch (e: Exception) {
errors++
Log.e(TAG, "Failed to reschedule next occurrence: ${schedule.id}", e)
}
}
}
}
else -> {
Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
}
}
} catch (e: Exception) {
errors++
Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e)
}
}
// Record boot recovery in history
try {
db.historyDao().insert(
History(
refId = "boot_recovery_${System.currentTimeMillis()}",
kind = "boot_recovery",
occurredAt = System.currentTimeMillis(),
outcome = if (errors == 0) "success" else "partial",
diagJson = """
{
"schedules_rescheduled": $futureRescheduled,
"missed_detected": $missedDetected,
"missed_rescheduled": $missedRescheduled,
"errors": $errors
}
""".trimIndent()
)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to record boot recovery history", e)
// Don't fail boot recovery if history recording fails
}
Log.i(TAG, "Boot recovery complete: $futureRescheduled future, $missedDetected missed, $missedRescheduled next occurrences, $errors errors")
}
```
**Note**: All data integrity checks are handled by ReactivationManager (Phase 2). BootReceiver does not perform any data operations directly.
### 3.1 Missed Alarm Detection Validation
```kotlin
/**
* Handle missed alarm detected during boot recovery
* Phase 3: Marks missed alarm in database
*
* @param context Application context
* @param schedule Schedule that was missed
* @param scheduledTime When the alarm was scheduled
* @param db Database instance
*/
private suspend fun handleMissedAlarmOnBoot(
context: Context,
schedule: Schedule,
scheduledTime: Long,
db: DailyNotificationDatabase
) {
try {
// Data integrity check
if (schedule.id.isBlank()) {
Log.w(TAG, "Skipping invalid schedule: empty ID")
return
}
// Try to find existing NotificationContentEntity
val notificationId = schedule.id
val existingNotification = db.notificationContentDao().getNotificationById(notificationId)
if (existingNotification != null) {
// Update existing notification
existingNotification.deliveryStatus = "missed"
existingNotification.lastDeliveryAttempt = System.currentTimeMillis()
existingNotification.deliveryAttempts = (existingNotification.deliveryAttempts ?: 0) + 1
db.notificationContentDao().updateNotification(existingNotification)
Log.d(TAG, "Marked missed notification on boot: $notificationId")
} else {
// No NotificationContentEntity found - this is okay for boot recovery
// The schedule exists but content may not have been fetched yet
Log.d(TAG, "No NotificationContentEntity found for missed schedule: $notificationId (expected for boot recovery)")
}
// Record missed alarm in history
try {
db.historyDao().insert(
History(
refId = "missed_boot_${schedule.id}_${System.currentTimeMillis()}",
kind = "missed_alarm",
occurredAt = System.currentTimeMillis(),
outcome = "missed",
diagJson = """
{
"schedule_id": "${schedule.id}",
"scheduled_time": $scheduledTime,
"detected_at": ${System.currentTimeMillis()},
"scenario": "boot_recovery"
}
""".trimIndent()
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to record missed alarm history", e)
// Don't fail if history recording fails
}
} catch (e: Exception) {
Log.e(TAG, "Failed to handle missed alarm on boot: ${schedule.id}", e)
// Don't throw - continue with boot recovery
}
}
```
### 2.5 Helper Methods
**⚠️ Implementation Consistency**: These helpers must match the implementation used in `ReactivationManager` (Phase 2). Treat ReactivationManager as canonical and keep these in sync.
```kotlin
/**
* Check if schedule is repeating
*
* **Implementation Note**: Must match `isRepeating()` in ReactivationManager (Phase 2).
* This is a duplication for now; treat ReactivationManager as canonical.
*
* @param schedule Schedule to check
* @return true if repeating, false if one-time
*/
private fun isRepeating(schedule: Schedule): Boolean {
// Schedules with cron or clockTime are repeating
return schedule.cron != null || schedule.clockTime != null
}
/**
* Calculate next occurrence for repeating schedule
*
* **Implementation Note**: Must match `calculateNextOccurrence()` in ReactivationManager (Phase 2).
* This is a duplication for now; treat ReactivationManager as canonical.
*
* @param schedule Schedule to calculate for
* @param fromTime Calculate next occurrence after this time
* @return Next occurrence time in milliseconds
*/
private fun calculateNextOccurrence(schedule: Schedule, fromTime: Long): Long {
// TODO: Implement proper calculation based on cron/clockTime
// For now, simplified: daily schedules add 24 hours
// This should match the logic in ReactivationManager (Phase 2)
return when {
schedule.cron != null -> {
// Parse cron and calculate next occurrence
// For now, simplified: daily schedules
fromTime + (24 * 60 * 60 * 1000L)
}
schedule.clockTime != null -> {
// Parse HH:mm and calculate next occurrence
// For now, simplified: daily schedules
fromTime + (24 * 60 * 60 * 1000L)
}
else -> {
// Not repeating
fromTime
}
}
}
```
---
## 3. Data Integrity Checks
### 3.1 Missed Alarm Detection Validation
**Boot Flag Rules**:
- ✅ BootReceiver sets flag immediately on BOOT_COMPLETED
- ✅ Flag is valid for 60 seconds after boot
- ✅ ReactivationManager clears flag after reading
- ✅ Stale flags are ignored (prevents false positives)
**Edge Cases**:
- ✅ Multiple boot broadcasts: Flag is overwritten (last one wins)
- ✅ App not launched after boot: Flag expires after 60 seconds
- ✅ SharedPreferences errors: Log error, recovery continues
---
## 4. Rollback Safety
### 4.1 No-Crash Guarantee
**All boot recovery operations must:**
1. **Catch all exceptions** - Never propagate exceptions
2. **Continue processing** - One schedule failure doesn't stop recovery
3. **Log errors** - All failures logged with context
4. **Timeout protection** - Boot receiver has 10-second timeout (Android limit)
### 4.2 Error Handling Strategy
| Error Type | Handling | Log Level |
|------------|----------|-----------|
| Schedule query failure | Return empty list, log error | ERROR |
| Invalid schedule data | Skip schedule, continue | WARN |
| Missed alarm marking failure | Log error, continue | ERROR |
| Next occurrence calculation failure | Log error, don't reschedule | ERROR |
| Alarm reschedule failure | Log error, continue | ERROR |
| History recording failure | Log warning, don't fail | WARN |
---
## 5. Testing Requirements
### 5.1 Test 1: Boot Recovery Missed Detection
**Purpose**: Verify boot receiver detects missed alarms.
**Steps**:
1. Schedule notification for 5 minutes in future
2. Verify alarm scheduled: `adb shell dumpsys alarm | grep timesafari`
3. Reboot device: `adb reboot`
4. Wait for boot: `adb wait-for-device && adb shell getprop sys.boot_completed` (wait for "1")
5. Wait 10 minutes (past scheduled time)
6. Check boot logs: `adb logcat -d | grep DNP-BOOT`
**Expected**:
- ✅ Log shows "Boot recovery: Found X enabled schedules"
- ✅ Log shows "Missed alarm detected on boot: <id>"
- ✅ Database shows `delivery_status = 'missed'`
**Pass Criteria**: Missed alarm detected during boot recovery.
### 5.2 Test 2: Next Occurrence Rescheduling
**Purpose**: Verify repeating schedules calculate next occurrence correctly.
**Steps**:
1. Schedule daily notification (cron: "0 9 * * *") for today at 9 AM
2. Reboot device
3. Wait until 10 AM (past scheduled time)
4. Check boot logs
5. Verify next occurrence scheduled: `adb shell dumpsys alarm | grep timesafari`
**Expected**:
- ✅ Log shows "Missed alarm detected on boot"
- ✅ Log shows "Rescheduled next occurrence for missed alarm"
- ✅ AlarmManager shows alarm scheduled for tomorrow 9 AM
**Pass Criteria**: Next occurrence correctly calculated and scheduled.
### 5.3 Test 3: Future Alarm Rescheduling
**Purpose**: Verify future alarms are still rescheduled normally.
**Steps**:
1. Schedule notification for 1 hour in future
2. Reboot device
3. Wait for boot
4. Verify alarm rescheduled: `adb shell dumpsys alarm | grep timesafari`
**Expected**:
- ✅ Log shows "Rescheduled future notification"
- ✅ AlarmManager shows alarm scheduled for original time
- ✅ No missed alarm detection for future alarms
**Pass Criteria**: Future alarms rescheduled normally.
### 5.4 Test 4: Non-Repeating Schedule Handling
**Purpose**: Verify non-repeating schedules don't reschedule next occurrence.
**Steps**:
1. Schedule one-time notification (no cron/clockTime) for 5 minutes in future
2. Reboot device
3. Wait 10 minutes (past scheduled time)
4. Check boot logs
5. Verify no next occurrence scheduled
**Expected**:
- ✅ Log shows "Missed alarm detected on boot"
- ✅ Log does NOT show "Rescheduled next occurrence"
- ✅ AlarmManager does NOT show new alarm
**Pass Criteria**: Non-repeating schedules don't reschedule.
### 5.5 Test 5: Boot Recovery Error Handling
**Purpose**: Verify boot recovery handles errors gracefully.
**Steps**:
1. Manually corrupt database (insert invalid schedule)
2. Reboot device
3. Check boot logs
**Expected**:
- ✅ Invalid schedule skipped with warning
- ✅ Boot recovery continues normally
- ✅ Valid schedules still recovered
- ✅ No crash or exception
**Pass Criteria**: Errors handled gracefully, recovery continues.
---
## 6. Implementation Checklist
- [ ] Update `BootReceiver.onReceive()` to set boot flag
- [ ] Update `BootReceiver.onReceive()` to queue ReactivationManager
- [ ] Remove all direct rescheduling logic from BootReceiver
- [ ] Verify ReactivationManager detects BOOT scenario correctly
- [ ] Update history recording to include missed alarm counts
- [ ] Add data integrity checks
- [ ] Add error handling
- [ ] Test boot recovery missed detection
- [ ] Test next occurrence rescheduling
- [ ] Test future alarm rescheduling
- [ ] Test non-repeating schedule handling
- [ ] Test error handling
- [ ] Verify no duplicate alarms
---
## 7. Code References
**Existing Code to Reuse**:
- `BootReceiver.rescheduleNotifications()` - Line 38 (update existing)
- `BootReceiver.calculateNextRunTime()` - Line 103 (already exists)
- `NotifyReceiver.scheduleExactNotification()` - Line 92
- `ScheduleDao.getEnabled()` - Line 298
- `NotificationContentDao.getNotificationById()` - Line 69
**New Code to Create**:
- `handleMissedAlarmOnBoot()` - Add to BootReceiver
- `isRepeating()` - Add to BootReceiver (or reuse from ReactivationManager)
- `calculateNextOccurrence()` - Add to BootReceiver (or reuse from ReactivationManager)
---
## 8. Success Criteria Summary
**Phase 3 is complete when:**
1. ✅ Boot receiver detects missed alarms
2. ✅ Missed alarms marked in database
3. ✅ Next occurrence rescheduled for repeating schedules
4. ✅ Future alarms rescheduled normally
5. ✅ Boot recovery never crashes
6. ✅ All tests pass
---
## Related Documentation
- [Phase 1: Cold Start Recovery](./android-implementation-directive-phase1.md) - Prerequisite
- [Phase 2: Force Stop Recovery](./android-implementation-directive-phase2.md) - Prerequisite
- [Full Implementation Directive](./android-implementation-directive.md) - Complete scope
- [Exploration Findings](./exploration-findings-initial.md) - Gap analysis
---
## Notes
- **Prerequisites**: Phase 1 and Phase 2 must be complete before starting Phase 3
- **Boot receiver timeout**: Android limits boot receiver execution to 10 seconds
- **Comprehensive recovery**: Boot recovery handles both missed and future alarms
- **Safety first**: All recovery operations are non-blocking and non-fatal
- **Code reuse**: Consider extracting helper methods to shared utility class

View File

@@ -6,15 +6,29 @@
## Purpose ## Purpose
This directive provides **step-by-step implementation guidance** for Android-specific gaps identified in the exploration: This directive provides **descriptive overview and integration guidance** for Android-specific gaps identified in the exploration:
1. App Launch Recovery (cold/warm/force-stop) 1. App Launch Recovery (cold/warm/force-stop)
2. Missed Alarm Detection 2. Missed Alarm Detection
3. Force Stop Detection 3. Force Stop Detection
4. Boot Receiver Missed Alarm Handling 4. Boot Receiver Missed Alarm Handling
**⚠️ CRITICAL**: This document is **descriptive and integrative**. The **normative implementation instructions** are in the Phase 13 directives below. **If any code or behavior in this file conflicts with a Phase directive, the Phase directive wins.**
**Reference**: See [Exploration Findings](./exploration-findings-initial.md) for gap analysis. **Reference**: See [Exploration Findings](./exploration-findings-initial.md) for gap analysis.
**⚠️ IMPORTANT**: For implementation, use the phase-specific directives (these are the canonical source of truth):
- **[Phase 1: Cold Start Recovery](./android-implementation-directive-phase1.md)** - Minimal viable recovery
- Explicit acceptance criteria, rollback safety, data integrity checks
- **Start here** for fastest implementation
- **[Phase 2: Force Stop Detection & Recovery](./android-implementation-directive-phase2.md)** - Comprehensive force stop handling
- Prerequisite: Phase 1 complete
- **[Phase 3: Boot Receiver Missed Alarm Handling](./android-implementation-directive-phase3.md)** - Boot recovery enhancement
- Prerequisites: Phase 1 and Phase 2 complete
--- ---
## 1. Implementation Overview ## 1. Implementation Overview
@@ -30,24 +44,40 @@ This directive provides **step-by-step implementation guidance** for Android-spe
### 1.2 Implementation Strategy ### 1.2 Implementation Strategy
**Phase 1**: Create `ReactivationManager` class **Phase 1** Cold start recovery only
- Centralizes all recovery logic - Missed notification detection + future alarm verification
- Handles cold/warm/force-stop scenarios - No force-stop detection, no boot handling
- Detects missed alarms - **See [Phase 1 directive](./android-implementation-directive-phase1.md) for implementation**
- Reschedules future alarms
**Phase 2**: Integrate into `DailyNotificationPlugin.load()` **Phase 2** Force stop detection & full recovery
- Call recovery manager on app launch - Force stop detection via AlarmManager state comparison
- Run asynchronously to avoid blocking - Comprehensive recovery of all schedules (notify + fetch)
- Past alarms marked as missed, future alarms rescheduled
- **See [Phase 2 directive](./android-implementation-directive-phase2.md) for implementation**
**Phase 3**: Enhance `BootReceiver` **Phase 3** Boot receiver missed alarm detection & rescheduling
- Add missed alarm detection - Boot receiver detects missed alarms during device reboot
- Handle alarms that were scheduled before reboot - Next occurrence rescheduled for repeating schedules
- **See [Phase 3 directive](./android-implementation-directive-phase3.md) for implementation**
--- ---
## 2. Implementation: ReactivationManager ## 2. Implementation: ReactivationManager
**⚠️ Illustrative only** See Phase 1 and Phase 2 directives for canonical implementation.
**ReactivationManager Responsibilities by Phase**:
| Phase | Responsibilities |
| ----- | ------------------------------------------------------------- |
| 1 | Cold start only (missed detection + verify/reschedule future) |
| 2 | Adds force stop detection & recovery |
| 3 | Warm start optimizations (future) |
**For implementation details, see:**
- [Phase 1: ReactivationManager creation](./android-implementation-directive-phase1.md#2-implementation-reactivationmanager)
- [Phase 2: Force stop detection](./android-implementation-directive-phase2.md#2-implementation-force-stop-detection)
### 2.1 Create New File ### 2.1 Create New File
**File**: `android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt` **File**: `android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt`
@@ -56,6 +86,8 @@ This directive provides **step-by-step implementation guidance** for Android-spe
### 2.2 Class Structure ### 2.2 Class Structure
**⚠️ Illustrative only** See Phase 1 for canonical implementation.
```kotlin ```kotlin
package com.timesafari.dailynotification package com.timesafari.dailynotification
@@ -113,6 +145,8 @@ class ReactivationManager(private val context: Context) {
### 2.3 Scenario Detection ### 2.3 Scenario Detection
**⚠️ Illustrative only** See Phase 2 for canonical scenario detection implementation.
```kotlin ```kotlin
/** /**
* Detect recovery scenario based on AlarmManager state vs database * Detect recovery scenario based on AlarmManager state vs database
@@ -176,6 +210,8 @@ enum class RecoveryScenario {
### 2.4 Force Stop Recovery ### 2.4 Force Stop Recovery
**⚠️ Illustrative only** See Phase 2 for canonical force stop recovery implementation.
```kotlin ```kotlin
/** /**
* Handle force stop recovery * Handle force stop recovery
@@ -234,6 +270,8 @@ private suspend fun handleForceStopRecovery() {
### 2.5 Cold Start Recovery ### 2.5 Cold Start Recovery
**⚠️ Illustrative only** See Phase 1 for canonical cold start recovery implementation.
```kotlin ```kotlin
/** /**
* Handle cold start recovery * Handle cold start recovery
@@ -571,6 +609,10 @@ override fun load() {
## 4. Enhancement: BootReceiver ## 4. Enhancement: BootReceiver
**⚠️ Illustrative only** See Phase 3 directive for canonical boot receiver implementation.
**For implementation details, see [Phase 3: Boot Receiver Enhancement](./android-implementation-directive-phase3.md#2-implementation-bootreceiver-enhancement)**
### 4.1 Update `rescheduleNotifications()` Method ### 4.1 Update `rescheduleNotifications()` Method
**File**: `android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt` **File**: `android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt`
@@ -705,6 +747,13 @@ private suspend fun handleMissedAlarmOnBoot(
## 5. Testing Requirements ## 5. Testing Requirements
**⚠️ Illustrative only** See Phase 1, Phase 2, and Phase 3 directives for canonical testing procedures.
**For testing details, see:**
- [Phase 1: Testing Requirements](./android-implementation-directive-phase1.md#8-testing-requirements)
- [Phase 2: Testing Requirements](./android-implementation-directive-phase2.md#6-testing-requirements)
- [Phase 3: Testing Requirements](./android-implementation-directive-phase3.md#5-testing-requirements)
### 5.1 Test Setup ### 5.1 Test Setup
**Package Name**: `com.timesafari.dailynotification` **Package Name**: `com.timesafari.dailynotification`