Merge branch 'master' into ios-implementation
529
AI_INTEGRATION_GUIDE.md
Normal 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
@@ -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>`
|
||||
|
||||
137
BUILDING.md
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
74
README.md
@@ -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
@@ -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
|
||||
- [ ] T–lead 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
@@ -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
|
||||
|
||||
2
android/.settings/org.eclipse.buildship.core.prefs
Normal file
@@ -0,0 +1,2 @@
|
||||
connection.project.dir=../../../../android
|
||||
eclipse.preferences.version=1
|
||||
69
android/BUILDING.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Building the Daily Notification Plugin
|
||||
|
||||
## Important: Standalone Build Limitations
|
||||
|
||||
**Capacitor plugins cannot be built standalone** because Capacitor dependencies are npm packages, not Maven artifacts.
|
||||
|
||||
### ✅ Correct Way to Build
|
||||
|
||||
Build the plugin **within a Capacitor app** that uses it:
|
||||
|
||||
```bash
|
||||
# In a consuming Capacitor app (e.g., test-apps/android-test-app or your app)
|
||||
cd /path/to/capacitor-app/android
|
||||
./gradlew assembleDebug
|
||||
|
||||
# Or use Capacitor CLI
|
||||
npx cap sync android
|
||||
npx cap run android
|
||||
```
|
||||
|
||||
### ❌ What Doesn't Work
|
||||
|
||||
```bash
|
||||
# This will fail - Capacitor dependencies aren't in Maven
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
### Why This Happens
|
||||
|
||||
1. **Capacitor dependencies are npm packages**, not Maven artifacts
|
||||
2. **Capacitor plugins are meant to be consumed**, not built standalone
|
||||
3. **The consuming app provides Capacitor** as a project dependency
|
||||
4. **When you run `npx cap sync`**, Capacitor sets up the correct dependency structure
|
||||
|
||||
### For Development & Testing
|
||||
|
||||
Use the test app at `test-apps/android-test-app/`:
|
||||
|
||||
```bash
|
||||
cd test-apps/android-test-app
|
||||
npm install
|
||||
npx cap sync android
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
The plugin will be built as part of the test app's build process.
|
||||
|
||||
### Gradle Wrapper Purpose
|
||||
|
||||
The gradle wrapper in `android/` is provided for:
|
||||
- ✅ **Syntax checking** - Verify build.gradle syntax
|
||||
- ✅ **Android Studio** - Open the plugin directory in Android Studio for editing
|
||||
- ✅ **Documentation** - Show available tasks and structure
|
||||
- ❌ **Not for standalone builds** - Requires a consuming app context
|
||||
|
||||
### Verifying Build Configuration
|
||||
|
||||
You can verify the build configuration is correct:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew tasks # Lists available tasks (may show dependency errors, that's OK)
|
||||
./gradlew clean # Cleans build directory
|
||||
```
|
||||
|
||||
The dependency errors are expected - they confirm the plugin needs a consuming app context.
|
||||
|
||||
310
android/DATABASE_CONSOLIDATION_PLAN.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Database Consolidation Plan
|
||||
|
||||
## Current State
|
||||
|
||||
### Database 1: Java (`daily_notification_plugin.db`)
|
||||
- `notification_content` - Specific notification instances
|
||||
- `notification_delivery` - Delivery tracking/analytics
|
||||
- `notification_config` - Configuration
|
||||
|
||||
### Database 2: Kotlin (`daily_notification_database`)
|
||||
- `content_cache` - Fetched content with TTL
|
||||
- `schedules` - Recurring schedule patterns (CRITICAL for reboot)
|
||||
- `callbacks` - Callback configurations
|
||||
- `history` - Execution history
|
||||
|
||||
## Unified Schema Design
|
||||
|
||||
### Required Tables (All Critical)
|
||||
|
||||
1. **`schedules`** - Recurring schedule patterns
|
||||
- Stores cron/clockTime patterns
|
||||
- Used to restore schedules after reboot
|
||||
- Fields: id, kind ('fetch'/'notify'), cron, clockTime, enabled, lastRunAt, nextRunAt, jitterMs, backoffPolicy, stateJson
|
||||
|
||||
2. **`content_cache`** - Fetched content with TTL
|
||||
- Stores prefetched content for offline-first display
|
||||
- Fields: id, fetchedAt, ttlSeconds, payload (BLOB), meta
|
||||
|
||||
3. **`notification_config`** - Plugin configuration
|
||||
- Stores user preferences and plugin settings
|
||||
- Fields: id, timesafariDid, configType, configKey, configValue, configDataType, isEncrypted, createdAt, updatedAt
|
||||
|
||||
4. **`callbacks`** - Callback configurations
|
||||
- Stores callback endpoint configurations
|
||||
- Fields: id, kind ('http'/'local'/'queue'), target, headersJson, enabled, createdAt
|
||||
|
||||
### Optional Tables (Analytics/Debugging)
|
||||
|
||||
5. **`notification_content`** - Specific notification instances
|
||||
- May still be needed for one-time notifications or TimeSafari integration
|
||||
- Fields: All existing fields from Java entity
|
||||
|
||||
6. **`notification_delivery`** - Delivery tracking
|
||||
- Analytics for delivery attempts and user interactions
|
||||
- Fields: All existing fields from Java entity
|
||||
|
||||
7. **`history`** - Execution history
|
||||
- Logs fetch/notify/callback execution
|
||||
- Fields: id, refId, kind, occurredAt, durationMs, outcome, diagJson
|
||||
|
||||
## Consolidation Strategy
|
||||
|
||||
- [x] Keep Kotlin schema as base - It already has critical tables
|
||||
- [x] Add Java tables to Kotlin schema - Merge missing entities
|
||||
- [x] Update all Java code - Use unified database instance
|
||||
- [x] Update all Kotlin code - Use unified database instance
|
||||
- [x] Single database file: `daily_notification_plugin.db`
|
||||
|
||||
## Migration Path
|
||||
|
||||
- [x] Create unified `DailyNotificationDatabase` with all entities
|
||||
- [x] Update Java code to use unified database
|
||||
- [x] Update Kotlin code to use unified database
|
||||
- [x] Remove old `DailyNotificationDatabase` files
|
||||
- [ ] Test reboot recovery
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Primary language**: Kotlin (more modern, better coroutine support)
|
||||
- **Database name**: `daily_notification_plugin.db` (Java naming convention)
|
||||
- **All entities**: Both Java and Kotlin compatible
|
||||
- **DAOs**: Mix of Java and Kotlin DAOs as needed
|
||||
|
||||
## TypeScript Interface Requirements
|
||||
|
||||
Since the plugin owns the database, the host app/webview needs TypeScript interfaces to read/write data.
|
||||
|
||||
### Required TypeScript Methods
|
||||
|
||||
#### Schedules Management
|
||||
```typescript
|
||||
// Read schedules
|
||||
getSchedules(options?: { kind?: 'fetch' | 'notify', enabled?: boolean }): Promise<Schedule[]>
|
||||
getSchedule(id: string): Promise<Schedule | null>
|
||||
|
||||
// Write schedules
|
||||
createSchedule(schedule: CreateScheduleInput): Promise<Schedule>
|
||||
updateSchedule(id: string, updates: Partial<Schedule>): Promise<Schedule>
|
||||
deleteSchedule(id: string): Promise<void>
|
||||
enableSchedule(id: string, enabled: boolean): Promise<void>
|
||||
|
||||
// Utility
|
||||
calculateNextRunTime(schedule: string): Promise<number>
|
||||
```
|
||||
|
||||
#### Content Cache Management
|
||||
```typescript
|
||||
// Read content cache
|
||||
getContentCache(options?: { id?: string }): Promise<ContentCache | null>
|
||||
getLatestContentCache(): Promise<ContentCache | null>
|
||||
getContentCacheHistory(limit?: number): Promise<ContentCache[]>
|
||||
|
||||
// Write content cache
|
||||
saveContentCache(content: CreateContentCacheInput): Promise<ContentCache>
|
||||
clearContentCache(options?: { olderThan?: number }): Promise<void>
|
||||
```
|
||||
|
||||
#### Configuration Management
|
||||
```typescript
|
||||
// Read config
|
||||
getConfig(key: string, options?: { timesafariDid?: string }): Promise<Config | null>
|
||||
getAllConfigs(options?: { timesafariDid?: string, configType?: string }): Promise<Config[]>
|
||||
|
||||
// Write config
|
||||
setConfig(config: CreateConfigInput): Promise<Config>
|
||||
updateConfig(key: string, value: string, options?: { timesafariDid?: string }): Promise<Config>
|
||||
deleteConfig(key: string, options?: { timesafariDid?: string }): Promise<void>
|
||||
```
|
||||
|
||||
#### Callbacks Management
|
||||
```typescript
|
||||
// Read callbacks
|
||||
getCallbacks(options?: { enabled?: boolean }): Promise<Callback[]>
|
||||
getCallback(id: string): Promise<Callback | null>
|
||||
|
||||
// Write callbacks
|
||||
registerCallback(callback: CreateCallbackInput): Promise<Callback>
|
||||
updateCallback(id: string, updates: Partial<Callback>): Promise<Callback>
|
||||
deleteCallback(id: string): Promise<void>
|
||||
enableCallback(id: string, enabled: boolean): Promise<void>
|
||||
```
|
||||
|
||||
#### History/Analytics (Optional)
|
||||
```typescript
|
||||
// Read history
|
||||
getHistory(options?: {
|
||||
since?: number,
|
||||
kind?: 'fetch' | 'notify' | 'callback',
|
||||
limit?: number
|
||||
}): Promise<History[]>
|
||||
getHistoryStats(): Promise<HistoryStats>
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```typescript
|
||||
interface Schedule {
|
||||
id: string
|
||||
kind: 'fetch' | 'notify'
|
||||
cron?: string
|
||||
clockTime?: string // HH:mm format
|
||||
enabled: boolean
|
||||
lastRunAt?: number
|
||||
nextRunAt?: number
|
||||
jitterMs: number
|
||||
backoffPolicy: string
|
||||
stateJson?: string
|
||||
}
|
||||
|
||||
interface ContentCache {
|
||||
id: string
|
||||
fetchedAt: number
|
||||
ttlSeconds: number
|
||||
payload: string // Base64 or JSON string
|
||||
meta?: string
|
||||
}
|
||||
|
||||
interface Config {
|
||||
id: string
|
||||
timesafariDid?: string
|
||||
configType: string
|
||||
configKey: string
|
||||
configValue: string
|
||||
configDataType: string
|
||||
isEncrypted: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
id: string
|
||||
kind: 'http' | 'local' | 'queue'
|
||||
target: string
|
||||
headersJson?: string
|
||||
enabled: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface History {
|
||||
id: number
|
||||
refId: string
|
||||
kind: 'fetch' | 'notify' | 'callback' | 'boot_recovery'
|
||||
occurredAt: number
|
||||
durationMs?: number
|
||||
outcome: string
|
||||
diagJson?: string
|
||||
}
|
||||
```
|
||||
|
||||
# Database Consolidation Plan
|
||||
|
||||
## Status: ✅ **CONSOLIDATION COMPLETE**
|
||||
|
||||
The unified database has been successfully created and all code has been migrated to use it.
|
||||
|
||||
## Current State
|
||||
|
||||
### Unified Database (`daily_notification_plugin.db`)
|
||||
Located in: `android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt`
|
||||
|
||||
**All Tables Consolidated:**
|
||||
- ✅ `content_cache` - Fetched content with TTL (Kotlin)
|
||||
- ✅ `schedules` - Recurring schedule patterns (Kotlin, CRITICAL for reboot)
|
||||
- ✅ `callbacks` - Callback configurations (Kotlin)
|
||||
- ✅ `history` - Execution history (Kotlin)
|
||||
- ✅ `notification_content` - Specific notification instances (Java)
|
||||
- ✅ `notification_delivery` - Delivery tracking/analytics (Java)
|
||||
- ✅ `notification_config` - Configuration management (Java)
|
||||
|
||||
### Old Database Files (DEPRECATED - REMOVED)
|
||||
- ✅ `android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java` - **REMOVED** - All functionality merged into unified database
|
||||
|
||||
## Migration Status
|
||||
|
||||
### ✅ Completed Tasks
|
||||
- [x] Analyzed both database schemas and identified all required tables
|
||||
- [x] Designed unified database schema with all required entities
|
||||
- [x] Created unified DailyNotificationDatabase class (Kotlin)
|
||||
- [x] Added migration from version 1 (Kotlin-only) to version 2 (unified)
|
||||
- [x] Updated all Java code to use unified database
|
||||
- [x] `DailyNotificationStorageRoom.java` - Uses unified database
|
||||
- [x] `DailyNotificationWorker.java` - Uses unified database
|
||||
- [x] Updated all Kotlin code to use unified database
|
||||
- [x] `DailyNotificationPlugin.kt` - Uses unified database
|
||||
- [x] `FetchWorker.kt` - Uses unified database
|
||||
- [x] `NotifyReceiver.kt` - Uses unified database
|
||||
- [x] `BootReceiver.kt` - Uses unified database
|
||||
- [x] Implemented all Config methods in PluginMethods
|
||||
- [x] TypeScript interfaces updated for database CRUD operations
|
||||
- [x] Documentation created for AI assistants
|
||||
|
||||
### ⏳ Pending Tasks
|
||||
- [x] Remove old database files (`DailyNotificationDatabase.java`)
|
||||
- [ ] Test reboot recovery with unified database
|
||||
- [ ] Verify migration path works correctly
|
||||
|
||||
## Unified Schema Design (IMPLEMENTED)
|
||||
|
||||
### Required Tables (All Critical)
|
||||
|
||||
1. **`schedules`** - Recurring schedule patterns
|
||||
- Stores cron/clockTime patterns
|
||||
- Used to restore schedules after reboot
|
||||
- Fields: id, kind ('fetch'/'notify'), cron, clockTime, enabled, lastRunAt, nextRunAt, jitterMs, backoffPolicy, stateJson
|
||||
|
||||
2. **`content_cache`** - Fetched content with TTL
|
||||
- Stores prefetched content for offline-first display
|
||||
- Fields: id, fetchedAt, ttlSeconds, payload (BLOB), meta
|
||||
|
||||
3. **`notification_config`** - Plugin configuration
|
||||
- Stores user preferences and plugin settings
|
||||
- Fields: id, timesafariDid, configType, configKey, configValue, configDataType, isEncrypted, createdAt, updatedAt, ttlSeconds, isActive, metadata
|
||||
|
||||
4. **`callbacks`** - Callback configurations
|
||||
- Stores callback endpoint configurations
|
||||
- Fields: id, kind ('http'/'local'/'queue'), target, headersJson, enabled, createdAt
|
||||
|
||||
5. **`notification_content`** - Specific notification instances
|
||||
- Stores notification content with plugin-specific fields
|
||||
- Fields: All existing fields from Java entity
|
||||
|
||||
6. **`notification_delivery`** - Delivery tracking
|
||||
- Analytics for delivery attempts and user interactions
|
||||
- Fields: All existing fields from Java entity
|
||||
|
||||
7. **`history`** - Execution history
|
||||
- Logs fetch/notify/callback execution
|
||||
- Fields: id, refId, kind, occurredAt, durationMs, outcome, diagJson
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Access
|
||||
- **Kotlin**: `DailyNotificationDatabase.getDatabase(context)`
|
||||
- **Java**: `DailyNotificationDatabase.getInstance(context)` (Java-compatible wrapper)
|
||||
|
||||
### Migration Path
|
||||
- Version 1 → Version 2: Automatically creates Java entity tables when upgrading from Kotlin-only schema
|
||||
- Migration runs automatically on first access after upgrade
|
||||
|
||||
### Thread Safety
|
||||
- All database operations use Kotlin coroutines (`Dispatchers.IO`)
|
||||
- Room handles thread safety internally
|
||||
- Singleton pattern ensures single database instance
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Remove Old Database File** ✅ COMPLETE
|
||||
- [x] Delete `android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java`
|
||||
- [x] Verify no remaining references
|
||||
|
||||
2. **Testing**
|
||||
- [ ] Test reboot recovery with unified database
|
||||
- [ ] Verify schedule restoration works correctly
|
||||
- [ ] Verify all Config methods work correctly
|
||||
- [ ] Test migration from v1 to v2
|
||||
|
||||
3. **Documentation**
|
||||
- [ ] Update any remaining documentation references
|
||||
- [ ] Verify AI documentation is complete
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
10
android/consumer-rules.pro
Normal 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 * { *; }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
7
android/gradlew
vendored
@@ -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
@@ -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 ##########################################################################
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
9
android/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
157
docs/DATABASE_INTERFACES_IMPLEMENTATION.md
Normal 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
111
scripts/fix-capacitor-plugin-path.js
Executable 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 };
|
||||
@@ -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
@@ -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!
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
0
test-apps/android-test-app/app/src/main/assets/public/cordova_plugins.js
vendored
Normal file
575
test-apps/android-test-app/app/src/main/assets/public/index.html
Normal 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>
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{
|
||||
"name": "DailyNotification",
|
||||
"class": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||
}
|
||||
]
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |