rename 'docs' directory to 'doc'

This commit is contained in:
2026-03-14 19:52:40 -06:00
parent ca6a75ded8
commit 11561991bd
198 changed files with 35779 additions and 115297 deletions

View File

@@ -0,0 +1,248 @@
# Android Alarm Persistence, Recovery, and Limitations
**⚠️ DEPRECATED**: This document has been superseded by [01-platform-capability-reference.md](./alarms/01-platform-capability-reference.md) as part of the unified alarm documentation structure.
**See**: [Unified Alarm Directive](./alarms/000-UNIFIED-ALARM-DIRECTIVE.md) for the new documentation structure.
**Author**: Matthew Raymer
**Date**: November 2025
**Status**: **DEPRECATED** - Superseded by unified structure
## Purpose
This document provides a **clean, consolidated, engineering-grade directive** summarizing Android's abilities and limitations for remembering, firing, and restoring alarms across:
- App kills
- Swipes from recents
- Device reboot
- **Force stop**
- User-triggered reactivation
This is the actionable version you can plug directly into your architecture docs.
---
## 1. Core Principle
Android does **not** guarantee persistence of alarms across process death, swipes, or reboot.
It is the app's responsibility to **persist alarm definitions** and **re-schedule them** under allowed system conditions.
The following directives outline **exactly what is possible** and **what is impossible**.
---
## 2. Allowed Behaviors (What *Can* Work)
### 2.1 Alarms survive UI kills (swipe from recents)
`AlarmManager.setExactAndAllowWhileIdle(...)` alarms **will fire** even after:
- App is swiped away
- App process is killed by the OS
The OS recreates your app's process to deliver the `PendingIntent`.
**Directive:**
Use `setExactAndAllowWhileIdle` for alarm execution.
---
### 2.2 Alarms can be preserved across device reboot
Android wipes all alarms on reboot, but **you may recreate them**.
**Directive:**
1. Persist all alarms in storage (Room DB or SharedPreferences).
2. Add a `BOOT_COMPLETED` / `LOCKED_BOOT_COMPLETED` broadcast receiver.
3. On boot, load all enabled alarms and reschedule them using AlarmManager.
**Permissions required:**
- `RECEIVE_BOOT_COMPLETED`
**Conditions:**
- User must have launched your app at least once before reboot to grant boot receiver execution.
---
### 2.3 Alarms can fire full-screen notifications and wake the device
**Directive:**
Implement `setFullScreenIntent(...)`, use an IMPORTANCE_HIGH channel with `CATEGORY_ALARM`.
This allows Clock-appstyle alarms even when the app is not foregrounded.
---
### 2.4 Alarms can be restored after app restart
If the user re-opens the app (direct user action), you may:
- Scan the persistent DB
- Detect "missed" alarms
- Reschedule future alarms
- Fire "missed alarm" notifications
- Reconstruct WorkManager/JobScheduler tasks wiped by OS
**Directive:**
Create a `ReactivationManager` that runs on every app launch and recomputes the correct alarm state.
---
## 3. Forbidden Behaviors (What *Cannot* Work)
### 3.1 You cannot survive "Force Stop"
**Settings → Apps → YourApp → Force Stop** triggers:
- Removal of all alarms
- Removal of WorkManager tasks
- Blocking of all broadcast receivers (including BOOT_COMPLETED)
- Blocking of all JobScheduler jobs
- Blocking of AlarmManager callbacks
- Your app will NOT run until the user manually launches it again
**Directive:**
Accept that FORCE STOP is a hard kill.
No scheduling, alarms, jobs, or receivers may execute afterward.
---
### 3.2 You cannot auto-resume after "Force Stop"
You may only resume tasks when:
- The user opens your app
- The user taps a notification belonging to your app
- The user interacts with a widget/deep link
- Another app explicitly targets your component
**Directive:**
Provide user-facing reactivation pathways (icon, widget, notification).
---
### 3.3 Alarms cannot be preserved solely in RAM
Android can kill your app's RAM state at any time.
**Directive:**
All alarm data must be persisted in durable storage.
---
### 3.4 You cannot bypass Doze or battery optimization restrictions without permission
Doze may defer inexact alarms; exact alarms with `setExactAndAllowWhileIdle` are allowed.
**Directive:**
Request `SCHEDULE_EXACT_ALARM` on Android 12+.
---
## 4. Required Implementation Components
### 4.1 Persistent Storage
Create a table or serialized structure for alarms:
```
id: Int
timeMillis: Long
repeat: NONE | DAILY | WEEKLY | CUSTOM
label: String
enabled: Boolean
```
---
### 4.2 Alarm Scheduling
Use:
```kotlin
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
```
---
### 4.3 Boot Receiver
Reschedules alarms from storage.
---
### 4.4 Reactivation Manager
Runs on **every app launch** and performs:
- Load pending alarms
- Detect overdue alarms
- Reschedule future alarms
- Trigger notifications for missed alarms
---
### 4.5 Full-Screen Alarm UI
Use a `BroadcastReceiver` → Notification with full-screen intent → Activity.
---
## 5. Summary of Android Alarm Capability Matrix
| Scenario | Will Alarm Fire? | Reason |
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------- |
| **Swipe from Recents** | ✅ Yes | AlarmManager resurrects the app process |
| **App silently killed by OS** | ✅ Yes | AlarmManager still holds scheduled alarms |
| **Device Reboot** | ❌ No (auto) / ✅ Yes (if you reschedule) | Alarms wiped on reboot |
| **Doze Mode** | ⚠️ Only "exact" alarms | Must use `setExactAndAllowWhileIdle` |
| **Force Stop** | ❌ Never | Android blocks all callbacks + receivers until next user launch |
| **User reopens app** | ✅ You may reschedule & recover | All logic must be implemented by app |
| **PendingIntent from user interaction** | ✅ If triggered by user | User action unlocks the app |
---
## 6. Final Directive
> **Design alarm behavior with the assumption that Android will destroy all scheduled work on reboot or force-stop.
>
> Persist all alarm definitions. On every boot or app reactivation, reconstruct and reschedule alarms.
>
> Never rely on the OS to preserve alarms except across UI process kills.
>
> Accept that "force stop" is a hard stop that cannot be bypassed.**
---
## Related Documentation
- [Boot Receiver Testing Guide](./boot-receiver-testing-guide.md)
- [App Startup Recovery Solution](./app-startup-recovery-solution.md)
- [Reboot Testing Procedure](./reboot-testing-procedure.md)
---
## Future Directives
Potential follow-up directives:
- **How to implement the minimal alarm system**
- **How to implement a Clock-style robust alarm system**
- **How to map this to your own app's architecture**

View File

@@ -0,0 +1,680 @@
# Android App Analysis: DailyNotification Plugin Test App
**Author**: Matthew Raymer
**Date**: 2025-10-24
**Version**: 1.0.0
## Overview
This document provides a comprehensive analysis of the `/android/app` portion of the DailyNotification plugin, examining its structure, purpose, and interaction with the `/www` web assets. This analysis is designed to help understand the plugin's test application architecture and provide context for ChatGPT analysis.
## Table of Contents
- [Architecture Overview](#architecture-overview)
- [Directory Structure](#directory-structure)
- [Key Components](#key-components)
- [Web Asset Integration](#web-asset-integration)
- [Plugin Integration](#plugin-integration)
- [Build Configuration](#build-configuration)
- [Runtime Behavior](#runtime-behavior)
- [Testing Capabilities](#testing-capabilities)
## Architecture Overview
### Purpose
The `/android/app` directory contains a **Capacitor-based Android test application** specifically designed to test and demonstrate the DailyNotification plugin functionality. It serves as:
1. **Plugin Testing Environment**: Interactive testing interface for all plugin features
2. **Development Tool**: Real-time debugging and validation of plugin behavior
3. **Integration Example**: Reference implementation for plugin integration
4. **Documentation**: Live demonstration of plugin capabilities
### Architecture Pattern
```
┌─────────────────────────────────────────────────────────────┐
│ Android App Container │
├─────────────────────────────────────────────────────────────┤
│ MainActivity (BridgeActivity) │
│ ├── Capacitor Bridge │
│ ├── Plugin Discovery │
│ └── WebView Container │
├─────────────────────────────────────────────────────────────┤
│ Web Assets (/www) │
│ ├── index.html (Test Interface) │
│ ├── capacitor.js (Capacitor Runtime) │
│ └── plugins/ (Plugin JavaScript) │
├─────────────────────────────────────────────────────────────┤
│ Native Plugin Integration │
│ ├── DailyNotificationPlugin.java │
│ ├── BootReceiver.java │
│ ├── DailyNotificationReceiver.java │
│ └── Supporting Classes (34 files) │
└─────────────────────────────────────────────────────────────┘
```
## Directory Structure
### Root Android App Structure
```
android/app/
├── build.gradle # App build configuration
├── capacitor.build.gradle # Auto-generated Capacitor config
├── proguard-rules.pro # Code obfuscation rules
└── src/
├── main/
│ ├── AndroidManifest.xml # App permissions and components
│ ├── assets/ # Web assets (Capacitor www)
│ │ ├── capacitor.config.json
│ │ ├── capacitor.plugins.json
│ │ └── public/ # Web application files
│ │ ├── index.html # Main test interface
│ │ ├── capacitor.js # Capacitor runtime
│ │ ├── capacitor_plugins.js
│ │ └── plugins/ # Plugin JavaScript files
│ ├── java/
│ │ └── com/timesafari/dailynotification/
│ │ └── MainActivity.java # Capacitor BridgeActivity
│ └── res/ # Android resources
│ ├── drawable/ # App icons and images
│ ├── layout/ # Android layouts
│ ├── mipmap/ # App launcher icons
│ ├── values/ # Strings, styles, colors
│ └── xml/ # Configuration files
├── androidTest/ # Instrumented tests
└── test/ # Unit tests
```
## Key Components
### 1. MainActivity.java
**Purpose**: Entry point extending Capacitor's BridgeActivity
**Location**: `src/main/java/com/timesafari/dailynotification/MainActivity.java`
```java
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
}
```
**Key Features**:
- Minimal implementation - Capacitor handles most functionality
- Extends `BridgeActivity` for automatic plugin discovery
- Provides WebView container for web assets
- Handles plugin registration and JavaScript bridge
### 2. AndroidManifest.xml
**Purpose**: App configuration, permissions, and component declarations
**Location**: `src/main/AndroidManifest.xml`
**Key Declarations**:
```xml
<!-- App Configuration -->
<application android:name="org.timesafari.dailynotification">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
> **Note:** Set `android:name` only if you provide a custom `Application` class; otherwise remove to avoid ClassNotFound at runtime.
**Safe default (no custom Application class):**
```xml
<application>
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
```
<!-- Plugin Components -->
<!-- Internal receiver: keep non-exported unless intentionally public -->
<receiver
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="org.timesafari.daily.NOTIFICATION" />
</intent-filter>
</receiver>
<receiver
android:name="org.timesafari.dailynotification.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter android:priority="1000">
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
> **Note:** `android:priority` has no practical effect for `BOOT_COMPLETED`; safe to omit.
**Minimal example (recommended):**
```xml
<receiver
android:name="org.timesafari.dailynotification.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
```
</application>
<!-- Required Permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
> **Tip:** `WAKE_LOCK` is typically unnecessary with AlarmManager/WorkManager; remove unless you explicitly acquire/release your own wakelocks.
> **Note:** `POST_NOTIFICATIONS` is required **on Android 13+**; lower API levels ignore it gracefully.
```
**Critical Permissions**:
- `POST_NOTIFICATIONS`: Required for Android 13+ notification posting
- `SCHEDULE_EXACT_ALARM`: Required for precise notification timing
- `WAKE_LOCK` **not required** unless you explicitly acquire/release your own wakelocks (AlarmManager & WorkManager handle theirs)
- `INTERNET`: Required for content fetching
- `RECEIVE_BOOT_COMPLETED`: Required for reboot recovery
> **Note:** If you later introduce foreground services, revisit WAKE_LOCK; otherwise keep it out.
### 3. Capacitor Configuration Files
#### capacitor.config.json
**Purpose**: Capacitor runtime configuration
```json
{
"appId": "org.timesafari.dailynotification",
"appName": "DailyNotification Test App",
"webDir": "www",
"server": {
"androidScheme": "https"
},
"plugins": {
"DailyNotification": {
"fetchUrl": "https://api.example.com/daily-content",
"scheduleTime": "09:00",
"enableNotifications": true,
"debugMode": true
}
}
}
```
#### capacitor.plugins.json
**Purpose**: Plugin discovery and registration
**Note**: Auto-generated on `npx cap sync` - should not be manually edited
```json
[
{
"name": "DailyNotification",
"classpath": "org.timesafari.dailynotification.DailyNotificationPlugin"
}
]
```
## Web Asset Integration
### /www Directory Structure
The `/www` directory (mapped to `assets/public/`) contains the web application that runs inside the Capacitor WebView:
> Capacitor builds copy your `webDir` (e.g., `www/`) to `src/main/assets/public/` on Android; the WebView serves from that `public/` folder.
```
assets/public/
├── index.html # Main test interface (549 lines)
├── capacitor.js # Capacitor runtime
├── capacitor_plugins.js # Plugin JavaScript bridge
> **Note:** On pure Capacitor builds, the runtime is `capacitor.js`. Only include `cordova.js/cordova_plugins.js` if Cordova-compat is enabled; otherwise remove those references for accuracy.
└── plugins/ # Plugin JavaScript files
```
### index.html Analysis
**Purpose**: Interactive test interface for plugin functionality
**Size**: 549 lines of HTML, CSS, and JavaScript
**Features**:
#### 1. User Interface
- **Modern Design**: Gradient background, responsive layout
- **Interactive Buttons**: 12 test functions with visual feedback
- **Status Display**: Real-time feedback with color-coded results
- **Mobile-Optimized**: Touch-friendly interface
#### 2. Test Categories
```javascript
// Plugin Testing
- testPlugin() // Basic plugin availability
- configurePlugin() // Plugin configuration
- checkStatus() // Plugin status check
// Notification Testing
- testNotification() // Immediate notification test
- scheduleNotification() // Scheduled notification test
- showReminder() // Daily reminder test
// Permission Management
- checkPermissions() // Permission status check
- requestPermissions() // Permission request
- openExactAlarmSettings() // Settings navigation
// Channel Management
- checkChannelStatus() // Notification channel status
- openChannelSettings() // Channel settings navigation
- checkComprehensiveStatus() // Complete status check
```
#### 3. Plugin Integration
```javascript
// Plugin Access Pattern
window.DailyNotification = window.Capacitor.Plugins.DailyNotification;
// Example Usage
window.DailyNotification.scheduleDailyNotification({
time: "09:00",
title: "Test Notification",
body: "This is a test notification!",
sound: true,
priority: "high"
});
```
#### 4. Error Handling
- **Visual Feedback**: Color-coded status indicators
- **Error Messages**: Detailed error reporting
- **Graceful Degradation**: Fallback behavior for missing features
## Plugin Integration
### Plugin Discovery Process
1. **Capacitor Startup**: Loads `capacitor.plugins.json`
2. **Plugin Registration**: Discovers `DailyNotificationPlugin` class
3. **JavaScript Bridge**: Creates `window.Capacitor.Plugins.DailyNotification`
4. **Method Exposure**: Exposes `@PluginMethod` annotated methods
### Plugin Class Structure
**Location**: `android/plugin/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java`
**Key Methods** (from `@PluginMethod` annotations):
```java
@PluginMethod
public void configure(PluginCall call) { ... }
@PluginMethod
public void scheduleDailyNotification(PluginCall call) { ... }
@PluginMethod
public void scheduleDailyReminder(PluginCall call) { ... }
@PluginMethod
public void getNotificationStatus(PluginCall call) { ... }
@PluginMethod
public void checkPermissionStatus(PluginCall call) { ... }
@PluginMethod
public void requestNotificationPermissions(PluginCall call) { ... }
@PluginMethod
public void checkStatus(PluginCall call) { ... }
@PluginMethod
public void isChannelEnabled(PluginCall call) { ... }
@PluginMethod
public void openChannelSettings(PluginCall call) { ... }
@PluginMethod
public void openExactAlarmSettings(PluginCall call) { ... }
```
### Supporting Classes (34 files)
The plugin includes a comprehensive set of supporting classes:
**Core Components**:
- `BootReceiver.java` - Handles system boot events
- `DailyNotificationReceiver.java` - Handles notification events
- `DailyNotificationScheduler.java` - Manages notification scheduling
- `DailyNotificationFetcher.java` - Handles content fetching
**Storage & Database**:
- `DailyNotificationStorage.java` - Storage abstraction
- `DailyNotificationStorageRoom.java` - Room database implementation
- `DailyNotificationDatabase.java` - Database definition
- `dao/` - Data Access Objects (3 files)
- `entities/` - Database entities (3 files)
**Management & Utilities**:
- `PermissionManager.java` - Permission handling
- `ChannelManager.java` - Notification channel management
- `DailyNotificationExactAlarmManager.java` - Exact alarm management
- `DailyNotificationErrorHandler.java` - Error handling
- `DailyNotificationPerformanceOptimizer.java` - Performance optimization
**Workers & Background Tasks**:
- `DailyNotificationWorker.java` - Main background worker
- `DailyNotificationFetchWorker.java` - Content fetching worker
- `DailyNotificationMaintenanceWorker.java` - Maintenance tasks
- `DozeFallbackWorker.java` - Doze mode handling
- `SoftRefetchWorker.java` - Soft refresh handling
## Build Configuration
### app/build.gradle
**Purpose**: App-level build configuration and dependencies
**Key Dependencies**:
```gradle
dependencies {
// Capacitor Core
implementation project(':capacitor-android')
implementation project(':plugin') // DailyNotification plugin
// AndroidX Libraries
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
// Plugin-Specific Dependencies
implementation "androidx.room:room-runtime:2.6.1"
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
// Cordova compatibility (include ONLY if using Cordova plugins)
debugImplementation(project(':capacitor-cordova-android-plugins')) { transitive = false }
releaseImplementation(project(':capacitor-cordova-android-plugins')) { transitive = false }
> **Note:** Include `capacitor-cordova-android-plugins` **only** when using Cordova plugins.
}
```
**Build Configuration**:
```gradle
android {
namespace "org.timesafari.dailynotification"
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "org.timesafari.dailynotification"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
}
```
### capacitor.build.gradle
**Purpose**: Auto-generated Capacitor configuration
**Note**: Regenerated on each `npx cap sync` - should not be manually edited
**Manifest Hygiene (Quick Scan)**
- [ ] `<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>`
- [ ] `<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>` (if you truly need exact)
- [ ] `<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>`
- [ ] BootReceiver: `exported="true"` + BOOT_COMPLETED filter
- [ ] Other receivers exported=false unless needed
- [ ] No stray `android:permission=` on BootReceiver
## Runtime Behavior
### App Startup Sequence
1. **Android System**: Launches `MainActivity`
2. **Capacitor Initialization**: Loads Capacitor runtime
3. **Plugin Discovery**: Scans `capacitor.plugins.json` for plugins
4. **Plugin Registration**: Instantiates `DailyNotificationPlugin`
5. **WebView Loading**: Loads `index.html` from assets
6. **JavaScript Bridge**: Establishes communication between web and native
7. **Plugin Availability**: `window.Capacitor.Plugins.DailyNotification` becomes available
### Plugin Method Execution Flow
```
JavaScript Call
Capacitor Bridge
Plugin Method (@PluginMethod)
Native Implementation
Response (JSObject)
JavaScript Promise Resolution
```
### Background Processing
- **WorkManager**: Handles background content fetching
- **AlarmManager**: Manages notification scheduling
- **BootReceiver**: Reschedules notifications after reboot
- **Doze Mode**: Handles Android's battery optimization
> **Closed vs force-stopped:** Closing/swiping the app does not affect alarms or WorkManager. **Force-stopping** from Settings cancels alarms and suppresses receivers until the next launch, after which Boot/rehydration logic can restore future schedules.
## Testing Capabilities
### Interactive Testing Features
The test app provides comprehensive testing capabilities:
#### 1. Plugin Availability Testing
- **Basic Detection**: Verify plugin is loaded and accessible
- **Method Availability**: Check if specific methods are callable
- **Error Handling**: Test error conditions and edge cases
#### 2. Notification Testing
- **Immediate Notifications**: Test instant notification display
- **Scheduled Notifications**: Test time-based notification scheduling
- **Reminder Testing**: Test daily reminder functionality
- **Content Testing**: Test with different notification content
#### 3. Permission Management
- **Permission Status**: Check current permission state
- **Permission Requests**: Test permission request flow
- **Settings Navigation**: Test opening system settings
- **Permission Validation**: Verify permission changes
#### 4. Channel Management
- **Channel Status**: Check notification channel state
- **Channel Settings**: Test channel configuration
- **Importance Levels**: Test different importance settings
- **Channel Creation**: Test channel creation and management
#### 5. Comprehensive Status Checking
- **Overall Status**: Complete system status check
- **Issue Detection**: Identify configuration problems
- **Readiness Check**: Verify system is ready for notifications
- **Troubleshooting**: Help identify and resolve issues
### Debugging Features
- **Console Logging**: Detailed console output for debugging
- **Visual Feedback**: Color-coded status indicators
- **Error Reporting**: Detailed error messages and stack traces
- **Real-time Updates**: Live status updates during testing
## Integration Patterns
### Plugin Integration Pattern
```javascript
// 1. Check Plugin Availability
if (!window.DailyNotification) {
console.error('Plugin not available');
return;
}
// 2. Call Plugin Method
window.DailyNotification.scheduleDailyNotification({
time: "09:00",
title: "Daily Notification",
body: "Your daily content is ready!",
sound: true,
priority: "high"
})
.then(() => {
console.log('Notification scheduled successfully');
})
.catch(error => {
console.error('Scheduling failed:', error);
});
```
### Error Handling Pattern
```javascript
try {
const result = await window.DailyNotification.checkStatus();
// Handle success
} catch (error) {
// Handle error
console.error('Status check failed:', error.message);
}
```
### Permission Management Pattern
```javascript
// Check permissions first
const permissions = await window.DailyNotification.checkPermissionStatus();
if (!permissions.allPermissionsGranted) {
// Request permissions
await window.DailyNotification.requestNotificationPermissions();
}
```
## Key Insights
### Strengths
1. **Comprehensive Testing**: Covers all plugin functionality
2. **Interactive Interface**: Easy-to-use testing interface
3. **Real-time Feedback**: Immediate visual feedback
4. **Error Handling**: Robust error handling and reporting
5. **Permission Management**: Complete permission testing
6. **Documentation**: Self-documenting through interface
### Architecture Benefits
1. **Separation of Concerns**: Clear separation between web and native
2. **Plugin Isolation**: Plugin functionality is isolated and testable
3. **Capacitor Integration**: Leverages Capacitor's plugin system
4. **Cross-Platform**: Web interface works across platforms
5. **Maintainable**: Easy to update and maintain
### Use Cases
1. **Plugin Development**: Test new plugin features
2. **Integration Testing**: Verify plugin integration
3. **Debugging**: Debug plugin issues
4. **Documentation**: Demonstrate plugin capabilities
5. **Quality Assurance**: Validate plugin functionality
## Conclusion
The `/android/app` portion of the DailyNotification plugin represents a well-architected test application that provides comprehensive testing capabilities for the plugin. It demonstrates best practices for Capacitor plugin integration, including proper permission handling, error management, and user interface design.
The integration between the web assets (`/www`) and native Android code through Capacitor's bridge system creates a seamless testing environment that allows developers to validate plugin functionality in real-time while providing an intuitive interface for non-technical users to test and understand the plugin's capabilities.
This architecture serves as both a practical testing tool and a reference implementation for integrating the DailyNotification plugin into other applications.
## Assumptions & Versions
| Topic | Value | Notes |
|---|---|---|
| Android min/target SDK | 24 / 35 | Align with `compileSdkVersion`/`targetSdkVersion`. |
| Capacitor | v5.x | Confirm web asset naming (`capacitor.js` vs Cordova shims). |
| WorkManager | 2.9.0 | Matches Gradle deps listed. |
| Room | 2.6.1 | Matches Gradle deps listed. |
| Exact Alarms | Tiramisu+ | Requires user grant on many OEMs. |
## Bridge Surface (Summary)
- `scheduleDailyNotification(req: {time, title, body, sound, priority}) -> {success, scheduledAt?, error?}`
- `checkPermissionStatus() -> {postNotificationsGranted, exactAlarmGranted, batteryOptIgnored, channelEnabled, ...}`
- `openChannelSettings() -> {opened: boolean}`
- `openExactAlarmSettings() -> {opened: boolean}`
- `requestNotificationPermissions() -> {granted: boolean, permissions: {...}}`
- `getNotificationStatus() -> {isEnabled, isScheduled, nextNotificationTime, ...}`
**Status Matrix MUST include:** `postNotificationsGranted`, `exactAlarmGranted`, `channelEnabled`, `batteryOptimizationsIgnored`, `canScheduleNow`.
### Exact-Alarm Decision Rule (User-Visible)
If `SCHEDULE_EXACT_ALARM` is **granted** → schedule with `setExactAndAllowWhileIdle`.
If **denied or quota-limited** → schedule via WorkManager (exp backoff + jitter) and surface `E_EXACT_ALARM_DENIED` (with "Degraded timing — Doze may delay" hint).
> **Exact Alarm note:** `SCHEDULE_EXACT_ALARM` is a **special app-op**, not a runtime permission prompt. Users grant it via Settings; your UI should deep-link there and reflect denial by degrading to WorkManager.
## Permission & Settings Truth Table
| Symptom | Likely Cause | Action |
|---|---|---|
| No notification posts | `POST_NOTIFICATIONS` denied | Call `requestNotificationPermissions()` |
| Fires late/misses | No exact alarm grant / Doze | `openExactAlarmSettings()` or fallback to WorkManager |
| Silent notifications | Channel disabled/low importance | `openChannelSettings()` then retest |
| Battery optimization kills | App not whitelisted | Guide user to battery optimization settings |
| Boot reschedule fails | `RECEIVE_BOOT_COMPLETED` denied | Check manifest receiver registration |
> **Test UI Integration:** Use "Open Channel Settings" and "Open Exact Alarm Settings" buttons in the test interface to resolve channel and exact alarm issues.
## Runtime Flow Diagram
```mermaid
graph TD
A[JavaScript Call] --> B[Capacitor Bridge]
B --> C[@PluginMethod → Canonical Error]
C --> D[Use Case Handler → Canonical Error]
D --> E{Alarm vs WorkManager}
E -->|Exact Alarm| F[AlarmManager]
E -->|Fallback| G[WorkManager]
F --> H[BootReceiver]
G --> H
H --> I[NotificationReceiver]
I --> J[UI Update]
%% Error paths
C -->|Validation Error → Canonical Error| K[Canonical Error]
D -->|Use-case Error → Canonical Error| K
K --> L[JavaScript Promise Rejection]
```
## Cordova vs Capacitor Assets Accuracy Note
> **Note:** If using pure Capacitor v5, the web runtime is `capacitor.js`. If Cordova compatibility is enabled, `cordova.js/cordova_plugins.js` will appear; otherwise remove those references here for accuracy.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
# Building the Daily Notification Plugin
## Important: Standalone Build Limitations
**Capacitor plugins cannot be built standalone** because Capacitor dependencies are npm packages, not Maven artifacts.
### ✅ Correct Way to Build
Build the plugin **within a Capacitor app** that uses it:
```bash
# In a consuming Capacitor app (e.g., test-apps/android-test-app or your app)
cd /path/to/capacitor-app/android
./gradlew assembleDebug
# Or use Capacitor CLI
npx cap sync android
npx cap run android
```
### ❌ What Doesn't Work
```bash
# This will fail - Capacitor dependencies aren't in Maven
cd android
./gradlew assembleDebug
```
### Why This Happens
1. **Capacitor dependencies are npm packages**, not Maven artifacts
2. **Capacitor plugins are meant to be consumed**, not built standalone
3. **The consuming app provides Capacitor** as a project dependency
4. **When you run `npx cap sync`**, Capacitor sets up the correct dependency structure
### For Development & Testing
Use the test app at `test-apps/android-test-app/`:
```bash
cd test-apps/android-test-app
npm install
npx cap sync android
cd android
./gradlew assembleDebug
```
The plugin will be built as part of the test app's build process.
### Gradle Wrapper Purpose
The gradle wrapper in `android/` is provided for:
-**Syntax checking** - Verify build.gradle syntax
-**Android Studio** - Open the plugin directory in Android Studio for editing
-**Documentation** - Show available tasks and structure
-**Not for standalone builds** - Requires a consuming app context
### Verifying Build Configuration
You can verify the build configuration is correct:
```bash
cd android
./gradlew tasks # Lists available tasks (may show dependency errors, that's OK)
./gradlew clean # Cleans build directory
```
The dependency errors are expected - they confirm the plugin needs a consuming app context.

View File

@@ -0,0 +1,37 @@
# Consuming App Notes — Android Daily Notifications
Brief notes for apps that integrate the daily notification plugin on Android.
---
## Double schedule (rapid successive calls)
If your app calls `scheduleDailyNotification` twice in quick succession (e.g. within a few hundred ms) for the same reminder, the second call cancels the alarm just set and reschedules. On some devices or OEMs this can contribute to the alarm not firing.
**Recommendation:** Debounce or guard in the edit-reminder success path so you only call `scheduleDailyNotification` once per user action (e.g. wait for the first call to resolve before allowing another, or coalesce rapid calls).
---
## Alarm scheduled but not firing (e.g. 6:04)
When logs show "Scheduling OS alarm" and "Updated schedule in database" but the notification never appears:
1. **Confirm the broadcast is delivered**
Run logcat including the receiver:
```bash
adb logcat -v time -s DNP-SCHEDULE:V DailyNotificationWorker:V DailyNotificationReceiver:V
```
At the scheduled time, check whether `DailyNotificationReceiver` logs anything. If the Receiver runs, the issue is downstream (WorkManager / display). If it does not run, the OS did not deliver the alarm (Doze, OEM, or alarm replacement).
2. **Avoid double schedule**
Ensure the app is not calling `scheduleDailyNotification` twice in quick succession for the same reminder (see above).
3. **Plugin fix (v1.1.6+)**
The plugin no longer overwrites the apps schedule row when handling rollover work that uses a `daily_rollover_*` id, so the apps `nextRunAt` stays correct after a notification fires.
---
## References
- [ACTION_PLAN_INTEGRATION_FIXES.md](../integration/ACTION_PLAN_INTEGRATION_FIXES.md) — plugin and app integration checklist
- [CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md](./CONSUMING_APP_OPTIONAL_ANDROID_ID_CLEANUP.md) — optional cleanup of stale schedule rows

View File

@@ -0,0 +1,136 @@
# Optional: Use a Single Stable Schedule ID on iOS and Android
**Audience:** Consuming apps (e.g. TimeSafari / crowd-funder-for-time-pwa) that use `@timesafari/daily-notification-plugin`.
**Purpose:** Describe an optional app-side cleanup now that the plugins Android second-schedule bug is fixed (plugin v1.1.2+).
**Use:** Feed this doc into Cursor (or any editor) in the consuming app repo when implementing the cleanup.
---
## Context
- **Plugin fix (v1.1.2):** After cancel-then-schedule on Android, the plugin no longer skips the new schedule due to PendingIntent cache. Rescheduling works reliably whether or not the app passes an explicit `id` to `scheduleDailyNotification`.
- **Previous workaround:** Some apps avoided passing `id` on Android and used the plugin default `"daily_notification"` so that the (now-fixed) second-schedule bug would not trigger. On iOS they passed a stable id (e.g. `"daily_timesafari_reminder"`) for getStatus/cancel and verification.
- **Optional cleanup:** You can use the **same** stable schedule id on both iOS and Android. That simplifies code (one id everywhere), makes getStatus/cancel and verification consistent across platforms, and is safe with plugin v1.1.2+.
---
## Prerequisites
- Depend on **`@timesafari/daily-notification-plugin@1.1.2`** (or `^1.1.2`) so the Android fix is in effect.
- No other code changes are required for the bug fix; this doc is only for the optional id cleanup.
---
## What to Change in the Consuming App
### 1. Single stable reminder ID (both platforms)
Use one reminder id for schedule, cancel, and getStatus on both iOS and Android.
**Example (current pattern):**
```ts
// Before: different id per platform
private get reminderId(): string {
return Capacitor.getPlatform() === "ios"
? "daily_timesafari_reminder"
: "daily_notification";
}
```
**After (optional cleanup):**
```ts
// After: same stable id on both platforms (requires plugin >= 1.1.2)
private readonly reminderId = "daily_timesafari_reminder";
```
Or keep a getter if you prefer:
```ts
private get reminderId(): string {
return "daily_timesafari_reminder";
}
```
Use whatever stable string your app already uses on iOS (e.g. `"daily_timesafari_reminder"`); no need to change the value.
---
### 2. Pass `id` when scheduling on Android
Today you may only add `scheduleOptions.id` on iOS. Add it for Android too so the plugin stores and returns this id (getStatus, getScheduledReminders, cancel all use it).
**Example (current pattern):**
```ts
const scheduleOptions = {
time: options.time,
title: options.title,
body: options.body,
sound: true,
priority: (options.priority || "normal") as "low" | "default" | "high",
};
if (Capacitor.getPlatform() === "ios") {
scheduleOptions.id = this.reminderId;
}
await DailyNotification.scheduleDailyNotification(scheduleOptions);
```
**After (optional cleanup):**
```ts
const scheduleOptions = {
time: options.time,
title: options.title,
body: options.body,
sound: true,
priority: (options.priority || "normal") as "low" | "default" | "high",
id: this.reminderId, // same id on iOS and Android (plugin >= 1.1.2)
};
await DailyNotification.scheduleDailyNotification(scheduleOptions);
```
So: always pass `id: this.reminderId` (or your chosen constant) for both platforms.
---
### 3. Update comments
Remove or update comments that say Android must not receive an `id` to avoid the second-schedule bug, and that the plugin uses `"daily_notification"` on Android. Replace with a short note that a single stable id is used on both platforms and requires plugin v1.1.2+.
**Example comment to add/update:**
```ts
/**
* Stable schedule/reminder ID used for schedule, cancel, and getStatus.
* Same value on iOS and Android (plugin v1.1.2+ fixes Android reschedule with custom id).
*/
private readonly reminderId = "daily_timesafari_reminder";
```
---
## Files to Touch (typical)
- **Native notification service** (e.g. `src/services/notifications/NativeNotificationService.ts`):
- `reminderId`: use single value for both platforms.
- `scheduleDailyNotification`: always pass `id` in `scheduleOptions` (include Android).
- Adjust comments as above.
No changes are required to cancel or getStatus if they already use `this.reminderId`; they will now resolve the same schedule on Android as on iOS.
---
## Verification
1. **Android:** Schedule a daily notification, then change time and save again (reschedule). The second scheduled time should fire; no need to reinstall.
2. **getStatus:** After scheduling on Android, getStatus should return the scheduled reminder with the same id you pass (e.g. `daily_timesafari_reminder`).
3. **Cancel:** Cancelling by that id on Android should clear the scheduled notification.
---
## References
- Plugin CHANGELOG: `[1.1.2] - 2026-02-13` — Android second daily notification not firing after reschedule.
- Issue context (if present in consuming app): `doc/android-daily-notification-second-schedule-issue.md`.

View File

@@ -0,0 +1,310 @@
# Database Consolidation Plan
## Current State
### Database 1: Java (`daily_notification_plugin.db`)
- `notification_content` - Specific notification instances
- `notification_delivery` - Delivery tracking/analytics
- `notification_config` - Configuration
### Database 2: Kotlin (`daily_notification_database`)
- `content_cache` - Fetched content with TTL
- `schedules` - Recurring schedule patterns (CRITICAL for reboot)
- `callbacks` - Callback configurations
- `history` - Execution history
## Unified Schema Design
### Required Tables (All Critical)
1. **`schedules`** - Recurring schedule patterns
- Stores cron/clockTime patterns
- Used to restore schedules after reboot
- Fields: id, kind ('fetch'/'notify'), cron, clockTime, enabled, lastRunAt, nextRunAt, jitterMs, backoffPolicy, stateJson
2. **`content_cache`** - Fetched content with TTL
- Stores prefetched content for offline-first display
- Fields: id, fetchedAt, ttlSeconds, payload (BLOB), meta
3. **`notification_config`** - Plugin configuration
- Stores user preferences and plugin settings
- Fields: id, timesafariDid, configType, configKey, configValue, configDataType, isEncrypted, createdAt, updatedAt
4. **`callbacks`** - Callback configurations
- Stores callback endpoint configurations
- Fields: id, kind ('http'/'local'/'queue'), target, headersJson, enabled, createdAt
### Optional Tables (Analytics/Debugging)
5. **`notification_content`** - Specific notification instances
- May still be needed for one-time notifications or TimeSafari integration
- Fields: All existing fields from Java entity
6. **`notification_delivery`** - Delivery tracking
- Analytics for delivery attempts and user interactions
- Fields: All existing fields from Java entity
7. **`history`** - Execution history
- Logs fetch/notify/callback execution
- Fields: id, refId, kind, occurredAt, durationMs, outcome, diagJson
## Consolidation Strategy
- [x] Keep Kotlin schema as base - It already has critical tables
- [x] Add Java tables to Kotlin schema - Merge missing entities
- [x] Update all Java code - Use unified database instance
- [x] Update all Kotlin code - Use unified database instance
- [x] Single database file: `daily_notification_plugin.db`
## Migration Path
- [x] Create unified `DailyNotificationDatabase` with all entities
- [x] Update Java code to use unified database
- [x] Update Kotlin code to use unified database
- [x] Remove old `DailyNotificationDatabase` files
- [ ] Test reboot recovery
## Key Decisions
- **Primary language**: Kotlin (more modern, better coroutine support)
- **Database name**: `daily_notification_plugin.db` (Java naming convention)
- **All entities**: Both Java and Kotlin compatible
- **DAOs**: Mix of Java and Kotlin DAOs as needed
## TypeScript Interface Requirements
Since the plugin owns the database, the host app/webview needs TypeScript interfaces to read/write data.
### Required TypeScript Methods
#### Schedules Management
```typescript
// Read schedules
getSchedules(options?: { kind?: 'fetch' | 'notify', enabled?: boolean }): Promise<Schedule[]>
getSchedule(id: string): Promise<Schedule | null>
// Write schedules
createSchedule(schedule: CreateScheduleInput): Promise<Schedule>
updateSchedule(id: string, updates: Partial<Schedule>): Promise<Schedule>
deleteSchedule(id: string): Promise<void>
enableSchedule(id: string, enabled: boolean): Promise<void>
// Utility
calculateNextRunTime(schedule: string): Promise<number>
```
#### Content Cache Management
```typescript
// Read content cache
getContentCache(options?: { id?: string }): Promise<ContentCache | null>
getLatestContentCache(): Promise<ContentCache | null>
getContentCacheHistory(limit?: number): Promise<ContentCache[]>
// Write content cache
saveContentCache(content: CreateContentCacheInput): Promise<ContentCache>
clearContentCache(options?: { olderThan?: number }): Promise<void>
```
#### Configuration Management
```typescript
// Read config
getConfig(key: string, options?: { timesafariDid?: string }): Promise<Config | null>
getAllConfigs(options?: { timesafariDid?: string, configType?: string }): Promise<Config[]>
// Write config
setConfig(config: CreateConfigInput): Promise<Config>
updateConfig(key: string, value: string, options?: { timesafariDid?: string }): Promise<Config>
deleteConfig(key: string, options?: { timesafariDid?: string }): Promise<void>
```
#### Callbacks Management
```typescript
// Read callbacks
getCallbacks(options?: { enabled?: boolean }): Promise<Callback[]>
getCallback(id: string): Promise<Callback | null>
// Write callbacks
registerCallback(callback: CreateCallbackInput): Promise<Callback>
updateCallback(id: string, updates: Partial<Callback>): Promise<Callback>
deleteCallback(id: string): Promise<void>
enableCallback(id: string, enabled: boolean): Promise<void>
```
#### History/Analytics (Optional)
```typescript
// Read history
getHistory(options?: {
since?: number,
kind?: 'fetch' | 'notify' | 'callback',
limit?: number
}): Promise<History[]>
getHistoryStats(): Promise<HistoryStats>
```
### Type Definitions
```typescript
interface Schedule {
id: string
kind: 'fetch' | 'notify'
cron?: string
clockTime?: string // HH:mm format
enabled: boolean
lastRunAt?: number
nextRunAt?: number
jitterMs: number
backoffPolicy: string
stateJson?: string
}
interface ContentCache {
id: string
fetchedAt: number
ttlSeconds: number
payload: string // Base64 or JSON string
meta?: string
}
interface Config {
id: string
timesafariDid?: string
configType: string
configKey: string
configValue: string
configDataType: string
isEncrypted: boolean
createdAt: number
updatedAt: number
}
interface Callback {
id: string
kind: 'http' | 'local' | 'queue'
target: string
headersJson?: string
enabled: boolean
createdAt: number
}
interface History {
id: number
refId: string
kind: 'fetch' | 'notify' | 'callback' | 'boot_recovery'
occurredAt: number
durationMs?: number
outcome: string
diagJson?: string
}
```
# Database Consolidation Plan
## Status: ✅ **CONSOLIDATION COMPLETE**
The unified database has been successfully created and all code has been migrated to use it.
## Current State
### Unified Database (`daily_notification_plugin.db`)
Located in: `android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt`
**All Tables Consolidated:**
-`content_cache` - Fetched content with TTL (Kotlin)
-`schedules` - Recurring schedule patterns (Kotlin, CRITICAL for reboot)
-`callbacks` - Callback configurations (Kotlin)
-`history` - Execution history (Kotlin)
-`notification_content` - Specific notification instances (Java)
-`notification_delivery` - Delivery tracking/analytics (Java)
-`notification_config` - Configuration management (Java)
### Old Database Files (DEPRECATED - REMOVED)
-`android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java` - **REMOVED** - All functionality merged into unified database
## Migration Status
### ✅ Completed Tasks
- [x] Analyzed both database schemas and identified all required tables
- [x] Designed unified database schema with all required entities
- [x] Created unified DailyNotificationDatabase class (Kotlin)
- [x] Added migration from version 1 (Kotlin-only) to version 2 (unified)
- [x] Updated all Java code to use unified database
- [x] `DailyNotificationStorageRoom.java` - Uses unified database
- [x] `DailyNotificationWorker.java` - Uses unified database
- [x] Updated all Kotlin code to use unified database
- [x] `DailyNotificationPlugin.kt` - Uses unified database
- [x] `FetchWorker.kt` - Uses unified database
- [x] `NotifyReceiver.kt` - Uses unified database
- [x] `BootReceiver.kt` - Uses unified database
- [x] Implemented all Config methods in PluginMethods
- [x] TypeScript interfaces updated for database CRUD operations
- [x] Documentation created for AI assistants
### ⏳ Pending Tasks
- [x] Remove old database files (`DailyNotificationDatabase.java`)
- [ ] Test reboot recovery with unified database
- [ ] Verify migration path works correctly
## Unified Schema Design (IMPLEMENTED)
### Required Tables (All Critical)
1. **`schedules`** - Recurring schedule patterns
- Stores cron/clockTime patterns
- Used to restore schedules after reboot
- Fields: id, kind ('fetch'/'notify'), cron, clockTime, enabled, lastRunAt, nextRunAt, jitterMs, backoffPolicy, stateJson
2. **`content_cache`** - Fetched content with TTL
- Stores prefetched content for offline-first display
- Fields: id, fetchedAt, ttlSeconds, payload (BLOB), meta
3. **`notification_config`** - Plugin configuration
- Stores user preferences and plugin settings
- Fields: id, timesafariDid, configType, configKey, configValue, configDataType, isEncrypted, createdAt, updatedAt, ttlSeconds, isActive, metadata
4. **`callbacks`** - Callback configurations
- Stores callback endpoint configurations
- Fields: id, kind ('http'/'local'/'queue'), target, headersJson, enabled, createdAt
5. **`notification_content`** - Specific notification instances
- Stores notification content with plugin-specific fields
- Fields: All existing fields from Java entity
6. **`notification_delivery`** - Delivery tracking
- Analytics for delivery attempts and user interactions
- Fields: All existing fields from Java entity
7. **`history`** - Execution history
- Logs fetch/notify/callback execution
- Fields: id, refId, kind, occurredAt, durationMs, outcome, diagJson
## Implementation Details
### Database Access
- **Kotlin**: `DailyNotificationDatabase.getDatabase(context)`
- **Java**: `DailyNotificationDatabase.getInstance(context)` (Java-compatible wrapper)
### Migration Path
- Version 1 → Version 2: Automatically creates Java entity tables when upgrading from Kotlin-only schema
- Migration runs automatically on first access after upgrade
### Thread Safety
- All database operations use Kotlin coroutines (`Dispatchers.IO`)
- Room handles thread safety internally
- Singleton pattern ensures single database instance
## Next Steps
1. **Remove Old Database File** ✅ COMPLETE
- [x] Delete `android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java`
- [x] Verify no remaining references
2. **Testing**
- [ ] Test reboot recovery with unified database
- [ ] Verify schedule restoration works correctly
- [ ] Verify all Config methods work correctly
- [ ] Test migration from v1 to v2
3. **Documentation**
- [ ] Update any remaining documentation references
- [ ] Verify AI documentation is complete

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,712 @@
# Android Implementation Directive: Phase 1 - Cold Start Recovery
**Author**: Matthew Raymer
**Date**: November 2025
**Status**: Phase 1 - Minimal Viable Recovery
**Version**: 1.0.0
**Last Synced With Plugin Version**: v1.1.0
**Implements**: [Plugin Requirements §3.1.2 - App Cold Start](./alarms/03-plugin-requirements.md#312-app-cold-start)
## Purpose
Phase 1 implements **minimal viable app launch recovery** for cold start scenarios. This focuses on detecting and handling missed notifications when the app launches after the process was killed.
**Scope**: Phase 1 implements **cold start recovery only**. Force stop detection, warm start optimization, and boot receiver enhancements are **out of scope** for this phase and deferred to later phases.
**Reference**:
- [Plugin Requirements](./alarms/03-plugin-requirements.md) - Requirements this phase implements
- [Platform Capability Reference](./alarms/01-platform-capability-reference.md) - OS-level facts
- [Full Implementation Directive](./android-implementation-directive.md) - Complete scope
- [Phase 2: Force Stop Recovery](./android-implementation-directive-phase2.md) - Next phase
- [Phase 3: Boot Receiver Enhancement](./android-implementation-directive-phase3.md) - Final phase
---
## 1. Acceptance Criteria
### 1.1 Definition of Done
**Phase 1 is complete when:**
1.**On cold start, missed notifications are detected**
- Notifications with `scheduled_time < currentTime` and `delivery_status != 'delivered'` are identified
- Detection runs automatically on app launch (via `DailyNotificationPlugin.load()`)
- Detection completes within 2 seconds (non-blocking)
2.**Missed notifications are marked in database**
- `delivery_status` updated to `'missed'`
- `last_delivery_attempt` updated to current time
- Status change logged in history table
3.**Future alarms are verified and rescheduled if missing**
- All enabled `notify` schedules checked against AlarmManager
- Missing alarms rescheduled using existing `NotifyReceiver.scheduleExactNotification()`
- No duplicate alarms created (verified before rescheduling)
4.**Recovery never crashes the app**
- All exceptions caught and logged
- Database errors don't propagate
- Invalid data handled gracefully
5.**Recovery is observable**
- All recovery actions logged with `DNP-REACTIVATION` tag
- Recovery metrics recorded in history table
- Logs include counts: missed detected, rescheduled, errors
### 1.2 Success Metrics
| Metric | Target | Measurement |
|--------|--------|-------------|
| Recovery execution time | < 2 seconds | Log timestamp difference |
| Missed detection accuracy | 100% | Manual verification via logs |
| Reschedule success rate | > 95% | History table outcome field |
| Crash rate | 0% | No exceptions propagate to app |
### 1.3 Out of Scope (Phase 1)
- ❌ Force stop detection (Phase 2)
- ❌ Warm start optimization (Phase 2)
- ❌ Boot receiver missed alarm handling (Phase 2)
- ❌ Callback event emission (Phase 2)
- ❌ Fetch work recovery (Phase 2)
---
## 2. Implementation: ReactivationManager
### 2.1 Create New File
**File**: `android/src/main/java/com/timesafari/dailynotification/ReactivationManager.kt`
**Purpose**: Centralized cold start recovery logic
### 2.2 Class Structure
```kotlin
package org.timesafari.dailynotification
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.concurrent.TimeUnit
/**
* Manages recovery of alarms and notifications on app launch
* Phase 1: Cold start recovery only
*
* @author Matthew Raymer
* @version 1.0.0
*/
class ReactivationManager(private val context: Context) {
companion object {
private const val TAG = "DNP-REACTIVATION"
private const val RECOVERY_TIMEOUT_SECONDS = 2L
}
/**
* Perform recovery on app launch
* Phase 1: Calls only performColdStartRecovery() when DB is non-empty
*
* Scenario detection is not implemented in Phase 1 - all app launches
* with non-empty DB are treated as cold start. Force stop, boot, and
* warm start handling are deferred to Phase 2.
*
* **Correction**: Must not run when DB is empty (first launch).
*
* Runs asynchronously with timeout to avoid blocking app startup
*
* Rollback Safety: If recovery fails, app continues normally
*/
fun performRecovery() {
CoroutineScope(Dispatchers.IO).launch {
try {
withTimeout(TimeUnit.SECONDS.toMillis(RECOVERY_TIMEOUT_SECONDS)) {
Log.i(TAG, "Starting app launch recovery (Phase 1: cold start only)")
// Correction: Short-circuit if DB is empty (first launch)
val db = DailyNotificationDatabase.getDatabase(context)
val dbSchedules = db.scheduleDao().getEnabled()
if (dbSchedules.isEmpty()) {
Log.i(TAG, "No schedules present — skipping recovery (first launch)")
return@withTimeout
}
val result = performColdStartRecovery()
Log.i(TAG, "App launch recovery completed: $result")
}
} catch (e: Exception) {
// Rollback: Log error but don't crash
Log.e(TAG, "Recovery failed (non-fatal): ${e.message}", e)
// Record failure in history (best effort, don't fail if this fails)
try {
recordRecoveryFailure(e)
} catch (historyError: Exception) {
Log.w(TAG, "Failed to record recovery failure in history", historyError)
}
}
}
}
// ... implementation methods below ...
}
```
### 2.3 Cold Start Recovery
**Platform Reference**: [Android §2.1.4](./alarms/01-platform-capability-reference.md#214-alarms-can-be-restored-after-app-restart) - Alarms can be restored after app restart
```kotlin
/**
* Perform cold start recovery
*
* Steps:
* 1. Detect missed notifications (scheduled_time < now, not delivered)
* 2. Mark missed notifications in database
* 3. Verify future alarms are scheduled
* 4. Reschedule missing future alarms
*
* @return RecoveryResult with counts
*/
private suspend fun performColdStartRecovery(): RecoveryResult {
val db = DailyNotificationDatabase.getDatabase(context)
val currentTime = System.currentTimeMillis()
Log.i(TAG, "Cold start recovery: checking for missed notifications")
// Step 1: Detect missed notifications
val missedNotifications = try {
db.notificationContentDao().getNotificationsReadyForDelivery(currentTime)
.filter { it.deliveryStatus != "delivered" }
} catch (e: Exception) {
Log.e(TAG, "Failed to query missed notifications", e)
emptyList()
}
var missedCount = 0
var missedErrors = 0
// Step 2: Mark missed notifications
missedNotifications.forEach { notification ->
try {
// Data integrity check: verify notification is valid
if (notification.id.isBlank()) {
Log.w(TAG, "Skipping invalid notification: empty ID")
return@forEach
}
// Update delivery status
notification.deliveryStatus = "missed"
notification.lastDeliveryAttempt = currentTime
notification.deliveryAttempts = (notification.deliveryAttempts ?: 0) + 1
db.notificationContentDao().updateNotification(notification)
missedCount++
Log.d(TAG, "Marked missed notification: ${notification.id}")
} catch (e: Exception) {
missedErrors++
Log.e(TAG, "Failed to mark missed notification: ${notification.id}", e)
// Continue processing other notifications
}
}
// Step 3: Verify and reschedule future alarms
val schedules = try {
db.scheduleDao().getEnabled()
.filter { it.kind == "notify" }
} catch (e: Exception) {
Log.e(TAG, "Failed to query schedules", e)
emptyList()
}
var rescheduledCount = 0
var verifiedCount = 0
var rescheduleErrors = 0
schedules.forEach { schedule ->
try {
// Data integrity check: verify schedule is valid
if (schedule.id.isBlank() || schedule.nextRunAt == null) {
Log.w(TAG, "Skipping invalid schedule: ${schedule.id}")
return@forEach
}
val nextRunTime = schedule.nextRunAt!!
// Only check future alarms
if (nextRunTime >= currentTime) {
// Verify alarm is scheduled
val isScheduled = NotifyReceiver.isAlarmScheduled(context, nextRunTime)
if (isScheduled) {
verifiedCount++
Log.d(TAG, "Verified scheduled alarm: ${schedule.id} at $nextRunTime")
} else {
// Reschedule missing alarm
rescheduleAlarm(schedule, nextRunTime, db)
rescheduledCount++
Log.i(TAG, "Rescheduled missing alarm: ${schedule.id} at $nextRunTime")
}
}
} catch (e: Exception) {
rescheduleErrors++
Log.e(TAG, "Failed to verify/reschedule: ${schedule.id}", e)
// Continue processing other schedules
}
}
// Step 4: Record recovery in history
val result = RecoveryResult(
missedCount = missedCount,
rescheduledCount = rescheduledCount,
verifiedCount = verifiedCount,
errors = missedErrors + rescheduleErrors
)
recordRecoveryHistory(db, "cold_start", result)
Log.i(TAG, "Cold start recovery complete: $result")
return result
}
/**
* Data class for recovery results
*/
private data class RecoveryResult(
val missedCount: Int,
val rescheduledCount: Int,
val verifiedCount: Int,
val errors: Int
) {
override fun toString(): String {
return "missed=$missedCount, rescheduled=$rescheduledCount, verified=$verifiedCount, errors=$errors"
}
}
```
### 2.4 Helper Methods
```kotlin
/**
* Reschedule an alarm
*
* Data integrity: Validates schedule before rescheduling
*/
private suspend fun rescheduleAlarm(
schedule: Schedule,
nextRunTime: Long,
db: DailyNotificationDatabase
) {
try {
// Use existing BootReceiver logic for calculating next run time
// For now, use schedule.nextRunAt directly
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
// Update schedule in database (best effort)
try {
db.scheduleDao().updateRunTimes(schedule.id, schedule.lastRunAt, nextRunTime)
} catch (e: Exception) {
Log.w(TAG, "Failed to update schedule in database: ${schedule.id}", e)
// Don't fail rescheduling if DB update fails
}
Log.i(TAG, "Rescheduled alarm: ${schedule.id} for $nextRunTime")
} catch (e: Exception) {
Log.e(TAG, "Failed to reschedule alarm: ${schedule.id}", e)
throw e // Re-throw to be caught by caller
}
}
/**
* Record recovery in history
*
* Rollback safety: If history recording fails, log warning but don't fail recovery
*/
private suspend fun recordRecoveryHistory(
db: DailyNotificationDatabase,
scenario: String,
result: RecoveryResult
) {
try {
db.historyDao().insert(
History(
refId = "recovery_${System.currentTimeMillis()}",
kind = "recovery",
occurredAt = System.currentTimeMillis(),
outcome = if (result.errors == 0) "success" else "partial",
diagJson = """
{
"scenario": "$scenario",
"missed_count": ${result.missedCount},
"rescheduled_count": ${result.rescheduledCount},
"verified_count": ${result.verifiedCount},
"errors": ${result.errors}
}
""".trimIndent()
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to record recovery history (non-fatal)", e)
// Don't throw - history recording failure shouldn't fail recovery
}
}
/**
* Record recovery failure in history
*/
private suspend fun recordRecoveryFailure(e: Exception) {
try {
val db = DailyNotificationDatabase.getDatabase(context)
db.historyDao().insert(
History(
refId = "recovery_failure_${System.currentTimeMillis()}",
kind = "recovery",
occurredAt = System.currentTimeMillis(),
outcome = "failure",
diagJson = """
{
"error": "${e.message}",
"error_type": "${e.javaClass.simpleName}"
}
""".trimIndent()
)
)
} catch (historyError: Exception) {
// Silently fail - we're already in error handling
Log.w(TAG, "Failed to record recovery failure", historyError)
}
}
```
---
## 3. Integration: DailyNotificationPlugin
### 3.1 Update `load()` Method
**File**: `android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`
**Location**: After database initialization (line 98)
**Current Code**:
```kotlin
override fun load() {
super.load()
try {
if (context == null) {
Log.e(TAG, "Context is null, cannot initialize database")
return
}
db = DailyNotificationDatabase.getDatabase(context)
Log.i(TAG, "Daily Notification Plugin loaded successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
}
}
```
**Updated Code**:
```kotlin
override fun load() {
super.load()
try {
if (context == null) {
Log.e(TAG, "Context is null, cannot initialize database")
return
}
db = DailyNotificationDatabase.getDatabase(context)
Log.i(TAG, "Daily Notification Plugin loaded successfully")
// Phase 1: Perform app launch recovery (cold start only)
// Runs asynchronously, non-blocking, with timeout
val reactivationManager = ReactivationManager(context)
reactivationManager.performRecovery()
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
// Don't throw - allow plugin to load even if recovery fails
}
}
```
---
## 4. Data Integrity Checks
### 4.1 Validation Rules
**Notification Validation**:
-`id` must not be blank
-`scheduled_time` must be valid timestamp
-`delivery_status` must be valid enum value
**Schedule Validation**:
-`id` must not be blank
-`kind` must be "notify" or "fetch"
-`nextRunAt` must be set for verification
-`enabled` must be true (filtered by DAO)
### 4.2 Orphaned Data Handling
**Orphaned Notifications** (no matching schedule):
- Log warning but don't fail recovery
- Mark as missed if past scheduled time
**Orphaned Schedules** (no matching notification content):
- Log warning but don't fail recovery
- Reschedule if future alarm is missing
**Mismatched Data**:
- If `NotificationContentEntity.scheduled_time` doesn't match `Schedule.nextRunAt`, use `scheduled_time` for missed detection
- Log warning for data inconsistency
---
## 5. Rollback Safety
### 5.1 No-Crash Guarantee
**All recovery operations must:**
1. **Catch all exceptions** - Never propagate exceptions to app
2. **Log errors** - All failures logged with context
3. **Continue processing** - One failure doesn't stop recovery
4. **Timeout protection** - Recovery completes within 2 seconds or times out
5. **Best-effort updates** - Database failures don't prevent alarm rescheduling
### 5.2 Error Handling Strategy
| Error Type | Handling | Log Level |
|------------|----------|-----------|
| Database query failure | Return empty list, continue | ERROR |
| Invalid notification data | Skip notification, continue | WARN |
| Alarm reschedule failure | Log error, continue to next | ERROR |
| History recording failure | Log warning, don't fail | WARN |
| Timeout | Log timeout, abort recovery | WARN |
### 5.3 Fallback Behavior
**If recovery fails completely:**
- App continues normally
- No alarms are lost (existing alarms remain scheduled)
- User can manually trigger recovery via app restart
- Error logged in history table (if possible)
---
## 6. Callback Behavior (Phase 1 - Deferred)
**Phase 1 does NOT emit callbacks.** Callback behavior is deferred to Phase 2.
**Future callback contract** (for Phase 2):
| Event | Fired When | Payload | Guarantees |
|-------|------------|---------|------------|
| `missed_notification` | Missed notification detected | `{notificationId, scheduledTime, detectedAt}` | Fired once per missed notification |
| `recovery_complete` | Recovery finished | `{scenario, missedCount, rescheduledCount, errors}` | Fired once per recovery run |
**Implementation notes:**
- Callbacks will use Capacitor event system
- Events batched if multiple missed notifications detected
- Callbacks fire after database updates complete
---
## 7. Versioning & Migration
### 7.1 Version Bump
**Plugin Version**: Increment patch version (e.g., `1.1.0``1.1.1`)
**Reason**: New feature (recovery), no breaking changes
### 7.2 Database Migration
**No database migration required** for Phase 1.
**Existing tables used:**
- `notification_content` - Already has `delivery_status` field
- `schedules` - Already has `nextRunAt` field
- `history` - Already supports recovery events
### 7.3 Backward Compatibility
**Phase 1 is backward compatible:**
- Existing alarms continue to work
- No schema changes
- Recovery is additive (doesn't break existing functionality)
---
## 8. Testing Requirements
### 8.1 Test 1: Cold Start Missed Detection
**Purpose**: Verify missed notifications are detected and marked.
**Steps**:
1. Schedule notification for 2 minutes in future
2. Kill app process: `adb shell am kill org.timesafari.dailynotification`
3. Wait 5 minutes (past scheduled time)
4. Launch app: `adb shell am start -n org.timesafari.dailynotification/.MainActivity`
5. Check logs: `adb logcat -d | grep DNP-REACTIVATION`
**Expected**:
- ✅ Log shows "Cold start recovery: checking for missed notifications"
- ✅ Log shows "Marked missed notification: <id>"
- ✅ Database shows `delivery_status = 'missed'`
- ✅ History table has recovery entry
**Pass Criteria**: Missed notification detected and marked in database.
### 8.2 Test 2: Future Alarm Rescheduling
**Purpose**: Verify missing future alarms are rescheduled.
**Steps**:
1. Schedule notification for 10 minutes in future
2. Manually cancel alarm: `adb shell dumpsys alarm | grep timesafari` (note request code)
3. Launch app
4. Check logs: `adb logcat -d | grep DNP-REACTIVATION`
5. Verify alarm rescheduled: `adb shell dumpsys alarm | grep timesafari`
**Expected**:
- ✅ Log shows "Rescheduled missing alarm: <id>"
- ✅ AlarmManager shows rescheduled alarm
- ✅ No duplicate alarms created
**Pass Criteria**: Missing alarm rescheduled, no duplicates.
### 8.3 Test 3: Recovery Timeout
**Purpose**: Verify recovery times out gracefully.
**Steps**:
1. Create large number of schedules (100+)
2. Launch app
3. Check logs for timeout
**Expected**:
- ✅ Recovery completes within 2 seconds OR times out
- ✅ App doesn't crash
- ✅ Partial recovery logged if timeout occurs
**Pass Criteria**: Recovery doesn't block app launch.
### 8.4 Test 4: Invalid Data Handling
**Purpose**: Verify invalid data doesn't crash recovery.
**Steps**:
1. Manually insert invalid notification (empty ID) into database
2. Launch app
3. Check logs
**Expected**:
- ✅ Invalid notification skipped
- ✅ Warning logged
- ✅ Recovery continues normally
**Pass Criteria**: Invalid data handled gracefully.
### 8.4 Emulator Test Harness
The manual tests in §8.1§8.3 are codified in the script `test-phase1.sh` in:
```bash
test-apps/android-test-app/test-phase1.sh
```
**Status:**
* ✅ Script implemented and polished
* ✅ Verified on Android Emulator (Pixel 8 API 34) on 27 November 2025
* ✅ Correctly recognizes both `verified>0` and `rescheduled>0` as PASS cases
* ✅ Treats `DELETE_FAILED_INTERNAL_ERROR` on uninstall as non-fatal
For regression testing, use `PHASE1-EMULATOR-TESTING.md` + `test-phase1.sh` as the canonical procedure.
---
## 9. Implementation Checklist
- [ ] Create `ReactivationManager.kt` file
- [ ] Implement `performRecovery()` with timeout
- [ ] Implement `performColdStartRecovery()`
- [ ] Implement missed notification detection
- [ ] Implement missed notification marking
- [ ] Implement future alarm verification
- [ ] Implement missing alarm rescheduling
- [ ] Add data integrity checks
- [ ] Add error handling (no-crash guarantee)
- [ ] Add recovery history recording
- [ ] Update `DailyNotificationPlugin.load()` to call recovery
- [ ] Test cold start missed detection
- [ ] Test future alarm rescheduling
- [ ] Test recovery timeout
- [ ] Test invalid data handling
- [ ] Verify no duplicate alarms
- [ ] Verify recovery doesn't block app launch
---
## 10. Code References
**Existing Code to Reuse**:
- `NotifyReceiver.scheduleExactNotification()` - Line 92
- `NotifyReceiver.isAlarmScheduled()` - Line 279
- `BootReceiver.calculateNextRunTime()` - Line 103 (for Phase 2)
- `NotificationContentDao.getNotificationsReadyForDelivery()` - Line 99
- `ScheduleDao.getEnabled()` - Line 298
**New Code to Create**:
- `ReactivationManager.kt` - New file (Phase 1)
---
## 11. Success Criteria Summary
**Phase 1 is complete when:**
1. ✅ Missed notifications detected on cold start
2. ✅ Missed notifications marked in database
3. ✅ Future alarms verified and rescheduled if missing
4. ✅ Recovery never crashes app
5. ✅ Recovery completes within 2 seconds
6. ✅ All tests pass
7. ✅ No duplicate alarms created
---
## Related Documentation
- [Unified Alarm Directive](./alarms/000-UNIFIED-ALARM-DIRECTIVE.md) - Master coordination document
- [Plugin Requirements](./alarms/03-plugin-requirements.md) - Requirements this phase implements
- [Platform Capability Reference](./alarms/01-platform-capability-reference.md) - OS-level facts
- [Plugin Behavior Exploration](./alarms/02-plugin-behavior-exploration.md) - Test scenarios
- [Full Implementation Directive](./android-implementation-directive.md) - Complete scope (all phases)
---
## Notes
- **Incremental approach**: Phase 1 focuses on cold start only. Force stop and boot recovery in Phase 2.
- **Safety first**: All recovery operations are non-blocking and non-fatal.
- **Observability**: Extensive logging for debugging and monitoring.
- **Data integrity**: Validation prevents invalid data from causing failures.

View File

@@ -0,0 +1,787 @@
# 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
**Last Synced With Plugin Version**: v1.1.0
**Implements**: [Plugin Requirements §3.1.4 - Force Stop Recovery](./alarms/03-plugin-requirements.md#314-force-stop-recovery-android-only)
## 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**:
- [Plugin Requirements](./alarms/03-plugin-requirements.md) - Requirements this phase implements
- [Platform Capability Reference](./alarms/01-platform-capability-reference.md) - OS-level facts
- [Phase 1](./android-implementation-directive-phase1.md) - Prerequisite
- [Full Implementation Directive](./android-implementation-directive.md) - 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 = "org.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 org.timesafari.dailynotification`
4. Verify alarms cancelled: `adb shell dumpsys alarm | grep timesafari` (should be empty)
5. Launch app: `adb shell am start -n org.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,221 @@
# Android Implementation Directive Phase 3
## Boot-Time Recovery (Device Reboot / System Restart)
**Plugin:** Daily Notification Plugin
**Author:** Matthew Raymer
**Applies to:** Android Plugin (Kotlin), Capacitor Bridge
**Related Docs:**
- `03-plugin-requirements.md`
- `000-UNIFIED-ALARM-DIRECTIVE.md`
- `android-implementation-directive-phase1.md`
- `android-implementation-directive-phase2.md`
- `ACTIVATION-GUIDE.md`
---
## 1. Purpose
Phase 3 introduces **Boot-Time Recovery**, which restores daily notifications after:
- Device reboot
- OS restart
- Update-related restart
- App not opened after reboot (silent recovery)
Android clears **all alarms** on reboot.
Therefore, if our plugin is not actively rescheduling on boot, the user will miss all daily notifications until they manually launch the app.
Phase 3 ensures:
1. Schedules stored in SQLite survive reboot
2. Alarms are fully reconstructed
3. No duplication / double-scheduling
4. Boot behavior avoids unnecessary heavy recovery
5. Recovery occurs even if the user does **not** manually open the app
---
## 2. Boot-Time Recovery Flow
### Trigger:
`BOOT_COMPLETED` broadcast received
→ Plugin's Boot Receiver invoked
→ Recovery logic executed with `scenario=BOOT`
### Recovery Steps
1. **Load all schedules** from SQLite (`NotificationRepository.getAllSchedules()`)
2. **For each schedule:**
- Calculate next runtime based on cron expression
- Compare with current time
3. **If the next scheduled time is in the future:**
- Recreate alarm with `setAlarmClock`
- Log:
`Rescheduled alarm: <id> for <ts>`
4. **If schedule was *in the past* at boot time:**
- Mark as missed
- Schedule next run according to cron rules
5. **If no schedules found:**
- Quiet exit, log only one line:
`BOOT: No schedules found`
6. **Safeties:**
- Boot recovery must **not** modify Plugin Settings
- Must not regenerate Fetcher configuration
- Must not overwrite database records
---
## 3. Required Android Components
### 3.1 Boot Receiver
```xml
<receiver
android:name=".BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
```
### 3.2 Kotlin Class
```kotlin
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action != Intent.ACTION_BOOT_COMPLETED) return
ReactivationManager.runBootRecovery(context)
}
}
```
---
## 4. ReactivationManager Boot Logic
### Method Signature
```kotlin
fun runBootRecovery(context: Context)
```
### Required Logging (canonical)
```
DNP-REACTIVATION: Starting boot recovery
DNP-REACTIVATION: Loaded <N> schedules from DB
DNP-REACTIVATION: Rescheduled alarm: <id> for <ts>
DNP-REACTIVATION: Marked missed notification: <id>
DNP-REACTIVATION: Boot recovery complete: missed=X, rescheduled=Y, errors=Z
```
### Required Fields
* `scenario=BOOT`
* `missed`
* `rescheduled`
* `verified` **MUST BE 0** (boot has no verification phase)
---
## 5. Constraints & Guardrails
1. **No plugin initialization**
Boot must *not* require running the app UI.
2. **No heavy processing**
* limit to 2 seconds
* use the same timeout guard as Phase 2
3. **No scheduling duplicates**
* Must detect existing AlarmManager entries
* Boot always clears them, so all reschedules should be fresh
4. **App does not need to be opened**
* Entire recovery must run in background context
5. **Idempotency**
* Running twice should produce identical logs
---
## 6. Implementation Checklist
### Mandatory
* [ ] BootReceiver included
* [ ] Manifest entry added
* [ ] `runBootRecovery()` implemented
* [ ] Scenario logged as `BOOT`
* [ ] All alarms recreated
* [ ] Timeout protection
* [ ] No modifications to preferences or plugin settings
### Optional
* [ ] Additional telemetry for analytics
* [ ] Optional debug toast for dev builds only
---
## 7. Expected Output Examples
### Example 1 Normal Boot (future alarms exist)
```
DNP-REACTIVATION: Starting boot recovery
DNP-REACTIVATION: Loaded 2 schedules from DB
DNP-REACTIVATION: Rescheduled alarm: daily_1764233911265 for 1764236120000
DNP-REACTIVATION: Rescheduled alarm: daily_1764233465343 for 1764233700000
DNP-REACTIVATION: Boot recovery complete: missed=0, rescheduled=2, errors=0
```
### Example 2 Schedules present but some in past
```
Marked missed notification: daily_1764233300000
Rescheduled alarm: daily_1764233300000 for next day
```
### Example 3 No schedules
```
DNP-REACTIVATION: BOOT: No schedules found
```
---
## 8. Status
| Item | Status |
| -------------------- | -------------------------------- |
| Directive | **Complete** |
| Implementation | ☐ Pending / ✅ **Complete** (plugin v1.2+) |
| Emulator Test Script | Ready (`test-phase3.sh`) |
| Verification Doc | Ready (`PHASE3-VERIFICATION.md`) |
---
## 9. Related Documentation
- [Unified Alarm Directive](./alarms/000-UNIFIED-ALARM-DIRECTIVE.md) - Master coordination document
- [Plugin Requirements](./alarms/03-plugin-requirements.md) - Requirements this phase implements
- [Platform Capability Reference](./alarms/01-platform-capability-reference.md) - OS-level facts
- [Phase 1](./android-implementation-directive-phase1.md) - Prerequisite
- [Phase 2](./android-implementation-directive-phase2.md) - Prerequisite
- [Phase 3 Emulator Testing](./alarms/PHASE3-EMULATOR-TESTING.md) - Test procedures
- [Phase 3 Verification](./alarms/PHASE3-VERIFICATION.md) - Verification report
---
**Status**: Directive complete, ready for implementation
**Last Updated**: November 2025

View File

@@ -0,0 +1,460 @@
# Android Notification Implementation Comparison
**Test App (Working)** vs **TimeSafari (Not Working)**
This document identifies the critical differences between the test app where notifications work correctly and the TimeSafari app where notifications don't work at all. Use this as a checklist to fix TimeSafari.
---
## Critical Issues (Must Fix)
### 1. Missing Custom Application Class
**This is likely the primary cause of failure.**
**Test App (Working):**
```xml
<!-- AndroidManifest.xml -->
<application
android:name=".TestApplication"
...>
```
```java
// TestApplication.java
public class TestApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
Context context = getApplicationContext();
NativeNotificationContentFetcher testFetcher =
new org.timesafari.dailynotification.test.TestNativeFetcher(context);
DailyNotificationPlugin.setNativeFetcher(testFetcher);
}
}
```
**TimeSafari (Broken):**
```xml
<!-- AndroidManifest.xml - NO android:name attribute -->
<application
android:allowBackup="true"
...>
```
- No custom Application class exists
- No native fetcher is registered
- Plugin cannot fetch notification content
**Fix Required:**
1. Create `TimeSafariApplication.java` in `android/app/src/main/java/app/timesafari/`
2. Implement `NativeNotificationContentFetcher` specific to TimeSafari
3. Add `android:name=".TimeSafariApplication"` to AndroidManifest.xml
---
### 2. Missing Capacitor Plugin Configuration
**Test App (Working):**
```typescript
// capacitor.config.ts
plugins: {
DailyNotification: {
debugMode: true,
enableNotifications: true,
timesafariConfig: {
activeDid: "did:ethr:0x...",
endpoints: {
projectsLastUpdated: "http://..."
},
starredProjectsConfig: {
enabled: true,
starredPlanHandleIds: [...],
fetchInterval: '0 8 * * *'
},
credentialConfig: {
jwtSecret: '...',
tokenExpirationMinutes: 1
}
},
networkConfig: {
timeout: 30000,
retryAttempts: 3,
retryDelay: 1000
},
contentFetch: {
enabled: true,
schedule: '0 00 * * *',
fetchLeadTimeMinutes: 5
}
}
}
```
**TimeSafari (Broken):**
```typescript
// capacitor.config.ts - NO DailyNotification configuration at all
plugins: {
App: { ... },
SplashScreen: { ... },
CapSQLite: { ... }
// DailyNotification is MISSING
}
```
**Fix Required:**
Add `DailyNotification` configuration to `capacitor.config.ts` with appropriate values for TimeSafari.
---
### 3. Missing Permissions in AndroidManifest.xml
**Test App has these permissions that TimeSafari is missing:**
```xml
<!-- Add to TimeSafari's AndroidManifest.xml -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
```
**Current TimeSafari permissions (incomplete):**
-`INTERNET`
-`POST_NOTIFICATIONS`
-`SCHEDULE_EXACT_ALARM`
-`RECEIVE_BOOT_COMPLETED`
-`WAKE_LOCK`
-`ACCESS_NETWORK_STATE` - **MISSING**
-`FOREGROUND_SERVICE` - **MISSING**
-`SYSTEM_ALERT_WINDOW` - **MISSING**
-`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` - **MISSING**
---
### 4. Missing Gradle Dependencies
**Test App (Working):**
```gradle
// android/app/build.gradle
dependencies {
// Capacitor annotation processor for automatic plugin discovery
annotationProcessor project(':capacitor-android')
// Required dependencies for the plugin
implementation 'androidx.work:work-runtime:2.9.0'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1'
}
```
**TimeSafari (Broken):**
```gradle
dependencies {
// Missing: annotationProcessor project(':capacitor-android')
implementation "androidx.work:work-runtime-ktx:2.9.0" // Using Kotlin version
// Missing: androidx.lifecycle:lifecycle-service
// Missing: com.google.code.gson:gson
}
```
**Fix Required:**
Add to TimeSafari's `android/app/build.gradle`:
```gradle
annotationProcessor project(':capacitor-android')
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1'
```
---
## Secondary Issues (Should Fix)
### 5. DailyNotificationReceiver Export Status
**Test App (Working):**
```xml
<receiver
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="false"> <!-- Note: false -->
```
**TimeSafari (Broken):**
```xml
<receiver
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="true"> <!-- Note: true - potential security issue -->
```
The test app uses `exported="false"` because the plugin creates PendingIntents with explicit component targeting. Using `exported="true"` is unnecessary and a potential security concern.
---
### 6. Missing Network Security Config
**Test App (Working):**
```xml
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
```
**TimeSafari (Broken):**
```xml
<application>
<!-- No networkSecurityConfig -->
```
This may affect HTTP (non-HTTPS) requests during development.
---
### 7. Missing Java Compile Options
**Test App (Working):**
```gradle
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
```
**TimeSafari (Broken):**
No explicit compile options set.
---
## Complete Fix Checklist
### Step 1: Create Custom Application Class
Create file: `android/app/src/main/java/app/timesafari/TimeSafariApplication.java`
```java
package app.timesafari;
import android.app.Application;
import android.content.Context;
import android.util.Log;
import org.timesafari.dailynotification.DailyNotificationPlugin;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
public class TimeSafariApplication extends Application {
private static final String TAG = "TimeSafariApplication";
@Override
public void onCreate() {
super.onCreate();
Log.i(TAG, "Initializing TimeSafari notifications");
// Register native fetcher with application context
Context context = getApplicationContext();
NativeNotificationContentFetcher fetcher =
new TimeSafariNativeFetcher(context);
DailyNotificationPlugin.setNativeFetcher(fetcher);
Log.i(TAG, "Native fetcher registered");
}
}
```
### Step 2: Create Native Fetcher Implementation
Create file: `android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java`
```java
package app.timesafari;
import android.content.Context;
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
import org.timesafari.dailynotification.NotificationContent;
public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
private final Context context;
public TimeSafariNativeFetcher(Context context) {
this.context = context;
}
@Override
public NotificationContent fetchContent(String scheduleId) {
// TODO: Implement actual content fetching for TimeSafari
// This should query the TimeSafari API for notification content
return new NotificationContent(
"timesafari_" + System.currentTimeMillis(),
"TimeSafari Update",
"Check your starred projects for updates!",
System.currentTimeMillis(),
null,
System.currentTimeMillis()
);
}
}
```
### Step 3: Update AndroidManifest.xml
```xml
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".TimeSafariApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<!-- ... existing content ... -->
<!-- Fix: Change exported to false -->
<receiver
android:name="org.timesafari.dailynotification.DailyNotificationReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="org.timesafari.daily.NOTIFICATION" />
</intent-filter>
</receiver>
<!-- ... rest of receivers ... -->
</application>
<!-- Existing permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- ADD these missing permissions -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
</manifest>
```
### Step 4: Update build.gradle
Add to `android/app/build.gradle`:
```gradle
android {
// ... existing config ...
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
// ... existing dependencies ...
// ADD these for notification plugin
annotationProcessor project(':capacitor-android')
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'com.google.code.gson:gson:2.10.1'
}
```
### Step 5: Update capacitor.config.ts
Add DailyNotification configuration:
```typescript
plugins: {
// ... existing plugins ...
DailyNotification: {
debugMode: true,
enableNotifications: true,
timesafariConfig: {
activeDid: '', // Will be set dynamically from user's DID
endpoints: {
projectsLastUpdated: 'https://api.endorser.ch/api/v2/report/plansLastUpdatedBetween'
},
starredProjectsConfig: {
enabled: true,
starredPlanHandleIds: [],
fetchInterval: '0 8 * * *'
}
},
networkConfig: {
timeout: 30000,
retryAttempts: 3,
retryDelay: 1000
},
contentFetch: {
enabled: true,
schedule: '0 8 * * *',
fetchLeadTimeMinutes: 5
}
}
}
```
### Step 6: Rebuild
```bash
npx cap sync android
cd android && ./gradlew clean
cd .. && npx cap build android
```
---
## Verification
After implementing fixes, verify:
1. **Check logs for Application initialization:**
```bash
adb logcat | grep -E "TimeSafariApplication|Native fetcher"
```
2. **Check alarm scheduling:**
```bash
adb shell dumpsys alarm | grep -i timesafari
```
3. **Test receiver manually:**
```bash
adb shell am broadcast -a org.timesafari.daily.NOTIFICATION \
--es id "test_notification" \
-n app.timesafari.app/org.timesafari.dailynotification.DailyNotificationReceiver
```
4. **Check notification permissions:**
```bash
adb shell dumpsys package app.timesafari.app | grep -A 5 "granted=true"
```
---
## Summary of Critical Differences
| Component | Test App (Working) | TimeSafari (Broken) |
|-----------|-------------------|---------------------|
| Custom Application class | ✅ TestApplication.java | ❌ None |
| Native fetcher registration | ✅ In Application.onCreate() | ❌ Not registered |
| DailyNotification config | ✅ Full config in capacitor.config.ts | ❌ Not configured |
| ACCESS_NETWORK_STATE | ✅ Present | ❌ Missing |
| FOREGROUND_SERVICE | ✅ Present | ❌ Missing |
| REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | ✅ Present | ❌ Missing |
| Gson dependency | ✅ Present | ❌ Missing |
| lifecycle-service dependency | ✅ Present | ❌ Missing |
| Capacitor annotation processor | ✅ Present | ❌ Missing |
**The most critical missing piece is the custom Application class with native fetcher registration.** Without this, the plugin has no way to fetch notification content when the alarm fires.