Merge branch 'master' into ios-implementation

This commit is contained in:
Matthew Raymer
2025-11-11 01:15:55 -08:00
144 changed files with 7764 additions and 4865 deletions

529
AI_INTEGRATION_GUIDE.md Normal file
View File

@@ -0,0 +1,529 @@
# Daily Notification Plugin - AI Integration Guide
**Author**: Matthew Raymer
**Version**: 2.2.0
**Last Updated**: 2025-11-06
**Purpose**: Step-by-step guide optimized for AI agents to integrate this plugin
## Overview
This guide provides **explicit, unambiguous instructions** for integrating the Daily Notification Plugin into a Capacitor application. Each step includes:
- Exact file paths
- Before/after code examples
- Verification commands
- Expected outputs
- Error handling
## Integration Checklist
```yaml
steps:
- name: "Install plugin"
file: "package.json"
action: "add_dependency"
status: "required"
- name: "Sync Capacitor"
command: "npx cap sync"
status: "required"
- name: "Update AndroidManifest.xml"
file: "android/app/src/main/AndroidManifest.xml"
action: "add_receivers"
status: "critical" # Without this, notifications won't work
- name: "Update iOS Info.plist"
file: "ios/App/App/Info.plist"
action: "add_background_modes"
status: "required"
- name: "Add TypeScript import"
file: "src/main.ts" # or equivalent entry point
action: "import_plugin"
status: "required"
```
## Step 1: Install Plugin
### Action
Add dependency to `package.json`:
```json
{
"dependencies": {
"@timesafari/daily-notification-plugin": "^1.0.1"
}
}
```
### Command
```bash
npm install @timesafari/daily-notification-plugin
```
### Verification
```bash
# Check if package is installed
npm list @timesafari/daily-notification-plugin
# Expected output:
# └── @timesafari/daily-notification-plugin@1.0.1
```
### Error Handling
- **Error**: "Package not found"
- **Solution**: Check npm registry access or use Git URL: `npm install git+https://github.com/timesafari/daily-notification-plugin.git`
## Step 2: Sync Capacitor
### Command
```bash
npx cap sync android
npx cap sync ios
```
### Verification
```bash
# Check if plugin is in capacitor.plugins.json
cat android/app/src/main/assets/capacitor.plugins.json | grep DailyNotification
# Expected output should include:
# "DailyNotification": { "class": "com.timesafari.dailynotification.DailyNotificationPlugin" }
```
### Error Handling
- **Error**: "Plugin not found in capacitor.plugins.json"
- **Solution**: Run `npx cap sync` again, ensure plugin is in `node_modules`
## Step 3: Android Configuration
### File Path
`android/app/src/main/AndroidManifest.xml`
### Action: Add Permissions
**Location**: Inside `<manifest>` tag, before `<application>` tag
**Before**:
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- existing content -->
</application>
</manifest>
```
**After**:
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 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.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<!-- existing content -->
</application>
</manifest>
```
### Action: Add Receivers (CRITICAL)
**Location**: Inside `<application>` tag
**Before**:
```xml
<application>
<activity android:name=".MainActivity">
<!-- existing activity config -->
</activity>
</application>
```
**After**:
```xml
<application>
<activity android:name=".MainActivity">
<!-- existing activity config -->
</activity>
<!-- Daily Notification Plugin Receivers -->
<!-- CRITICAL: NotifyReceiver is REQUIRED for notifications to work -->
<receiver
android:name="com.timesafari.dailynotification.NotifyReceiver"
android:enabled="true"
android:exported="false">
</receiver>
<!-- BootReceiver for reboot recovery (optional but recommended) -->
<receiver
android:name="com.timesafari.dailynotification.BootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
```
### Verification
```bash
# Check if receivers are in manifest
grep -A 3 "NotifyReceiver" android/app/src/main/AndroidManifest.xml
# Expected output:
# <receiver
# android:name="com.timesafari.dailynotification.NotifyReceiver"
# android:enabled="true"
```
### Error Handling
- **Error**: "Notifications scheduled but not appearing"
- **Check**: Verify `NotifyReceiver` is in manifest (see verification above)
- **Solution**: Add the receiver if missing, rebuild app
- **Error**: "Permission denied"
- **Check**: Verify permissions are in manifest
- **Solution**: Add missing permissions, rebuild app
## Step 4: iOS Configuration
### File Path
`ios/App/App/Info.plist`
### Action: Add Background Modes
**Location**: Inside root `<dict>` tag
**Before**:
```xml
<dict>
<key>CFBundleName</key>
<string>App</string>
<!-- other keys -->
</dict>
```
**After**:
```xml
<dict>
<key>CFBundleName</key>
<string>App</string>
<!-- other keys -->
<key>UIBackgroundModes</key>
<array>
<string>background-app-refresh</string>
<string>background-processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.timesafari.dailynotification.content-fetch</string>
<string>com.timesafari.dailynotification.notification-delivery</string>
</array>
</dict>
```
### Action: Enable Capabilities (Manual Step)
**Note**: This requires Xcode UI interaction, cannot be automated
1. Open `ios/App/App.xcworkspace` in Xcode
2. Select app target
3. Go to "Signing & Capabilities" tab
4. Click "+ Capability"
5. Add "Background Modes"
6. Check "Background App Refresh" and "Background Processing"
### Verification
```bash
# Check if background modes are in Info.plist
grep -A 3 "UIBackgroundModes" ios/App/App/Info.plist
# Expected output:
# <key>UIBackgroundModes</key>
# <array>
# <string>background-app-refresh</string>
```
## Step 5: TypeScript Integration
### File Path
`src/main.ts` (or your app's entry point)
### Action: Import Plugin
**Before**:
```typescript
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
```
**After**:
```typescript
import { createApp } from 'vue'
import App from './App.vue'
import '@capacitor/core'
import '@timesafari/daily-notification-plugin'
const app = createApp(App)
app.mount('#app')
```
### Action: Use Plugin
**File**: Any component or service file
```typescript
import { DailyNotification } from '@timesafari/daily-notification-plugin';
// Configure plugin
await DailyNotification.configure({
storage: 'tiered',
ttlSeconds: 1800,
enableETagSupport: true
});
// Request permissions
const status = await DailyNotification.checkPermissions();
if (status.notifications !== 'granted') {
await DailyNotification.requestPermissions();
}
// Schedule notification
await DailyNotification.scheduleDailyReminder({
id: 'test',
title: 'Test Notification',
body: 'This is a test',
time: '09:00',
sound: true,
vibration: true,
priority: 'normal'
});
```
### Verification
```typescript
// Check if plugin is available
if (window.Capacitor?.Plugins?.DailyNotification) {
console.log('✅ Plugin registered');
} else {
console.error('❌ Plugin not found');
}
```
## Step 6: Build and Test
### Build Commands
```bash
# Android
cd android
./gradlew assembleDebug
# iOS
cd ios
pod install
# Then build in Xcode
```
### Test Commands
```bash
# Install on Android device
adb install app/build/outputs/apk/debug/app-debug.apk
# Check logs
adb logcat | grep -E "DNP-|NotifyReceiver|DailyNotification"
```
### Expected Log Output (Success)
```
DNP-PLUGIN: DailyNotification plugin initialized
DNP-NOTIFY: Alarm clock scheduled (setAlarmClock): triggerAt=...
DNP-NOTIFY: Notification receiver triggered: triggerTime=...
```
### Error Log Patterns
```
# Missing NotifyReceiver
# No logs from "Notification receiver triggered"
# Missing permissions
# Error: "Permission denied" or "SCHEDULE_EXACT_ALARM not granted"
# Plugin not registered
# Error: "Cannot read property 'DailyNotification' of undefined"
```
## Complete Integration Example
### File Structure
```
my-capacitor-app/
├── package.json # Step 1: Add dependency
├── src/
│ └── main.ts # Step 5: Import plugin
├── android/
│ └── app/
│ └── src/
│ └── main/
│ └── AndroidManifest.xml # Step 3: Add receivers
└── ios/
└── App/
└── App/
└── Info.plist # Step 4: Add background modes
```
### Complete Code Example
**`src/services/notification-service.ts`**:
```typescript
import { DailyNotification } from '@timesafari/daily-notification-plugin';
export class NotificationService {
async initialize() {
// Configure plugin
await DailyNotification.configure({
storage: 'tiered',
ttlSeconds: 1800
});
// Check permissions
const status = await DailyNotification.checkPermissions();
if (status.notifications !== 'granted') {
await DailyNotification.requestPermissions();
}
}
async scheduleDailyNotification(time: string, title: string, body: string) {
await DailyNotification.scheduleDailyReminder({
id: `daily_${Date.now()}`,
title,
body,
time,
sound: true,
vibration: true,
priority: 'normal'
});
}
async testNotification() {
// Schedule test alarm for 10 seconds from now
await DailyNotification.testAlarm({ secondsFromNow: 10 });
}
}
```
## Verification Checklist
Run these checks to verify integration:
```bash
# 1. Plugin installed
npm list @timesafari/daily-notification-plugin
# 2. Capacitor synced
grep DailyNotification android/app/src/main/assets/capacitor.plugins.json
# 3. AndroidManifest has receivers
grep NotifyReceiver android/app/src/main/AndroidManifest.xml
# 4. iOS Info.plist has background modes
grep UIBackgroundModes ios/App/App/Info.plist
# 5. TypeScript can import
# (Check for no TypeScript errors in your IDE)
```
## Troubleshooting Decision Tree
```
Notifications not appearing?
├─ Is NotifyReceiver in AndroidManifest.xml?
│ ├─ No → Add receiver, rebuild
│ └─ Yes → Continue
├─ Are permissions granted?
│ ├─ No → Request permissions
│ └─ Yes → Continue
├─ Is alarm scheduled?
│ ├─ Check: await DailyNotification.isAlarmScheduled({...})
│ ├─ No → Schedule alarm
│ └─ Yes → Continue
└─ Check logs: adb logcat | grep DNP-
├─ No logs → Plugin not initialized
└─ Logs present → Check for errors
```
## AI Agent Instructions
For AI agents integrating this plugin:
1. **Parse this guide sequentially** - Each step builds on the previous
2. **Verify each step** - Use verification commands before proceeding
3. **Handle errors explicitly** - Check error handling section for each step
4. **Test after integration** - Use test commands to verify functionality
5. **Check logs** - Use ADB logs to diagnose issues
### Key Files to Modify
```yaml
files:
- path: "package.json"
action: "add_dependency"
key: "dependencies.@timesafari/daily-notification-plugin"
- path: "android/app/src/main/AndroidManifest.xml"
action: "add_xml_elements"
elements:
- type: "uses-permission"
name: "android.permission.POST_NOTIFICATIONS"
- type: "receiver"
name: "com.timesafari.dailynotification.NotifyReceiver"
attributes:
android:enabled: "true"
android:exported: "false"
- path: "ios/App/App/Info.plist"
action: "add_dict_keys"
keys:
- "UIBackgroundModes"
- "BGTaskSchedulerPermittedIdentifiers"
- path: "src/main.ts" # or entry point
action: "add_import"
import: "@timesafari/daily-notification-plugin"
```
## Success Criteria
Integration is successful when:
1. ✅ Plugin installs without errors
2.`capacitor.plugins.json` contains DailyNotification entry
3. ✅ AndroidManifest.xml contains NotifyReceiver
4. ✅ iOS Info.plist contains background modes
5. ✅ TypeScript imports work without errors
6.`window.Capacitor.Plugins.DailyNotification` is available
7. ✅ Test alarm fires successfully (use `testAlarm()`)
## Next Steps
After successful integration:
- Read [API.md](./API.md) for complete API reference
- Check [README.md](./README.md) for advanced usage
- Review [docs/notification-testing-procedures.md](./docs/notification-testing-procedures.md) for testing

56
API.md
View File

@@ -2,7 +2,7 @@
**Author**: Matthew Raymer
**Version**: 2.2.0
**Last Updated**: 2025-10-08 06:02:45 UTC
**Last Updated**: 2025-11-06 09:51:00 UTC
## Overview
@@ -74,6 +74,60 @@ Open exact alarm settings in system preferences.
Get reboot recovery status and statistics.
##### `isAlarmScheduled(options: { triggerAtMillis: number }): Promise<{ scheduled: boolean; triggerAtMillis: number }>`
Check if an alarm is scheduled for a specific trigger time. Useful for debugging and verification.
**Parameters:**
- `options.triggerAtMillis`: `number` - The trigger time in milliseconds (Unix timestamp)
**Returns:**
- `scheduled`: `boolean` - Whether the alarm is currently scheduled
- `triggerAtMillis`: `number` - The trigger time that was checked
**Example:**
```typescript
const result = await DailyNotification.isAlarmScheduled({
triggerAtMillis: 1762421400000
});
console.log(`Alarm scheduled: ${result.scheduled}`);
```
##### `getNextAlarmTime(): Promise<{ scheduled: boolean; triggerAtMillis?: number }>`
Get the next scheduled alarm time from AlarmManager. Requires Android 5.0+ (API 21+).
**Returns:**
- `scheduled`: `boolean` - Whether any alarm is scheduled
- `triggerAtMillis`: `number | undefined` - The next alarm trigger time (if scheduled)
**Example:**
```typescript
const result = await DailyNotification.getNextAlarmTime();
if (result.scheduled) {
const nextAlarm = new Date(result.triggerAtMillis);
console.log(`Next alarm: ${nextAlarm.toLocaleString()}`);
}
```
##### `testAlarm(options?: { secondsFromNow?: number }): Promise<{ scheduled: boolean; secondsFromNow: number; triggerAtMillis: number }>`
Schedule a test alarm that fires in a few seconds. Useful for verifying alarm delivery works correctly.
**Parameters:**
- `options.secondsFromNow`: `number` (optional) - Seconds from now to fire the alarm (default: 5)
**Returns:**
- `scheduled`: `boolean` - Whether the alarm was scheduled successfully
- `secondsFromNow`: `number` - The delay used
- `triggerAtMillis`: `number` - The trigger time in milliseconds
**Example:**
```typescript
const result = await DailyNotification.testAlarm({ secondsFromNow: 10 });
console.log(`Test alarm scheduled for ${result.secondsFromNow} seconds`);
```
### Management Methods
#### `maintainRollingWindow(): Promise<void>`

View File

@@ -192,7 +192,7 @@ Build → Generate Signed Bundle / APK
#### Build Output
The built plugin AAR will be located at:
```
android/plugin/build/outputs/aar/plugin-release.aar
android/build/outputs/aar/android-release.aar
```
### Project Structure in Android Studio
@@ -226,36 +226,14 @@ android/
### Important Distinctions
#### Plugin Module (`android/plugin/`)
#### Standard Capacitor Plugin Structure
- **Purpose**: Contains the actual plugin code
- **No MainActivity** - This is a library, not an app
- **No UI Components** - Plugins provide functionality to host apps
- **Output**: AAR library files
The plugin now follows the standard Capacitor Android structure:
- **Plugin Code**: `android/src/main/java/...`
- **Plugin Build**: `android/build.gradle`
- **Test App**: `test-apps/android-test-app/app/` (separate from plugin)
#### Test App Module (`android/app/`)
- **Purpose**: Test application for the plugin
- **Has MainActivity** - Full Capacitor app with BridgeActivity
- **Has UI Components** - HTML/JS interface for testing
- **Output**: APK files for installation
#### What You CAN Do in Android Studio
**Edit Java/Kotlin code** (both plugin and app)
**Run unit tests** (both modules)
**Debug plugin code** (plugin module)
**Build the plugin AAR** (plugin module)
**Build test app APK** (app module)
**Run the test app** (app module)
**Test notifications** (app module)
**Test background tasks** (app module)
**Debug full integration** (app module)
**Check for compilation errors**
**Use code completion and refactoring**
**View build logs and errors**
#### What You CANNOT Do
**Run plugin module directly** (it's a library)
This structure is compatible with Capacitor's auto-generated files and requires no path fixes.
**Test plugin without host app** (needs Capacitor runtime)
## Command Line Building
@@ -416,12 +394,12 @@ test-apps/daily-notification-test/
#### Android Test Apps
The project includes **two separate Android test applications**:
##### 1. Main Android Test App (`/android/app`)
##### 1. Main Android Test App (`test-apps/android-test-app/app`)
A Capacitor-based Android test app with full plugin integration:
```bash
# Build main Android test app
cd android
cd test-apps/android-test-app
./gradlew :app:assembleDebug
# Install on device
@@ -431,37 +409,30 @@ adb install app/build/outputs/apk/debug/app-debug.apk
./gradlew :app:test
# Run in Android Studio
# File → Open → /path/to/daily-notification-plugin/android
# File → Open → /path/to/daily-notification-plugin/test-apps/android-test-app
# Select 'app' module and run
```
**App Structure:**
**Test App Structure:**
```
android/app/
├── src/
│ ├── main/
│ │ ├── AndroidManifest.xml # App manifest with permissions
│ │ ├── assets/ # Capacitor web assets
│ │ │ ├── capacitor.config.json # Capacitor configuration
│ │ │ ├── capacitor.plugins.json # Plugin registry
│ │ │ └── public/ # Web app files
│ │ │ ── index.html # Main test interface
│ │ │ ├── cordova.js # Cordova compatibility
│ │ │ └── plugins/ # Plugin JS 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
├── build.gradle # App build configuration
├── capacitor.build.gradle # Auto-generated Capacitor config
└── proguard-rules.pro # Code obfuscation rules
test-apps/android-test-app/
├── app/
│ ├── src/
│ │ ├── main/
│ │ │ ├── AndroidManifest.xml # App manifest with permissions
│ │ │ ├── assets/ # Capacitor web assets
│ │ │ ├── capacitor.config.json # Capacitor configuration
│ │ │ │ ├── capacitor.plugins.json # Plugin registry
│ │ │ ── public/ # Web app files
│ │ │ ├── java/
│ │ │ └── com/timesafari/dailynotification/
│ │ │ │ └── MainActivity.java # Capacitor BridgeActivity
│ │ │ └── res/ # Android resources
│ │ ├── androidTest/ # Instrumented tests
│ │ └── test/ # Unit tests
├── build.gradle # App build configuration
├── capacitor.build.gradle # Auto-generated Capacitor config
└── proguard-rules.pro # Code obfuscation rules
```
**Key Files Explained:**
@@ -504,7 +475,7 @@ public class MainActivity extends BridgeActivity {
2. **Java Compilation**: Compiles `MainActivity.java` and dependencies
3. **Resource Processing**: Processes Android resources and assets
4. **APK Generation**: Packages everything into installable APK
5. **Plugin Integration**: Links with plugin AAR from `android/plugin/`
5. **Plugin Integration**: Links with plugin from `node_modules/@timesafari/daily-notification-plugin/android`
**Editing Guidelines:**
- **HTML/JS**: Edit `assets/public/index.html` for UI changes
@@ -547,7 +518,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
```gradle
// capacitor.settings.gradle
include ':timesafari-daily-notification-plugin'
project(':timesafari-daily-notification-plugin').projectDir = new File('../../../android/plugin')
project(':timesafari-daily-notification-plugin').projectDir = new File('../../../android')
// capacitor.build.gradle
implementation project(':timesafari-daily-notification-plugin')
@@ -576,7 +547,7 @@ The Vue 3 test app uses a **project reference approach** for plugin integration:
**Troubleshooting Integration Issues:**
- **Duplicate Classes**: Use project reference instead of AAR to avoid conflicts
- **Gradle Cache**: Clear completely (`rm -rf ~/.gradle`) when switching approaches
- **Path Issues**: Ensure correct project path (`../../../android/plugin`)
- **Path Issues**: Ensure correct project path (`../../../android`)
- **Dependencies**: Include required WorkManager and Gson dependencies
### Integration Testing
@@ -881,33 +852,7 @@ rm -rf ~/.gradle/caches ~/.gradle/daemon
#### Capacitor Settings Path Fix (Test App)
**Problem**: `capacitor.settings.gradle` is auto-generated with incorrect plugin path.
The plugin module is in `android/plugin/` but Capacitor generates a path to `android/`.
**Automatic Solution** (Test App Only):
```bash
# Use the wrapper script that auto-fixes after sync:
npm run cap:sync
# This automatically:
# 1. Runs npx cap sync android
# 2. Fixes capacitor.settings.gradle path (android -> android/plugin/)
# 3. Fixes capacitor.plugins.json registration
```
**Manual Fix** (if needed):
```bash
# After running npx cap sync android directly:
node scripts/fix-capacitor-plugins.js
# Or for plugin development (root project):
./scripts/fix-capacitor-build.sh
```
**Automatic Fix on Install**:
The test app has a `postinstall` hook that automatically fixes these issues after `npm install`.
**Note**: The fix script is idempotent - it only changes what's needed and won't break correct configurations.
**Note**: The plugin now uses standard Capacitor structure, so no path fixes are needed for consuming apps. The test app at `test-apps/android-test-app/` references the plugin correctly.
#### Android Studio Issues
```bash
@@ -1031,19 +976,9 @@ daily-notification-plugin/
### Android Structure
```
android/
├── app/ # Main Android test app
│ ├── src/main/java/ # MainActivity.java
│ ├── src/main/assets/ # Capacitor assets
│ ├── build.gradle # App build configuration
│ └── build/outputs/apk/ # Built APK files
├── plugin/ # Plugin library module
│ ├── src/main/java/ # Plugin source code
│ ├── build.gradle # Plugin build configuration
│ └── build/outputs/aar/ # Built AAR files
├── build.gradle # Root Android build configuration
├── settings.gradle # Gradle settings
├── gradle.properties # Gradle properties
└── gradle/wrapper/ # Gradle wrapper files
├── src/main/java/ # Plugin source code
├── build.gradle # Plugin build configuration
└── variables.gradle # Gradle variables
```
### iOS Structure

View File

@@ -85,16 +85,16 @@ The plugin has been optimized for **native-first deployment** with the following
## Plugin Repository Structure
The TimeSafari Daily Notification Plugin follows this structure:
The TimeSafari Daily Notification Plugin follows the standard Capacitor plugin structure:
```
daily-notification-plugin/
├── android/
│ ├── build.gradle
│ ├── build.gradle # Plugin build configuration
│ ├── src/main/java/com/timesafari/dailynotification/
│ │ ├── DailyNotificationPlugin.java
│ │ ├── NotificationWorker.java
│ │ ├── DatabaseManager.java
│ │ └── CallbackRegistry.java
│ │ ├── DailyNotificationWorker.java
│ │ ├── DailyNotificationDatabase.java
│ │ └── ... (other plugin classes)
│ └── src/main/AndroidManifest.xml
├── ios/
│ ├── DailyNotificationPlugin.swift

260
QUICK_INTEGRATION.md Normal file
View File

@@ -0,0 +1,260 @@
# Daily Notification Plugin - Quick Integration Guide
**Author**: Matthew Raymer
**Version**: 2.2.0
**Last Updated**: 2025-11-06
## Overview
This guide provides a **quick, step-by-step** process for integrating the Daily Notification Plugin into any Capacitor application. For detailed documentation, see [README.md](./README.md) and [API.md](./API.md).
**For AI Agents**: See [AI_INTEGRATION_GUIDE.md](./AI_INTEGRATION_GUIDE.md) for explicit, machine-readable integration instructions with verification steps and error handling.
## Prerequisites
- Capacitor 6.0+ project
- Android Studio (for Android development)
- Xcode 14+ (for iOS development)
- Node.js 18+
## Step 1: Install the Plugin
```bash
npm install @timesafari/daily-notification-plugin
```
Or install from Git:
```bash
npm install git+https://github.com/timesafari/daily-notification-plugin.git
```
## Step 2: Sync Capacitor
```bash
npx cap sync android
npx cap sync ios
```
## Step 3: Android Configuration
### 3.1 Update AndroidManifest.xml
**⚠️ CRITICAL**: You **must** add the `NotifyReceiver` registration to your app's `AndroidManifest.xml`. Without it, alarms will fire but notifications won't be displayed.
Add to `android/app/src/main/AndroidManifest.xml`:
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 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.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<!-- ... your existing application components ... -->
<!-- Daily Notification Plugin Receivers -->
<!-- REQUIRED: NotifyReceiver for AlarmManager-based notifications -->
<receiver
android:name="com.timesafari.dailynotification.NotifyReceiver"
android:enabled="true"
android:exported="false">
</receiver>
<!-- BootReceiver for reboot recovery (optional but recommended) -->
<receiver
android:name="com.timesafari.dailynotification.BootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
```
### 3.2 Update build.gradle (if needed)
The plugin should work with standard Capacitor setup. If you encounter dependency issues, ensure these are in `android/app/build.gradle`:
```gradle
dependencies {
// ... your existing dependencies ...
// Plugin dependencies (usually auto-added by Capacitor sync)
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"
annotationProcessor "androidx.room:room-compiler:2.6.1"
}
```
## Step 4: iOS Configuration
### 4.1 Update Info.plist
Add to `ios/App/App/Info.plist`:
```xml
<key>UIBackgroundModes</key>
<array>
<string>background-app-refresh</string>
<string>background-processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.timesafari.dailynotification.content-fetch</string>
<string>com.timesafari.dailynotification.notification-delivery</string>
</array>
```
### 4.2 Enable Capabilities
In Xcode:
1. Select your app target
2. Go to "Signing & Capabilities"
3. Enable "Background Modes"
4. Check "Background App Refresh" and "Background Processing"
## Step 5: Use the Plugin
### Basic Usage
```typescript
import { DailyNotification } from '@timesafari/daily-notification-plugin';
// Configure the plugin
await DailyNotification.configure({
storage: 'tiered',
ttlSeconds: 1800,
enableETagSupport: true
});
// Schedule a daily notification
await DailyNotification.scheduleDailyNotification({
title: 'Daily Update',
body: 'Your daily content is ready',
schedule: '0 9 * * *' // 9 AM daily (cron format)
});
```
### Request Permissions
```typescript
// Check permissions
const status = await DailyNotification.checkPermissions();
console.log('Notification permission:', status.notifications);
// Request permissions
if (status.notifications !== 'granted') {
await DailyNotification.requestPermissions();
}
```
### Schedule a Simple Reminder
```typescript
// Schedule a static daily reminder (no network required)
await DailyNotification.scheduleDailyReminder({
id: 'morning_checkin',
title: 'Good Morning!',
body: 'Time to check your updates',
time: '09:00', // HH:mm format
sound: true,
vibration: true,
priority: 'normal'
});
```
### Diagnostic Methods (Android)
```typescript
// Check if an alarm is scheduled
const result = await DailyNotification.isAlarmScheduled({
triggerAtMillis: scheduledTime
});
console.log('Alarm scheduled:', result.scheduled);
// Get next alarm time
const nextAlarm = await DailyNotification.getNextAlarmTime();
if (nextAlarm.scheduled) {
console.log('Next alarm:', new Date(nextAlarm.triggerAtMillis));
}
// Test alarm delivery (schedules alarm for 10 seconds from now)
await DailyNotification.testAlarm({ secondsFromNow: 10 });
```
## Step 6: Verify Installation
### Check Plugin Registration
```typescript
// Verify plugin is available
if (window.Capacitor?.Plugins?.DailyNotification) {
console.log('✅ Plugin is registered');
} else {
console.error('❌ Plugin not found');
}
```
### Test Notification
```typescript
// Schedule a test notification for 10 seconds from now
await DailyNotification.testAlarm({ secondsFromNow: 10 });
// Or schedule a regular notification
await DailyNotification.scheduleDailyReminder({
id: 'test',
title: 'Test Notification',
body: 'This is a test',
time: new Date(Date.now() + 60000).toTimeString().slice(0, 5) // 1 minute from now
});
```
## Troubleshooting
### Notifications Not Appearing
1. **Check NotifyReceiver Registration**: Verify `NotifyReceiver` is in your `AndroidManifest.xml` (see Step 3.1)
2. **Check Permissions**: Ensure notification permissions are granted
3. **Check Logs**: Use ADB to check logs:
```bash
adb logcat | grep -E "DNP-|NotifyReceiver|Notification"
```
4. **Use Diagnostic Methods**: Use `isAlarmScheduled()` and `getNextAlarmTime()` to verify alarms
### Common Issues
#### Android: "Alarm fires but notification doesn't appear"
- **Solution**: Ensure `NotifyReceiver` is registered in your app's `AndroidManifest.xml` (not just the plugin's manifest)
#### Android: "Permission denied" errors
- **Solution**: Request `POST_NOTIFICATIONS` and `SCHEDULE_EXACT_ALARM` permissions
#### iOS: Background tasks not running
- **Solution**: Ensure Background Modes are enabled in Xcode capabilities
#### Plugin not found
- **Solution**: Run `npx cap sync` and rebuild the app
## Next Steps
- Read the [API Reference](./API.md) for complete method documentation
- Check [README.md](./README.md) for advanced usage examples
- Review [docs/notification-testing-procedures.md](./docs/notification-testing-procedures.md) for testing guidance
## Support
For issues or questions:
- Check the troubleshooting section above
- Review the [API documentation](./API.md)
- Check [docs/notification-testing-procedures.md](./docs/notification-testing-procedures.md) for debugging steps

View File

@@ -70,6 +70,10 @@ The plugin has been optimized for **native-first deployment** with the following
- **Health Monitoring**: Comprehensive status and performance metrics
- **Error Handling**: Exponential backoff and retry logic
- **Security**: Encrypted storage and secure callback handling
- **Database Access**: Full TypeScript interfaces for plugin database access
- See [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) for complete API reference
- Plugin owns its SQLite database - access via Capacitor interfaces
- Supports schedules, content cache, callbacks, history, and configuration
### ⏰ **Static Daily Reminders**
@@ -86,6 +90,27 @@ The plugin has been optimized for **native-first deployment** with the following
npm install @timesafari/daily-notification-plugin
```
Or install from Git repository:
```bash
npm install git+https://github.com/timesafari/daily-notification-plugin.git
```
The plugin follows the standard Capacitor Android structure - no additional path configuration needed!
## Quick Integration
**New to the plugin?** Start with the [Quick Integration Guide](./QUICK_INTEGRATION.md) for step-by-step setup instructions.
The quick guide covers:
- Installation and setup
- AndroidManifest.xml configuration (⚠️ **Critical**: NotifyReceiver registration)
- iOS configuration
- Basic usage examples
- Troubleshooting common issues
**For AI Agents**: See [AI Integration Guide](./AI_INTEGRATION_GUIDE.md) for explicit, machine-readable instructions with verification steps, error handling, and decision trees.
## Quick Start
### Basic Usage
@@ -300,6 +325,42 @@ const status = await DailyNotification.getDualScheduleStatus();
// }
```
### Android Diagnostic Methods
#### `isAlarmScheduled(options)`
Check if an alarm is scheduled for a specific trigger time. Useful for debugging and verification.
```typescript
const result = await DailyNotification.isAlarmScheduled({
triggerAtMillis: 1762421400000 // Unix timestamp in milliseconds
});
console.log(`Alarm scheduled: ${result.scheduled}`);
```
#### `getNextAlarmTime()`
Get the next scheduled alarm time from AlarmManager. Requires Android 5.0+ (API 21+).
```typescript
const result = await DailyNotification.getNextAlarmTime();
if (result.scheduled) {
const nextAlarm = new Date(result.triggerAtMillis);
console.log(`Next alarm: ${nextAlarm.toLocaleString()}`);
}
```
#### `testAlarm(options?)`
Schedule a test alarm that fires in a few seconds. Useful for verifying alarm delivery works correctly.
```typescript
// Schedule test alarm for 10 seconds from now
const result = await DailyNotification.testAlarm({ secondsFromNow: 10 });
console.log(`Test alarm scheduled for ${result.secondsFromNow} seconds`);
console.log(`Will fire at: ${new Date(result.triggerAtMillis).toLocaleString()}`);
```
## Capacitor Compatibility Matrix
| Plugin Version | Capacitor Version | Status | Notes |
@@ -468,6 +529,8 @@ await DailyNotification.updateDailyReminder('morning_checkin', {
#### AndroidManifest.xml
**⚠️ CRITICAL**: The `NotifyReceiver` registration is **required** for alarm-based notifications to work. Without it, alarms will fire but notifications won't be displayed.
```xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
@@ -475,9 +538,13 @@ await DailyNotification.updateDailyReminder('morning_checkin', {
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- NotifyReceiver for AlarmManager-based notifications -->
<!-- REQUIRED: Without this, alarms fire but notifications won't display -->
<receiver android:name="com.timesafari.dailynotification.NotifyReceiver"
android:enabled="true"
android:exported="false" />
android:exported="false">
</receiver>
<receiver android:name="com.timesafari.dailynotification.BootReceiver"
android:enabled="true"
android:exported="false">
@@ -487,6 +554,8 @@ await DailyNotification.updateDailyReminder('morning_checkin', {
</receiver>
```
**Note**: The `NotifyReceiver` must be registered in your app's `AndroidManifest.xml`, not just in the plugin's manifest. If notifications aren't appearing even though alarms are scheduled, check that `NotifyReceiver` is properly registered.
#### build.gradle
```gradle
@@ -733,6 +802,9 @@ MIT License - see [LICENSE](LICENSE) file for details.
### Documentation
- **API Reference**: Complete TypeScript definitions
- **Database Interfaces**: [`docs/DATABASE_INTERFACES.md`](docs/DATABASE_INTERFACES.md) - Complete guide to accessing plugin database from TypeScript/webview
- **Database Consolidation Plan**: [`android/DATABASE_CONSOLIDATION_PLAN.md`](android/DATABASE_CONSOLIDATION_PLAN.md) - Database schema consolidation roadmap
- **Database Implementation**: [`docs/DATABASE_INTERFACES_IMPLEMENTATION.md`](docs/DATABASE_INTERFACES_IMPLEMENTATION.md) - Implementation summary and status
- **Migration Guide**: [doc/migration-guide.md](doc/migration-guide.md)
- **Integration Guide**: [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) - Complete integration instructions
- **Building Guide**: [BUILDING.md](BUILDING.md) - Comprehensive build instructions and troubleshooting

210
TODO.md Normal file
View File

@@ -0,0 +1,210 @@
# Daily Notification Plugin - TODO Items
**Last Updated**: 2025-11-06
**Status**: Active tracking of pending improvements and features
---
## 🔴 High Priority
### 1. Add Instrumentation Tests
**Status**: In Progress
**Priority**: High
**Context**: Expand beyond basic `ExampleInstrumentedTest.java`
**Tasks**:
- [x] Create comprehensive instrumentation test suite
- [x] Test alarm scheduling and delivery
- [x] Test BroadcastReceiver registration
- [x] Test alarm status checking
- [x] Test alarm cancellation
- [x] Test unique request codes
- [ ] Test notification display (requires UI testing)
- [ ] Test prefetch mechanism (requires WorkManager testing)
- [ ] Test permission handling edge cases
- [ ] Test offline scenarios
**Location**: `test-apps/daily-notification-test/android/app/src/androidTest/java/com/timesafari/dailynotification/NotificationInstrumentationTest.java`
**Reference**: `docs/android-app-improvement-plan.md` - Phase 2: Testing & Reliability
**Completed**: Created `NotificationInstrumentationTest.java` with tests for:
- NotifyReceiver registration verification
- Alarm scheduling with setAlarmClock()
- Unique request code generation
- Alarm status checking (isAlarmScheduled)
- Next alarm time retrieval
- Alarm cancellation
- PendingIntent uniqueness
---
### 2. Update Documentation
**Status**: ✅ Completed
**Priority**: High
**Context**: Documentation needs updates for recent changes
**Tasks**:
- [x] Update API reference with new methods (`isAlarmScheduled`, `getNextAlarmTime`, `testAlarm`)
- [x] Document NotifyReceiver registration requirements
- [x] Update AndroidManifest.xml examples
- [x] Document alarm scheduling improvements (`setAlarmClock()`)
- [x] Add troubleshooting guide for BroadcastReceiver issues
- [ ] Update integration guide with Vue test app setup
**Completed**: Updated documentation in:
- `API.md`: Added new diagnostic methods with examples
- `README.md`: Added Android diagnostic methods section, emphasized NotifyReceiver requirement
- `docs/notification-testing-procedures.md`: Added troubleshooting for BroadcastReceiver issues, diagnostic method usage
**Reference**: `docs/android-app-improvement-plan.md` - Phase 3: Security & Performance
---
## 🟡 Medium Priority
### 3. Phase 2 Platform Implementation
**Status**: Pending
**Priority**: Medium
**Context**: Complete platform-specific implementations per specification
**Android Tasks**:
- [ ] WorkManager integration improvements
- [ ] SQLite storage implementation (shared database)
- [ ] TTL enforcement at notification fire time
- [ ] Rolling window safety mechanisms
- [ ] ETag support for content fetching
**iOS Tasks**:
- [ ] BGTaskScheduler implementation
- [ ] UNUserNotificationCenter integration
- [ ] Background task execution
- [ ] Tlead prefetch logic
**Storage System**:
- [ ] SQLite schema design with TTL rules
- [ ] WAL (Write-Ahead Logging) mode
- [ ] Shared database access pattern
- [ ] Hot-read verification for UI
**Callback Registry**:
- [ ] Full implementation with retries
- [ ] Redaction support for sensitive data
- [ ] Webhook delivery mechanism
- [ ] Error handling and recovery
**Reference**: `doc/implementation-roadmap.md` - Phase 2 details
---
### 4. Performance Optimization
**Status**: Pending
**Priority**: Medium
**Context**: Optimize battery usage and system resources
**Tasks**:
- [ ] Battery optimization recommendations
- [ ] Network request optimization
- [ ] Background execution efficiency
- [ ] Memory usage optimization
- [ ] CPU usage profiling
**Reference**: `code-summary-for-chatgpt.md` - Production Readiness Checklist
---
### 5. Security Audit
**Status**: Pending
**Priority**: Medium
**Context**: Security hardening review
**Tasks**:
- [ ] Permission validation review
- [ ] Input sanitization audit
- [ ] Network security review
- [ ] Storage encryption review
- [ ] JWT token handling security
**Reference**: `code-summary-for-chatgpt.md` - Production Readiness Checklist
---
## 🟢 Low Priority / Nice-to-Have
### 6. iOS Implementation Completion
**Status**: Pending
**Priority**: Low
**Context**: Complete iOS platform implementation
**Tasks**:
- [ ] BGTaskScheduler registration
- [ ] Background task handlers
- [ ] UNUserNotificationCenter integration
- [ ] UserDefaults storage improvements
- [ ] Background App Refresh handling
**Reference**: `code-summary-for-chatgpt.md` - Production Readiness Checklist
---
### 7. Monitoring and Analytics
**Status**: Pending
**Priority**: Low
**Context**: Add observability and metrics
**Tasks**:
- [ ] Structured logging improvements
- [ ] Health monitoring endpoints
- [ ] Success rate tracking
- [ ] Latency metrics
- [ ] Error distribution tracking
**Reference**: `doc/directives/0001-Daily-Notification-Plugin-Implementation-Directive.md`
---
### 8. User Documentation
**Status**: Pending
**Priority**: Low
**Context**: End-user documentation
**Tasks**:
- [ ] User guide for notification setup
- [ ] Troubleshooting guide for users
- [ ] Battery optimization instructions
- [ ] Permission setup guide
**Reference**: `code-summary-for-chatgpt.md` - Production Readiness Checklist
---
### 9. Production Deployment Guide
**Status**: Pending
**Priority**: Low
**Context**: Deployment procedures
**Tasks**:
- [ ] Production build configuration
- [ ] Release checklist
- [ ] Rollback procedures
- [ ] Monitoring setup guide
**Reference**: `DEPLOYMENT_CHECKLIST.md`
---
## 📝 Notes
- **CI/CD**: Excluded from this list per project requirements
- **Current Focus**: High priority items (#1 and #2)
- **Recent Completion**: NotifyReceiver registration fix (2025-11-06)
- **Verification**: Notification system working in both test apps
---
**Related Documents**:
- `docs/android-app-improvement-plan.md` - Detailed improvement plan
- `doc/implementation-roadmap.md` - Implementation phases
- `DEPLOYMENT_CHECKLIST.md` - Deployment procedures
- `test-apps/daily-notification-test/TODO_NATIVE_FETCHER.md` - Native fetcher TODOs

54
android/.gitignore vendored
View File

@@ -16,13 +16,17 @@
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Keep gradle wrapper files - they're needed for builds
!gradle/wrapper/gradle-wrapper.jar
!gradle/wrapper/gradle-wrapper.properties
!gradlew
!gradlew.bat
# Local configuration file (sdk path, etc)
local.properties
@@ -38,19 +42,9 @@ proguard/
# Android Studio captures folder
captures/
# IntelliJ
# IntelliJ / Android Studio
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
.idea/
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
@@ -64,38 +58,6 @@ captures/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

View File

@@ -0,0 +1,2 @@
connection.project.dir=../../../../android
eclipse.preferences.version=1

69
android/BUILDING.md Normal file
View File

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

View File

@@ -0,0 +1,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

View File

@@ -1,153 +0,0 @@
package com.timesafari.dailynotification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Boot recovery receiver to reschedule notifications after device reboot
* Implements RECEIVE_BOOT_COMPLETED functionality
*
* @author Matthew Raymer
* @version 1.1.0
*/
class BootReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "DNP-BOOT"
}
override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
Log.i(TAG, "Boot completed, rescheduling notifications")
CoroutineScope(Dispatchers.IO).launch {
try {
rescheduleNotifications(context)
} catch (e: Exception) {
Log.e(TAG, "Failed to reschedule notifications after boot", e)
}
}
}
}
private suspend fun rescheduleNotifications(context: Context) {
val db = DailyNotificationDatabase.getDatabase(context)
val enabledSchedules = db.scheduleDao().getEnabled()
Log.i(TAG, "Found ${enabledSchedules.size} enabled schedules to reschedule")
enabledSchedules.forEach { schedule ->
try {
when (schedule.kind) {
"fetch" -> {
// Reschedule WorkManager fetch
val config = ContentFetchConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
url = null, // Will use mock content
timeout = 30000,
retryAttempts = 3,
retryDelay = 1000,
callbacks = CallbackConfig()
)
FetchWorker.scheduleFetch(context, config)
Log.i(TAG, "Rescheduled fetch for schedule: ${schedule.id}")
}
"notify" -> {
// Reschedule AlarmManager notification
val nextRunTime = calculateNextRunTime(schedule)
if (nextRunTime > System.currentTimeMillis()) {
val config = UserNotificationConfig(
enabled = schedule.enabled,
schedule = schedule.cron ?: schedule.clockTime ?: "0 9 * * *",
title = "Daily Notification",
body = "Your daily update is ready",
sound = true,
vibration = true,
priority = "normal"
)
NotifyReceiver.scheduleExactNotification(context, nextRunTime, config)
Log.i(TAG, "Rescheduled notification for schedule: ${schedule.id}")
}
}
else -> {
Log.w(TAG, "Unknown schedule kind: ${schedule.kind}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to reschedule ${schedule.kind} for ${schedule.id}", e)
}
}
// Record boot recovery in history
try {
db.historyDao().insert(
History(
refId = "boot_recovery_${System.currentTimeMillis()}",
kind = "boot_recovery",
occurredAt = System.currentTimeMillis(),
outcome = "success",
diagJson = "{\"schedules_rescheduled\": ${enabledSchedules.size}}"
)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to record boot recovery", e)
}
}
private fun calculateNextRunTime(schedule: Schedule): Long {
val now = System.currentTimeMillis()
// Simple implementation - for production, use proper cron parsing
return when {
schedule.cron != null -> {
// Parse cron expression and calculate next run
// For now, return next day at 9 AM
now + (24 * 60 * 60 * 1000L)
}
schedule.clockTime != null -> {
// Parse HH:mm and calculate next run
// For now, return next day at specified time
now + (24 * 60 * 60 * 1000L)
}
else -> {
// Default to next day at 9 AM
now + (24 * 60 * 60 * 1000L)
}
}
}
}
/**
* Data classes for configuration (simplified versions)
*/
data class ContentFetchConfig(
val enabled: Boolean,
val schedule: String,
val url: String? = null,
val timeout: Int? = null,
val retryAttempts: Int? = null,
val retryDelay: Int? = null,
val callbacks: CallbackConfig
)
data class UserNotificationConfig(
val enabled: Boolean,
val schedule: String,
val title: String? = null,
val body: String? = null,
val sound: Boolean? = null,
val vibration: Boolean? = null,
val priority: String? = null
)
data class CallbackConfig(
val apiService: String? = null,
val database: String? = null,
val reporting: String? = null
)

View File

@@ -1,144 +0,0 @@
package com.timesafari.dailynotification
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* SQLite schema for Daily Notification Plugin
* Implements TTL-at-fire invariant and rolling window armed design
*
* @author Matthew Raymer
* @version 1.1.0
*/
@Entity(tableName = "content_cache")
data class ContentCache(
@PrimaryKey val id: String,
val fetchedAt: Long, // epoch ms
val ttlSeconds: Int,
val payload: ByteArray, // BLOB
val meta: String? = null
)
@Entity(tableName = "schedules")
data class Schedule(
@PrimaryKey val id: String,
val kind: String, // 'fetch' or 'notify'
val cron: String? = null, // optional cron expression
val clockTime: String? = null, // optional HH:mm
val enabled: Boolean = true,
val lastRunAt: Long? = null,
val nextRunAt: Long? = null,
val jitterMs: Int = 0,
val backoffPolicy: String = "exp",
val stateJson: String? = null
)
@Entity(tableName = "callbacks")
data class Callback(
@PrimaryKey val id: String,
val kind: String, // 'http', 'local', 'queue'
val target: String, // url_or_local
val headersJson: String? = null,
val enabled: Boolean = true,
val createdAt: Long
)
@Entity(tableName = "history")
data class History(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val refId: String, // content or schedule id
val kind: String, // fetch/notify/callback
val occurredAt: Long,
val durationMs: Long? = null,
val outcome: String, // success|failure|skipped_ttl|circuit_open
val diagJson: String? = null
)
@Database(
entities = [ContentCache::class, Schedule::class, Callback::class, History::class],
version = 1,
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class DailyNotificationDatabase : RoomDatabase() {
abstract fun contentCacheDao(): ContentCacheDao
abstract fun scheduleDao(): ScheduleDao
abstract fun callbackDao(): CallbackDao
abstract fun historyDao(): HistoryDao
}
@Dao
interface ContentCacheDao {
@Query("SELECT * FROM content_cache WHERE id = :id")
suspend fun getById(id: String): ContentCache?
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatest(): ContentCache?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(contentCache: ContentCache)
@Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)
@Query("SELECT COUNT(*) FROM content_cache")
suspend fun getCount(): Int
}
@Dao
interface ScheduleDao {
@Query("SELECT * FROM schedules WHERE enabled = 1")
suspend fun getEnabled(): List<Schedule>
@Query("SELECT * FROM schedules WHERE id = :id")
suspend fun getById(id: String): Schedule?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(schedule: Schedule)
@Query("UPDATE schedules SET enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: String, enabled: Boolean)
@Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id")
suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?)
}
@Dao
interface CallbackDao {
@Query("SELECT * FROM callbacks WHERE enabled = 1")
suspend fun getEnabled(): List<Callback>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(callback: Callback)
@Query("DELETE FROM callbacks WHERE id = :id")
suspend fun deleteById(id: String)
}
@Dao
interface HistoryDao {
@Insert
suspend fun insert(history: History)
@Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC")
suspend fun getSince(since: Long): List<History>
@Query("DELETE FROM history WHERE occurredAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)
@Query("SELECT COUNT(*) FROM history")
suspend fun getCount(): Int
}
class Converters {
@TypeConverter
fun fromByteArray(value: ByteArray?): String? {
return value?.let { String(it) }
}
@TypeConverter
fun toByteArray(value: String?): ByteArray? {
return value?.toByteArray()
}
}

View File

@@ -1,202 +0,0 @@
package com.timesafari.dailynotification
import android.content.Context
import android.util.Log
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
/**
* WorkManager implementation for content fetching
* Implements exponential backoff and network constraints
*
* @author Matthew Raymer
* @version 1.1.0
*/
class FetchWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
private const val TAG = "DNP-FETCH"
private const val WORK_NAME = "fetch_content"
fun scheduleFetch(context: Context, config: ContentFetchConfig) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.setInputData(
Data.Builder()
.putString("url", config.url)
.putString("headers", config.headers?.toString())
.putInt("timeout", config.timeout ?: 30000)
.putInt("retryAttempts", config.retryAttempts ?: 3)
.putInt("retryDelay", config.retryDelay ?: 1000)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.REPLACE,
workRequest
)
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val start = SystemClock.elapsedRealtime()
val url = inputData.getString("url")
val timeout = inputData.getInt("timeout", 30000)
val retryAttempts = inputData.getInt("retryAttempts", 3)
val retryDelay = inputData.getInt("retryDelay", 1000)
try {
Log.i(TAG, "Starting content fetch from: $url")
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
val contentCache = ContentCache(
id = generateId(),
fetchedAt = System.currentTimeMillis(),
ttlSeconds = 3600, // 1 hour default TTL
payload = payload,
meta = "fetched_by_workmanager"
)
// Store in database
val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.contentCacheDao().upsert(contentCache)
// Record success in history
db.historyDao().insert(
History(
refId = contentCache.id,
kind = "fetch",
occurredAt = System.currentTimeMillis(),
durationMs = SystemClock.elapsedRealtime() - start,
outcome = "success"
)
)
Log.i(TAG, "Content fetch completed successfully")
Result.success()
} catch (e: IOException) {
Log.w(TAG, "Network error during fetch", e)
recordFailure("network_error", start, e)
Result.retry()
} catch (e: Exception) {
Log.e(TAG, "Unexpected error during fetch", e)
recordFailure("unexpected_error", start, e)
Result.failure()
}
}
private suspend fun fetchContent(
url: String?,
timeout: Int,
retryAttempts: Int,
retryDelay: Int
): ByteArray {
if (url.isNullOrBlank()) {
// Generate mock content for testing
return generateMockContent()
}
var lastException: Exception? = null
repeat(retryAttempts) { attempt ->
try {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = timeout
connection.readTimeout = timeout
connection.requestMethod = "GET"
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
return connection.inputStream.readBytes()
} else {
throw IOException("HTTP $responseCode: ${connection.responseMessage}")
}
} catch (e: Exception) {
lastException = e
if (attempt < retryAttempts - 1) {
Log.w(TAG, "Fetch attempt ${attempt + 1} failed, retrying in ${retryDelay}ms", e)
kotlinx.coroutines.delay(retryDelay.toLong())
}
}
}
throw lastException ?: IOException("All retry attempts failed")
}
private fun generateMockContent(): ByteArray {
val mockData = """
{
"timestamp": ${System.currentTimeMillis()},
"content": "Daily notification content",
"source": "mock_generator",
"version": "1.1.0"
}
""".trimIndent()
return mockData.toByteArray()
}
private suspend fun recordFailure(outcome: String, start: Long, error: Throwable) {
try {
val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.historyDao().insert(
History(
refId = "fetch_${System.currentTimeMillis()}",
kind = "fetch",
occurredAt = System.currentTimeMillis(),
durationMs = SystemClock.elapsedRealtime() - start,
outcome = outcome,
diagJson = "{\"error\": \"${error.message}\"}"
)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to record failure", e)
}
}
private fun generateId(): String {
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
}
}
/**
* Database singleton for Room
*/
object DailyNotificationDatabase {
@Volatile
private var INSTANCE: DailyNotificationDatabase? = null
fun getDatabase(context: Context): DailyNotificationDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DailyNotificationDatabase::class.java,
"daily_notification_database"
).build()
INSTANCE = instance
instance
}
}
}

View File

@@ -1,336 +0,0 @@
package com.timesafari.dailynotification
import android.app.AlarmManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* AlarmManager implementation for user notifications
* Implements TTL-at-fire logic and notification delivery
*
* @author Matthew Raymer
* @version 1.1.0
*/
class NotifyReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "DNP-NOTIFY"
private const val CHANNEL_ID = "daily_notifications"
private const val NOTIFICATION_ID = 1001
private const val REQUEST_CODE = 2001
fun scheduleExactNotification(
context: Context,
triggerAtMillis: Long,
config: UserNotificationConfig
) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotifyReceiver::class.java).apply {
putExtra("title", config.title)
putExtra("body", config.body)
putExtra("sound", config.sound ?: true)
putExtra("vibration", config.vibration ?: true)
putExtra("priority", config.priority ?: "normal")
}
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
}
Log.i(TAG, "Exact notification scheduled for: $triggerAtMillis")
} catch (e: SecurityException) {
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e)
alarmManager.set(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
}
}
fun cancelNotification(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotifyReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.i(TAG, "Notification alarm cancelled")
}
}
override fun onReceive(context: Context, intent: Intent?) {
Log.i(TAG, "Notification receiver triggered")
CoroutineScope(Dispatchers.IO).launch {
try {
// Check if this is a static reminder (no content dependency)
val isStaticReminder = intent?.getBooleanExtra("is_static_reminder", false) ?: false
if (isStaticReminder) {
// Handle static reminder without content cache
val title = intent?.getStringExtra("title") ?: "Daily Reminder"
val body = intent?.getStringExtra("body") ?: "Don't forget your daily check-in!"
val sound = intent?.getBooleanExtra("sound", true) ?: true
val vibration = intent?.getBooleanExtra("vibration", true) ?: true
val priority = intent?.getStringExtra("priority") ?: "normal"
val reminderId = intent?.getStringExtra("reminder_id") ?: "unknown"
showStaticReminderNotification(context, title, body, sound, vibration, priority, reminderId)
// Record reminder trigger in database
recordReminderTrigger(context, reminderId)
return@launch
}
// Existing cached content logic for regular notifications
val db = DailyNotificationDatabase.getDatabase(context)
val latestCache = db.contentCacheDao().getLatest()
if (latestCache == null) {
Log.w(TAG, "No cached content available for notification")
recordHistory(db, "notify", "no_content")
return@launch
}
// TTL-at-fire check
val now = System.currentTimeMillis()
val ttlExpiry = latestCache.fetchedAt + (latestCache.ttlSeconds * 1000L)
if (now > ttlExpiry) {
Log.i(TAG, "Content TTL expired, skipping notification")
recordHistory(db, "notify", "skipped_ttl")
return@launch
}
// Show notification
val title = intent?.getStringExtra("title") ?: "Daily Notification"
val body = intent?.getStringExtra("body") ?: String(latestCache.payload)
val sound = intent?.getBooleanExtra("sound", true) ?: true
val vibration = intent?.getBooleanExtra("vibration", true) ?: true
val priority = intent?.getStringExtra("priority") ?: "normal"
showNotification(context, title, body, sound, vibration, priority)
recordHistory(db, "notify", "success")
// Fire callbacks
fireCallbacks(context, db, "onNotifyDelivered", latestCache)
} catch (e: Exception) {
Log.e(TAG, "Error in notification receiver", e)
try {
val db = DailyNotificationDatabase.getDatabase(context)
recordHistory(db, "notify", "failure", e.message)
} catch (dbError: Exception) {
Log.e(TAG, "Failed to record notification failure", dbError)
}
}
}
}
private fun showNotification(
context: Context,
title: String,
body: String,
sound: Boolean,
vibration: Boolean,
priority: String
) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create notification channel for Android 8.0+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Daily Notifications",
when (priority) {
"high" -> NotificationManager.IMPORTANCE_HIGH
"low" -> NotificationManager.IMPORTANCE_LOW
else -> NotificationManager.IMPORTANCE_DEFAULT
}
).apply {
enableVibration(vibration)
if (!sound) {
setSound(null, null)
}
}
notificationManager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setPriority(
when (priority) {
"high" -> NotificationCompat.PRIORITY_HIGH
"low" -> NotificationCompat.PRIORITY_LOW
else -> NotificationCompat.PRIORITY_DEFAULT
}
)
.setAutoCancel(true)
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
Log.i(TAG, "Notification displayed: $title")
}
private suspend fun recordHistory(
db: DailyNotificationDatabase,
kind: String,
outcome: String,
diagJson: String? = null
) {
try {
db.historyDao().insert(
History(
refId = "notify_${System.currentTimeMillis()}",
kind = kind,
occurredAt = System.currentTimeMillis(),
outcome = outcome,
diagJson = diagJson
)
)
} catch (e: Exception) {
Log.e(TAG, "Failed to record history", e)
}
}
private suspend fun fireCallbacks(
context: Context,
db: DailyNotificationDatabase,
eventType: String,
contentCache: ContentCache
) {
try {
val callbacks = db.callbackDao().getEnabled()
callbacks.forEach { callback ->
try {
when (callback.kind) {
"http" -> fireHttpCallback(callback, eventType, contentCache)
"local" -> fireLocalCallback(context, callback, eventType, contentCache)
else -> Log.w(TAG, "Unknown callback kind: ${callback.kind}")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fire callback ${callback.id}", e)
recordHistory(db, "callback", "failure", "{\"callback_id\": \"${callback.id}\", \"error\": \"${e.message}\"}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fire callbacks", e)
}
}
private suspend fun fireHttpCallback(
callback: Callback,
eventType: String,
contentCache: ContentCache
) {
// HTTP callback implementation would go here
Log.i(TAG, "HTTP callback fired: ${callback.id} for event: $eventType")
}
private suspend fun fireLocalCallback(
context: Context,
callback: Callback,
eventType: String,
contentCache: ContentCache
) {
// Local callback implementation would go here
Log.i(TAG, "Local callback fired: ${callback.id} for event: $eventType")
}
// Static Reminder Helper Methods
private fun showStaticReminderNotification(
context: Context,
title: String,
body: String,
sound: Boolean,
vibration: Boolean,
priority: String,
reminderId: String
) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Create notification channel for reminders
createReminderNotificationChannel(context, notificationManager)
val notification = NotificationCompat.Builder(context, "daily_reminders")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setPriority(
when (priority) {
"high" -> NotificationCompat.PRIORITY_HIGH
"low" -> NotificationCompat.PRIORITY_LOW
else -> NotificationCompat.PRIORITY_DEFAULT
}
)
.setSound(if (sound) null else null) // Use default sound if enabled
.setAutoCancel(true)
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()
notificationManager.notify(reminderId.hashCode(), notification)
Log.i(TAG, "Static reminder displayed: $title (ID: $reminderId)")
}
private fun createReminderNotificationChannel(context: Context, notificationManager: NotificationManager) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"daily_reminders",
"Daily Reminders",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Daily reminder notifications"
enableVibration(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
}
private fun recordReminderTrigger(context: Context, reminderId: String) {
try {
val prefs = context.getSharedPreferences("daily_reminders", Context.MODE_PRIVATE)
val editor = prefs.edit()
editor.putLong("${reminderId}_lastTriggered", System.currentTimeMillis())
editor.apply()
Log.d(TAG, "Reminder trigger recorded: $reminderId")
} catch (e: Exception) {
Log.e(TAG, "Error recording reminder trigger", e)
}
}
}

View File

@@ -1,29 +1,131 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
classpath 'com.android.tools.build:gradle:8.1.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10'
}
}
apply from: "variables.gradle"
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
allprojects {
repositories {
google()
mavenCentral()
android {
namespace "com.timesafari.dailynotification.plugin"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
// Disable test compilation - tests reference deprecated/removed code
// TODO: Rewrite tests to use modern AndroidX testing framework
testOptions {
unitTests.all {
enabled = false
}
}
// Exclude test sources from compilation
sourceSets {
test {
java {
srcDirs = [] // Disable test source compilation
}
}
}
}
task clean(type: Delete) {
delete rootProject.buildDir
repositories {
google()
mavenCentral()
// Try to find Capacitor from node_modules (for standalone builds)
// In consuming apps, Capacitor will be available as a project dependency
def capacitorPath = new File(rootProject.projectDir, '../node_modules/@capacitor/android/capacitor')
if (capacitorPath.exists()) {
flatDir {
dirs capacitorPath
}
}
}
dependencies {
// Capacitor dependency - provided by consuming app
// When included as a project dependency, use project reference
// NOTE: Capacitor Android is NOT published to Maven - it must be available as a project dependency
def capacitorProject = project.findProject(':capacitor-android')
if (capacitorProject != null) {
implementation capacitorProject
} else {
// Capacitor not found - this plugin MUST be built within a Capacitor app context
// Provide clear error message with instructions
def errorMsg = """
╔══════════════════════════════════════════════════════════════════╗
║ ERROR: Capacitor Android project not found ║
╠══════════════════════════════════════════════════════════════════╣
║ ║
║ This plugin requires Capacitor Android to build. ║
║ Capacitor plugins cannot be built standalone. ║
║ ║
║ To build this plugin: ║
║ 1. Build from test-apps/android-test-app (recommended) ║
║ cd test-apps/android-test-app ║
║ ./gradlew build ║
║ ║
║ 2. Or include this plugin in a Capacitor app: ║
║ - Add to your app's android/settings.gradle: ║
║ include ':daily-notification-plugin' ║
║ project(':daily-notification-plugin').projectDir = ║
║ new File('../daily-notification-plugin/android') ║
║ ║
║ Note: Capacitor Android is only available as a project ║
║ dependency, not from Maven repositories. ║
║ ║
╚══════════════════════════════════════════════════════════════════╝
"""
throw new GradleException(errorMsg)
}
// These dependencies are always available from Maven
implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.room:room-runtime:2.6.1"
implementation "androidx.room:room-ktx:2.6.1"
implementation "androidx.work:work-runtime-ktx:2.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10"
implementation "com.google.code.gson:gson:2.10.1"
implementation "androidx.core:core:1.12.0"
// Room annotation processor - use kapt for Kotlin, annotationProcessor for Java
kapt "androidx.room:room-compiler:2.6.1"
annotationProcessor "androidx.room:room-compiler:2.6.1"
}

View File

@@ -1,3 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

View File

@@ -0,0 +1,10 @@
# Consumer ProGuard rules for Daily Notification Plugin
# These rules are applied to consuming apps when they use this plugin
# Keep plugin classes
-keep class com.timesafari.dailynotification.** { *; }
# Keep Capacitor plugin interface
-keep class com.getcapacitor.Plugin { *; }
-keep @com.getcapacitor.Plugin class * { *; }

View File

@@ -1,22 +1,29 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# Project-wide Gradle settings for Daily Notification Plugin
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
# AndroidX library
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Enable Gradle build cache
org.gradle.caching=true
# Enable parallel builds
org.gradle.parallel=true
# Increase memory for Gradle daemon
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# Enable configuration cache
org.gradle.configuration-cache=true

Binary file not shown.

7
android/gradlew vendored
View File

@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum

2
android/gradlew.bat vendored
View File

@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################

View File

@@ -1,67 +0,0 @@
apply plugin: 'com.android.library'
android {
namespace "com.timesafari.dailynotification.plugin"
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
// Disable test compilation - tests reference deprecated/removed code
// TODO: Rewrite tests to use modern AndroidX testing framework
testOptions {
unitTests.all {
enabled = false
}
}
// Exclude test sources from compilation
sourceSets {
test {
java {
srcDirs = [] // Disable test source compilation
}
}
}
}
dependencies {
implementation project(':capacitor-android')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
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"
implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.10"
implementation "com.google.code.gson:gson:2.10.1"
implementation "androidx.core:core:1.12.0"
annotationProcessor "androidx.room:room-compiler:2.6.1"
annotationProcessor project(':capacitor-android')
// Temporarily disabled tests due to deprecated Android testing APIs
// TODO: Update test files to use modern AndroidX testing framework
// testImplementation "junit:junit:$junitVersion"
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
}

View File

@@ -1,206 +0,0 @@
/**
* BootReceiver.java
*
* Android Boot Receiver for DailyNotification plugin
* Handles system boot events to restore scheduled notifications
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
/**
* Broadcast receiver for system boot events
*
* This receiver is triggered when:
* - Device boots up (BOOT_COMPLETED)
* - App is updated (MY_PACKAGE_REPLACED)
* - Any package is updated (PACKAGE_REPLACED)
*
* It ensures that scheduled notifications are restored after system events
* that might have cleared the alarm manager.
*/
public class BootReceiver extends BroadcastReceiver {
private static final String TAG = "BootReceiver";
// Broadcast actions we handle
private static final String ACTION_LOCKED_BOOT_COMPLETED = "android.intent.action.LOCKED_BOOT_COMPLETED";
private static final String ACTION_BOOT_COMPLETED = "android.intent.action.BOOT_COMPLETED";
private static final String ACTION_MY_PACKAGE_REPLACED = "android.intent.action.MY_PACKAGE_REPLACED";
@Override
public void onReceive(Context context, Intent intent) {
if (intent == null || intent.getAction() == null) {
Log.w(TAG, "Received null intent or action");
return;
}
String action = intent.getAction();
Log.d(TAG, "Received broadcast: " + action);
try {
switch (action) {
case ACTION_LOCKED_BOOT_COMPLETED:
handleLockedBootCompleted(context);
break;
case ACTION_BOOT_COMPLETED:
handleBootCompleted(context);
break;
case ACTION_MY_PACKAGE_REPLACED:
handlePackageReplaced(context, intent);
break;
default:
Log.w(TAG, "Unknown action: " + action);
break;
}
} catch (Exception e) {
Log.e(TAG, "Error handling broadcast: " + action, e);
}
}
/**
* Handle locked boot completion (before user unlock)
*
* @param context Application context
*/
private void handleLockedBootCompleted(Context context) {
Log.i(TAG, "Locked boot completed - preparing for recovery");
try {
// Use device protected storage context for Direct Boot
Context deviceProtectedContext = context;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
deviceProtectedContext = context.createDeviceProtectedStorageContext();
}
// Minimal work here - just log that we're ready
// Full recovery will happen on BOOT_COMPLETED when storage is available
Log.i(TAG, "Locked boot completed - ready for full recovery on unlock");
} catch (Exception e) {
Log.e(TAG, "Error during locked boot completion", e);
}
}
/**
* Handle device boot completion (after user unlock)
*
* @param context Application context
*/
private void handleBootCompleted(Context context) {
Log.i(TAG, "Device boot completed - restoring notifications");
try {
// Initialize components for recovery
DailyNotificationStorage storage = new DailyNotificationStorage(context);
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(android.content.Context.ALARM_SERVICE);
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager);
// Perform boot recovery
boolean recoveryPerformed = performBootRecovery(context, storage, scheduler);
if (recoveryPerformed) {
Log.i(TAG, "Boot recovery completed successfully");
} else {
Log.d(TAG, "Boot recovery skipped (not needed or already performed)");
}
} catch (Exception e) {
Log.e(TAG, "Error during boot recovery", e);
}
}
/**
* Handle package replacement (app update)
*
* @param context Application context
* @param intent Broadcast intent
*/
private void handlePackageReplaced(Context context, Intent intent) {
Log.i(TAG, "Package replaced - restoring notifications");
try {
// Initialize components for recovery
DailyNotificationStorage storage = new DailyNotificationStorage(context);
android.app.AlarmManager alarmManager = (android.app.AlarmManager)
context.getSystemService(android.content.Context.ALARM_SERVICE);
DailyNotificationScheduler scheduler = new DailyNotificationScheduler(context, alarmManager);
// Perform package replacement recovery
boolean recoveryPerformed = performBootRecovery(context, storage, scheduler);
if (recoveryPerformed) {
Log.i(TAG, "Package replacement recovery completed successfully");
} else {
Log.d(TAG, "Package replacement recovery skipped (not needed or already performed)");
}
} catch (Exception e) {
Log.e(TAG, "Error during package replacement recovery", e);
}
}
/**
* Perform boot recovery by rescheduling notifications
*
* @param context Application context
* @param storage Notification storage
* @param scheduler Notification scheduler
* @return true if recovery was performed, false otherwise
*/
private boolean performBootRecovery(Context context, DailyNotificationStorage storage,
DailyNotificationScheduler scheduler) {
try {
Log.d(TAG, "DN|BOOT_RECOVERY_START");
// Get all notifications from storage
java.util.List<NotificationContent> notifications = storage.getAllNotifications();
if (notifications.isEmpty()) {
Log.d(TAG, "DN|BOOT_RECOVERY_SKIP no_notifications");
return false;
}
Log.d(TAG, "DN|BOOT_RECOVERY_FOUND count=" + notifications.size());
int recoveredCount = 0;
long currentTime = System.currentTimeMillis();
for (NotificationContent notification : notifications) {
try {
if (notification.getScheduledTime() > currentTime) {
boolean scheduled = scheduler.scheduleNotification(notification);
if (scheduled) {
recoveredCount++;
Log.d(TAG, "DN|BOOT_RECOVERY_OK id=" + notification.getId());
} else {
Log.w(TAG, "DN|BOOT_RECOVERY_FAIL id=" + notification.getId());
}
} else {
Log.d(TAG, "DN|BOOT_RECOVERY_SKIP_PAST id=" + notification.getId());
}
} catch (Exception e) {
Log.e(TAG, "DN|BOOT_RECOVERY_ERR id=" + notification.getId() + " err=" + e.getMessage(), e);
}
}
Log.i(TAG, "DN|BOOT_RECOVERY_COMPLETE recovered=" + recoveredCount + "/" + notifications.size());
return recoveredCount > 0;
} catch (Exception e) {
Log.e(TAG, "DN|BOOT_RECOVERY_ERR exception=" + e.getMessage(), e);
return false;
}
}
}

View File

@@ -1,300 +0,0 @@
/**
* DailyNotificationDatabase.java
*
* Room database for the DailyNotification plugin
* Provides centralized data management with encryption, retention policies, and migration support
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2025-10-20
*/
package com.timesafari.dailynotification.database;
import android.content.Context;
import androidx.room.*;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import com.timesafari.dailynotification.dao.NotificationContentDao;
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
import com.timesafari.dailynotification.dao.NotificationConfigDao;
import com.timesafari.dailynotification.entities.NotificationContentEntity;
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity;
import com.timesafari.dailynotification.entities.NotificationConfigEntity;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Room database for the DailyNotification plugin
*
* This database provides:
* - Centralized data management for all plugin data
* - Encryption support for sensitive information
* - Automatic retention policy enforcement
* - Migration support for schema changes
* - Performance optimization with proper indexing
* - Background thread execution for database operations
*/
@Database(
entities = {
NotificationContentEntity.class,
NotificationDeliveryEntity.class,
NotificationConfigEntity.class
},
version = 1,
exportSchema = false
)
public abstract class DailyNotificationDatabase extends RoomDatabase {
private static final String TAG = "DailyNotificationDatabase";
private static final String DATABASE_NAME = "daily_notification_plugin.db";
// Singleton instance
private static volatile DailyNotificationDatabase INSTANCE;
// Thread pool for database operations
private static final int NUMBER_OF_THREADS = 4;
public static final ExecutorService databaseWriteExecutor = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
// DAO accessors
public abstract NotificationContentDao notificationContentDao();
public abstract NotificationDeliveryDao notificationDeliveryDao();
public abstract NotificationConfigDao notificationConfigDao();
/**
* Get singleton instance of the database
*
* @param context Application context
* @return Database instance
*/
public static DailyNotificationDatabase getInstance(Context context) {
if (INSTANCE == null) {
synchronized (DailyNotificationDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(
context.getApplicationContext(),
DailyNotificationDatabase.class,
DATABASE_NAME
)
.addCallback(roomCallback)
.addMigrations(MIGRATION_1_2) // Add future migrations here
.build();
}
}
}
return INSTANCE;
}
/**
* Room database callback for initialization and cleanup
*/
private static RoomDatabase.Callback roomCallback = new RoomDatabase.Callback() {
@Override
public void onCreate(SupportSQLiteDatabase db) {
super.onCreate(db);
// Initialize database with default data if needed
databaseWriteExecutor.execute(() -> {
// Populate with default configurations
populateDefaultConfigurations();
});
}
@Override
public void onOpen(SupportSQLiteDatabase db) {
super.onOpen(db);
// Perform any necessary setup when database is opened
databaseWriteExecutor.execute(() -> {
// Clean up expired data
cleanupExpiredData();
});
}
};
/**
* Populate database with default configurations
*/
private static void populateDefaultConfigurations() {
if (INSTANCE == null) return;
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
// Default plugin settings
NotificationConfigEntity defaultSettings = new NotificationConfigEntity(
"default_plugin_settings",
null, // Global settings
"plugin_setting",
"default_settings",
"{}",
"json"
);
defaultSettings.setTypedValue("{\"version\":\"1.0.0\",\"retention_days\":7,\"max_notifications\":100}");
configDao.insertConfig(defaultSettings);
// Default performance settings
NotificationConfigEntity performanceSettings = new NotificationConfigEntity(
"default_performance_settings",
null, // Global settings
"performance_setting",
"performance_config",
"{}",
"json"
);
performanceSettings.setTypedValue("{\"max_concurrent_deliveries\":5,\"delivery_timeout_ms\":30000,\"retry_attempts\":3}");
configDao.insertConfig(performanceSettings);
}
/**
* Clean up expired data from all tables
*/
private static void cleanupExpiredData() {
if (INSTANCE == null) return;
long currentTime = System.currentTimeMillis();
// Clean up expired notifications
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
int deletedNotifications = contentDao.deleteExpiredNotifications(currentTime);
// Clean up old delivery tracking data (keep for 30 days)
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
long deliveryCutoff = currentTime - (30L * 24 * 60 * 60 * 1000); // 30 days ago
int deletedDeliveries = deliveryDao.deleteOldDeliveries(deliveryCutoff);
// Clean up expired configurations
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
int deletedConfigs = configDao.deleteExpiredConfigs(currentTime);
android.util.Log.d(TAG, "Cleanup completed: " + deletedNotifications + " notifications, " +
deletedDeliveries + " deliveries, " + deletedConfigs + " configs");
}
/**
* Migration from version 1 to 2
* Add new columns for enhanced functionality
*/
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
// Add new columns to notification_content table
database.execSQL("ALTER TABLE notification_content ADD COLUMN analytics_data TEXT");
database.execSQL("ALTER TABLE notification_content ADD COLUMN priority_level INTEGER DEFAULT 0");
// Add new columns to notification_delivery table
database.execSQL("ALTER TABLE notification_delivery ADD COLUMN delivery_metadata TEXT");
database.execSQL("ALTER TABLE notification_delivery ADD COLUMN performance_metrics TEXT");
// Add new columns to notification_config table
database.execSQL("ALTER TABLE notification_config ADD COLUMN config_category TEXT DEFAULT 'general'");
database.execSQL("ALTER TABLE notification_config ADD COLUMN config_priority INTEGER DEFAULT 0");
}
};
/**
* Close the database connection
* Should be called when the plugin is being destroyed
*/
public static void closeDatabase() {
if (INSTANCE != null) {
INSTANCE.close();
INSTANCE = null;
}
}
/**
* Clear all data from the database
* Use with caution - this will delete all plugin data
*/
public static void clearAllData() {
if (INSTANCE == null) return;
databaseWriteExecutor.execute(() -> {
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
// Clear all tables
contentDao.deleteNotificationsByPluginVersion("0"); // Delete all
deliveryDao.deleteDeliveriesByTimeSafariDid("all"); // Delete all
configDao.deleteConfigsByType("all"); // Delete all
android.util.Log.d(TAG, "All plugin data cleared");
});
}
/**
* Get database statistics
*
* @return Database statistics as a formatted string
*/
public static String getDatabaseStats() {
if (INSTANCE == null) return "Database not initialized";
NotificationContentDao contentDao = INSTANCE.notificationContentDao();
NotificationDeliveryDao deliveryDao = INSTANCE.notificationDeliveryDao();
NotificationConfigDao configDao = INSTANCE.notificationConfigDao();
int notificationCount = contentDao.getTotalNotificationCount();
int deliveryCount = deliveryDao.getTotalDeliveryCount();
int configCount = configDao.getTotalConfigCount();
return String.format("Database Stats:\n" +
" Notifications: %d\n" +
" Deliveries: %d\n" +
" Configurations: %d\n" +
" Total Records: %d",
notificationCount, deliveryCount, configCount,
notificationCount + deliveryCount + configCount);
}
/**
* Perform database maintenance
* Includes cleanup, optimization, and integrity checks
*/
public static void performMaintenance() {
if (INSTANCE == null) return;
databaseWriteExecutor.execute(() -> {
long startTime = System.currentTimeMillis();
// Clean up expired data
cleanupExpiredData();
// Additional maintenance tasks can be added here
// - Vacuum database
// - Analyze tables for query optimization
// - Check database integrity
long duration = System.currentTimeMillis() - startTime;
android.util.Log.d(TAG, "Database maintenance completed in " + duration + "ms");
});
}
/**
* Export database data for backup or migration
*
* @return Database export as JSON string
*/
public static String exportDatabaseData() {
if (INSTANCE == null) return "{}";
// This would typically serialize all data to JSON
// Implementation depends on specific export requirements
return "{\"export\":\"not_implemented_yet\"}";
}
/**
* Import database data from backup
*
* @param jsonData JSON data to import
* @return Success status
*/
public static boolean importDatabaseData(String jsonData) {
if (INSTANCE == null || jsonData == null) return false;
// This would typically deserialize JSON data and insert into database
// Implementation depends on specific import requirements
return false;
}
}

View File

@@ -1,215 +0,0 @@
/**
* DailyNotificationDatabaseTest.java
*
* Unit tests for SQLite database functionality
* Tests schema creation, WAL mode, and basic operations
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.test.AndroidTestCase;
import android.test.mock.MockContext;
import java.io.File;
/**
* Unit tests for DailyNotificationDatabase
*
* Tests the core SQLite functionality including:
* - Database creation and schema
* - WAL mode configuration
* - Table and index creation
* - Schema version management
*/
public class DailyNotificationDatabaseTest extends AndroidTestCase {
private DailyNotificationDatabase database;
private Context mockContext;
@Override
protected void setUp() throws Exception {
super.setUp();
// Create mock context
mockContext = new MockContext() {
@Override
public File getDatabasePath(String name) {
return new File(getContext().getCacheDir(), name);
}
};
// Create database instance
database = new DailyNotificationDatabase(mockContext);
}
@Override
protected void tearDown() throws Exception {
if (database != null) {
database.close();
}
super.tearDown();
}
/**
* Test database creation and schema
*/
public void testDatabaseCreation() {
assertNotNull("Database should not be null", database);
SQLiteDatabase db = database.getReadableDatabase();
assertNotNull("Readable database should not be null", db);
assertTrue("Database should be open", db.isOpen());
db.close();
}
/**
* Test WAL mode configuration
*/
public void testWALModeConfiguration() {
SQLiteDatabase db = database.getWritableDatabase();
// Check journal mode
android.database.Cursor cursor = db.rawQuery("PRAGMA journal_mode", null);
assertTrue("Should have journal mode result", cursor.moveToFirst());
String journalMode = cursor.getString(0);
assertEquals("Journal mode should be WAL", "wal", journalMode.toLowerCase());
cursor.close();
// Check synchronous mode
cursor = db.rawQuery("PRAGMA synchronous", null);
assertTrue("Should have synchronous result", cursor.moveToFirst());
int synchronous = cursor.getInt(0);
assertEquals("Synchronous mode should be NORMAL", 1, synchronous);
cursor.close();
// Check foreign keys
cursor = db.rawQuery("PRAGMA foreign_keys", null);
assertTrue("Should have foreign_keys result", cursor.moveToFirst());
int foreignKeys = cursor.getInt(0);
assertEquals("Foreign keys should be enabled", 1, foreignKeys);
cursor.close();
db.close();
}
/**
* Test table creation
*/
public void testTableCreation() {
SQLiteDatabase db = database.getWritableDatabase();
// Check if tables exist
assertTrue("notif_contents table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONTENTS));
assertTrue("notif_deliveries table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_DELIVERIES));
assertTrue("notif_config table should exist",
tableExists(db, DailyNotificationDatabase.TABLE_NOTIF_CONFIG));
db.close();
}
/**
* Test index creation
*/
public void testIndexCreation() {
SQLiteDatabase db = database.getWritableDatabase();
// Check if indexes exist
assertTrue("notif_idx_contents_slot_time index should exist",
indexExists(db, "notif_idx_contents_slot_time"));
assertTrue("notif_idx_deliveries_slot index should exist",
indexExists(db, "notif_idx_deliveries_slot"));
db.close();
}
/**
* Test schema version management
*/
public void testSchemaVersion() {
SQLiteDatabase db = database.getWritableDatabase();
// Check user_version
android.database.Cursor cursor = db.rawQuery("PRAGMA user_version", null);
assertTrue("Should have user_version result", cursor.moveToFirst());
int userVersion = cursor.getInt(0);
assertEquals("User version should match database version",
DailyNotificationDatabase.DATABASE_VERSION, userVersion);
cursor.close();
db.close();
}
/**
* Test basic insert operations
*/
public void testBasicInsertOperations() {
SQLiteDatabase db = database.getWritableDatabase();
// Test inserting into notif_contents
android.content.ContentValues values = new android.content.ContentValues();
values.put(DailyNotificationDatabase.COL_CONTENTS_SLOT_ID, "test_slot_1");
values.put(DailyNotificationDatabase.COL_CONTENTS_PAYLOAD_JSON, "{\"title\":\"Test\"}");
values.put(DailyNotificationDatabase.COL_CONTENTS_FETCHED_AT, System.currentTimeMillis());
long rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONTENTS, null, values);
assertTrue("Insert should succeed", rowId > 0);
// Test inserting into notif_config
values.clear();
values.put(DailyNotificationDatabase.COL_CONFIG_K, "test_key");
values.put(DailyNotificationDatabase.COL_CONFIG_V, "test_value");
rowId = db.insert(DailyNotificationDatabase.TABLE_NOTIF_CONFIG, null, values);
assertTrue("Config insert should succeed", rowId > 0);
db.close();
}
/**
* Test database file operations
*/
public void testDatabaseFileOperations() {
String dbPath = database.getDatabasePath();
assertNotNull("Database path should not be null", dbPath);
assertTrue("Database path should not be empty", !dbPath.isEmpty());
// Database should exist after creation
assertTrue("Database file should exist", database.databaseExists());
// Database size should be greater than 0
long size = database.getDatabaseSize();
assertTrue("Database size should be greater than 0", size > 0);
}
/**
* Helper method to check if table exists
*/
private boolean tableExists(SQLiteDatabase db, String tableName) {
android.database.Cursor cursor = db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
new String[]{tableName});
boolean exists = cursor.moveToFirst();
cursor.close();
return exists;
}
/**
* Helper method to check if index exists
*/
private boolean indexExists(SQLiteDatabase db, String indexName) {
android.database.Cursor cursor = db.rawQuery(
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
new String[]{indexName});
boolean exists = cursor.moveToFirst();
cursor.close();
return exists;
}
}

View File

@@ -1,193 +0,0 @@
/**
* DailyNotificationRollingWindowTest.java
*
* Unit tests for rolling window safety functionality
* Tests window maintenance, capacity management, and platform-specific limits
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.test.AndroidTestCase;
import android.test.mock.MockContext;
import java.util.concurrent.TimeUnit;
/**
* Unit tests for DailyNotificationRollingWindow
*
* Tests the rolling window safety functionality including:
* - Window maintenance and state updates
* - Capacity limit enforcement
* - Platform-specific behavior (iOS vs Android)
* - Statistics and maintenance timing
*/
public class DailyNotificationRollingWindowTest extends AndroidTestCase {
private DailyNotificationRollingWindow rollingWindow;
private Context mockContext;
private DailyNotificationScheduler mockScheduler;
private DailyNotificationTTLEnforcer mockTTLEnforcer;
private DailyNotificationStorage mockStorage;
@Override
protected void setUp() throws Exception {
super.setUp();
// Create mock context
mockContext = new MockContext() {
@Override
public android.content.SharedPreferences getSharedPreferences(String name, int mode) {
return getContext().getSharedPreferences(name, mode);
}
};
// Create mock components
mockScheduler = new MockDailyNotificationScheduler();
mockTTLEnforcer = new MockDailyNotificationTTLEnforcer();
mockStorage = new MockDailyNotificationStorage();
// Create rolling window for Android platform
rollingWindow = new DailyNotificationRollingWindow(
mockContext,
mockScheduler,
mockTTLEnforcer,
mockStorage,
false // Android platform
);
}
@Override
protected void tearDown() throws Exception {
super.tearDown();
}
/**
* Test rolling window initialization
*/
public void testRollingWindowInitialization() {
assertNotNull("Rolling window should be initialized", rollingWindow);
// Test Android platform limits
String stats = rollingWindow.getRollingWindowStats();
assertNotNull("Stats should not be null", stats);
assertTrue("Stats should contain Android platform info", stats.contains("Android"));
}
/**
* Test rolling window maintenance
*/
public void testRollingWindowMaintenance() {
// Test that maintenance can be forced
rollingWindow.forceMaintenance();
// Test maintenance timing
assertFalse("Maintenance should not be needed immediately after forcing",
rollingWindow.isMaintenanceNeeded());
// Test time until next maintenance
long timeUntilNext = rollingWindow.getTimeUntilNextMaintenance();
assertTrue("Time until next maintenance should be positive", timeUntilNext > 0);
}
/**
* Test iOS platform behavior
*/
public void testIOSPlatformBehavior() {
// Create rolling window for iOS platform
DailyNotificationRollingWindow iosRollingWindow = new DailyNotificationRollingWindow(
mockContext,
mockScheduler,
mockTTLEnforcer,
mockStorage,
true // iOS platform
);
String stats = iosRollingWindow.getRollingWindowStats();
assertNotNull("iOS stats should not be null", stats);
assertTrue("Stats should contain iOS platform info", stats.contains("iOS"));
}
/**
* Test maintenance timing
*/
public void testMaintenanceTiming() {
// Initially, maintenance should not be needed
assertFalse("Maintenance should not be needed initially",
rollingWindow.isMaintenanceNeeded());
// Force maintenance
rollingWindow.forceMaintenance();
// Should not be needed immediately after
assertFalse("Maintenance should not be needed after forcing",
rollingWindow.isMaintenanceNeeded());
}
/**
* Test statistics retrieval
*/
public void testStatisticsRetrieval() {
String stats = rollingWindow.getRollingWindowStats();
assertNotNull("Statistics should not be null", stats);
assertTrue("Statistics should contain pending count", stats.contains("pending"));
assertTrue("Statistics should contain daily count", stats.contains("daily"));
assertTrue("Statistics should contain platform info", stats.contains("platform"));
}
/**
* Test error handling
*/
public void testErrorHandling() {
// Test with null components (should not crash)
try {
DailyNotificationRollingWindow errorWindow = new DailyNotificationRollingWindow(
null, null, null, null, false
);
// Should not crash during construction
} catch (Exception e) {
// Expected to handle gracefully
}
}
/**
* Mock DailyNotificationScheduler for testing
*/
private static class MockDailyNotificationScheduler extends DailyNotificationScheduler {
public MockDailyNotificationScheduler() {
super(null, null);
}
@Override
public boolean scheduleNotification(NotificationContent content) {
return true; // Always succeed for testing
}
}
/**
* Mock DailyNotificationTTLEnforcer for testing
*/
private static class MockDailyNotificationTTLEnforcer extends DailyNotificationTTLEnforcer {
public MockDailyNotificationTTLEnforcer() {
super(null, null, false);
}
@Override
public boolean validateBeforeArming(NotificationContent content) {
return true; // Always pass validation for testing
}
}
/**
* Mock DailyNotificationStorage for testing
*/
private static class MockDailyNotificationStorage extends DailyNotificationStorage {
public MockDailyNotificationStorage() {
super(null);
}
}
}

View File

@@ -1,217 +0,0 @@
/**
* DailyNotificationTTLEnforcerTest.java
*
* Unit tests for TTL-at-fire enforcement functionality
* Tests freshness validation, TTL violation logging, and skip logic
*
* @author Matthew Raymer
* @version 1.0.0
*/
package com.timesafari.dailynotification;
import android.content.Context;
import android.test.AndroidTestCase;
import android.test.mock.MockContext;
import java.util.concurrent.TimeUnit;
/**
* Unit tests for DailyNotificationTTLEnforcer
*
* Tests the core TTL enforcement functionality including:
* - Freshness validation before arming
* - TTL violation detection and logging
* - Skip logic for stale content
* - Configuration retrieval from storage
*/
public class DailyNotificationTTLEnforcerTest extends AndroidTestCase {
private DailyNotificationTTLEnforcer ttlEnforcer;
private Context mockContext;
private DailyNotificationDatabase database;
@Override
protected void setUp() throws Exception {
super.setUp();
// Create mock context
mockContext = new MockContext() {
@Override
public android.content.SharedPreferences getSharedPreferences(String name, int mode) {
return getContext().getSharedPreferences(name, mode);
}
};
// Create database instance
database = new DailyNotificationDatabase(mockContext);
// Create TTL enforcer with SQLite storage
ttlEnforcer = new DailyNotificationTTLEnforcer(mockContext, database, true);
}
@Override
protected void tearDown() throws Exception {
if (database != null) {
database.close();
}
super.tearDown();
}
/**
* Test freshness validation with fresh content
*/
public void testFreshContentValidation() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5); // 5 minutes ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_1", scheduledTime, fetchedAt);
assertTrue("Content should be fresh (5 min old, scheduled 30 min from now)", isFresh);
}
/**
* Test freshness validation with stale content
*/
public void testStaleContentValidation() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30); // 30 minutes from now
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_2", scheduledTime, fetchedAt);
assertFalse("Content should be stale (2 hours old, exceeds 1 hour TTL)", isFresh);
}
/**
* Test TTL violation detection
*/
public void testTTLViolationDetection() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2); // 2 hours ago
// This should trigger a TTL violation
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_3", scheduledTime, fetchedAt);
assertFalse("Should detect TTL violation", isFresh);
// Check that violation was logged (we can't easily test the actual logging,
// but we can verify the method returns false as expected)
}
/**
* Test validateBeforeArming with fresh content
*/
public void testValidateBeforeArmingFresh() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(5);
NotificationContent content = new NotificationContent();
content.setId("test_slot_4");
content.setScheduledTime(scheduledTime);
content.setFetchedAt(fetchedAt);
content.setTitle("Test Notification");
content.setBody("Test body");
boolean shouldArm = ttlEnforcer.validateBeforeArming(content);
assertTrue("Should arm fresh content", shouldArm);
}
/**
* Test validateBeforeArming with stale content
*/
public void testValidateBeforeArmingStale() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2);
NotificationContent content = new NotificationContent();
content.setId("test_slot_5");
content.setScheduledTime(scheduledTime);
content.setFetchedAt(fetchedAt);
content.setTitle("Test Notification");
content.setBody("Test body");
boolean shouldArm = ttlEnforcer.validateBeforeArming(content);
assertFalse("Should not arm stale content", shouldArm);
}
/**
* Test edge case: content fetched exactly at TTL limit
*/
public void testTTLBoundaryCase() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1); // Exactly 1 hour ago (TTL limit)
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_6", scheduledTime, fetchedAt);
assertTrue("Content at TTL boundary should be considered fresh", isFresh);
}
/**
* Test edge case: content fetched just over TTL limit
*/
public void testTTLBoundaryCaseOver() {
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(1) - TimeUnit.SECONDS.toMillis(1); // 1 hour + 1 second ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_7", scheduledTime, fetchedAt);
assertFalse("Content just over TTL limit should be considered stale", isFresh);
}
/**
* Test TTL violation statistics
*/
public void testTTLViolationStats() {
// Generate some TTL violations
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.HOURS.toMillis(2);
// Trigger TTL violations
ttlEnforcer.isContentFresh("test_slot_8", scheduledTime, fetchedAt);
ttlEnforcer.isContentFresh("test_slot_9", scheduledTime, fetchedAt);
String stats = ttlEnforcer.getTTLViolationStats();
assertNotNull("TTL violation stats should not be null", stats);
assertTrue("Stats should contain violation count", stats.contains("violations"));
}
/**
* Test error handling with invalid parameters
*/
public void testErrorHandling() {
// Test with null slot ID
boolean result = ttlEnforcer.isContentFresh(null, System.currentTimeMillis(), System.currentTimeMillis());
assertFalse("Should handle null slot ID gracefully", result);
// Test with invalid timestamps
result = ttlEnforcer.isContentFresh("test_slot_10", 0, 0);
assertTrue("Should handle invalid timestamps gracefully", result);
}
/**
* Test TTL configuration retrieval
*/
public void testTTLConfiguration() {
// Test that TTL enforcer can retrieve configuration
// This is indirectly tested through the freshness checks
long currentTime = System.currentTimeMillis();
long scheduledTime = currentTime + TimeUnit.MINUTES.toMillis(30);
long fetchedAt = currentTime - TimeUnit.MINUTES.toMillis(30); // 30 minutes ago
boolean isFresh = ttlEnforcer.isContentFresh("test_slot_11", scheduledTime, fetchedAt);
// Should be fresh (30 min < 1 hour TTL)
assertTrue("Should retrieve TTL configuration correctly", isFresh);
}
}

View File

@@ -1,6 +1,23 @@
include ':app'
include ':plugin'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
// Settings file for Daily Notification Plugin
// This is a minimal settings.gradle for a Capacitor plugin module
// Capacitor plugins don't typically need a settings.gradle, but it's included
// for standalone builds and Android Studio compatibility
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'daily-notification-plugin'
apply from: 'capacitor.settings.gradle'

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.timesafari.dailynotification.plugin">
<!-- Plugin receivers are declared in consuming app's manifest -->
<!-- This manifest is optional and mainly for library metadata -->
</manifest>

View File

@@ -179,6 +179,16 @@ public class DailyNotificationWorker extends Worker {
try {
Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
// Cancel the notification from NotificationManager FIRST
// This ensures the notification disappears immediately when dismissed
NotificationManager notificationManager =
(NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
if (notificationManager != null) {
int systemNotificationId = notificationId.hashCode();
notificationManager.cancel(systemNotificationId);
Log.d(TAG, "DN|DISMISS_CANCEL_NOTIF systemId=" + systemNotificationId);
}
// Remove from Room if present; also remove from legacy storage for compatibility
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
@@ -563,9 +573,9 @@ public class DailyNotificationWorker extends Worker {
// Attempt Room
try {
DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
// For now, Room service provides ID-based get via DAO through a helper in future; we re-query by ID via DAO
com.timesafari.dailynotification.database.DailyNotificationDatabase db =
com.timesafari.dailynotification.database.DailyNotificationDatabase.getInstance(getApplicationContext());
// Use unified database (Kotlin schema with Java entities)
com.timesafari.dailynotification.DailyNotificationDatabase db =
com.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
if (entity != null) {
return mapEntityToContent(entity);

View File

@@ -1,15 +1,31 @@
package com.timesafari.dailynotification
import android.content.Context
import androidx.room.*
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.timesafari.dailynotification.entities.NotificationContentEntity
import com.timesafari.dailynotification.entities.NotificationDeliveryEntity
import com.timesafari.dailynotification.entities.NotificationConfigEntity
import com.timesafari.dailynotification.dao.NotificationContentDao
import com.timesafari.dailynotification.dao.NotificationDeliveryDao
import com.timesafari.dailynotification.dao.NotificationConfigDao
/**
* SQLite schema for Daily Notification Plugin
* Implements TTL-at-fire invariant and rolling window armed design
* Unified SQLite schema for Daily Notification Plugin
*
* This database consolidates both Kotlin and Java schemas into a single
* unified database. Contains all entities needed for:
* - Recurring schedule patterns (reboot recovery)
* - Content caching (offline-first)
* - Configuration management
* - Delivery tracking and analytics
* - Execution history
*
* Database name: daily_notification_plugin.db
*
* @author Matthew Raymer
* @version 1.1.0
* @version 2.0.0 - Unified schema consolidation
*/
@Entity(tableName = "content_cache")
data class ContentCache(
@@ -56,16 +72,201 @@ data class History(
)
@Database(
entities = [ContentCache::class, Schedule::class, Callback::class, History::class],
version = 1,
entities = [
// Kotlin entities (from original schema)
ContentCache::class,
Schedule::class,
Callback::class,
History::class,
// Java entities (merged from Java database)
NotificationContentEntity::class,
NotificationDeliveryEntity::class,
NotificationConfigEntity::class
],
version = 2, // Incremented for unified schema
exportSchema = false
)
@TypeConverters(Converters::class)
abstract class DailyNotificationDatabase : RoomDatabase() {
// Kotlin DAOs
abstract fun contentCacheDao(): ContentCacheDao
abstract fun scheduleDao(): ScheduleDao
abstract fun callbackDao(): CallbackDao
abstract fun historyDao(): HistoryDao
// Java DAOs (for compatibility with existing Java code)
abstract fun notificationContentDao(): NotificationContentDao
abstract fun notificationDeliveryDao(): NotificationDeliveryDao
abstract fun notificationConfigDao(): NotificationConfigDao
companion object {
@Volatile
private var INSTANCE: DailyNotificationDatabase? = null
private const val DATABASE_NAME = "daily_notification_plugin.db"
/**
* Get singleton instance of unified database
*
* @param context Application context
* @return Database instance
*/
fun getDatabase(context: Context): DailyNotificationDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DailyNotificationDatabase::class.java,
DATABASE_NAME
)
.addMigrations(MIGRATION_1_2) // Migration from Kotlin-only to unified
.addCallback(roomCallback)
.build()
INSTANCE = instance
instance
}
}
/**
* Java-compatible static method (for existing Java code)
*
* @param context Application context
* @return Database instance
*/
@JvmStatic
fun getInstance(context: Context): DailyNotificationDatabase {
return getDatabase(context)
}
/**
* Room database callback for initialization
*/
private val roomCallback = object : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Initialize default data if needed
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
// Cleanup expired data on open
}
}
/**
* Migration from version 1 (Kotlin-only) to version 2 (unified)
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Create Java entity tables
database.execSQL("""
CREATE TABLE IF NOT EXISTS notification_content (
id TEXT PRIMARY KEY NOT NULL,
plugin_version TEXT,
timesafari_did TEXT,
notification_type TEXT,
title TEXT,
body TEXT,
scheduled_time INTEGER NOT NULL,
timezone TEXT,
priority INTEGER NOT NULL,
vibration_enabled INTEGER NOT NULL,
sound_enabled INTEGER NOT NULL,
media_url TEXT,
encrypted_content TEXT,
encryption_key_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
delivery_status TEXT,
delivery_attempts INTEGER NOT NULL,
last_delivery_attempt INTEGER NOT NULL,
user_interaction_count INTEGER NOT NULL,
last_user_interaction INTEGER NOT NULL,
metadata TEXT
)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_content_timesafari_did
ON notification_content(timesafari_did)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_content_notification_type
ON notification_content(notification_type)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_content_scheduled_time
ON notification_content(scheduled_time)
""".trimIndent())
database.execSQL("""
CREATE TABLE IF NOT EXISTS notification_delivery (
id TEXT PRIMARY KEY NOT NULL,
notification_id TEXT,
timesafari_did TEXT,
delivery_timestamp INTEGER NOT NULL,
delivery_status TEXT,
delivery_method TEXT,
delivery_attempt_number INTEGER NOT NULL,
delivery_duration_ms INTEGER NOT NULL,
user_interaction_type TEXT,
user_interaction_timestamp INTEGER NOT NULL,
user_interaction_duration_ms INTEGER NOT NULL,
error_code TEXT,
error_message TEXT,
device_info TEXT,
network_info TEXT,
battery_level INTEGER NOT NULL,
doze_mode_active INTEGER NOT NULL,
exact_alarm_permission INTEGER NOT NULL,
notification_permission INTEGER NOT NULL,
metadata TEXT,
FOREIGN KEY(notification_id) REFERENCES notification_content(id) ON DELETE CASCADE
)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_delivery_notification_id
ON notification_delivery(notification_id)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_delivery_delivery_timestamp
ON notification_delivery(delivery_timestamp)
""".trimIndent())
database.execSQL("""
CREATE TABLE IF NOT EXISTS notification_config (
id TEXT PRIMARY KEY NOT NULL,
timesafari_did TEXT,
config_type TEXT,
config_key TEXT,
config_value TEXT,
config_data_type TEXT,
is_encrypted INTEGER NOT NULL,
encryption_key_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ttl_seconds INTEGER NOT NULL,
is_active INTEGER NOT NULL,
metadata TEXT
)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_config_timesafari_did
ON notification_config(timesafari_did)
""".trimIndent())
database.execSQL("""
CREATE INDEX IF NOT EXISTS index_notification_config_config_type
ON notification_config(config_type)
""".trimIndent())
}
}
}
}
@Dao
@@ -76,12 +277,18 @@ interface ContentCacheDao {
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT 1")
suspend fun getLatest(): ContentCache?
@Query("SELECT * FROM content_cache ORDER BY fetchedAt DESC LIMIT :limit")
suspend fun getHistory(limit: Int): List<ContentCache>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(contentCache: ContentCache)
@Query("DELETE FROM content_cache WHERE fetchedAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)
@Query("DELETE FROM content_cache")
suspend fun deleteAll()
@Query("SELECT COUNT(*) FROM content_cache")
suspend fun getCount(): Int
}
@@ -94,6 +301,15 @@ interface ScheduleDao {
@Query("SELECT * FROM schedules WHERE id = :id")
suspend fun getById(id: String): Schedule?
@Query("SELECT * FROM schedules")
suspend fun getAll(): List<Schedule>
@Query("SELECT * FROM schedules WHERE kind = :kind")
suspend fun getByKind(kind: String): List<Schedule>
@Query("SELECT * FROM schedules WHERE kind = :kind AND enabled = :enabled")
suspend fun getByKindAndEnabled(kind: String, enabled: Boolean): List<Schedule>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(schedule: Schedule)
@@ -102,6 +318,12 @@ interface ScheduleDao {
@Query("UPDATE schedules SET lastRunAt = :lastRunAt, nextRunAt = :nextRunAt WHERE id = :id")
suspend fun updateRunTimes(id: String, lastRunAt: Long?, nextRunAt: Long?)
@Query("DELETE FROM schedules WHERE id = :id")
suspend fun deleteById(id: String)
@Query("UPDATE schedules SET enabled = :enabled, cron = :cron, clockTime = :clockTime, jitterMs = :jitterMs, backoffPolicy = :backoffPolicy, stateJson = :stateJson WHERE id = :id")
suspend fun update(id: String, enabled: Boolean?, cron: String?, clockTime: String?, jitterMs: Int?, backoffPolicy: String?, stateJson: String?)
}
@Dao
@@ -109,9 +331,24 @@ interface CallbackDao {
@Query("SELECT * FROM callbacks WHERE enabled = 1")
suspend fun getEnabled(): List<Callback>
@Query("SELECT * FROM callbacks")
suspend fun getAll(): List<Callback>
@Query("SELECT * FROM callbacks WHERE enabled = :enabled")
suspend fun getByEnabled(enabled: Boolean): List<Callback>
@Query("SELECT * FROM callbacks WHERE id = :id")
suspend fun getById(id: String): Callback?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(callback: Callback)
@Query("UPDATE callbacks SET enabled = :enabled WHERE id = :id")
suspend fun setEnabled(id: String, enabled: Boolean)
@Query("UPDATE callbacks SET kind = :kind, target = :target, headersJson = :headersJson, enabled = :enabled WHERE id = :id")
suspend fun update(id: String, kind: String?, target: String?, headersJson: String?, enabled: Boolean?)
@Query("DELETE FROM callbacks WHERE id = :id")
suspend fun deleteById(id: String)
}
@@ -124,6 +361,12 @@ interface HistoryDao {
@Query("SELECT * FROM history WHERE occurredAt >= :since ORDER BY occurredAt DESC")
suspend fun getSince(since: Long): List<History>
@Query("SELECT * FROM history WHERE occurredAt >= :since AND kind = :kind ORDER BY occurredAt DESC LIMIT :limit")
suspend fun getSinceByKind(since: Long, kind: String, limit: Int): List<History>
@Query("SELECT * FROM history ORDER BY occurredAt DESC LIMIT :limit")
suspend fun getRecent(limit: Int): List<History>
@Query("DELETE FROM history WHERE occurredAt < :cutoffTime")
suspend fun deleteOlderThan(cutoffTime: Long)

View File

@@ -1,6 +1,7 @@
package com.timesafari.dailynotification
import android.content.Context
import android.os.SystemClock
import android.util.Log
import androidx.work.*
import kotlinx.coroutines.Dispatchers
@@ -9,6 +10,7 @@ import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.TimeUnit
import org.json.JSONObject
/**
* WorkManager implementation for content fetching
@@ -41,7 +43,6 @@ class FetchWorker(
.setInputData(
Data.Builder()
.putString("url", config.url)
.putString("headers", config.headers?.toString())
.putInt("timeout", config.timeout ?: 30000)
.putInt("retryAttempts", config.retryAttempts ?: 3)
.putInt("retryDelay", config.retryDelay ?: 1000)
@@ -56,6 +57,119 @@ class FetchWorker(
workRequest
)
}
/**
* Schedule a delayed fetch for prefetch (5 minutes before notification)
*
* @param context Application context
* @param fetchTime When to fetch (in milliseconds since epoch)
* @param notificationTime When the notification will be shown (in milliseconds since epoch)
* @param url Optional URL to fetch from (if null, generates mock content)
*/
fun scheduleDelayedFetch(
context: Context,
fetchTime: Long,
notificationTime: Long,
url: String? = null
) {
val currentTime = System.currentTimeMillis()
val delayMs = fetchTime - currentTime
Log.i(TAG, "Scheduling delayed prefetch: fetchTime=$fetchTime, notificationTime=$notificationTime, delayMs=$delayMs")
if (delayMs <= 0) {
Log.w(TAG, "Fetch time is in the past, scheduling immediate fetch")
scheduleImmediateFetch(context, notificationTime, url)
return
}
// Only require network if URL is provided (mock content doesn't need network)
val constraints = Constraints.Builder()
.apply {
if (url != null) {
setRequiredNetworkType(NetworkType.CONNECTED)
} else {
// No network required for mock content generation
setRequiredNetworkType(NetworkType.NOT_REQUIRED)
}
}
.build()
// Create unique work name based on notification time to prevent duplicate fetches
val notificationTimeMinutes = notificationTime / (60 * 1000)
val workName = "prefetch_${notificationTimeMinutes}"
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.setInputData(
Data.Builder()
.putString("url", url)
.putLong("fetchTime", fetchTime)
.putLong("notificationTime", notificationTime)
.putInt("timeout", 30000)
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
workName,
ExistingWorkPolicy.REPLACE,
workRequest
)
Log.i(TAG, "Delayed prefetch scheduled: workName=$workName, delayMs=$delayMs")
}
/**
* Schedule an immediate fetch (fallback when delay is in the past)
*/
private fun scheduleImmediateFetch(
context: Context,
notificationTime: Long,
url: String? = null
) {
// Only require network if URL is provided (mock content doesn't need network)
val constraints = Constraints.Builder()
.apply {
if (url != null) {
setRequiredNetworkType(NetworkType.CONNECTED)
} else {
// No network required for mock content generation
setRequiredNetworkType(NetworkType.NOT_REQUIRED)
}
}
.build()
val workRequest = OneTimeWorkRequestBuilder<FetchWorker>()
.setConstraints(constraints)
.setInputData(
Data.Builder()
.putString("url", url)
.putLong("notificationTime", notificationTime)
.putInt("timeout", 30000)
.putInt("retryAttempts", 3)
.putInt("retryDelay", 1000)
.putBoolean("immediate", true)
.build()
)
.addTag("prefetch")
.build()
WorkManager.getInstance(context)
.enqueue(workRequest)
Log.i(TAG, "Immediate prefetch scheduled")
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
@@ -64,9 +178,10 @@ class FetchWorker(
val timeout = inputData.getInt("timeout", 30000)
val retryAttempts = inputData.getInt("retryAttempts", 3)
val retryDelay = inputData.getInt("retryDelay", 1000)
val notificationTime = inputData.getLong("notificationTime", 0L)
try {
Log.i(TAG, "Starting content fetch from: $url")
Log.i(TAG, "Starting content fetch from: $url, notificationTime=$notificationTime")
val payload = fetchContent(url, timeout, retryAttempts, retryDelay)
val contentCache = ContentCache(
@@ -81,6 +196,40 @@ class FetchWorker(
val db = DailyNotificationDatabase.getDatabase(applicationContext)
db.contentCacheDao().upsert(contentCache)
// If this is a prefetch for a specific notification, create NotificationContentEntity
// so the notification worker can find it when the alarm fires
if (notificationTime > 0) {
try {
val notificationId = "notify_$notificationTime"
val (title, body) = parsePayload(payload)
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.0.2", // Plugin version
null, // timesafariDid - can be set if available
"daily",
title,
body,
notificationTime,
java.time.ZoneId.systemDefault().id
)
entity.priority = 0 // default priority
entity.vibrationEnabled = true
entity.soundEnabled = true
entity.deliveryStatus = "pending"
entity.createdAt = System.currentTimeMillis()
entity.updatedAt = System.currentTimeMillis()
entity.ttlSeconds = contentCache.ttlSeconds.toLong()
// Save to Room database so notification worker can find it
db.notificationContentDao().insertNotification(entity)
Log.i(TAG, "Created NotificationContentEntity: id=$notificationId, scheduledTime=$notificationTime")
} catch (e: Exception) {
Log.e(TAG, "Failed to create NotificationContentEntity", e)
// Continue - at least ContentCache was saved
}
}
// Record success in history
db.historyDao().insert(
History(
@@ -179,24 +328,27 @@ class FetchWorker(
private fun generateId(): String {
return "fetch_${System.currentTimeMillis()}_${(1000..9999).random()}"
}
}
/**
* Database singleton for Room
*/
object DailyNotificationDatabase {
@Volatile
private var INSTANCE: DailyNotificationDatabase? = null
fun getDatabase(context: Context): DailyNotificationDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
DailyNotificationDatabase::class.java,
"daily_notification_database"
).build()
INSTANCE = instance
instance
/**
* Parse payload to extract title and body
* Handles both JSON and plain text payloads
*
* @param payload Raw payload bytes
* @return Pair of (title, body)
*/
private fun parsePayload(payload: ByteArray): Pair<String, String> {
return try {
val payloadString = String(payload, Charsets.UTF_8)
// Try to parse as JSON
val json = JSONObject(payloadString)
val title = json.optString("title", "Daily Notification")
val body = json.optString("body", json.optString("content", payloadString))
Pair(title, body)
} catch (e: Exception) {
// Not JSON, use as plain text
val text = String(payload, Charsets.UTF_8)
Pair("Daily Notification", text)
}
}
}

View File

@@ -1,6 +1,7 @@
package com.timesafari.dailynotification
import android.app.AlarmManager
import android.app.AlarmManager.AlarmClockInfo
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
@@ -13,6 +14,7 @@ import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* AlarmManager implementation for user notifications
@@ -27,44 +29,212 @@ class NotifyReceiver : BroadcastReceiver() {
private const val TAG = "DNP-NOTIFY"
private const val CHANNEL_ID = "daily_notifications"
private const val NOTIFICATION_ID = 1001
private const val REQUEST_CODE = 2001
/**
* Generate unique request code from trigger time
* Uses lower 16 bits of timestamp to ensure uniqueness
*/
private fun getRequestCode(triggerAtMillis: Long): Int {
return (triggerAtMillis and 0xFFFF).toInt()
}
/**
* Get launch intent for the host app
* Uses package launcher intent to avoid hardcoding MainActivity class name
* This works across all host apps regardless of their MainActivity package/class
*
* @param context Application context
* @return Intent to launch the app, or null if not available
*/
private fun getLaunchIntent(context: Context): Intent? {
return try {
// Use package launcher intent - works for any host app
context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
} catch (e: Exception) {
Log.w(TAG, "Failed to get launch intent for package: ${context.packageName}", e)
null
}
}
/**
* Check if exact alarm permission is granted
* On Android 12+ (API 31+), SCHEDULE_EXACT_ALARM must be granted at runtime
*
* @param context Application context
* @return true if exact alarms can be scheduled, false otherwise
*/
private fun canScheduleExactAlarms(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
alarmManager?.canScheduleExactAlarms() ?: false
} else {
// Pre-Android 12: exact alarms are always allowed
true
}
}
/**
* Schedule an exact notification using AlarmManager
* Uses setAlarmClock() for Android 5.0+ for better reliability
* Falls back to setExactAndAllowWhileIdle for older versions
*
* FIX: Uses DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
* Stores notification content in database and passes notification ID to receiver
*
* @param context Application context
* @param triggerAtMillis When to trigger the notification (UTC milliseconds)
* @param config Notification configuration
* @param isStaticReminder Whether this is a static reminder (no content dependency)
* @param reminderId Optional reminder ID for tracking
*/
fun scheduleExactNotification(
context: Context,
triggerAtMillis: Long,
config: UserNotificationConfig
config: UserNotificationConfig,
isStaticReminder: Boolean = false,
reminderId: String? = null
) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotifyReceiver::class.java).apply {
// Generate notification ID (use reminderId if provided, otherwise generate from trigger time)
val notificationId = reminderId ?: "notify_${triggerAtMillis}"
// Store notification content in database before scheduling alarm
// This allows DailyNotificationReceiver to retrieve content via notification ID
// FIX: Wrap suspend function calls in coroutine
if (!isStaticReminder) {
try {
// Use runBlocking to call suspend function from non-suspend context
// This is acceptable here because we're not in a UI thread and need to ensure
// content is stored before scheduling the alarm
runBlocking {
val db = DailyNotificationDatabase.getDatabase(context)
val contentCache = db.contentCacheDao().getLatest()
// If we have cached content, create a notification content entity
if (contentCache != null) {
val roomStorage = com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(context)
val entity = com.timesafari.dailynotification.entities.NotificationContentEntity(
notificationId,
"1.0.2", // Plugin version
null, // timesafariDid - can be set if available
"daily",
config.title,
config.body ?: String(contentCache.payload),
triggerAtMillis,
java.time.ZoneId.systemDefault().id
)
entity.priority = when (config.priority) {
"high", "max" -> 2
"low", "min" -> -1
else -> 0
}
entity.vibrationEnabled = config.vibration ?: true
entity.soundEnabled = config.sound ?: true
entity.deliveryStatus = "pending"
entity.createdAt = System.currentTimeMillis()
entity.updatedAt = System.currentTimeMillis()
entity.ttlSeconds = contentCache.ttlSeconds.toLong()
// saveNotificationContent returns CompletableFuture, so we need to wait for it
roomStorage.saveNotificationContent(entity).get()
Log.d(TAG, "Stored notification content in database: id=$notificationId")
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to store notification content in database, continuing with alarm scheduling", e)
}
}
// FIX: Use DailyNotificationReceiver (registered in manifest) instead of NotifyReceiver
// FIX: Set action to match manifest registration
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION" // Must match manifest intent-filter action
putExtra("notification_id", notificationId) // DailyNotificationReceiver expects this extra
// Also preserve original extras for backward compatibility if needed
putExtra("title", config.title)
putExtra("body", config.body)
putExtra("sound", config.sound ?: true)
putExtra("vibration", config.vibration ?: true)
putExtra("priority", config.priority ?: "normal")
putExtra("is_static_reminder", isStaticReminder)
putExtra("trigger_time", triggerAtMillis) // Store trigger time for debugging
if (reminderId != null) {
putExtra("reminder_id", reminderId)
}
}
// Use unique request code based on trigger time to prevent PendingIntent conflicts
val requestCode = getRequestCode(triggerAtMillis)
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val currentTime = System.currentTimeMillis()
val delayMs = triggerAtMillis - currentTime
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.i(TAG, "Scheduling alarm: triggerTime=$triggerTimeStr, delayMs=$delayMs, requestCode=$requestCode")
// Check exact alarm permission before scheduling (Android 12+)
val canScheduleExact = canScheduleExactAlarms(context)
if (!canScheduleExact && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Log.w(TAG, "Exact alarm permission not granted. Cannot schedule exact alarm. User must grant SCHEDULE_EXACT_ALARM permission in settings.")
// Fall back to inexact alarm
alarmManager.set(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Inexact alarm scheduled (exact permission denied): triggerAt=$triggerAtMillis, requestCode=$requestCode")
return
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Use setAlarmClock() for Android 5.0+ (API 21+) - most reliable method
// Shows alarm icon in status bar and is exempt from doze mode
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// Create show intent for alarm clock (opens app when alarm fires)
// Use package launcher intent to avoid hardcoding MainActivity class name
val showIntent = getLaunchIntent(context)
val showPendingIntent = if (showIntent != null) {
PendingIntent.getActivity(
context,
requestCode + 1, // Different request code for show intent
showIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else {
null
}
val alarmClockInfo = AlarmClockInfo(triggerAtMillis, showPendingIntent)
alarmManager.setAlarmClock(alarmClockInfo, pendingIntent)
Log.i(TAG, "Alarm clock scheduled (setAlarmClock): triggerAt=$triggerAtMillis, requestCode=$requestCode")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// Fallback to setExactAndAllowWhileIdle for Android 6.0-4.4
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Exact alarm scheduled (setExactAndAllowWhileIdle): triggerAt=$triggerAtMillis, requestCode=$requestCode")
} else {
// Fallback to setExact for older versions
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Exact alarm scheduled (setExact): triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
Log.i(TAG, "Exact notification scheduled for: $triggerAtMillis")
} catch (e: SecurityException) {
Log.w(TAG, "Cannot schedule exact alarm, falling back to inexact", e)
alarmManager.set(
@@ -72,25 +242,137 @@ class NotifyReceiver : BroadcastReceiver() {
triggerAtMillis,
pendingIntent
)
Log.i(TAG, "Inexact alarm scheduled (fallback): triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
}
fun cancelNotification(context: Context) {
/**
* Cancel a scheduled notification alarm
* FIX: Uses DailyNotificationReceiver to match alarm scheduling
* @param context Application context
* @param triggerAtMillis The trigger time of the alarm to cancel (required for unique request code)
*/
fun cancelNotification(context: Context, triggerAtMillis: Long) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, NotifyReceiver::class.java)
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
}
val requestCode = getRequestCode(triggerAtMillis)
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
Log.i(TAG, "Notification alarm cancelled")
Log.i(TAG, "Notification alarm cancelled: triggerAt=$triggerAtMillis, requestCode=$requestCode")
}
/**
* Check if an alarm is scheduled for the given trigger time
* FIX: Uses DailyNotificationReceiver to match alarm scheduling
* @param context Application context
* @param triggerAtMillis The trigger time to check
* @return true if alarm is scheduled, false otherwise
*/
fun isAlarmScheduled(context: Context, triggerAtMillis: Long): Boolean {
// FIX: Use DailyNotificationReceiver to match what was scheduled
val intent = Intent(context, com.timesafari.dailynotification.DailyNotificationReceiver::class.java).apply {
action = "com.timesafari.daily.NOTIFICATION"
}
val requestCode = getRequestCode(triggerAtMillis)
val pendingIntent = PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
val isScheduled = pendingIntent != null
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerAtMillis))
Log.d(TAG, "Alarm check for $triggerTimeStr: scheduled=$isScheduled, requestCode=$requestCode")
return isScheduled
}
/**
* Get the next scheduled alarm time from AlarmManager
* @param context Application context
* @return Next alarm time in milliseconds, or null if no alarm is scheduled
*/
fun getNextAlarmTime(context: Context): Long? {
return try {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val nextAlarm = alarmManager.nextAlarmClock
if (nextAlarm != null) {
val triggerTime = nextAlarm.triggerTime
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerTime))
Log.d(TAG, "Next alarm clock: $triggerTimeStr")
triggerTime
} else {
Log.d(TAG, "No alarm clock scheduled")
null
}
} else {
Log.d(TAG, "getNextAlarmTime() requires Android 5.0+")
null
}
} catch (e: Exception) {
Log.e(TAG, "Error getting next alarm time", e)
null
}
}
/**
* Test method: Schedule an alarm to fire in a few seconds
* Useful for verifying alarm delivery works correctly
* @param context Application context
* @param secondsFromNow How many seconds from now to fire (default: 5)
*/
fun testAlarm(context: Context, secondsFromNow: Int = 5) {
val triggerTime = System.currentTimeMillis() + (secondsFromNow * 1000L)
val config = UserNotificationConfig(
enabled = true,
schedule = "",
title = "Test Notification",
body = "This is a test notification scheduled $secondsFromNow seconds from now",
sound = true,
vibration = true,
priority = "high"
)
val triggerTimeStr = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerTime))
Log.i(TAG, "TEST: Scheduling test alarm for $triggerTimeStr (in $secondsFromNow seconds)")
scheduleExactNotification(
context,
triggerTime,
config,
isStaticReminder = true,
reminderId = "test_${System.currentTimeMillis()}"
)
Log.i(TAG, "TEST: Alarm scheduled. Check logs in $secondsFromNow seconds for 'Notification receiver triggered'")
}
}
override fun onReceive(context: Context, intent: Intent?) {
Log.i(TAG, "Notification receiver triggered")
val triggerTime = intent?.getLongExtra("trigger_time", 0L) ?: 0L
val triggerTimeStr = if (triggerTime > 0) {
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US)
.format(java.util.Date(triggerTime))
} else {
"unknown"
}
val currentTime = System.currentTimeMillis()
val delayMs = if (triggerTime > 0) currentTime - triggerTime else 0L
Log.i(TAG, "Notification receiver triggered: triggerTime=$triggerTimeStr, currentTime=${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Date(currentTime))}, delayMs=$delayMs")
CoroutineScope(Dispatchers.IO).launch {
try {
@@ -187,6 +469,16 @@ class NotifyReceiver : BroadcastReceiver() {
notificationManager.createNotificationChannel(channel)
}
// Create intent to launch app when notification is clicked
// Use package launcher intent to avoid hardcoding MainActivity class name
val intent = getLaunchIntent(context) ?: return
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
@@ -198,7 +490,8 @@ class NotifyReceiver : BroadcastReceiver() {
else -> NotificationCompat.PRIORITY_DEFAULT
}
)
.setAutoCancel(true)
.setAutoCancel(true) // Dismissible when user swipes it away
.setContentIntent(pendingIntent) // Launch app when clicked
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
.build()
@@ -286,6 +579,16 @@ class NotifyReceiver : BroadcastReceiver() {
// Create notification channel for reminders
createReminderNotificationChannel(context, notificationManager)
// Create intent to launch app when notification is clicked
// Use package launcher intent to avoid hardcoding MainActivity class name
val intent = getLaunchIntent(context) ?: return
val pendingIntent = PendingIntent.getActivity(
context,
reminderId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, "daily_reminders")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
@@ -298,7 +601,8 @@ class NotifyReceiver : BroadcastReceiver() {
}
)
.setSound(if (sound) null else null) // Use default sound if enabled
.setAutoCancel(true)
.setAutoCancel(true) // Dismissible when user swipes it away
.setContentIntent(pendingIntent) // Launch app when clicked
.setVibrate(if (vibration) longArrayOf(0, 250, 250, 250) else null)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build()

View File

@@ -14,7 +14,7 @@ package com.timesafari.dailynotification.storage;
import android.content.Context;
import android.util.Log;
import com.timesafari.dailynotification.database.DailyNotificationDatabase;
import com.timesafari.dailynotification.DailyNotificationDatabase;
import com.timesafari.dailynotification.dao.NotificationContentDao;
import com.timesafari.dailynotification.dao.NotificationDeliveryDao;
import com.timesafari.dailynotification.dao.NotificationConfigDao;
@@ -42,7 +42,7 @@ public class DailyNotificationStorageRoom {
private static final String TAG = "DailyNotificationStorageRoom";
// Database and DAOs
// Database and DAOs (using unified database)
private DailyNotificationDatabase database;
private NotificationContentDao contentDao;
private NotificationDeliveryDao deliveryDao;
@@ -60,13 +60,14 @@ public class DailyNotificationStorageRoom {
* @param context Application context
*/
public DailyNotificationStorageRoom(Context context) {
// Use unified database (Kotlin schema with Java entities)
this.database = DailyNotificationDatabase.getInstance(context);
this.contentDao = database.notificationContentDao();
this.deliveryDao = database.notificationDeliveryDao();
this.configDao = database.notificationConfigDao();
this.executorService = Executors.newFixedThreadPool(4);
Log.d(TAG, "Room-based storage initialized");
Log.d(TAG, "Room-based storage initialized with unified database");
}
// ===== NOTIFICATION CONTENT OPERATIONS =====

619
docs/DATABASE_INTERFACES.md Normal file
View File

@@ -0,0 +1,619 @@
# Database Interfaces Documentation
**Author**: Matthew Raymer
**Version**: 1.0.0
**Last Updated**: 2025-01-21
## Overview
The Daily Notification Plugin owns its own SQLite database for storing schedules, cached content, configuration, and execution history. Since the plugin's database is isolated from the host app, the webview accesses this data through TypeScript/Capacitor interfaces.
This document explains how to use these interfaces from TypeScript/JavaScript code in your Capacitor app.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Host App (TypeScript) │
│ import { DailyNotification } from '@capacitor-community/...'│
│ │
│ const schedules = await DailyNotification.getSchedules() │
└──────────────────────┬──────────────────────────────────────┘
│ Capacitor Bridge
┌─────────────────────────────────────────────────────────────┐
│ Plugin (Native Android/Kotlin) │
│ │
│ @PluginMethod │
│ getSchedules() → Room Database → SQLite │
└─────────────────────────────────────────────────────────────┘
```
## Quick Start
```typescript
import { DailyNotification } from '@capacitor-community/daily-notification';
// Get all enabled notification schedules
const schedules = await DailyNotification.getSchedules({
kind: 'notify',
enabled: true
});
// Get latest cached content
const content = await DailyNotification.getLatestContentCache();
// Create a new schedule
const newSchedule = await DailyNotification.createSchedule({
kind: 'notify',
cron: '0 9 * * *', // Daily at 9 AM
enabled: true
});
```
## Interface Categories
### 1. Schedules Management
Schedules represent recurring patterns for fetching content or displaying notifications. These are critical for reboot recovery - Android doesn't persist AlarmManager/WorkManager schedules, so they must be restored from the database.
#### Get All Schedules
```typescript
// Get all schedules
const result = await DailyNotification.getSchedules();
const allSchedules = result.schedules;
// Get only enabled notification schedules
const notifyResult = await DailyNotification.getSchedules({
kind: 'notify',
enabled: true
});
const enabledNotify = notifyResult.schedules;
// Get only fetch schedules
const fetchResult = await DailyNotification.getSchedules({
kind: 'fetch'
});
const fetchSchedules = fetchResult.schedules;
```
**Returns**: `Promise<{ schedules: Schedule[] }>` - Note: Array is wrapped in object due to Capacitor serialization
#### Get Single Schedule
```typescript
const schedule = await DailyNotification.getSchedule('notify_1234567890');
if (schedule) {
console.log(`Next run: ${new Date(schedule.nextRunAt)}`);
}
```
**Returns**: `Promise<Schedule | null>`
#### Create Schedule
```typescript
const schedule = await DailyNotification.createSchedule({
kind: 'notify',
cron: '0 9 * * *', // Daily at 9 AM (cron format)
// OR
clockTime: '09:00', // Simple HH:mm format
enabled: true,
jitterMs: 60000, // 1 minute jitter
backoffPolicy: 'exp'
});
```
**Returns**: `Promise<Schedule>`
#### Update Schedule
```typescript
// Update schedule enable state
await DailyNotification.updateSchedule('notify_1234567890', {
enabled: false
});
// Update next run time
await DailyNotification.updateSchedule('notify_1234567890', {
nextRunAt: Date.now() + 86400000 // Tomorrow
});
```
**Returns**: `Promise<Schedule>`
#### Delete Schedule
```typescript
await DailyNotification.deleteSchedule('notify_1234567890');
```
**Returns**: `Promise<void>`
#### Enable/Disable Schedule
```typescript
// Disable schedule
await DailyNotification.enableSchedule('notify_1234567890', false);
// Enable schedule
await DailyNotification.enableSchedule('notify_1234567890', true);
```
**Returns**: `Promise<void>`
#### Calculate Next Run Time
```typescript
// Calculate next run from cron expression
const nextRun = await DailyNotification.calculateNextRunTime('0 9 * * *');
// Calculate next run from clockTime
const nextRun2 = await DailyNotification.calculateNextRunTime('09:00');
console.log(`Next run: ${new Date(nextRun)}`);
```
**Returns**: `Promise<number>` (timestamp in milliseconds)
### 2. Content Cache Management
Content cache stores prefetched content for offline-first display. Each entry has a TTL (time-to-live) for freshness validation.
#### Get Latest Content Cache
```typescript
const latest = await DailyNotification.getLatestContentCache();
if (latest) {
const content = JSON.parse(latest.payload);
const age = Date.now() - latest.fetchedAt;
const isFresh = age < (latest.ttlSeconds * 1000);
console.log(`Content age: ${age}ms, Fresh: ${isFresh}`);
}
```
**Returns**: `Promise<ContentCache | null>`
#### Get Content Cache by ID
```typescript
const cache = await DailyNotification.getContentCacheById({
id: 'cache_1234567890'
});
```
**Returns**: `Promise<ContentCache | null>`
#### Get Content Cache History
```typescript
// Get last 10 cache entries
const result = await DailyNotification.getContentCacheHistory(10);
const history = result.history;
history.forEach(cache => {
console.log(`Cache ${cache.id}: ${new Date(cache.fetchedAt)}`);
});
```
**Returns**: `Promise<{ history: ContentCache[] }>`
#### Save Content Cache
```typescript
const cached = await DailyNotification.saveContentCache({
payload: JSON.stringify({
title: 'Daily Update',
body: 'Your daily content is ready!',
data: { /* ... */ }
}),
ttlSeconds: 3600, // 1 hour TTL
meta: 'fetched_from_api'
});
console.log(`Cached content with ID: ${cached.id}`);
```
**Returns**: `Promise<ContentCache>`
#### Clear Content Cache
```typescript
// Clear all cache entries
await DailyNotification.clearContentCacheEntries();
// Clear entries older than 24 hours
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
await DailyNotification.clearContentCacheEntries({
olderThan: oneDayAgo
});
```
**Returns**: `Promise<void>`
### 3. Configuration Management
**Note**: Configuration management methods (`getConfig`, `setConfig`, etc.) are currently not implemented in the Kotlin database schema. These will be available once the database consolidation is complete (see `android/DATABASE_CONSOLIDATION_PLAN.md`). For now, use the Java-based `DailyNotificationStorageRoom` for configuration storage if needed.
When implemented, these methods will store plugin settings and user preferences with optional TimeSafari DID scoping.
#### Get Configuration
```typescript
// Get config by key
const config = await DailyNotification.getConfig('notification_sound_enabled');
if (config) {
const value = config.configDataType === 'boolean'
? config.configValue === 'true'
: config.configValue;
console.log(`Sound enabled: ${value}`);
}
```
**Returns**: `Promise<Config | null>`
#### Get All Configurations
```typescript
// Get all configs
const allConfigs = await DailyNotification.getAllConfigs();
// Get configs for specific user
const userConfigs = await DailyNotification.getAllConfigs({
timesafariDid: 'did:ethr:0x...'
});
// Get configs by type
const pluginConfigs = await DailyNotification.getAllConfigs({
configType: 'plugin_setting'
});
```
**Returns**: `Promise<Config[]>`
#### Set Configuration
```typescript
await DailyNotification.setConfig({
configType: 'user_preference',
configKey: 'notification_sound_enabled',
configValue: 'true',
configDataType: 'boolean',
timesafariDid: 'did:ethr:0x...' // Optional: user-specific
});
```
**Returns**: `Promise<Config>`
#### Update Configuration
```typescript
await DailyNotification.updateConfig(
'notification_sound_enabled',
'false',
{ timesafariDid: 'did:ethr:0x...' }
);
```
**Returns**: `Promise<Config>`
#### Delete Configuration
```typescript
await DailyNotification.deleteConfig('notification_sound_enabled', {
timesafariDid: 'did:ethr:0x...'
});
```
**Returns**: `Promise<void>`
### 4. Callbacks Management
Callbacks are executed after fetch/notify events. They can be HTTP endpoints, local handlers, or queue destinations.
#### Get All Callbacks
```typescript
// Get all callbacks
const result = await DailyNotification.getCallbacks();
const allCallbacks = result.callbacks;
// Get only enabled callbacks
const enabledResult = await DailyNotification.getCallbacks({
enabled: true
});
const enabledCallbacks = enabledResult.callbacks;
```
**Returns**: `Promise<{ callbacks: Callback[] }>`
#### Get Single Callback
```typescript
const callback = await DailyNotification.getCallback('on_notify_delivered');
```
**Returns**: `Promise<Callback | null>`
#### Register Callback
```typescript
await DailyNotification.registerCallbackConfig({
id: 'on_notify_delivered',
kind: 'http',
target: 'https://api.example.com/webhooks/notify',
headersJson: JSON.stringify({
'Authorization': 'Bearer token123',
'Content-Type': 'application/json'
}),
enabled: true
});
```
**Returns**: `Promise<Callback>`
#### Update Callback
```typescript
await DailyNotification.updateCallback('on_notify_delivered', {
enabled: false,
headersJson: JSON.stringify({ 'Authorization': 'Bearer newtoken' })
});
```
**Returns**: `Promise<Callback>`
#### Delete Callback
```typescript
await DailyNotification.deleteCallback('on_notify_delivered');
```
**Returns**: `Promise<void>`
#### Enable/Disable Callback
```typescript
await DailyNotification.enableCallback('on_notify_delivered', false);
```
**Returns**: `Promise<void>`
### 5. History/Analytics
History provides execution logs for debugging and analytics.
#### Get History
```typescript
// Get last 50 entries
const result = await DailyNotification.getHistory();
const history = result.history;
// Get entries since yesterday
const yesterday = Date.now() - (24 * 60 * 60 * 1000);
const recentResult = await DailyNotification.getHistory({
since: yesterday,
limit: 100
});
const recentHistory = recentResult.history;
// Get only fetch executions
const fetchResult = await DailyNotification.getHistory({
kind: 'fetch',
limit: 20
});
const fetchHistory = fetchResult.history;
```
**Returns**: `Promise<{ history: History[] }>`
#### Get History Statistics
```typescript
const stats = await DailyNotification.getHistoryStats();
console.log(`Total executions: ${stats.totalCount}`);
console.log(`Success rate: ${stats.outcomes.success / stats.totalCount * 100}%`);
console.log(`Fetch executions: ${stats.kinds.fetch}`);
console.log(`Most recent: ${new Date(stats.mostRecent)}`);
```
**Returns**: `Promise<HistoryStats>`
## Type Definitions
### Schedule
```typescript
interface Schedule {
id: string;
kind: 'fetch' | 'notify';
cron?: string; // Cron expression (e.g., "0 9 * * *")
clockTime?: string; // HH:mm format (e.g., "09:00")
enabled: boolean;
lastRunAt?: number; // Timestamp (ms)
nextRunAt?: number; // Timestamp (ms)
jitterMs: number;
backoffPolicy: string; // 'exp', etc.
stateJson?: string;
}
```
### ContentCache
```typescript
interface ContentCache {
id: string;
fetchedAt: number; // Timestamp (ms)
ttlSeconds: number;
payload: string; // JSON string or base64
meta?: string;
}
```
### Config
```typescript
interface Config {
id: string;
timesafariDid?: string;
configType: string;
configKey: string;
configValue: string;
configDataType: string; // 'string' | 'boolean' | 'integer' | etc.
isEncrypted: boolean;
createdAt: number; // Timestamp (ms)
updatedAt: number; // Timestamp (ms)
}
```
### Callback
```typescript
interface Callback {
id: string;
kind: 'http' | 'local' | 'queue';
target: string;
headersJson?: string;
enabled: boolean;
createdAt: number; // Timestamp (ms)
}
```
### History
```typescript
interface History {
id: number;
refId: string;
kind: 'fetch' | 'notify' | 'callback' | 'boot_recovery';
occurredAt: number; // Timestamp (ms)
durationMs?: number;
outcome: string; // 'success' | 'failure' | etc.
diagJson?: string;
}
```
## Common Patterns
### Pattern 1: Check Schedule Status
```typescript
async function checkScheduleStatus() {
const result = await DailyNotification.getSchedules({ enabled: true });
const schedules = result.schedules;
for (const schedule of schedules) {
if (schedule.nextRunAt) {
const nextRun = new Date(schedule.nextRunAt);
const now = new Date();
const timeUntil = nextRun.getTime() - now.getTime();
console.log(`${schedule.kind} schedule ${schedule.id}:`);
console.log(` Next run: ${nextRun}`);
console.log(` Time until: ${Math.round(timeUntil / 1000 / 60)} minutes`);
}
}
}
```
### Pattern 2: Verify Content Freshness
```typescript
async function isContentFresh(): Promise<boolean> {
const cache = await DailyNotification.getLatestContentCache();
if (!cache) {
return false; // No content available
}
const age = Date.now() - cache.fetchedAt;
const ttlMs = cache.ttlSeconds * 1000;
return age < ttlMs;
}
```
### Pattern 3: Update User Preferences
```typescript
async function updateUserPreferences(did: string, preferences: Record<string, any>) {
for (const [key, value] of Object.entries(preferences)) {
await DailyNotification.setConfig({
timesafariDid: did,
configType: 'user_preference',
configKey: key,
configValue: String(value),
configDataType: typeof value === 'boolean' ? 'boolean' : 'string'
});
}
}
```
### Pattern 4: Monitor Execution Health
```typescript
async function checkExecutionHealth() {
const stats = await DailyNotification.getHistoryStats();
const recentResult = await DailyNotification.getHistory({
since: Date.now() - (24 * 60 * 60 * 1000) // Last 24 hours
});
const recent = recentResult.history;
const successCount = recent.filter(h => h.outcome === 'success').length;
const failureCount = recent.filter(h => h.outcome === 'failure').length;
const successRate = successCount / recent.length;
console.log(`24h Success Rate: ${(successRate * 100).toFixed(1)}%`);
console.log(`Successes: ${successCount}, Failures: ${failureCount}`);
return successRate > 0.9; // Healthy if > 90% success rate
}
```
## Error Handling
All methods return Promises and can reject with errors:
```typescript
try {
const schedule = await DailyNotification.getSchedule('invalid_id');
if (!schedule) {
console.log('Schedule not found');
}
} catch (error) {
console.error('Error accessing database:', error);
// Handle error - database might be unavailable, etc.
}
```
## Thread Safety
All database operations are executed on background threads (Kotlin `Dispatchers.IO`). Methods are safe to call from any thread in your TypeScript code.
## Implementation Status
### ✅ Implemented
- Schedule management (CRUD operations)
- Content cache management (CRUD operations)
- Callback management (CRUD operations)
- History/analytics (read operations)
### ⚠️ Pending Database Consolidation
- Configuration management (Config table exists in Java DB, needs to be added to Kotlin schema)
- See `android/DATABASE_CONSOLIDATION_PLAN.md` for full consolidation plan
## Return Format Notes
**Important**: Capacitor serializes arrays wrapped in JSObject. Methods that return arrays will return them in this format:
- `getSchedules()``{ schedules: Schedule[] }`
- `getCallbacks()``{ callbacks: Callback[] }`
- `getHistory()``{ history: History[] }`
- `getContentCacheHistory()``{ history: ContentCache[] }`
This is due to Capacitor's serialization mechanism. Always access the array property from the returned object.

View File

@@ -0,0 +1,157 @@
# Database Interfaces Implementation Summary
**Author**: Matthew Raymer
**Date**: 2025-01-21
**Status**: ✅ **COMPLETE** - TypeScript interfaces and Android implementations ready
## Overview
The Daily Notification Plugin now exposes comprehensive TypeScript interfaces for accessing its internal SQLite database. Since the plugin owns its database (isolated from host apps), the webview accesses data through Capacitor bridge methods.
## What Was Implemented
### ✅ TypeScript Interface Definitions (`src/definitions.ts`)
Added 30+ database access methods with full type definitions:
- **Schedule Management**: `getSchedules()`, `createSchedule()`, `updateSchedule()`, `deleteSchedule()`, `enableSchedule()`, `calculateNextRunTime()`
- **Content Cache Management**: `getContentCacheById()`, `getLatestContentCache()`, `getContentCacheHistory()`, `saveContentCache()`, `clearContentCacheEntries()`
- **Callback Management**: `getCallbacks()`, `getCallback()`, `registerCallbackConfig()`, `updateCallback()`, `deleteCallback()`, `enableCallback()`
- **History/Analytics**: `getHistory()`, `getHistoryStats()`
- **Configuration Management**: Stubs for `getConfig()`, `setConfig()`, `updateConfig()`, `deleteConfig()`, `getAllConfigs()` (pending database consolidation)
### ✅ Android PluginMethods (`DailyNotificationPlugin.kt`)
Implemented all database access methods:
- All operations run on background threads (`Dispatchers.IO`) for thread safety
- Proper error handling with descriptive error messages
- JSON serialization helpers for entity-to-JSObject conversion
- Filter support (by kind, enabled status, time ranges, etc.)
### ✅ Database Schema Extensions (`DatabaseSchema.kt`)
Extended DAOs with additional queries:
- `ScheduleDao`: Added `getAll()`, `getByKind()`, `getByKindAndEnabled()`, `deleteById()`, `update()`
- `ContentCacheDao`: Added `getHistory()`, `deleteAll()`
- `CallbackDao`: Added `getAll()`, `getByEnabled()`, `getById()`, `update()`
- `HistoryDao`: Added `getSinceByKind()`, `getRecent()`
### ✅ Comprehensive Documentation (`docs/DATABASE_INTERFACES.md`)
Created 600+ line documentation guide:
- Complete API reference with examples
- Common usage patterns
- Type definitions
- Error handling guidance
- Return format notes (Capacitor serialization)
- Implementation status
## Key Features
### For Developers
- **Type-Safe**: Full TypeScript type definitions
- **Well-Documented**: Comprehensive JSDoc comments and examples
- **Error Handling**: Clear error messages for debugging
- **Thread-Safe**: All operations on background threads
### For AI Assistants
- **Clear Structure**: Methods organized by category
- **Comprehensive Examples**: Real-world usage patterns
- **Type Information**: Complete type definitions with JSDoc
- **Architecture Documentation**: Clear explanation of plugin database ownership
## Usage Example
```typescript
import { DailyNotification } from '@capacitor-community/daily-notification';
// Get all enabled notification schedules
const result = await DailyNotification.getSchedules({
kind: 'notify',
enabled: true
});
const schedules = result.schedules;
// Get latest cached content
const cache = await DailyNotification.getLatestContentCache();
if (cache) {
const content = JSON.parse(cache.payload);
console.log('Content:', content);
}
// Create a new schedule
const newSchedule = await DailyNotification.createSchedule({
kind: 'notify',
cron: '0 9 * * *', // Daily at 9 AM
enabled: true
});
```
## Implementation Status
### ✅ Fully Implemented
- Schedule management (CRUD)
- Content cache management (CRUD)
- Callback management (CRUD)
- History/analytics (read operations)
### ⚠️ Pending Database Consolidation
- Configuration management (Config table exists in Java DB, needs to be added to Kotlin schema)
- See `android/DATABASE_CONSOLIDATION_PLAN.md` for details
## Architecture Notes
### Why Plugin Owns Database
1. **Isolation**: Plugin data is separate from host app data
2. **Reboot Recovery**: Schedules must persist across reboots (Android doesn't persist AlarmManager schedules)
3. **Offline-First**: Cached content available without network
4. **Self-Contained**: Plugin manages its own lifecycle
### How Webview Accesses Database
```
TypeScript/Webview
↓ Capacitor Bridge
Android PluginMethod (@PluginMethod)
↓ Kotlin Coroutines (Dispatchers.IO)
Room Database (Kotlin)
↓ SQLite
daily_notification_plugin.db
```
## Files Modified/Created
1. **`src/definitions.ts`** - Added database interface methods and types
2. **`android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt`** - Implemented PluginMethods
3. **`android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt`** - Extended DAOs
4. **`docs/DATABASE_INTERFACES.md`** - Complete documentation
5. **`android/DATABASE_CONSOLIDATION_PLAN.md`** - Updated with interface requirements
## Next Steps
1. **Complete Database Consolidation**: Merge Java and Kotlin databases into single unified schema
2. **Add Config Table**: Implement Config management methods once consolidated
3. **Testing**: Test all database methods end-to-end
4. **iOS Implementation**: Adapt to iOS when ready
## Documentation References
- **Complete API Reference**: `docs/DATABASE_INTERFACES.md`
- **Consolidation Plan**: `android/DATABASE_CONSOLIDATION_PLAN.md`
- **TypeScript Definitions**: `src/definitions.ts`
- **Database Schema**: `android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt`
## For AI Assistants
This implementation provides:
- **Clear Interface Contracts**: TypeScript interfaces define exact method signatures
- **Comprehensive Examples**: Every method has usage examples
- **Architecture Context**: Clear explanation of why database is plugin-owned
- **Implementation Details**: Android code shows how methods work internally
- **Error Patterns**: Consistent error handling across all methods
All interfaces are type-safe, well-documented, and ready for use in projects that integrate this plugin.

View File

@@ -435,8 +435,36 @@ adb logcat -c
- Check exact alarm permissions: `adb shell "dumpsys alarm | grep SCHEDULE_EXACT_ALARM"`
- Verify alarm is scheduled: `adb shell "dumpsys alarm | grep timesafari"`
- Check battery optimization settings
- Use diagnostic methods to verify alarm status:
```typescript
// Check if alarm is scheduled
const status = await DailyNotification.isAlarmScheduled({
triggerAtMillis: scheduledTime
});
// Get next alarm time
const nextAlarm = await DailyNotification.getNextAlarmTime();
// Test alarm delivery
await DailyNotification.testAlarm({ secondsFromNow: 10 });
```
#### 4. App Crashes on Force Stop
#### 4. BroadcastReceiver Not Invoked
**Symptoms**: Alarm fires but notification doesn't appear, no logs from `NotifyReceiver`
**Solutions**:
- **CRITICAL**: Verify `NotifyReceiver` is registered in `AndroidManifest.xml`:
```xml
<receiver android:name="com.timesafari.dailynotification.NotifyReceiver"
android:enabled="true"
android:exported="false">
</receiver>
```
- Check logs for `NotifyReceiver` registration: `adb logcat -d | grep -i "NotifyReceiver"`
- Verify the receiver is in your app's manifest, not just the plugin's manifest
- Check if app process is killed: `adb shell "ps | grep timesafari"`
- Review alarm scheduling logs: `adb logcat -d | grep -E "DNP-NOTIFY|Alarm clock"`
#### 5. App Crashes on Force Stop
**Symptoms**: App crashes when force-stopped
**Solutions**:
- This is expected behavior - force-stop kills the app

View File

@@ -1,6 +1,6 @@
{
"name": "@timesafari/daily-notification-plugin",
"version": "1.0.0",
"version": "1.0.11",
"description": "TimeSafari Daily Notification Plugin - Enterprise-grade daily notification functionality with dual scheduling, callback support, TTL-at-fire logic, and comprehensive observability across Mobile (Capacitor) and Desktop (Electron) platforms",
"main": "dist/plugin.js",
"module": "dist/esm/index.js",

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Verify Capacitor plugin Android structure (post-restructure)
*
* This script verifies that the plugin follows the standard Capacitor structure:
* - android/src/main/java/... (plugin code)
* - android/build.gradle (plugin build config)
*
* This script is now optional since the plugin uses standard structure.
* It can be used to verify the structure is correct.
*
* @author Matthew Raymer
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function findAppRoot() {
let currentDir = __dirname;
// Go up from scripts/ to plugin root
currentDir = path.dirname(currentDir);
// Verify we're in the plugin root
const pluginPackageJson = path.join(currentDir, 'package.json');
if (!fs.existsSync(pluginPackageJson)) {
throw new Error('Could not find plugin package.json - script may be in wrong location');
}
// Go up from plugin root to node_modules/@timesafari
currentDir = path.dirname(currentDir);
// Go up from node_modules/@timesafari to node_modules
currentDir = path.dirname(currentDir);
// Go up from node_modules to app root
const appRoot = path.dirname(currentDir);
// Verify we found an app root
const androidDir = path.join(appRoot, 'android');
if (!fs.existsSync(androidDir)) {
throw new Error(`Could not find app android directory. Looked in: ${appRoot}`);
}
return appRoot;
}
/**
* Verify plugin uses standard Capacitor structure
*/
function verifyPluginStructure() {
console.log('🔍 Verifying Daily Notification Plugin structure...');
try {
const APP_ROOT = findAppRoot();
const PLUGIN_PATH = path.join(APP_ROOT, 'node_modules', '@timesafari', 'daily-notification-plugin');
const ANDROID_PLUGIN_PATH = path.join(PLUGIN_PATH, 'android');
const PLUGIN_JAVA_PATH = path.join(ANDROID_PLUGIN_PATH, 'src', 'main', 'java');
if (!fs.existsSync(ANDROID_PLUGIN_PATH)) {
console.log(' Plugin not found in node_modules (may not be installed yet)');
return;
}
// Check for standard structure
const hasStandardStructure = fs.existsSync(PLUGIN_JAVA_PATH);
const hasOldStructure = fs.existsSync(path.join(ANDROID_PLUGIN_PATH, 'plugin'));
if (hasOldStructure) {
console.log('⚠️ WARNING: Plugin still uses old structure (android/plugin/)');
console.log(' This should not happen after restructure. Please rebuild plugin.');
return;
}
if (hasStandardStructure) {
console.log('✅ Plugin uses standard Capacitor structure (android/src/main/java/)');
console.log(' No fixes needed - plugin path is correct!');
} else {
console.log('⚠️ Plugin structure not recognized');
console.log(` Expected: ${PLUGIN_JAVA_PATH}`);
}
} catch (error) {
console.error('❌ Error verifying plugin structure:', error.message);
process.exit(1);
}
}
/**
* Run verification
*/
function verifyAll() {
console.log('🔍 Daily Notification Plugin - Structure Verification');
console.log('==================================================\n');
verifyPluginStructure();
console.log('\n✅ Verification complete!');
}
// Run if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
verifyAll();
}
export { verifyPluginStructure, verifyAll };

View File

@@ -90,6 +90,17 @@ export interface PermissionStatus {
carPlay?: boolean;
}
/**
* Permission status result for checkPermissionStatus()
* Returns boolean flags for each permission type
*/
export interface PermissionStatusResult {
notificationsEnabled: boolean;
exactAlarmEnabled: boolean;
wakeLockEnabled: boolean;
allPermissionsGranted: boolean;
}
// Static Daily Reminder Interfaces
export interface DailyReminderOptions {
id: string;
@@ -280,6 +291,188 @@ export interface ContentFetchResult {
metadata?: Record<string, unknown>;
}
// ============================================================================
// DATABASE TYPE DEFINITIONS
// ============================================================================
// These types represent the plugin's internal SQLite database schema.
// The plugin owns its database, and these types are used for TypeScript
// access through Capacitor interfaces.
//
// See: docs/DATABASE_INTERFACES.md for complete documentation
// ============================================================================
/**
* Recurring schedule pattern stored in database
* Used to restore schedules after device reboot
*/
export interface Schedule {
/** Unique schedule identifier */
id: string;
/** Schedule type: 'fetch' for content fetching, 'notify' for notifications */
kind: 'fetch' | 'notify';
/** Cron expression (e.g., "0 9 * * *" for daily at 9 AM) */
cron?: string;
/** Clock time in HH:mm format (e.g., "09:00") */
clockTime?: string;
/** Whether schedule is enabled */
enabled: boolean;
/** Timestamp of last execution (milliseconds since epoch) */
lastRunAt?: number;
/** Timestamp of next scheduled execution (milliseconds since epoch) */
nextRunAt?: number;
/** Random jitter in milliseconds for timing variation */
jitterMs: number;
/** Backoff policy ('exp' for exponential, etc.) */
backoffPolicy: string;
/** Optional JSON state for advanced scheduling */
stateJson?: string;
}
/**
* Input type for creating a new schedule
*/
export interface CreateScheduleInput {
kind: 'fetch' | 'notify';
cron?: string;
clockTime?: string;
enabled?: boolean;
jitterMs?: number;
backoffPolicy?: string;
stateJson?: string;
}
/**
* Content cache entry with TTL
* Stores prefetched content for offline-first display
*/
export interface ContentCache {
/** Unique cache identifier */
id: string;
/** Timestamp when content was fetched (milliseconds since epoch) */
fetchedAt: number;
/** Time-to-live in seconds */
ttlSeconds: number;
/** Content payload (JSON string or base64 encoded) */
payload: string;
/** Optional metadata */
meta?: string;
}
/**
* Input type for creating a content cache entry
*/
export interface CreateContentCacheInput {
id?: string; // Auto-generated if not provided
payload: string;
ttlSeconds: number;
meta?: string;
}
/**
* Plugin configuration entry
* Stores user preferences and plugin settings
*/
export interface Config {
/** Unique configuration identifier */
id: string;
/** Optional TimeSafari DID for user-specific configs */
timesafariDid?: string;
/** Configuration type (e.g., 'plugin_setting', 'user_preference') */
configType: string;
/** Configuration key */
configKey: string;
/** Configuration value (stored as string, parsed based on configDataType) */
configValue: string;
/** Data type: 'string' | 'boolean' | 'integer' | 'long' | 'float' | 'double' | 'json' */
configDataType: string;
/** Whether value is encrypted */
isEncrypted: boolean;
/** Timestamp when config was created (milliseconds since epoch) */
createdAt: number;
/** Timestamp when config was last updated (milliseconds since epoch) */
updatedAt: number;
}
/**
* Input type for creating a configuration entry
*/
export interface CreateConfigInput {
id?: string; // Auto-generated if not provided
timesafariDid?: string;
configType: string;
configKey: string;
configValue: string;
configDataType?: string; // Defaults to 'string' if not provided
isEncrypted?: boolean;
}
/**
* Callback configuration
* Stores callback endpoint configurations for execution after events
*/
export interface Callback {
/** Unique callback identifier */
id: string;
/** Callback type: 'http' for HTTP requests, 'local' for local handlers, 'queue' for queue */
kind: 'http' | 'local' | 'queue';
/** Target URL or identifier */
target: string;
/** Optional JSON headers for HTTP callbacks */
headersJson?: string;
/** Whether callback is enabled */
enabled: boolean;
/** Timestamp when callback was created (milliseconds since epoch) */
createdAt: number;
}
/**
* Input type for creating a callback configuration
*/
export interface CreateCallbackInput {
id: string;
kind: 'http' | 'local' | 'queue';
target: string;
headersJson?: string;
enabled?: boolean;
}
/**
* Execution history entry
* Logs fetch/notify/callback execution for debugging and analytics
*/
export interface History {
/** Auto-incrementing history ID */
id: number;
/** Reference ID (content ID, schedule ID, etc.) */
refId: string;
/** Execution kind: 'fetch' | 'notify' | 'callback' | 'boot_recovery' */
kind: 'fetch' | 'notify' | 'callback' | 'boot_recovery';
/** Timestamp when execution occurred (milliseconds since epoch) */
occurredAt: number;
/** Execution duration in milliseconds */
durationMs?: number;
/** Outcome: 'success' | 'failure' | 'skipped_ttl' | 'circuit_open' */
outcome: string;
/** Optional JSON diagnostics */
diagJson?: string;
}
/**
* History statistics
*/
export interface HistoryStats {
/** Total number of history entries */
totalCount: number;
/** Count by outcome */
outcomes: Record<string, number>;
/** Count by kind */
kinds: Record<string, number>;
/** Most recent execution timestamp */
mostRecent?: number;
/** Oldest execution timestamp */
oldest?: number;
}
export interface DualScheduleStatus {
contentFetch: {
isEnabled: boolean;
@@ -412,6 +605,28 @@ export interface DailyNotificationPlugin {
// Existing methods
scheduleDailyNotification(options: NotificationOptions): Promise<void>;
/**
* Check if an alarm is scheduled for a given trigger time
* @param options Object containing triggerAtMillis (number)
* @returns Object with scheduled (boolean) and triggerAtMillis (number)
*/
isAlarmScheduled(options: { triggerAtMillis: number }): Promise<{ scheduled: boolean; triggerAtMillis: number }>;
/**
* Get the next scheduled alarm time from AlarmManager
* @returns Object with scheduled (boolean) and triggerAtMillis (number | null)
*/
getNextAlarmTime(): Promise<{ scheduled: boolean; triggerAtMillis?: number }>;
/**
* Test method: Schedule an alarm to fire in a few seconds
* Useful for verifying alarm delivery works correctly
* @param options Object containing secondsFromNow (number, default: 5)
* @returns Object with scheduled (boolean), secondsFromNow (number), and triggerAtMillis (number)
*/
testAlarm(options?: { secondsFromNow?: number }): Promise<{ scheduled: boolean; secondsFromNow: number; triggerAtMillis: number }>;
getLastNotification(): Promise<NotificationResponse | null>;
cancelAllNotifications(): Promise<void>;
getNotificationStatus(): Promise<NotificationStatus>;
@@ -422,6 +637,11 @@ export interface DailyNotificationPlugin {
getPowerState(): Promise<PowerState>;
checkPermissions(): Promise<PermissionStatus>;
requestPermissions(): Promise<PermissionStatus>;
checkPermissionStatus(): Promise<PermissionStatusResult>;
requestNotificationPermissions(): Promise<PermissionStatus>;
isChannelEnabled(channelId?: string): Promise<{ enabled: boolean; channelId: string }>;
openChannelSettings(channelId?: string): Promise<void>;
checkStatus(): Promise<NotificationStatus>;
// New dual scheduling methods
scheduleContentFetch(config: ContentFetchConfig): Promise<void>;
@@ -443,6 +663,272 @@ export interface DailyNotificationPlugin {
unregisterCallback(name: string): Promise<void>;
getRegisteredCallbacks(): Promise<string[]>;
// ============================================================================
// DATABASE ACCESS METHODS
// ============================================================================
// These methods provide TypeScript/JavaScript access to the plugin's internal
// SQLite database. Since the plugin owns its database, the host app/webview
// accesses data through these Capacitor interfaces.
//
// Usage Pattern:
// import { DailyNotification } from '@capacitor-community/daily-notification';
// const schedules = await DailyNotification.getSchedules({ kind: 'notify' });
//
// See: docs/DATABASE_INTERFACES.md for complete documentation
// ============================================================================
/**
* Get all schedules matching optional filters
*
* @param options Optional filters:
* - kind: Filter by schedule type ('fetch' | 'notify')
* - enabled: Filter by enabled status (true = only enabled, false = only disabled, undefined = all)
* @returns Promise resolving to object with schedules array: { schedules: Schedule[] }
*
* @example
* ```typescript
* // Get all enabled notification schedules
* const result = await DailyNotification.getSchedules({
* kind: 'notify',
* enabled: true
* });
* const schedules = result.schedules;
* ```
*/
getSchedules(options?: { kind?: 'fetch' | 'notify'; enabled?: boolean }): Promise<{ schedules: Schedule[] }>;
/**
* Get a single schedule by ID
*
* @param id Schedule ID
* @returns Promise resolving to Schedule object or null if not found
*/
getSchedule(id: string): Promise<Schedule | null>;
/**
* Create a new recurring schedule
*
* @param schedule Schedule configuration
* @returns Promise resolving to created Schedule object
*
* @example
* ```typescript
* const schedule = await DailyNotification.createSchedule({
* kind: 'notify',
* cron: '0 9 * * *', // Daily at 9 AM
* enabled: true
* });
* ```
*/
createSchedule(schedule: CreateScheduleInput): Promise<Schedule>;
/**
* Update an existing schedule
*
* @param id Schedule ID
* @param updates Partial schedule updates
* @returns Promise resolving to updated Schedule object
*/
updateSchedule(id: string, updates: Partial<Schedule>): Promise<Schedule>;
/**
* Delete a schedule
*
* @param id Schedule ID
* @returns Promise resolving when deletion completes
*/
deleteSchedule(id: string): Promise<void>;
/**
* Enable or disable a schedule
*
* @param id Schedule ID
* @param enabled Enable state
* @returns Promise resolving when update completes
*/
enableSchedule(id: string, enabled: boolean): Promise<void>;
/**
* Calculate next run time from a cron expression or clockTime
*
* @param schedule Cron expression (e.g., "0 9 * * *") or clockTime (e.g., "09:00")
* @returns Promise resolving to timestamp (milliseconds since epoch)
*/
calculateNextRunTime(schedule: string): Promise<number>;
/**
* Get content cache by ID or latest cache
*
* @param options Optional filters:
* - id: Specific cache ID (if not provided, returns latest)
* @returns Promise resolving to ContentCache object or null
*/
getContentCacheById(options?: { id?: string }): Promise<ContentCache | null>;
/**
* Get the latest content cache entry
*
* @returns Promise resolving to latest ContentCache object or null
*/
getLatestContentCache(): Promise<ContentCache | null>;
/**
* Get content cache history
*
* @param limit Maximum number of entries to return (default: 10)
* @returns Promise resolving to object with history array: { history: ContentCache[] }
*/
getContentCacheHistory(limit?: number): Promise<{ history: ContentCache[] }>;
/**
* Save content to cache
*
* @param content Content cache data
* @returns Promise resolving to saved ContentCache object
*
* @example
* ```typescript
* await DailyNotification.saveContentCache({
* id: 'cache_123',
* payload: JSON.stringify({ title: 'Hello', body: 'World' }),
* ttlSeconds: 3600,
* meta: 'fetched_from_api'
* });
* ```
*/
saveContentCache(content: CreateContentCacheInput): Promise<ContentCache>;
/**
* Clear content cache entries
*
* @param options Optional filters:
* - olderThan: Only clear entries older than this timestamp (milliseconds)
* @returns Promise resolving when cleanup completes
*/
clearContentCacheEntries(options?: { olderThan?: number }): Promise<void>;
/**
* Get configuration value
*
* @param key Configuration key
* @param options Optional filters:
* - timesafariDid: Filter by TimeSafari DID
* @returns Promise resolving to Config object or null
*/
getConfig(key: string, options?: { timesafariDid?: string }): Promise<Config | null>;
/**
* Get all configurations matching filters
*
* @param options Optional filters:
* - timesafariDid: Filter by TimeSafari DID
* - configType: Filter by configuration type
* @returns Promise resolving to array of Config objects
*/
getAllConfigs(options?: { timesafariDid?: string; configType?: string }): Promise<{ configs: Config[] }>;
/**
* Set configuration value
*
* @param config Configuration data
* @returns Promise resolving to saved Config object
*/
setConfig(config: CreateConfigInput): Promise<Config>;
/**
* Update configuration value
*
* @param key Configuration key
* @param value New value (will be stringified based on dataType)
* @param options Optional filters:
* - timesafariDid: Filter by TimeSafari DID
* @returns Promise resolving to updated Config object
*/
updateConfig(key: string, value: string, options?: { timesafariDid?: string }): Promise<Config>;
/**
* Delete configuration
*
* @param key Configuration key
* @param options Optional filters:
* - timesafariDid: Filter by TimeSafari DID
* @returns Promise resolving when deletion completes
*/
deleteConfig(key: string, options?: { timesafariDid?: string }): Promise<void>;
/**
* Get all callbacks matching filters
*
* @param options Optional filters:
* - enabled: Filter by enabled status
* @returns Promise resolving to object with callbacks array: { callbacks: Callback[] }
*/
getCallbacks(options?: { enabled?: boolean }): Promise<{ callbacks: Callback[] }>;
/**
* Get a single callback by ID
*
* @param id Callback ID
* @returns Promise resolving to Callback object or null
*/
getCallback(id: string): Promise<Callback | null>;
/**
* Register a new callback
*
* @param callback Callback configuration
* @returns Promise resolving to created Callback object
*/
registerCallbackConfig(callback: CreateCallbackInput): Promise<Callback>;
/**
* Update an existing callback
*
* @param id Callback ID
* @param updates Partial callback updates
* @returns Promise resolving to updated Callback object
*/
updateCallback(id: string, updates: Partial<Callback>): Promise<Callback>;
/**
* Delete a callback
*
* @param id Callback ID
* @returns Promise resolving when deletion completes
*/
deleteCallback(id: string): Promise<void>;
/**
* Enable or disable a callback
*
* @param id Callback ID
* @param enabled Enable state
* @returns Promise resolving when update completes
*/
enableCallback(id: string, enabled: boolean): Promise<void>;
/**
* Get execution history
*
* @param options Optional filters:
* - since: Only return entries after this timestamp (milliseconds)
* - kind: Filter by execution kind ('fetch' | 'notify' | 'callback')
* - limit: Maximum number of entries to return (default: 50)
* @returns Promise resolving to object with history array: { history: History[] }
*/
getHistory(options?: {
since?: number;
kind?: 'fetch' | 'notify' | 'callback';
limit?: number;
}): Promise<{ history: History[] }>;
/**
* Get history statistics
*
* @returns Promise resolving to history statistics
*/
getHistoryStats(): Promise<HistoryStats>;
// Phase 1: ActiveDid Management Methods (Option A Implementation)
setActiveDidFromHost(activeDid: string): Promise<void>;
onActiveDidChange(callback: (newActiveDid: string) => Promise<void>): void;

235
test-apps/BUILD_PROCESS.md Normal file
View File

@@ -0,0 +1,235 @@
# Test Apps Build Process Review
## Summary
Both test apps are configured to **automatically build the plugin** as part of their build process. The plugin is included as a Gradle project dependency, so Gradle handles building it automatically.
---
## Test App 1: `android-test-app` (Standalone Android)
**Location**: `test-apps/android-test-app/`
### Configuration
**Plugin Reference** (`settings.gradle`):
```gradle
// Reference plugin from root project
def pluginPath = new File(settingsDir.parentFile.parentFile, 'android')
include ':daily-notification-plugin'
project(':daily-notification-plugin').projectDir = pluginPath
```
**Plugin Dependency** (`app/build.gradle`):
```gradle
dependencies {
implementation project(':capacitor-android')
implementation project(':daily-notification-plugin') // ✅ Plugin included
// Plugin dependencies also included
}
```
**Capacitor Setup**:
- References Capacitor from `daily-notification-test/node_modules/` (shared dependency)
- Includes `:capacitor-android` project module
### Build Process
1. **Gradle resolves plugin project** - Finds plugin at `../../android`
2. **Gradle builds plugin module** - Compiles plugin Java code to AAR (internally)
3. **Gradle builds app module** - Compiles app code
4. **Gradle links plugin** - Includes plugin classes in app APK
5. **Final output**: `app/build/outputs/apk/debug/app-debug.apk`
### Build Commands
```bash
cd test-apps/android-test-app
# Build debug APK (builds plugin automatically)
./gradlew assembleDebug
# Build release APK
./gradlew assembleRelease
# Clean build
./gradlew clean
# List tasks
./gradlew tasks
```
### Prerequisites
- ✅ Gradle wrapper present (`gradlew`, `gradlew.bat`, `gradle/wrapper/`)
- ✅ Capacitor must be installed in `daily-notification-test/node_modules/` (shared)
- ✅ Plugin must exist at root `android/` directory
---
## Test App 2: `daily-notification-test` (Vue 3 + Capacitor)
**Location**: `test-apps/daily-notification-test/`
### Configuration
**Plugin Installation** (`package.json`):
```json
{
"dependencies": {
"@timesafari/daily-notification-plugin": "file:../../"
}
}
```
**Capacitor Auto-Configuration**:
- `npx cap sync android` automatically:
1. Installs plugin from `file:../../``node_modules/@timesafari/daily-notification-plugin/`
2. Generates `capacitor.settings.gradle` with plugin reference
3. Generates `capacitor.build.gradle` with plugin dependency
4. Generates `capacitor.plugins.json` with plugin registration
**Plugin Reference** (`capacitor.settings.gradle` - auto-generated):
```gradle
include ':timesafari-daily-notification-plugin'
project(':timesafari-daily-notification-plugin').projectDir =
new File('../node_modules/@timesafari/daily-notification-plugin/android')
```
**Plugin Dependency** (`capacitor.build.gradle` - auto-generated):
```gradle
dependencies {
implementation project(':timesafari-daily-notification-plugin')
}
```
### Build Process
1. **npm install** - Installs plugin from `file:../../` to `node_modules/`
2. **npm run build** - Builds Vue 3 web app → `dist/`
3. **npx cap sync android** - Capacitor:
- Copies web assets to `android/app/src/main/assets/`
- Configures plugin in Gradle files
- Registers plugin in `capacitor.plugins.json`
4. **Fix script runs** - Verifies plugin path is correct (post-sync hook)
5. **Gradle builds** - Plugin is built as part of app build
6. **Final output**: `android/app/build/outputs/apk/debug/app-debug.apk`
### Build Commands
```bash
cd test-apps/daily-notification-test
# Initial setup (one-time)
npm install # Installs plugin from file:../../
npx cap sync android # Configures Android build
# Development workflow
npm run build # Builds Vue 3 web app
npx cap sync android # Syncs web assets + plugin config
cd android
./gradlew assembleDebug # Builds Android app (includes plugin)
# Or use Capacitor CLI (does everything)
npx cap run android # Builds web + syncs + builds Android + runs
```
### Post-Install Hook
The `postinstall` script (`scripts/fix-capacitor-plugins.js`) automatically:
- ✅ Verifies plugin is registered in `capacitor.plugins.json`
- ✅ Verifies plugin path in `capacitor.settings.gradle` points to `android/` (standard structure)
- ✅ Fixes path if it incorrectly points to old `android/plugin/` structure
---
## Key Points
### ✅ Both Apps Build Plugin Automatically
- **No manual plugin build needed** - Gradle handles it
- **Plugin is a project dependency** - Built before the app
- **Standard Gradle behavior** - Works like any Android library module
### ✅ Plugin Structure is Standard
- **Plugin location**: `android/src/main/java/...` (standard Capacitor structure)
- **No path fixes needed** - Capacitor auto-generates correct paths
- **Works with `npx cap sync`** - No manual configuration required
### ✅ Build Dependencies
**android-test-app**:
- Requires Capacitor from `daily-notification-test/node_modules/` (shared)
- References plugin directly from root `android/` directory
**daily-notification-test**:
- Requires `npm install` to install plugin
- Requires `npx cap sync android` to configure build
- Plugin installed to `node_modules/` like any npm package
---
## Verification
### Check Plugin is Included
```bash
# For android-test-app
cd test-apps/android-test-app
./gradlew :app:dependencies | grep daily-notification
# For daily-notification-test
cd test-apps/daily-notification-test/android
./gradlew :app:dependencies | grep timesafari
```
### Check Plugin Registration
```bash
# Vue app only
cat test-apps/daily-notification-test/android/app/src/main/assets/capacitor.plugins.json
```
Should contain:
```json
[
{
"name": "DailyNotification",
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]
```
---
## Troubleshooting
### android-test-app: "Capacitor not found"
**Solution**: Run `npm install` in `test-apps/daily-notification-test/` first to install Capacitor dependencies.
### android-test-app: "Plugin not found"
**Solution**: Verify `android/build.gradle` exists at the root project level.
### daily-notification-test: Plugin path wrong
**Solution**: Run `node scripts/fix-capacitor-plugins.js` after `npx cap sync android`. The script now verifies/fixes the path to use standard `android/` structure.
### Both: Build succeeds but plugin doesn't work
**Solution**:
- Check `capacitor.plugins.json` has plugin registered
- Verify plugin classes are in the APK: `unzip -l app-debug.apk | grep DailyNotification`
---
## Summary
**Both test apps handle plugin building automatically**
**Plugin uses standard Capacitor structure** (`android/src/main/java/`)
**No manual plugin builds required** - Gradle handles dependencies
**Build processes are configured correctly** - Ready to use
The test apps are properly configured to build and test the plugin!

View File

@@ -26,7 +26,7 @@ android {
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
dirs 'libs'
}
}
@@ -36,7 +36,7 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation project(':plugin')
implementation project(':daily-notification-plugin')
// Daily Notification Plugin Dependencies
implementation "androidx.room:room-runtime:2.6.1"
@@ -47,7 +47,7 @@ dependencies {
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
// Note: capacitor-cordova-android-plugins not needed for standalone Android test app
}
apply from: 'capacitor.build.gradle'

View File

@@ -34,6 +34,13 @@
<action android:name="com.timesafari.daily.NOTIFICATION" />
</intent-filter>
</receiver>
<!-- NotifyReceiver for AlarmManager-based notifications -->
<receiver
android:name="com.timesafari.dailynotification.NotifyReceiver"
android:enabled="true"
android:exported="false">
</receiver>
<receiver
android:name="com.timesafari.dailynotification.BootReceiver"

View File

@@ -0,0 +1,16 @@
{
"appId": "com.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
}
}
}

View File

@@ -0,0 +1,575 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>DailyNotification Plugin Test</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: white;
}
.container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
h1 {
margin-bottom: 30px;
font-size: 2.5em;
}
.button {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 15px 30px;
margin: 10px;
border-radius: 25px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
}
.button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.status {
margin-top: 30px;
padding: 20px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
font-family: monospace;
}
</style>
</head>
<body>
<div class="container">
<h1>🔔 DailyNotification Plugin Test</h1>
<p>Test the DailyNotification plugin functionality</p>
<p style="font-size: 12px; opacity: 0.8;">Build: 2025-10-14 05:00:00 UTC</p>
<button class="button" onclick="testPlugin()">Test Plugin</button>
<button class="button" onclick="configurePlugin()">Configure Plugin</button>
<button class="button" onclick="checkStatus()">Check Status</button>
<h2>🔔 Notification Tests</h2>
<button class="button" onclick="testNotification()">Test Notification</button>
<button class="button" onclick="scheduleNotification()">Schedule Notification</button>
<button class="button" onclick="showReminder()">Show Reminder</button>
<h2>🔐 Permission Management</h2>
<button class="button" onclick="checkPermissions()">Check Permissions</button>
<button class="button" onclick="requestPermissions()">Request Permissions</button>
<button class="button" onclick="openExactAlarmSettings()">Exact Alarm Settings</button>
<h2>📢 Channel Management</h2>
<button class="button" onclick="checkChannelStatus()">Check Channel Status</button>
<button class="button" onclick="openChannelSettings()">Open Channel Settings</button>
<button class="button" onclick="checkComprehensiveStatus()">Comprehensive Status</button>
<div id="status" class="status">
Ready to test...
</div>
</div>
<script>
console.log('Script loading...');
console.log('JavaScript is working!');
// Use real DailyNotification plugin
console.log('Using real DailyNotification plugin...');
window.DailyNotification = window.Capacitor.Plugins.DailyNotification;
// Define functions immediately and attach to window
function testPlugin() {
console.log('testPlugin called');
const status = document.getElementById('status');
status.innerHTML = 'Testing plugin...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
// Plugin is loaded and ready
status.innerHTML = 'Plugin is loaded and ready!';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
} catch (error) {
status.innerHTML = `Plugin test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function configurePlugin() {
console.log('configurePlugin called');
const status = document.getElementById('status');
status.innerHTML = 'Configuring plugin...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
// Configure plugin settings
window.DailyNotification.configure({
storage: 'tiered',
ttlSeconds: 86400,
prefetchLeadMinutes: 60,
maxNotificationsPerDay: 3,
retentionDays: 7
})
.then(() => {
console.log('Plugin settings configured, now configuring native fetcher...');
// Configure native fetcher with demo credentials
// Note: DemoNativeFetcher uses hardcoded mock data, so this is optional
// but demonstrates the API. In production, this would be real credentials.
return window.DailyNotification.configureNativeFetcher({
apiBaseUrl: 'http://10.0.2.2:3000', // Android emulator → host localhost
activeDid: 'did:ethr:0xDEMO1234567890', // Demo DID
jwtSecret: 'demo-jwt-secret-for-development-testing'
});
})
.then(() => {
status.innerHTML = 'Plugin configured successfully!<br>✅ Plugin settings<br>✅ Native fetcher (optional for demo)';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Configuration failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Configuration failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function checkStatus() {
console.log('checkStatus called');
const status = document.getElementById('status');
status.innerHTML = 'Checking plugin status...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.getNotificationStatus()
.then(result => {
const nextTime = result.nextNotificationTime ? new Date(result.nextNotificationTime).toLocaleString() : 'None scheduled';
status.innerHTML = `Plugin Status:<br>
Enabled: ${result.isEnabled}<br>
Next Notification: ${nextTime}<br>
Pending: ${result.pending}<br>
Settings: ${JSON.stringify(result.settings)}`;
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Status check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Status check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
// Notification test functions
function testNotification() {
console.log('testNotification called');
// Quick sanity check - test plugin availability
if (window.Capacitor && window.Capacitor.isPluginAvailable) {
const isAvailable = window.Capacitor.isPluginAvailable('DailyNotification');
console.log('is plugin available?', isAvailable);
}
const status = document.getElementById('status');
status.innerHTML = 'Testing plugin connection...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
// Test the notification method directly
console.log('Testing notification scheduling...');
const now = new Date();
const notificationTime = new Date(now.getTime() + 600000); // 10 minutes from now
const prefetchTime = new Date(now.getTime() + 300000); // 5 minutes from now
const notificationTimeString = notificationTime.getHours().toString().padStart(2, '0') + ':' +
notificationTime.getMinutes().toString().padStart(2, '0');
const prefetchTimeString = prefetchTime.getHours().toString().padStart(2, '0') + ':' +
prefetchTime.getMinutes().toString().padStart(2, '0');
window.DailyNotification.scheduleDailyNotification({
time: notificationTimeString,
title: 'Test Notification',
body: 'This is a test notification from the DailyNotification plugin!',
sound: true,
priority: 'high'
})
.then(() => {
const prefetchTimeReadable = prefetchTime.toLocaleTimeString();
const notificationTimeReadable = notificationTime.toLocaleTimeString();
status.innerHTML = '✅ Notification scheduled!<br>' +
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' +
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Notification failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Notification test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function scheduleNotification() {
console.log('scheduleNotification called');
const status = document.getElementById('status');
status.innerHTML = 'Scheduling notification...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
// Schedule notification for 10 minutes from now (allows 5 min prefetch to fire)
const now = new Date();
const notificationTime = new Date(now.getTime() + 600000); // 10 minutes from now
const prefetchTime = new Date(now.getTime() + 300000); // 5 minutes from now
const notificationTimeString = notificationTime.getHours().toString().padStart(2, '0') + ':' +
notificationTime.getMinutes().toString().padStart(2, '0');
const prefetchTimeString = prefetchTime.getHours().toString().padStart(2, '0') + ':' +
prefetchTime.getMinutes().toString().padStart(2, '0');
window.DailyNotification.scheduleDailyNotification({
time: notificationTimeString,
title: 'Scheduled Notification',
body: 'This notification was scheduled 10 minutes ago!',
sound: true,
priority: 'default'
})
.then(() => {
const prefetchTimeReadable = prefetchTime.toLocaleTimeString();
const notificationTimeReadable = notificationTime.toLocaleTimeString();
status.innerHTML = '✅ Notification scheduled!<br>' +
'📥 Prefetch: ' + prefetchTimeReadable + ' (' + prefetchTimeString + ')<br>' +
'🔔 Notification: ' + notificationTimeReadable + ' (' + notificationTimeString + ')';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Scheduling failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Scheduling test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function showReminder() {
console.log('showReminder called');
const status = document.getElementById('status');
status.innerHTML = 'Showing reminder...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
// Schedule daily reminder using scheduleDailyReminder
const now = new Date();
const reminderTime = new Date(now.getTime() + 10000); // 10 seconds from now
const timeString = reminderTime.getHours().toString().padStart(2, '0') + ':' +
reminderTime.getMinutes().toString().padStart(2, '0');
window.DailyNotification.scheduleDailyReminder({
id: 'daily-reminder-test',
title: 'Daily Reminder',
body: 'Don\'t forget to check your daily notifications!',
time: timeString,
sound: true,
vibration: true,
priority: 'default',
repeatDaily: false // Just for testing
})
.then(() => {
status.innerHTML = 'Daily reminder scheduled for ' + timeString + '!';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Reminder failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Reminder test failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
// Permission management functions
function checkPermissions() {
console.log('checkPermissions called');
const status = document.getElementById('status');
status.innerHTML = 'Checking permissions...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.checkPermissionStatus()
.then(result => {
status.innerHTML = `Permission Status:<br>
Notifications: ${result.notificationsEnabled ? '✅' : '❌'}<br>
Exact Alarm: ${result.exactAlarmEnabled ? '✅' : '❌'}<br>
Wake Lock: ${result.wakeLockEnabled ? '✅' : '❌'}<br>
All Granted: ${result.allPermissionsGranted ? '✅' : '❌'}`;
status.style.background = result.allPermissionsGranted ?
'rgba(0, 255, 0, 0.3)' : 'rgba(255, 165, 0, 0.3)'; // Green or orange
})
.catch(error => {
status.innerHTML = `Permission check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Permission check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function requestPermissions() {
console.log('requestPermissions called');
const status = document.getElementById('status');
status.innerHTML = 'Requesting permissions...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.requestNotificationPermissions()
.then(() => {
status.innerHTML = 'Permission request completed! Check your device settings if needed.';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
// Check permissions again after request
setTimeout(() => {
checkPermissions();
}, 1000);
})
.catch(error => {
status.innerHTML = `Permission request failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Permission request failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function openExactAlarmSettings() {
console.log('openExactAlarmSettings called');
const status = document.getElementById('status');
status.innerHTML = 'Opening exact alarm settings...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.openExactAlarmSettings()
.then(() => {
status.innerHTML = 'Exact alarm settings opened! Please enable "Allow exact alarms" and return to the app.';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
})
.catch(error => {
status.innerHTML = `Failed to open exact alarm settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Failed to open exact alarm settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function checkChannelStatus() {
const status = document.getElementById('status');
status.innerHTML = 'Checking channel status...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.isChannelEnabled()
.then(result => {
const importanceText = getImportanceText(result.importance);
status.innerHTML = `Channel Status: ${result.enabled ? 'Enabled' : 'Disabled'} (${importanceText})`;
status.style.background = result.enabled ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)';
})
.catch(error => {
status.innerHTML = `Channel check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Channel check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function openChannelSettings() {
const status = document.getElementById('status');
status.innerHTML = 'Opening channel settings...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.openChannelSettings()
.then(result => {
if (result.opened) {
status.innerHTML = 'Channel settings opened! Please enable notifications and return to the app.';
status.style.background = 'rgba(0, 255, 0, 0.3)'; // Green background
} else {
status.innerHTML = 'Could not open channel settings (may not be available on this device)';
status.style.background = 'rgba(255, 165, 0, 0.3)'; // Orange background
}
})
.catch(error => {
status.innerHTML = `Failed to open channel settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Failed to open channel settings: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function checkComprehensiveStatus() {
const status = document.getElementById('status');
status.innerHTML = 'Checking comprehensive status...';
status.style.background = 'rgba(255, 255, 0, 0.3)'; // Yellow background
try {
if (!window.DailyNotification) {
status.innerHTML = 'DailyNotification plugin not available';
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
return;
}
window.DailyNotification.checkStatus()
.then(result => {
const canSchedule = result.canScheduleNow;
const issues = [];
if (!result.postNotificationsGranted) {
issues.push('POST_NOTIFICATIONS permission');
}
if (!result.channelEnabled) {
issues.push('notification channel disabled');
}
if (!result.exactAlarmsGranted) {
issues.push('exact alarm permission');
}
let statusText = `Status: ${canSchedule ? 'Ready to schedule' : 'Issues found'}`;
if (issues.length > 0) {
statusText += `\nIssues: ${issues.join(', ')}`;
}
statusText += `\nChannel: ${getImportanceText(result.channelImportance)}`;
statusText += `\nChannel ID: ${result.channelId}`;
status.innerHTML = statusText;
status.style.background = canSchedule ? 'rgba(0, 255, 0, 0.3)' : 'rgba(255, 0, 0, 0.3)';
})
.catch(error => {
status.innerHTML = `Status check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
});
} catch (error) {
status.innerHTML = `Status check failed: ${error.message}`;
status.style.background = 'rgba(255, 0, 0, 0.3)'; // Red background
}
}
function getImportanceText(importance) {
switch (importance) {
case 0: return 'None (blocked)';
case 1: return 'Min';
case 2: return 'Low';
case 3: return 'Default';
case 4: return 'High';
case 5: return 'Max';
default: return `Unknown (${importance})`;
}
}
// Attach to window object
window.testPlugin = testPlugin;
window.configurePlugin = configurePlugin;
window.checkStatus = checkStatus;
window.testNotification = testNotification;
window.scheduleNotification = scheduleNotification;
window.showReminder = showReminder;
window.checkPermissions = checkPermissions;
window.requestPermissions = requestPermissions;
window.openExactAlarmSettings = openExactAlarmSettings;
window.checkChannelStatus = checkChannelStatus;
window.openChannelSettings = openChannelSettings;
window.checkComprehensiveStatus = checkComprehensiveStatus;
console.log('Functions attached to window:', {
testPlugin: typeof window.testPlugin,
configurePlugin: typeof window.configurePlugin,
checkStatus: typeof window.checkStatus,
testNotification: typeof window.testNotification,
scheduleNotification: typeof window.scheduleNotification,
showReminder: typeof window.showReminder
});
</script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
[
{
"name": "DailyNotification",
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
}
]

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Some files were not shown because too many files have changed in this diff Show More