diff --git a/docs/alarms/000-UNIFIED-ALARM-DIRECTIVE.md b/docs/alarms/000-UNIFIED-ALARM-DIRECTIVE.md new file mode 100644 index 0000000..d2a5e12 --- /dev/null +++ b/docs/alarms/000-UNIFIED-ALARM-DIRECTIVE.md @@ -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 1โ€“3) +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 P1โ€“P3. 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 1โ€“3)** + +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 P1โ€“P3 | 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 1โ€“3 & 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 1โ€“3 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 1โ€“3 + * 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 Aโ€“C 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 1โ€“3 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. + diff --git a/docs/android-implementation-directive-phase1.md b/docs/android-implementation-directive-phase1.md new file mode 100644 index 0000000..db8bd5a --- /dev/null +++ b/docs/android-implementation-directive-phase1.md @@ -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: " +- โœ… 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: " +- โœ… 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. + diff --git a/docs/android-implementation-directive-phase2.md b/docs/android-implementation-directive-phase2.md new file mode 100644 index 0000000..8b3afe5 --- /dev/null +++ b/docs/android-implementation-directive-phase2.md @@ -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 + diff --git a/docs/android-implementation-directive-phase3.md b/docs/android-implementation-directive-phase3.md new file mode 100644 index 0000000..7acbed9 --- /dev/null +++ b/docs/android-implementation-directive-phase3.md @@ -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 1โ€“2 | 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: " +- โœ… 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 + diff --git a/docs/android-implementation-directive.md b/docs/android-implementation-directive.md index 09d991a..35aa8a0 100644 --- a/docs/android-implementation-directive.md +++ b/docs/android-implementation-directive.md @@ -6,15 +6,29 @@ ## 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) 2. Missed Alarm Detection 3. Force Stop Detection 4. Boot Receiver Missed Alarm Handling +**โš ๏ธ CRITICAL**: This document is **descriptive and integrative**. The **normative implementation instructions** are in the Phase 1โ€“3 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. +**โš ๏ธ 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 @@ -30,24 +44,40 @@ This directive provides **step-by-step implementation guidance** for Android-spe ### 1.2 Implementation Strategy -**Phase 1**: Create `ReactivationManager` class -- Centralizes all recovery logic -- Handles cold/warm/force-stop scenarios -- Detects missed alarms -- Reschedules future alarms +**Phase 1** โ€“ Cold start recovery only +- Missed notification detection + future alarm verification +- No force-stop detection, no boot handling +- **See [Phase 1 directive](./android-implementation-directive-phase1.md) for implementation** -**Phase 2**: Integrate into `DailyNotificationPlugin.load()` -- Call recovery manager on app launch -- Run asynchronously to avoid blocking +**Phase 2** โ€“ Force stop detection & full recovery +- Force stop detection via AlarmManager state comparison +- 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` -- Add missed alarm detection -- Handle alarms that were scheduled before reboot +**Phase 3** โ€“ Boot receiver missed alarm detection & rescheduling +- Boot receiver detects missed alarms during device reboot +- Next occurrence rescheduled for repeating schedules +- **See [Phase 3 directive](./android-implementation-directive-phase3.md) for implementation** --- ## 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 **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 +**โš ๏ธ Illustrative only** โ€“ See Phase 1 for canonical implementation. + ```kotlin package com.timesafari.dailynotification @@ -113,6 +145,8 @@ class ReactivationManager(private val context: Context) { ### 2.3 Scenario Detection +**โš ๏ธ Illustrative only** โ€“ See Phase 2 for canonical scenario detection implementation. + ```kotlin /** * Detect recovery scenario based on AlarmManager state vs database @@ -176,6 +210,8 @@ enum class RecoveryScenario { ### 2.4 Force Stop Recovery +**โš ๏ธ Illustrative only** โ€“ See Phase 2 for canonical force stop recovery implementation. + ```kotlin /** * Handle force stop recovery @@ -234,6 +270,8 @@ private suspend fun handleForceStopRecovery() { ### 2.5 Cold Start Recovery +**โš ๏ธ Illustrative only** โ€“ See Phase 1 for canonical cold start recovery implementation. + ```kotlin /** * Handle cold start recovery @@ -571,6 +609,10 @@ override fun load() { ## 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 **File**: `android/src/main/java/com/timesafari/dailynotification/BootReceiver.kt` @@ -705,6 +747,13 @@ private suspend fun handleMissedAlarmOnBoot( ## 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 **Package Name**: `com.timesafari.dailynotification`