Browse Source

feat(android): consolidate databases and add prefetch scheduling

Consolidate Java and Kotlin database implementations into unified
schema, add delayed prefetch scheduling, and fix notification
delivery issues.

Database Consolidation:
- Merge Java DailyNotificationDatabase into Kotlin DatabaseSchema
- Add migration path from v1 to v2 unified schema
- Include all entities: ContentCache, Schedule, Callback, History,
  NotificationContentEntity, NotificationDeliveryEntity,
  NotificationConfigEntity
- Add @JvmStatic getInstance() for Java interoperability
- Update DailyNotificationWorker and DailyNotificationStorageRoom
  to use unified database

Prefetch Functionality:
- Add scheduleDelayedFetch() to FetchWorker for 5-minute prefetch
  before notifications
- Support delayed WorkManager scheduling with initialDelay
- Update scheduleDailyNotification() to optionally schedule prefetch
  when URL is provided

Notification Delivery Fixes:
- Register NotifyReceiver in AndroidManifest.xml (was missing,
  causing notifications not to fire)
- Add safe database initialization with lazy getDatabase() helper
- Prevent PluginLoadException on database init failure

Build Configuration:
- Add kotlin-android and kotlin-kapt plugins
- Configure Room annotation processor (kapt) for Kotlin
- Add Room KTX dependency for coroutines support
- Fix Gradle settings with pluginManagement blocks

Plugin Methods Added:
- checkPermissionStatus() - detailed permission status
- requestNotificationPermissions() - request POST_NOTIFICATIONS
- scheduleDailyNotification() - schedule with AlarmManager
- configureNativeFetcher() - configure native content fetcher
- Various status and configuration methods

Code Cleanup:
- Remove duplicate BootReceiver.java (keep Kotlin version)
- Remove duplicate DailyNotificationPlugin.java (keep Kotlin version)
- Remove old Java database implementation
- Add native fetcher SPI registry (@JvmStatic methods)

The unified database ensures schedule persistence across reboots
and provides a single source of truth for all plugin data.
Prefetch scheduling enables content caching before notifications
fire, improving offline-first reliability.
master
Matthew Raymer 5 hours ago
parent
commit
18106e5ba8
  1. 7
      README.md
  2. 2
      android/.settings/org.eclipse.buildship.core.prefs
  3. 310
      android/DATABASE_CONSOLIDATION_PLAN.md
  4. 59
      android/build.gradle
  5. 16
      android/settings.gradle
  6. 206
      android/src/main/java/com/timesafari/dailynotification/BootReceiver.java
  7. 2533
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java
  8. 1401
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt
  9. 6
      android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java
  10. 253
      android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt
  11. 119
      android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt
  12. 300
      android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java
  13. 7
      android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java
  14. 619
      docs/DATABASE_INTERFACES.md
  15. 157
      docs/DATABASE_INTERFACES_IMPLEMENTATION.md
  16. 464
      src/definitions.ts
  17. 7
      test-apps/android-test-app/app/src/main/AndroidManifest.xml

7
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**
@ -741,6 +745,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

2
android/.settings/org.eclipse.buildship.core.prefs

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

310
android/DATABASE_CONSOLIDATION_PLAN.md

@ -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

59
android/build.gradle

@ -1,22 +1,25 @@
apply plugin: 'com.android.library'
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.android.tools.build:gradle:8.1.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10'
}
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
android {
namespace "com.timesafari.dailynotification.plugin"
compileSdk 35
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdk 23
targetSdk 35
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"
@ -38,6 +41,10 @@ android {
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 {
@ -73,30 +80,52 @@ repositories {
dependencies {
// Capacitor dependency - provided by consuming app
// When included as a project dependency, use project reference
// When building standalone, this will fail (expected - plugin must be built within a Capacitor app)
// 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 {
// Try to find from node_modules (for syntax checking only)
def capacitorPath = new File(rootProject.projectDir, '../node_modules/@capacitor/android/capacitor')
if (capacitorPath.exists() && new File(capacitorPath, 'build.gradle').exists()) {
// If we're in a Capacitor app context, try to include it
throw new GradleException("Capacitor Android project not found. This plugin must be built within a Capacitor app that includes :capacitor-android.")
} else {
throw new GradleException("Capacitor Android not found. This plugin must be built within a Capacitor app context.")
}
// 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"
}

16
android/settings.gradle

@ -3,5 +3,21 @@
// 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'

206
android/src/main/java/com/timesafari/dailynotification/BootReceiver.java

@ -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;
}
}
}

2533
android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java

File diff suppressed because it is too large

1401
android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt

File diff suppressed because it is too large

6
android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java

@ -563,9 +563,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);

253
android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt

@ -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)

119
android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt

@ -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
@ -41,7 +42,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 +56,103 @@ 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
}
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.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
) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.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) {
@ -180,23 +277,3 @@ class FetchWorker(
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
}
}
}

300
android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java

@ -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;
}
}

7
android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java

@ -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

@ -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

@ -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.

464
src/definitions.ts

@ -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;
@ -422,6 +615,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 +641,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;

7
test-apps/android-test-app/app/src/main/AndroidManifest.xml

@ -35,6 +35,13 @@
</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"
android:enabled="true"

Loading…
Cancel
Save