rename 'docs' directory to 'doc'
This commit is contained in:
248
doc/platform/android/ALARM_PERSISTENCE_DIRECTIVE.md
Normal file
248
doc/platform/android/ALARM_PERSISTENCE_DIRECTIVE.md
Normal 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-app–style 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**
|
||||
|
||||
680
doc/platform/android/APP_ANALYSIS.md
Normal file
680
doc/platform/android/APP_ANALYSIS.md
Normal 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.
|
||||
1131
doc/platform/android/APP_IMPROVEMENT_PLAN.md
Normal file
1131
doc/platform/android/APP_IMPROVEMENT_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
69
doc/platform/android/BUILDING.md
Normal file
69
doc/platform/android/BUILDING.md
Normal 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.
|
||||
|
||||
37
doc/platform/android/CONSUMING_APP_ANDROID_NOTES.md
Normal file
37
doc/platform/android/CONSUMING_APP_ANDROID_NOTES.md
Normal 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 app’s schedule row when handling rollover work that uses a `daily_rollover_*` id, so the app’s `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
|
||||
@@ -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 plugin’s 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`.
|
||||
310
doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md
Normal file
310
doc/platform/android/DATABASE_CONSOLIDATION_PLAN.md
Normal 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
|
||||
|
||||
1503
doc/platform/android/IMPLEMENTATION_DIRECTIVE.md
Normal file
1503
doc/platform/android/IMPLEMENTATION_DIRECTIVE.md
Normal file
File diff suppressed because it is too large
Load Diff
712
doc/platform/android/PHASE1_DIRECTIVE.md
Normal file
712
doc/platform/android/PHASE1_DIRECTIVE.md
Normal 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.
|
||||
|
||||
787
doc/platform/android/PHASE2_DIRECTIVE.md
Normal file
787
doc/platform/android/PHASE2_DIRECTIVE.md
Normal 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
|
||||
|
||||
221
doc/platform/android/PHASE3_DIRECTIVE.md
Normal file
221
doc/platform/android/PHASE3_DIRECTIVE.md
Normal 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
|
||||
460
doc/platform/android/TIMESAFARI_ANDROID_COMPARISON.md
Normal file
460
doc/platform/android/TIMESAFARI_ANDROID_COMPARISON.md
Normal 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.
|
||||
Reference in New Issue
Block a user