diff --git a/README.md b/README.md index cb3e437..ec7e5f9 100644 --- a/README.md +++ b/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 diff --git a/android/.settings/org.eclipse.buildship.core.prefs b/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..68c9fab --- /dev/null +++ b/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir=../../../../android +eclipse.preferences.version=1 diff --git a/android/DATABASE_CONSOLIDATION_PLAN.md b/android/DATABASE_CONSOLIDATION_PLAN.md new file mode 100644 index 0000000..a53090a --- /dev/null +++ b/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 +getSchedule(id: string): Promise + +// Write schedules +createSchedule(schedule: CreateScheduleInput): Promise +updateSchedule(id: string, updates: Partial): Promise +deleteSchedule(id: string): Promise +enableSchedule(id: string, enabled: boolean): Promise + +// Utility +calculateNextRunTime(schedule: string): Promise +``` + +#### Content Cache Management +```typescript +// Read content cache +getContentCache(options?: { id?: string }): Promise +getLatestContentCache(): Promise +getContentCacheHistory(limit?: number): Promise + +// Write content cache +saveContentCache(content: CreateContentCacheInput): Promise +clearContentCache(options?: { olderThan?: number }): Promise +``` + +#### Configuration Management +```typescript +// Read config +getConfig(key: string, options?: { timesafariDid?: string }): Promise +getAllConfigs(options?: { timesafariDid?: string, configType?: string }): Promise + +// Write config +setConfig(config: CreateConfigInput): Promise +updateConfig(key: string, value: string, options?: { timesafariDid?: string }): Promise +deleteConfig(key: string, options?: { timesafariDid?: string }): Promise +``` + +#### Callbacks Management +```typescript +// Read callbacks +getCallbacks(options?: { enabled?: boolean }): Promise +getCallback(id: string): Promise + +// Write callbacks +registerCallback(callback: CreateCallbackInput): Promise +updateCallback(id: string, updates: Partial): Promise +deleteCallback(id: string): Promise +enableCallback(id: string, enabled: boolean): Promise +``` + +#### History/Analytics (Optional) +```typescript +// Read history +getHistory(options?: { + since?: number, + kind?: 'fetch' | 'notify' | 'callback', + limit?: number +}): Promise +getHistoryStats(): Promise +``` + +### 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 + diff --git a/android/build.gradle b/android/build.gradle index cc6d3db..4070784 100644 --- a/android/build.gradle +++ b/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" } diff --git a/android/settings.gradle b/android/settings.gradle index 3ff3467..f76d415 100644 --- a/android/settings.gradle +++ b/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' diff --git a/android/src/main/java/com/timesafari/dailynotification/BootReceiver.java b/android/src/main/java/com/timesafari/dailynotification/BootReceiver.java deleted file mode 100644 index bec8096..0000000 --- a/android/src/main/java/com/timesafari/dailynotification/BootReceiver.java +++ /dev/null @@ -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 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; - } - } -} diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java deleted file mode 100644 index 8a26688..0000000 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.java +++ /dev/null @@ -1,2533 +0,0 @@ -/** - * DailyNotificationPlugin.java - * - * Android implementation of the Daily Notification Plugin for Capacitor - * Implements offline-first daily notifications with prefetch → cache → schedule → display pipeline - * - * @author Matthew Raymer - * @version 1.0.0 - */ - -package com.timesafari.dailynotification; - -import android.Manifest; -import android.app.AlarmManager; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.PowerManager; -import android.os.StrictMode; -import android.os.Trace; -import android.util.Log; - -import androidx.core.app.NotificationCompat; -import androidx.work.WorkManager; - -import com.getcapacitor.JSObject; -import com.getcapacitor.Plugin; -import com.getcapacitor.PluginCall; -import com.getcapacitor.PluginMethod; -import com.getcapacitor.annotation.CapacitorPlugin; -import com.getcapacitor.annotation.Permission; -import com.getcapacitor.annotation.PermissionCallback; -// BuildConfig will be available at compile time - -import java.util.Calendar; -import java.util.concurrent.TimeUnit; -import java.util.Map; -import java.util.HashMap; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import androidx.core.app.NotificationManagerCompat; - -import com.timesafari.dailynotification.storage.DailyNotificationStorageRoom; - -/** - * Main plugin class for handling daily notifications on Android - * - * This plugin provides functionality for scheduling and managing daily notifications - * with offline-first approach, background content fetching, and reliable delivery. - */ -@CapacitorPlugin( - name = "DailyNotification", - permissions = { - @Permission( - alias = "notifications", - strings = { - Manifest.permission.POST_NOTIFICATIONS, - Manifest.permission.SCHEDULE_EXACT_ALARM, - Manifest.permission.WAKE_LOCK, - Manifest.permission.INTERNET - } - ) - } -) -public class DailyNotificationPlugin extends Plugin { - - private static final String TAG = "DailyNotificationPlugin"; - private static final String CHANNEL_ID = "timesafari.daily"; - - private NotificationManager notificationManager; - private AlarmManager alarmManager; - private WorkManager workManager; - private PowerManager powerManager; - private DailyNotificationStorage storage; - private DailyNotificationStorageRoom roomStorage; - private DailyNotificationScheduler scheduler; - private DailyNotificationFetcher fetcher; - private ChannelManager channelManager; - - // Rolling window management - private DailyNotificationRollingWindow rollingWindow; - - // Exact alarm management - private DailyNotificationExactAlarmManager exactAlarmManager; - - // Reboot recovery management - private DailyNotificationRebootRecoveryManager rebootRecoveryManager; - - // Enhanced components - private DailyNotificationETagManager eTagManager; - private DailyNotificationJWTManager jwtManager; - private EnhancedDailyNotificationFetcher enhancedFetcher; - - // Daily reminder management - private DailyReminderManager reminderManager; - - // Permission management - private PermissionManager permissionManager; - - // TTL enforcement - private DailyNotificationTTLEnforcer ttlEnforcer; - - // TimeSafari integration management - private TimeSafariIntegrationManager timeSafariIntegration; - - // Integration Point Refactor (PR1): SPI for content fetching - private static volatile NativeNotificationContentFetcher nativeFetcher; - private boolean nativeFetcherEnabled = true; // Default enabled (required for background) - private SchedulingPolicy schedulingPolicy = SchedulingPolicy.createDefault(); - - /** - * Set native fetcher from host app's native code (Application.onCreate()) - * - * This is called from host app's Android native code, not through Capacitor bridge. - * Host app implements NativeNotificationContentFetcher and registers it here. - * - * @param fetcher Native fetcher implementation from host app - */ - public static void setNativeFetcher(NativeNotificationContentFetcher fetcher) { - nativeFetcher = fetcher; - Log.d("DailyNotificationPlugin", "SPI: Native fetcher registered: " + - (fetcher != null ? fetcher.getClass().getName() : "null")); - } - - /** - * Get native fetcher (static access for workers) - * - * @return Registered native fetcher or null - */ - public static NativeNotificationContentFetcher getNativeFetcherStatic() { - return nativeFetcher; - } - - /** - * Get native fetcher (non-static for instance access) - * - * @return Registered native fetcher or null - */ - protected NativeNotificationContentFetcher getNativeFetcher() { - return nativeFetcher; - } - - /** - * Configure native fetcher with API credentials (cross-platform method) - * - *

This plugin method receives configuration from TypeScript and passes it directly - * to the registered native fetcher implementation. This approach keeps the TypeScript - * interface cross-platform (works on Android, iOS, and web) without requiring - * platform-specific storage mechanisms.

- * - *

Usage Flow:

- *
    - *
  1. Host app registers native fetcher in {@code Application.onCreate()}
  2. - *
  3. TypeScript calls this method with API credentials
  4. - *
  5. Plugin validates parameters and calls {@code nativeFetcher.configure()}
  6. - *
  7. Native fetcher stores configuration for use in {@code fetchContent()}
  8. - *
- * - *

When to call:

- *
    - *
  • After app startup, once API credentials are available
  • - *
  • After user login/authentication, when activeDid changes
  • - *
  • When API server URL changes (e.g., switching between dev/staging/prod)
  • - *
- * - *

Error Handling:

- *
    - *
  • Rejects if required parameters are missing
  • - *
  • Rejects if no native fetcher is registered
  • - *
  • Returns error if native fetcher's {@code configure()} throws exception
  • - *
- * - *

Example TypeScript Usage:

- *
{@code
-     * import { DailyNotification } from '@capacitor-community/daily-notification';
-     * 
-     * await DailyNotification.configureNativeFetcher({
-     *   apiBaseUrl: 'http://10.0.2.2:3000',
-     *   activeDid: 'did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F',
-     *   jwtToken: 'eyJhbGciOiJFUzI1Nksi...' // Pre-generated JWT token
-     * });
-     * }
- * - *

Architecture Note: JWT tokens should be generated in TypeScript using - * TimeSafari's {@code createEndorserJwtForKey()} function (which uses DID-based ES256K - * signing), then passed to this method. This avoids the complexity of implementing - * DID-based JWT signing in Java.

- * - * @param call Plugin call containing configuration parameters: - *
    - *
  • {@code apiBaseUrl} (required): Base URL for API server. - * Android emulator: "http://10.0.2.2:3000" (maps to host localhost:3000). - * iOS simulator: "http://localhost:3000". - * Production: "https://api.timesafari.com"
  • - *
  • {@code activeDid} (required): Active DID for authentication. - * Format: "did:ethr:0x..."
  • - *
  • {@code jwtToken} (required): Pre-generated JWT token (ES256K signed). - * Generated in TypeScript using TimeSafari's {@code createEndorserJwtForKey()} - * function. Token format: "Bearer {token}" will be added automatically.
  • - *
- * - * @throws PluginException if configuration fails (rejected via call.reject()) - * - * @see NativeNotificationContentFetcher#configure(String, String, String) - */ - @PluginMethod - public void configureNativeFetcher(PluginCall call) { - try { - String apiBaseUrl = call.getString("apiBaseUrl"); - String activeDid = call.getString("activeDid"); - String jwtToken = call.getString("jwtToken"); - - if (apiBaseUrl == null || activeDid == null || jwtToken == null) { - call.reject("Missing required parameters: apiBaseUrl, activeDid, and jwtToken are required"); - return; - } - - NativeNotificationContentFetcher fetcher = getNativeFetcher(); - if (fetcher == null) { - call.reject("No native fetcher registered. Register one in Application.onCreate() before configuring."); - return; - } - - Log.d(TAG, "SPI: Configuring native fetcher - apiBaseUrl: " + - apiBaseUrl.substring(0, Math.min(50, apiBaseUrl.length())) + - "... activeDid: " + activeDid.substring(0, Math.min(30, activeDid.length())) + "..."); - - // Call configure on the native fetcher (defaults to no-op if not implemented) - fetcher.configure(apiBaseUrl, activeDid, jwtToken); - - Log.i(TAG, "SPI: Native fetcher configured successfully"); - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "SPI: Error configuring native fetcher", e); - call.reject("Failed to configure native fetcher: " + e.getMessage()); - } - } - - /** - * Initialize the plugin and create notification channel - */ - @Override - public void load() { - super.load(); - Log.i(TAG, "DN|PLUGIN_LOAD_START"); - - // Initialize performance monitoring (debug builds only) - initializePerformanceMonitoring(); - - try { - Trace.beginSection("DN:pluginLoad"); - - // Initialize system services - notificationManager = (NotificationManager) getContext() - .getSystemService(Context.NOTIFICATION_SERVICE); - alarmManager = (AlarmManager) getContext() - .getSystemService(Context.ALARM_SERVICE); - workManager = WorkManager.getInstance(getContext()); - powerManager = (PowerManager) getContext() - .getSystemService(Context.POWER_SERVICE); - - // Initialize components - storage = new DailyNotificationStorage(getContext()); - // Initialize Room-based storage (migration path) - try { - roomStorage = new com.timesafari.dailynotification.storage.DailyNotificationStorageRoom(getContext()); - Log.i(TAG, "DN|ROOM_STORAGE_INIT ok"); - } catch (Exception roomInitErr) { - Log.e(TAG, "DN|ROOM_STORAGE_INIT_ERR err=" + roomInitErr.getMessage(), roomInitErr); - } - scheduler = new DailyNotificationScheduler(getContext(), alarmManager); - fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage); - channelManager = new ChannelManager(getContext()); - permissionManager = new PermissionManager(getContext(), channelManager); - reminderManager = new DailyReminderManager(getContext(), scheduler); - - // Ensure notification channel exists and is properly configured - if (!channelManager.ensureChannelExists()) { - Log.w(TAG, "Notification channel is blocked - notifications will not appear"); - channelManager.logChannelStatus(); - } - - // Check if recovery is needed (app startup recovery) - checkAndPerformRecovery(); - - // Phase 1: Initialize TimeSafari Integration Components - eTagManager = new DailyNotificationETagManager(storage); - jwtManager = new DailyNotificationJWTManager(storage, eTagManager); - enhancedFetcher = new EnhancedDailyNotificationFetcher(getContext(), storage, eTagManager, jwtManager); - - // Initialize TTL enforcer and connect to scheduler - initializeTTLEnforcer(); - - // Initialize TimeSafari Integration Manager - try { - timeSafariIntegration = new TimeSafariIntegrationManager( - getContext(), - storage, - scheduler, - eTagManager, - jwtManager, - enhancedFetcher, - permissionManager, - channelManager, - ttlEnforcer, - createTimeSafariLogger() - ); - timeSafariIntegration.onLoad(); - Log.i(TAG, "TimeSafariIntegrationManager initialized"); - } catch (Exception e) { - Log.e(TAG, "Failed to initialize TimeSafariIntegrationManager", e); - } - - // Schedule next maintenance - scheduleMaintenance(); - - Log.i(TAG, "DN|PLUGIN_LOAD_OK"); - - } catch (Exception e) { - Log.e(TAG, "DN|PLUGIN_LOAD_ERR err=" + e.getMessage(), e); - } finally { - Trace.endSection(); - } - } - - /** - * Initialize performance monitoring for debug builds - * - * Enables StrictMode to catch main thread violations and adds - * performance monitoring capabilities for development. - */ - private void initializePerformanceMonitoring() { - try { - // Only enable StrictMode in debug builds - if (android.util.Log.isLoggable(TAG, android.util.Log.DEBUG)) { - Log.d(TAG, "DN|PERF_MONITOR_INIT debug_build=true"); - - // Enable StrictMode to catch main thread violations - StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() - .detectDiskReads() - .detectDiskWrites() - .detectNetwork() - .penaltyLog() - .penaltyFlashScreen() - .build()); - - StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() - .detectLeakedSqlLiteObjects() - .detectLeakedClosableObjects() - .penaltyLog() - .build()); - - Log.d(TAG, "DN|PERF_MONITOR_OK strictmode_enabled"); - } else { - Log.d(TAG, "DN|PERF_MONITOR_SKIP release_build"); - } - - } catch (Exception e) { - Log.e(TAG, "DN|PERF_MONITOR_ERR err=" + e.getMessage(), e); - } - } - - /** - * Create Logger implementation for TimeSafariIntegrationManager - */ - private TimeSafariIntegrationManager.Logger createTimeSafariLogger() { - return new TimeSafariIntegrationManager.Logger() { - @Override - public void d(String msg) { - Log.d(TAG, msg); - } - - @Override - public void w(String msg) { - Log.w(TAG, msg); - } - - @Override - public void e(String msg, Throwable t) { - Log.e(TAG, msg, t); - } - - @Override - public void i(String msg) { - Log.i(TAG, msg); - } - }; - } - - /** - * Perform app startup recovery - * - * @return true if recovery was performed, false otherwise - */ - private boolean performAppStartupRecovery() { - try { - Log.d(TAG, "DN|RECOVERY_START source=APP_STARTUP"); - - // Get all notifications from storage - List notifications = storage.getAllNotifications(); - - if (notifications.isEmpty()) { - Log.d(TAG, "DN|RECOVERY_SKIP no_notifications"); - return false; - } - - Log.d(TAG, "DN|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|RECOVERY_OK id=" + notification.getId()); - } else { - Log.w(TAG, "DN|RECOVERY_FAIL id=" + notification.getId()); - } - } else { - Log.d(TAG, "DN|RECOVERY_SKIP_PAST id=" + notification.getId()); - } - } catch (Exception e) { - Log.e(TAG, "DN|RECOVERY_ERR id=" + notification.getId() + " err=" + e.getMessage(), e); - } - } - - Log.i(TAG, "DN|RECOVERY_COMPLETE recovered=" + recoveredCount + "/" + notifications.size()); - return recoveredCount > 0; - - } catch (Exception e) { - Log.e(TAG, "DN|RECOVERY_ERR exception=" + e.getMessage(), e); - return false; - } - } - - /** - * Get recovery statistics - * - * @return Recovery statistics string - */ - private String getRecoveryStats() { - try { - List notifications = storage.getAllNotifications(); - long currentTime = System.currentTimeMillis(); - - int futureCount = 0; - int pastCount = 0; - - for (NotificationContent notification : notifications) { - if (notification.getScheduledTime() > currentTime) { - futureCount++; - } else { - pastCount++; - } - } - - return String.format("Total: %d, Future: %d, Past: %d", - notifications.size(), futureCount, pastCount); - - } catch (Exception e) { - Log.e(TAG, "DN|RECOVERY_STATS_ERR err=" + e.getMessage(), e); - return "Error getting recovery stats: " + e.getMessage(); - } - } - - /** - * Configure the plugin with database and storage options - * - * @param call Plugin call containing configuration parameters - */ - @PluginMethod - public void configure(PluginCall call) { - try { - Log.d(TAG, "Configuring plugin with new options"); - - // Get configuration options - String dbPath = call.getString("dbPath"); - String storageMode = call.getString("storage", "tiered"); - Integer ttlSeconds = call.getInt("ttlSeconds"); - Integer prefetchLeadMinutes = call.getInt("prefetchLeadMinutes"); - Integer maxNotificationsPerDay = call.getInt("maxNotificationsPerDay"); - Integer retentionDays = call.getInt("retentionDays"); - - // Phase 1: Process activeDidIntegration configuration - JSObject activeDidConfig = call.getObject("activeDidIntegration"); - if (activeDidConfig != null) { - configureActiveDidIntegration(activeDidConfig); - } - - // Store configuration in SharedPreferences - storeConfiguration(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); - - Log.i(TAG, "Plugin configuration completed successfully"); - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error configuring plugin", e); - call.reject("Configuration failed: " + e.getMessage()); - } - } - - /** - * Store configuration values - */ - private void storeConfiguration(Integer ttlSeconds, Integer prefetchLeadMinutes, - Integer maxNotificationsPerDay, Integer retentionDays) { - try { - // Store in SharedPreferences - storeConfigurationInSharedPreferences(ttlSeconds, prefetchLeadMinutes, maxNotificationsPerDay, retentionDays); - } catch (Exception e) { - Log.e(TAG, "Error storing configuration", e); - } - } - - /** - * Store configuration in SharedPreferences - */ - private void storeConfigurationInSharedPreferences(Integer ttlSeconds, Integer prefetchLeadMinutes, - Integer maxNotificationsPerDay, Integer retentionDays) { - try { - SharedPreferences prefs = getContext().getSharedPreferences("DailyNotificationPrefs", Context.MODE_PRIVATE); - SharedPreferences.Editor editor = prefs.edit(); - - if (ttlSeconds != null) { - editor.putInt("ttlSeconds", ttlSeconds); - } - if (prefetchLeadMinutes != null) { - editor.putInt("prefetchLeadMinutes", prefetchLeadMinutes); - } - if (maxNotificationsPerDay != null) { - editor.putInt("maxNotificationsPerDay", maxNotificationsPerDay); - } - if (retentionDays != null) { - editor.putInt("retentionDays", retentionDays); - } - - editor.apply(); - Log.d(TAG, "Configuration stored in SharedPreferences"); - - } catch (Exception e) { - Log.e(TAG, "Error storing configuration in SharedPreferences", e); - } - } - - /** - * Initialize TTL enforcer and connect to scheduler - */ - private void initializeTTLEnforcer() { - try { - Log.d(TAG, "Initializing TTL enforcer"); - - // Create TTL enforcer (using SharedPreferences storage) - this.ttlEnforcer = new DailyNotificationTTLEnforcer( - getContext(), - null, - false // Always use SharedPreferences (SQLite legacy removed) - ); - - // Connect to scheduler - scheduler.setTTLEnforcer(this.ttlEnforcer); - - // Initialize rolling window - initializeRollingWindow(this.ttlEnforcer); - - Log.i(TAG, "TTL enforcer initialized and connected to scheduler"); - - } catch (Exception e) { - Log.e(TAG, "Error initializing TTL enforcer", e); - } - } - - /** - * Initialize rolling window manager - */ - private void initializeRollingWindow(DailyNotificationTTLEnforcer ttlEnforcer) { - try { - Log.d(TAG, "Initializing rolling window manager"); - - // Detect platform (Android vs iOS) - boolean isIOSPlatform = false; // TODO: Implement platform detection - - // Create rolling window manager - rollingWindow = new DailyNotificationRollingWindow( - getContext(), - scheduler, - ttlEnforcer, - storage, - isIOSPlatform - ); - - // Initialize exact alarm manager - initializeExactAlarmManager(); - - // Initialize reboot recovery manager - initializeRebootRecoveryManager(); - - // Start initial window maintenance - rollingWindow.maintainRollingWindow(); - - Log.i(TAG, "Rolling window manager initialized"); - - } catch (Exception e) { - Log.e(TAG, "Error initializing rolling window manager", e); - } - } - - /** - * Initialize exact alarm manager - */ - private void initializeExactAlarmManager() { - try { - Log.d(TAG, "Initializing exact alarm manager"); - - // Create exact alarm manager - exactAlarmManager = new DailyNotificationExactAlarmManager( - getContext(), - alarmManager, - scheduler - ); - - // Connect to scheduler - scheduler.setExactAlarmManager(exactAlarmManager); - - Log.i(TAG, "Exact alarm manager initialized"); - - } catch (Exception e) { - Log.e(TAG, "Error initializing exact alarm manager", e); - } - } - - /** - * Initialize reboot recovery manager - */ - private void initializeRebootRecoveryManager() { - try { - Log.d(TAG, "Initializing reboot recovery manager"); - - // Create reboot recovery manager - rebootRecoveryManager = new DailyNotificationRebootRecoveryManager( - getContext(), - scheduler, - exactAlarmManager, - rollingWindow - ); - - // Register broadcast receivers - rebootRecoveryManager.registerReceivers(); - - Log.i(TAG, "Reboot recovery manager initialized"); - - } catch (Exception e) { - Log.e(TAG, "Error initializing reboot recovery manager", e); - } - } - - /** - * Schedule a daily notification with the specified options - * - * @param call Plugin call containing notification parameters - */ - - @PluginMethod - public void scheduleDailyNotification(PluginCall call) { - try { - Log.d(TAG, "Scheduling daily notification"); - - // Ensure storage is initialized - ensureStorageInitialized(); - - // Ensure scheduler is initialized - if (scheduler == null) { - Log.w(TAG, "DN|SCHEDULER_NULL initializing_scheduler"); - alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); - scheduler = new DailyNotificationScheduler(getContext(), alarmManager); - } - - // Validate required parameters - String time = call.getString("time"); - if (time == null || time.isEmpty()) { - call.reject("Time parameter is required"); - return; - } - - // Parse time (HH:mm format) - String[] timeParts = time.split(":"); - if (timeParts.length != 2) { - call.reject("Invalid time format. Use HH:mm"); - return; - } - - int hour, minute; - try { - hour = Integer.parseInt(timeParts[0]); - minute = Integer.parseInt(timeParts[1]); - } catch (NumberFormatException e) { - call.reject("Invalid time format. Use HH:mm"); - return; - } - - if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { - call.reject("Invalid time values"); - return; - } - - // Extract other parameters - String title = call.getString("title", "Daily Update"); - String body = call.getString("body", "Your daily notification is ready"); - boolean sound = call.getBoolean("sound", true); - String priority = call.getString("priority", "default"); - String url = call.getString("url", ""); - - // Create notification content with fresh fetch timestamp - // This represents content that was just fetched, so fetchedAt should be now - NotificationContent content = new NotificationContent(); - content.setTitle(title); - content.setBody(body); - content.setSound(sound); - content.setPriority(priority); - content.setUrl(url); - content.setScheduledTime(calculateNextScheduledTime(hour, minute)); - content.setScheduledAt(System.currentTimeMillis()); - - // Log the timestamps for debugging - Log.d(TAG, "Created notification content with fetchedAt=" + content.getFetchedAt() + - ", scheduledAt=" + content.getScheduledAt() + - ", scheduledTime=" + content.getScheduledTime()); - - // Check for existing notification at the same time to prevent duplicates - java.util.List existingNotifications = storage.getAllNotifications(); - boolean duplicateFound = false; - long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts - - for (NotificationContent existing : existingNotifications) { - if (Math.abs(existing.getScheduledTime() - content.getScheduledTime()) <= toleranceMs) { - Log.w(TAG, "DN|SCHEDULE_DUPLICATE id=" + content.getId() + - " existing_id=" + existing.getId() + - " time_diff_ms=" + Math.abs(existing.getScheduledTime() - content.getScheduledTime())); - duplicateFound = true; - break; - } - } - - if (duplicateFound) { - Log.i(TAG, "DN|SCHEDULE_SKIP id=" + content.getId() + " duplicate_prevented"); - call.reject("Notification already scheduled for this time"); - return; - } - - // Store notification content - storage.saveNotificationContent(content); - - // Schedule the notification - boolean scheduled = scheduler.scheduleNotification(content); - - Log.d(TAG, "DN|SCHEDULE_RESULT scheduled=" + scheduled + - " content_id=" + content.getId() + - " content_scheduled_time=" + content.getScheduledTime()); - - if (scheduled) { - Log.i(TAG, "DN|SCHEDULE_CALLBACK scheduled=true, calling scheduleBackgroundFetch"); - Log.d(TAG, "DN|SCHEDULE_CALLBACK content.getScheduledTime()=" + content.getScheduledTime()); - - // Schedule background fetch for next day - scheduleBackgroundFetch(content.getScheduledTime()); - - // Schedule WorkManager fallback tick for deep doze scenarios - scheduleDozeFallbackTick(content.getScheduledTime()); - - Log.i(TAG, "Daily notification scheduled successfully for " + time); - call.resolve(); - } else { - Log.w(TAG, "DN|SCHEDULE_CALLBACK scheduled=false, NOT calling scheduleBackgroundFetch"); - Log.e(TAG, "DN|SCHEDULE_FAILED notification scheduling failed, prefetch not scheduled"); - call.reject("Failed to schedule notification"); - } - - } catch (Exception e) { - Log.e(TAG, "Error scheduling daily notification", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Get the last notification that was delivered - * - * @param call Plugin call - */ - @PluginMethod - public void getLastNotification(PluginCall call) { - try { - Log.d(TAG, "Getting last notification"); - - // Ensure storage is initialized - ensureStorageInitialized(); - - NotificationContent lastNotification = storage.getLastNotification(); - - if (lastNotification != null) { - JSObject result = new JSObject(); - result.put("id", lastNotification.getId()); - result.put("title", lastNotification.getTitle()); - result.put("body", lastNotification.getBody()); - result.put("timestamp", lastNotification.getScheduledTime()); - result.put("url", lastNotification.getUrl()); - - call.resolve(result); - } else { - call.resolve(null); - } - - } catch (Exception e) { - Log.e(TAG, "Error getting last notification", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Cancel all scheduled notifications - * - * @param call Plugin call - */ - @PluginMethod - public void cancelAllNotifications(PluginCall call) { - try { - Log.d(TAG, "Cancelling all notifications"); - - // Ensure storage is initialized - ensureStorageInitialized(); - - scheduler.cancelAllNotifications(); - storage.clearAllNotifications(); - - Log.i(TAG, "All notifications cancelled successfully"); - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error cancelling notifications", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Get the current status of notifications - * - * @param call Plugin call - */ - @PluginMethod - public void getNotificationStatus(PluginCall call) { - try { - Log.d(TAG, "Getting notification status"); - - JSObject result = new JSObject(); - - // Check if notifications are enabled - boolean notificationsEnabled = areNotificationsEnabled(); - result.put("isEnabled", notificationsEnabled); - - // Get next notification time - long nextNotificationTime = scheduler.getNextNotificationTime(); - result.put("nextNotificationTime", nextNotificationTime); - - // Get current settings - JSObject settings = new JSObject(); - settings.put("sound", true); - settings.put("priority", "default"); - settings.put("timezone", "UTC"); - result.put("settings", settings); - - // Get pending notifications count - int pendingCount = scheduler.getPendingNotificationsCount(); - result.put("pending", pendingCount); - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error getting notification status", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Update notification settings - * - * @param call Plugin call containing new settings - */ - @PluginMethod - public void updateSettings(PluginCall call) { - try { - Log.d(TAG, "Updating notification settings"); - - // Ensure storage is initialized - ensureStorageInitialized(); - - // Extract settings - Boolean sound = call.getBoolean("sound"); - String priority = call.getString("priority"); - String timezone = call.getString("timezone"); - - // Update settings in storage - if (sound != null) { - storage.setSoundEnabled(sound); - } - if (priority != null) { - storage.setPriority(priority); - } - if (timezone != null) { - storage.setTimezone(timezone); - } - - // Update existing notifications with new settings - scheduler.updateNotificationSettings(); - - Log.i(TAG, "Notification settings updated successfully"); - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error updating notification settings", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Get battery status information - * - * @param call Plugin call - */ - @PluginMethod - public void getBatteryStatus(PluginCall call) { - try { - Log.d(TAG, "Getting battery status"); - - JSObject result = new JSObject(); - - // Get battery level (simplified - would need BatteryManager in real implementation) - result.put("level", 100); // Placeholder - result.put("isCharging", false); // Placeholder - result.put("powerState", 0); // Placeholder - result.put("isOptimizationExempt", false); // Placeholder - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error getting battery status", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Request battery optimization exemption - * - * @param call Plugin call - */ - @PluginMethod - public void requestBatteryOptimizationExemption(PluginCall call) { - try { - Log.d(TAG, "Requesting battery optimization exemption"); - - // This would typically open system settings - // For now, just log the request - Log.i(TAG, "Battery optimization exemption requested"); - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error requesting battery optimization exemption", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Set adaptive scheduling based on device state - * - * @param call Plugin call containing enabled flag - */ - @PluginMethod - public void setAdaptiveScheduling(PluginCall call) { - try { - Log.d(TAG, "Setting adaptive scheduling"); - - // Ensure storage is initialized - ensureStorageInitialized(); - - boolean enabled = call.getBoolean("enabled", true); - storage.setAdaptiveSchedulingEnabled(enabled); - - if (enabled) { - scheduler.enableAdaptiveScheduling(); - } else { - scheduler.disableAdaptiveScheduling(); - } - - Log.i(TAG, "Adaptive scheduling " + (enabled ? "enabled" : "disabled")); - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error setting adaptive scheduling", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Get current power state information - * - * @param call Plugin call - */ - @PluginMethod - public void getPowerState(PluginCall call) { - try { - Log.d(TAG, "Getting power state"); - - JSObject result = new JSObject(); - result.put("powerState", 0); // Placeholder - result.put("isOptimizationExempt", false); // Placeholder - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error getting power state", e); - call.reject("Internal error: " + e.getMessage()); - } - } - - /** - * Calculate the next scheduled time for the notification - * - * @param hour Hour of day (0-23) - * @param minute Minute of hour (0-59) - * @return Timestamp in milliseconds - */ - private long calculateNextScheduledTime(int hour, int minute) { - Calendar calendar = Calendar.getInstance(); - calendar.set(Calendar.HOUR_OF_DAY, hour); - calendar.set(Calendar.MINUTE, minute); - calendar.set(Calendar.SECOND, 0); - calendar.set(Calendar.MILLISECOND, 0); - - // If time has passed today, schedule for tomorrow - if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { - calendar.add(Calendar.DAY_OF_YEAR, 1); - } - - return calendar.getTimeInMillis(); - } - - /** - * Schedule background fetch for content - * - * @param scheduledTime When the notification is scheduled for - */ - private void scheduleBackgroundFetch(long scheduledTime) { - try { - Log.i(TAG, "DN|SCHEDULE_FETCH_START time=" + scheduledTime + " current=" + System.currentTimeMillis()); - - // Check if fetcher is initialized - if (fetcher == null) { - Log.e(TAG, "DN|SCHEDULE_FETCH_ERR fetcher is null - cannot schedule prefetch. Plugin may not be fully loaded."); - return; - } - - // Schedule fetch 5 minutes before notification - long fetchTime = scheduledTime - TimeUnit.MINUTES.toMillis(5); - long currentTime = System.currentTimeMillis(); - - Log.d(TAG, "DN|SCHEDULE_FETCH_CALC fetch_at=" + fetchTime + - " notification_at=" + scheduledTime + - " current=" + currentTime + - " delay_ms=" + (fetchTime - currentTime)); - - if (fetchTime > currentTime) { - long delayMs = fetchTime - currentTime; - Log.d(TAG, "DN|SCHEDULE_FETCH_FUTURE delay_hours=" + (delayMs / 3600000.0) + - " delay_minutes=" + (delayMs / 60000.0)); - fetcher.scheduleFetch(fetchTime); - Log.i(TAG, "DN|SCHEDULE_FETCH_OK Background fetch scheduled for " + fetchTime + " (5 minutes before notification at " + scheduledTime + ")"); - } else { - Log.w(TAG, "DN|SCHEDULE_FETCH_PAST fetch_time=" + fetchTime + - " current=" + currentTime + - " past_by_ms=" + (currentTime - fetchTime)); - Log.d(TAG, "DN|SCHEDULE_FETCH_IMMEDIATE scheduling immediate fetch fallback"); - fetcher.scheduleImmediateFetch(); - } - } catch (Exception e) { - Log.e(TAG, "DN|SCHEDULE_FETCH_ERR Error scheduling background fetch", e); - } - } - - /** - * Schedule WorkManager fallback tick for deep doze scenarios - * - * This ensures notifications still fire even when exact alarms get pruned - * during deep doze mode. The fallback tick runs 30-60 minutes before - * the notification time and re-arms the exact alarm if needed. - * - * @param scheduledTime When the notification is scheduled for - */ - private void scheduleDozeFallbackTick(long scheduledTime) { - try { - // Schedule fallback tick 30 minutes before notification (with 30 minute flex) - long fallbackTime = scheduledTime - TimeUnit.MINUTES.toMillis(30); - - if (fallbackTime > System.currentTimeMillis()) { - androidx.work.WorkManager workManager = androidx.work.WorkManager.getInstance(getContext()); - - // Create constraints for the fallback work - androidx.work.Constraints constraints = new androidx.work.Constraints.Builder() - .setRequiredNetworkType(androidx.work.NetworkType.NOT_REQUIRED) - .setRequiresBatteryNotLow(false) - .setRequiresCharging(false) - .setRequiresDeviceIdle(false) - .build(); - - // Create input data - androidx.work.Data inputData = new androidx.work.Data.Builder() - .putLong("scheduled_time", scheduledTime) - .putString("action", "doze_fallback") - .build(); - - // Create one-time work request - androidx.work.OneTimeWorkRequest fallbackWork = new androidx.work.OneTimeWorkRequest.Builder( - com.timesafari.dailynotification.DozeFallbackWorker.class) - .setConstraints(constraints) - .setInputData(inputData) - .setInitialDelay(fallbackTime - System.currentTimeMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) - .addTag("doze_fallback") - .build(); - - // Enqueue the work - workManager.enqueue(fallbackWork); - - Log.d(TAG, "DN|DOZE_FALLBACK_SCHEDULED scheduled_time=" + scheduledTime + - " fallback_time=" + fallbackTime); - } - } catch (Exception e) { - Log.e(TAG, "DN|DOZE_FALLBACK_ERR err=" + e.getMessage(), e); - } - } - - /** - * Schedule maintenance tasks - */ - private void scheduleMaintenance() { - try { - // Schedule daily maintenance at 2 AM - Calendar calendar = Calendar.getInstance(); - calendar.set(Calendar.HOUR_OF_DAY, 2); - calendar.set(Calendar.MINUTE, 0); - calendar.set(Calendar.SECOND, 0); - - if (calendar.getTimeInMillis() <= System.currentTimeMillis()) { - calendar.add(Calendar.DAY_OF_YEAR, 1); - } - - // This would typically use WorkManager for maintenance - Log.d(TAG, "Maintenance scheduled for " + calendar.getTimeInMillis()); - - } catch (Exception e) { - Log.e(TAG, "Error scheduling maintenance", e); - } - } - - /** - * Check if notifications are enabled - * - * @return true if notifications are enabled - */ - private boolean areNotificationsEnabled() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - == PackageManager.PERMISSION_GRANTED; - } - return NotificationManagerCompat.from(getContext()).areNotificationsEnabled(); - } - - /** - * Ensure storage is initialized - * - * @throws Exception if storage cannot be initialized - */ - private void ensureStorageInitialized() throws Exception { - if (storage == null) { - Log.w(TAG, "Storage not initialized, initializing now"); - storage = new DailyNotificationStorage(getContext()); - if (storage == null) { - throw new Exception("Failed to initialize storage"); - } - } - } - - /** - * Check and perform recovery if needed - * This is called on app startup to recover notifications after reboot - */ - private void checkAndPerformRecovery() { - try { - Log.d(TAG, "Checking if recovery is needed..."); - - // Ensure storage is initialized - ensureStorageInitialized(); - - // Perform app startup recovery - boolean recoveryPerformed = performAppStartupRecovery(); - - if (recoveryPerformed) { - Log.i(TAG, "App startup recovery completed successfully"); - } else { - Log.d(TAG, "App startup recovery skipped (not needed or already performed)"); - } - - } catch (Exception e) { - Log.e(TAG, "Error during recovery check", e); - } - } - - /** - * Get recovery statistics for debugging - */ - @PluginMethod - public void getRecoveryStats(PluginCall call) { - try { - ensureStorageInitialized(); - - String stats = getRecoveryStats(); - - JSObject result = new JSObject(); - result.put("stats", stats); - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error getting recovery stats", e); - call.reject("Error getting recovery stats: " + e.getMessage()); - } - } - - /** - * Request notification permissions (alias for requestNotificationPermissions) - * - * @param call Plugin call - */ - @PluginMethod - public void requestPermissions(PluginCall call) { - Log.d(TAG, "DEBUG: requestPermissions method called"); - Log.d(TAG, "DEBUG: Method call received from JavaScript"); - Log.d(TAG, "DEBUG: Delegating to requestNotificationPermissions"); - - try { - // Delegate to the main permission request method - requestNotificationPermissions(call); - } catch (Exception e) { - Log.e(TAG, "DEBUG: Error in requestPermissions delegation", e); - call.reject("Error in requestPermissions: " + e.getMessage()); - } - } - - /** - * Request notification permissions - * - * @param call Plugin call - */ - @PluginMethod - public void requestNotificationPermissions(PluginCall call) { - try { - Log.d(TAG, "DEBUG: requestNotificationPermissions method called"); - Log.d(TAG, "DEBUG: Android SDK version: " + Build.VERSION.SDK_INT); - Log.d(TAG, "DEBUG: TIRAMISU version: " + Build.VERSION_CODES.TIRAMISU); - Log.d(TAG, "Requesting notification permissions"); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Log.d(TAG, "DEBUG: Android 13+ detected, requesting POST_NOTIFICATIONS permission"); - - // Store call for manual checking since Capacitor doesn't callback on denial - final PluginCall savedCall = call; - - // Request POST_NOTIFICATIONS permission for Android 13+ - requestPermissionForAlias("notifications", call, "onPermissionResult"); - - // Manually check result after a short delay to handle denied permissions - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - // Wait for permission dialog to close - Thread.sleep(500); - - // Check permission status manually - boolean permissionGranted = getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - == PackageManager.PERMISSION_GRANTED; - - Log.d(TAG, "DEBUG: Manual permission check: " + permissionGranted); - - if (!permissionGranted) { - // Permission was denied, respond to the call - Log.w(TAG, "Notification permission denied by user"); - savedCall.reject("Notification permission denied by user"); - } - // If granted, the callback will handle it - } catch (InterruptedException e) { - Log.e(TAG, "Manual permission check interrupted", e); - } - } - }); - } else { - Log.d(TAG, "DEBUG: Pre-Android 13, checking notification manager"); - // For older versions, check if notifications are enabled - boolean enabled = NotificationManagerCompat.from(getContext()).areNotificationsEnabled(); - Log.d(TAG, "DEBUG: Notifications enabled: " + enabled); - if (enabled) { - Log.i(TAG, "Notifications already enabled"); - call.resolve(); - } else { - Log.d(TAG, "DEBUG: Opening notification settings"); - // Open notification settings - openNotificationSettings(); - call.resolve(); - } - } - - } catch (Exception e) { - Log.e(TAG, "DEBUG: Exception in requestNotificationPermissions", e); - Log.e(TAG, "Error requesting notification permissions", e); - call.reject("Error requesting permissions: " + e.getMessage()); - } - } - - /** - * Permission callback for notification permissions - * - * @param call Plugin call containing permission result - */ - @PermissionCallback - private void onPermissionResult(PluginCall call) { - try { - Log.d(TAG, "DEBUG: onPermissionResult callback received"); - - // Guard against null call - if (call == null) { - Log.e(TAG, "Permission callback received null call - cannot process"); - return; - } - - Log.d(TAG, "Permission callback received"); - - // Check if POST_NOTIFICATIONS permission was granted - boolean permissionGranted = getContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) - == PackageManager.PERMISSION_GRANTED; - - Log.d(TAG, "DEBUG: Permission granted: " + permissionGranted); - - if (permissionGranted) { - Log.i(TAG, "Notification permission granted"); - call.resolve(); - } else { - Log.w(TAG, "Notification permission denied"); - call.reject("Notification permission denied by user"); - } - - } catch (Exception e) { - Log.e(TAG, "DEBUG: Exception in onPermissionResult callback", e); - Log.e(TAG, "Error in permission callback", e); - if (call != null) { - call.reject("Error processing permission result: " + e.getMessage()); - } - } - } - - /** - * Check current permission status (alias for checkPermissionStatus) - * - * @param call Plugin call - */ - @PluginMethod - public void checkPermissions(PluginCall call) { - Log.d(TAG, "DEBUG: checkPermissions method called (alias)"); - Log.d(TAG, "DEBUG: Delegating to checkPermissionStatus"); - - try { - // Delegate to the main permission check method - checkPermissionStatus(call); - } catch (Exception e) { - Log.e(TAG, "DEBUG: Error in checkPermissions delegation", e); - call.reject("Error in checkPermissions: " + e.getMessage()); - } - } - - /** - * Check current permission status - * - * @param call Plugin call - */ - @PluginMethod - public void checkPermissionStatus(PluginCall call) { - try { - Log.d(TAG, "Checking permission status"); - - JSObject result = new JSObject(); - - // Check notification permissions - boolean notificationsEnabled = areNotificationsEnabled(); - result.put("notificationsEnabled", notificationsEnabled); - result.put("notifications", notificationsEnabled ? "granted" : "denied"); - - // Check exact alarm permissions (Android 12+) - boolean exactAlarmEnabled = true; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - exactAlarmEnabled = alarmManager.canScheduleExactAlarms(); - } - result.put("exactAlarmEnabled", exactAlarmEnabled); - - // Check wake lock permissions - boolean wakeLockEnabled = getContext().checkSelfPermission(Manifest.permission.WAKE_LOCK) - == PackageManager.PERMISSION_GRANTED; - result.put("wakeLockEnabled", wakeLockEnabled); - - // Overall status - boolean allPermissionsGranted = notificationsEnabled && exactAlarmEnabled && wakeLockEnabled; - result.put("allPermissionsGranted", allPermissionsGranted); - - Log.d(TAG, "Permission status - Notifications: " + notificationsEnabled + - ", Exact Alarm: " + exactAlarmEnabled + - ", Wake Lock: " + wakeLockEnabled); - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error checking permission status", e); - call.reject("Error checking permissions: " + e.getMessage()); - } - } - - /** - * Open notification settings - */ - private void openNotificationSettings() { - try { - Intent intent = new Intent(); - intent.setAction("android.settings.APP_NOTIFICATION_SETTINGS"); - intent.putExtra("android.provider.extra.APP_PACKAGE", getContext().getPackageName()); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - getContext().startActivity(intent); - Log.d(TAG, "Opened notification settings"); - } catch (Exception e) { - Log.e(TAG, "Error opening notification settings", e); - } - } - - /** - * Open exact alarm settings (Android 12+) - * - * @param call Plugin call - */ - @PluginMethod - public void openExactAlarmSettings(PluginCall call) { - try { - Log.d(TAG, "Opening exact alarm settings"); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Check if exact alarms are already allowed - if (alarmManager.canScheduleExactAlarms()) { - Log.d(TAG, "Exact alarms already allowed"); - call.resolve(); - return; - } - - // Open exact alarm settings - Intent intent = new Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM); - intent.setData(android.net.Uri.parse("package:" + getContext().getPackageName())); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - getContext().startActivity(intent); - Log.d(TAG, "Opened exact alarm settings"); - } else { - Log.d(TAG, "Exact alarm settings not needed on this Android version"); - } - - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error opening exact alarm settings", e); - call.reject("Error opening exact alarm settings: " + e.getMessage()); - } - } - - /** - * Check if notification channel is enabled - * - * @param call Plugin call - */ - @PluginMethod - public void isChannelEnabled(PluginCall call) { - try { - Log.d(TAG, "Checking channel status"); - ensureStorageInitialized(); - - boolean enabled = channelManager.isChannelEnabled(); - int importance = channelManager.getChannelImportance(); - - JSObject result = new JSObject(); - result.put("enabled", enabled); - result.put("importance", importance); - result.put("channelId", channelManager.getDefaultChannelId()); - - Log.d(TAG, "Channel status - enabled: " + enabled + ", importance: " + importance); - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error checking channel status", e); - call.reject("Error checking channel status: " + e.getMessage()); - } - } - - /** - * Open notification channel settings - * - * @param call Plugin call - */ - @PluginMethod - public void openChannelSettings(PluginCall call) { - try { - Log.d(TAG, "Opening channel settings"); - - boolean opened = channelManager.openChannelSettings(); - - JSObject result = new JSObject(); - result.put("opened", opened); - - if (opened) { - Log.d(TAG, "Channel settings opened successfully"); - } else { - Log.w(TAG, "Could not open channel settings"); - } - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error opening channel settings", e); - call.reject("Error opening channel settings: " + e.getMessage()); - } - } - - /** - * Get comprehensive notification status including permissions and channel - * - * @param call Plugin call - */ - @PluginMethod - public void checkStatus(PluginCall call) { - Trace.beginSection("DN:checkStatus"); - try { - Log.d(TAG, "DN|STATUS_CHECK_START"); - ensureStorageInitialized(); - - // Use the comprehensive status checker - NotificationStatusChecker statusChecker = new NotificationStatusChecker(getContext()); - JSObject result = statusChecker.getComprehensiveStatus(); - - Log.i(TAG, "DN|STATUS_CHECK_OK canSchedule=" + result.getBoolean("canScheduleNow")); - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "DN|STATUS_CHECK_ERR err=" + e.getMessage(), e); - call.reject("Error checking status: " + e.getMessage()); - } finally { - Trace.endSection(); - } - } - - /** - * Maintain rolling window (for testing or manual triggers) - * - * @param call Plugin call - */ - @PluginMethod - public void maintainRollingWindow(PluginCall call) { - try { - Log.d(TAG, "Manual rolling window maintenance requested"); - - if (rollingWindow != null) { - rollingWindow.forceMaintenance(); - call.resolve(); - } else { - call.reject("Rolling window not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error during manual rolling window maintenance", e); - call.reject("Error maintaining rolling window: " + e.getMessage()); - } - } - - /** - * Get rolling window statistics - * - * @param call Plugin call - */ - @PluginMethod - public void getRollingWindowStats(PluginCall call) { - try { - Log.d(TAG, "Rolling window stats requested"); - - if (rollingWindow != null) { - String stats = rollingWindow.getRollingWindowStats(); - JSObject result = new JSObject(); - result.put("stats", stats); - result.put("maintenanceNeeded", rollingWindow.isMaintenanceNeeded()); - result.put("timeUntilNextMaintenance", rollingWindow.getTimeUntilNextMaintenance()); - call.resolve(result); - } else { - call.reject("Rolling window not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error getting rolling window stats", e); - call.reject("Error getting rolling window stats: " + e.getMessage()); - } - } - - /** - * Trigger an immediate standalone fetch for content updates - * - * This method allows manual triggering of content fetches independently of - * scheduled notifications. Useful for on-demand content refresh, cache warming, - * or background sync operations. - * - * @param call Plugin call - */ - @PluginMethod - public void triggerImmediateFetch(PluginCall call) { - try { - Log.d(TAG, "Manual standalone fetch triggered"); - - // Ensure storage is initialized - ensureStorageInitialized(); - - // Ensure fetcher is initialized - if (fetcher == null) { - Log.w(TAG, "DN|FETCHER_NULL initializing_fetcher"); - fetcher = new DailyNotificationFetcher(getContext(), storage, roomStorage); - } - - // Trigger immediate fetch via WorkManager - fetcher.scheduleImmediateFetch(); - - Log.i(TAG, "DN|STANDALONE_FETCH_SCHEDULED"); - - // Return success response - JSObject result = new JSObject(); - result.put("success", true); - result.put("message", "Immediate fetch scheduled successfully"); - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "DN|STANDALONE_FETCH_ERR err=" + e.getMessage(), e); - call.reject("Error triggering standalone fetch: " + e.getMessage()); - } - } - - /** - * Get exact alarm status with enhanced Android 12+ support - * - * @param call Plugin call - */ - @PluginMethod - public void getExactAlarmStatus(PluginCall call) { - try { - Log.d(TAG, "Enhanced exact alarm status requested"); - - if (scheduler != null) { - DailyNotificationScheduler.ExactAlarmStatus status = scheduler.getExactAlarmStatus(); - JSObject result = new JSObject(); - result.put("supported", status.supported); - result.put("enabled", status.enabled); - result.put("canSchedule", status.canSchedule); - result.put("fallbackWindow", status.fallbackWindow); - - // Add additional debugging information - result.put("androidVersion", Build.VERSION.SDK_INT); - result.put("dozeCompatibility", Build.VERSION.SDK_INT >= Build.VERSION_CODES.M); - - Log.d(TAG, "Exact alarm status: supported=" + status.supported + - ", enabled=" + status.enabled + ", canSchedule=" + status.canSchedule); - - call.resolve(result); - } else { - call.reject("Scheduler not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error getting exact alarm status", e); - call.reject("Error getting exact alarm status: " + e.getMessage()); - } - } - - /** - * Request exact alarm permission with enhanced Android 12+ support - * - * @param call Plugin call - */ - @PluginMethod - public void requestExactAlarmPermission(PluginCall call) { - try { - Log.d(TAG, "Enhanced exact alarm permission request"); - - if (scheduler != null) { - boolean success = scheduler.requestExactAlarmPermission(); - if (success) { - Log.i(TAG, "Exact alarm permission request initiated successfully"); - call.resolve(); - } else { - Log.w(TAG, "Failed to initiate exact alarm permission request"); - call.reject("Failed to request exact alarm permission"); - } - } else { - call.reject("Scheduler not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error requesting exact alarm permission", e); - call.reject("Error requesting exact alarm permission: " + e.getMessage()); - } - } - - - /** - * Get reboot recovery status - * - * @param call Plugin call - */ - @PluginMethod - public void getRebootRecoveryStatus(PluginCall call) { - try { - Log.d(TAG, "Reboot recovery status requested"); - - if (rebootRecoveryManager != null) { - DailyNotificationRebootRecoveryManager.RecoveryStatus status = rebootRecoveryManager.getRecoveryStatus(); - JSObject result = new JSObject(); - result.put("inProgress", status.inProgress); - result.put("lastRecoveryTime", status.lastRecoveryTime); - result.put("timeSinceLastRecovery", status.timeSinceLastRecovery); - result.put("recoveryNeeded", rebootRecoveryManager.isRecoveryNeeded()); - call.resolve(result); - } else { - call.reject("Reboot recovery manager not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error getting reboot recovery status", e); - call.reject("Error getting reboot recovery status: " + e.getMessage()); - } - } - - // MARK: - Phase 1: TimeSafari Integration Methods - - /** - * Configure activeDid integration options - * - * @param config Configuration object with platform and storage type - */ - private void configureActiveDidIntegration(JSObject config) { - try { - if (timeSafariIntegration == null) { - Log.w(TAG, "TimeSafariIntegrationManager not initialized"); - return; - } - - String apiServer = config.getString("apiServer"); - String activeDid = config.getString("activeDid"); - Integer jwtExpirationSeconds = config.getInteger("jwtExpirationSeconds", 60); - boolean autoSync = config.getBoolean("autoSync", false); - Integer identityChangeGraceSeconds = config.getInteger("identityChangeGraceSeconds", 30); - - Log.d(TAG, "Configuring TimeSafari integration - API Server: " + apiServer + - ", ActiveDid: " + (activeDid != null ? activeDid.substring(0, Math.min(20, activeDid.length())) + "..." : "null") + - ", JWT Expiry: " + jwtExpirationSeconds + "s, AutoSync: " + autoSync); - - // Configure API server URL - if (apiServer != null && !apiServer.isEmpty()) { - timeSafariIntegration.setApiServerUrl(apiServer); - } - - // Configure active DID - if (activeDid != null && !activeDid.isEmpty()) { - timeSafariIntegration.setActiveDid(activeDid); - // JWT expiration is handled by JWT manager if needed separately - if (jwtManager != null && jwtExpirationSeconds != null) { - jwtManager.setActiveDid(activeDid, jwtExpirationSeconds); - } - } - - // Store auto-sync configuration for future use - storeAutoSyncConfiguration(autoSync, identityChangeGraceSeconds); - - Log.i(TAG, "TimeSafari integration configured successfully"); - - } catch (Exception e) { - Log.e(TAG, "Error configuring TimeSafari integration", e); - throw e; - } - } - - /** - * Store auto-sync configuration for background tasks - */ - private void storeAutoSyncConfiguration(boolean autoSync, int gracePeriodSeconds) { - try { - if (storage != null) { - // Store auto-sync settings in plugin storage - Map syncConfig = new HashMap<>(); - syncConfig.put("autoSync", autoSync); - syncConfig.put("gracePeriodSeconds", gracePeriodSeconds); - syncConfig.put("configuredAt", System.currentTimeMillis()); - - // Store in SharedPreferences for persistence - android.content.SharedPreferences preferences = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - preferences.edit() - .putBoolean("autoSync", autoSync) - .putInt("gracePeriodSeconds", gracePeriodSeconds) - .putLong("configuredAt", System.currentTimeMillis()) - .apply(); - - Log.d(TAG, "Phase 2: Auto-sync configuration stored"); - } - } catch (Exception e) { - Log.e(TAG, "Error storing auto-sync configuration", e); - } - } - - /** - * Set active DID from host application - * - * This implements the Option A pattern where the host provides activeDid - */ - @PluginMethod - public void setActiveDidFromHost(PluginCall call) { - try { - String activeDid = call.getString("activeDid"); - if (activeDid == null || activeDid.isEmpty()) { - call.reject("activeDid cannot be null or empty"); - return; - } - - if (timeSafariIntegration != null) { - timeSafariIntegration.setActiveDid(activeDid); - call.resolve(); - } else { - call.reject("TimeSafariIntegrationManager not initialized"); - } - } catch (Exception e) { - Log.e(TAG, "Error setting activeDid", e); - call.reject("Error: " + e.getMessage()); - } - } - - /** - * Refresh authentication for new identity - */ - @PluginMethod - public void refreshAuthenticationForNewIdentity(PluginCall call) { - try { - String activeDid = call.getString("activeDid"); - if (activeDid == null || activeDid.isEmpty()) { - call.reject("activeDid cannot be null or empty"); - return; - } - - if (timeSafariIntegration != null) { - timeSafariIntegration.setActiveDid(activeDid); // Handles refresh internally - call.resolve(); - } else { - call.reject("TimeSafariIntegrationManager not initialized"); - } - } catch (Exception e) { - Log.e(TAG, "Error refreshing authentication", e); - call.reject("Error: " + e.getMessage()); - } - } - - /** - * Clear cached content for new identity - */ - @PluginMethod - public void clearCacheForNewIdentity(PluginCall call) { - try { - if (timeSafariIntegration != null) { - timeSafariIntegration.setActiveDid(null); // Setting to null clears caches - call.resolve(); - } else { - call.reject("TimeSafariIntegrationManager not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error clearing cache for new identity", e); - call.reject("Error clearing cache: " + e.getMessage()); - } - } - - /** - * Update background tasks with new identity - */ - @PluginMethod - public void updateBackgroundTaskIdentity(PluginCall call) { - try { - String activeDid = call.getString("activeDid"); - if (activeDid == null || activeDid.isEmpty()) { - call.reject("activeDid cannot be null or empty"); - return; - } - - if (timeSafariIntegration != null) { - timeSafariIntegration.setActiveDid(activeDid); - call.resolve(); - } else { - call.reject("TimeSafariIntegrationManager not initialized"); - } - } catch (Exception e) { - Log.e(TAG, "Error updating background task identity", e); - call.reject("Error: " + e.getMessage()); - } - } - - /** - * Update starred plan IDs from host application - * - * This allows the TimeSafari app to dynamically update the list of starred - * project IDs when users star or unstar projects. The IDs are stored persistently - * and used for prefetch operations that query for starred project updates. - * - * @param call Contains: - * - planIds: string[] - Array of starred plan handle IDs - */ - @PluginMethod - public void updateStarredPlans(PluginCall call) { - try { - JSObject data = call.getData(); - if (data == null) { - call.reject("No data provided"); - return; - } - - Object planIdsObj = data.get("planIds"); - if (planIdsObj == null) { - call.reject("planIds is required"); - return; - } - - // Convert to List - List planIds; - if (planIdsObj instanceof List) { - @SuppressWarnings("unchecked") - List objList = (List) planIdsObj; - planIds = new java.util.ArrayList<>(); - for (Object obj : objList) { - if (obj != null) { - planIds.add(obj.toString()); - } - } - } else { - call.reject("planIds must be an array"); - return; - } - - Log.i(TAG, "DN|UPDATE_STARRED_PLANS count=" + planIds.size()); - - // Store in SharedPreferences for persistence - SharedPreferences preferences = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - - // Store as JSON string for easy retrieval - org.json.JSONArray jsonArray = new org.json.JSONArray(); - for (String planId : planIds) { - jsonArray.put(planId); - } - - preferences.edit() - .putString("starredPlanIds", jsonArray.toString()) - .putLong("starredPlansUpdatedAt", System.currentTimeMillis()) - .apply(); - - Log.d(TAG, "DN|STARRED_PLANS_STORED count=" + planIds.size() + - " stored_at=" + System.currentTimeMillis()); - - // Update TimeSafariIntegrationManager if it needs the IDs immediately - if (timeSafariIntegration != null) { - // The TimeSafariIntegrationManager will read from SharedPreferences - // when it needs the starred plan IDs, so no direct update needed - Log.d(TAG, "DN|STARRED_PLANS_UPDATED TimeSafariIntegrationManager will use stored IDs"); - } - - JSObject result = new JSObject(); - result.put("success", true); - result.put("planIdsCount", planIds.size()); - result.put("updatedAt", System.currentTimeMillis()); - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "DN|UPDATE_STARRED_PLANS_ERR Error updating starred plans", e); - call.reject("Error updating starred plans: " + e.getMessage()); - } - } - - /** - * Get current starred plan IDs - * - * Returns the currently stored starred plan IDs from SharedPreferences. - * This is useful for the host app to verify what IDs are stored. - */ - @PluginMethod - public void getStarredPlans(PluginCall call) { - try { - SharedPreferences preferences = getContext() - .getSharedPreferences("daily_notification_timesafari", Context.MODE_PRIVATE); - - String starredPlansJson = preferences.getString("starredPlanIds", "[]"); - long updatedAt = preferences.getLong("starredPlansUpdatedAt", 0); - - org.json.JSONArray jsonArray = new org.json.JSONArray(starredPlansJson); - List planIds = new java.util.ArrayList<>(); - for (int i = 0; i < jsonArray.length(); i++) { - planIds.add(jsonArray.getString(i)); - } - - JSObject result = new JSObject(); - org.json.JSONArray planIdsArray = new org.json.JSONArray(); - for (String planId : planIds) { - planIdsArray.put(planId); - } - result.put("planIds", planIdsArray); - result.put("count", planIds.size()); - result.put("updatedAt", updatedAt); - - Log.d(TAG, "DN|GET_STARRED_PLANS count=" + planIds.size()); - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "DN|GET_STARRED_PLANS_ERR Error getting starred plans", e); - call.reject("Error getting starred plans: " + e.getMessage()); - } - } - - /** - * Test JWT generation for debugging - */ - @PluginMethod - public void testJWTGeneration(PluginCall call) { - try { - Log.d(TAG, "Testing JWT generation"); - - String activeDid = call.getString("activeDid", "did:example:test"); - - if (jwtManager != null) { - jwtManager.setActiveDid(activeDid); - String token = jwtManager.getCurrentJWTToken(); - String debugInfo = jwtManager.getTokenDebugInfo(); - - JSObject result = new JSObject(); - result.put("success", true); - result.put("activeDid", activeDid); - result.put("tokenLength", token != null ? token.length() : 0); - result.put("debugInfo", debugInfo); - result.put("authenticated", jwtManager.isAuthenticated()); - - Log.d(TAG, "JWT test completed successfully"); - call.resolve(result); - } else { - call.reject("JWT manager not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error testing JWT generation", e); - call.reject("JWT test failed: " + e.getMessage()); - } - } - - /** - * Test Endorser.ch API calls - */ - @PluginMethod - public void testEndorserAPI(PluginCall call) { - try { - Log.d(TAG, "Testing Endorser.ch API calls"); - - String activeDid = call.getString("activeDid", "did:example:test"); - String apiServer = call.getString("apiServer", "https://api.endorser.ch"); - - if (enhancedFetcher != null) { - // Set up test configuration - enhancedFetcher.setApiServerUrl(apiServer); - - EnhancedDailyNotificationFetcher.TimeSafariUserConfig userConfig = - new EnhancedDailyNotificationFetcher.TimeSafariUserConfig(); - userConfig.activeDid = activeDid; - userConfig.fetchOffersToPerson = true; - userConfig.fetchOffersToProjects = true; - userConfig.fetchProjectUpdates = true; - - // Execute test fetch - CompletableFuture future = - enhancedFetcher.fetchAllTimeSafariData(userConfig); - - // For immediate testing, we'll create a simple response - JSObject result = new JSObject(); - result.put("success", true); - result.put("activeDid", activeDid); - result.put("apiServer", apiServer); - result.put("testCompleted", true); - result.put("message", "Endorser.ch API test initiated successfully"); - - Log.d(TAG, "Endorser.ch API test completed successfully"); - call.resolve(result); - } else { - call.reject("Enhanced fetcher not initialized"); - } - - } catch (Exception e) { - Log.e(TAG, "Error testing Endorser.ch API", e); - call.reject("Endorser.ch API test failed: " + e.getMessage()); - } - } - - // MARK: - Phase 3: TimeSafari Background Coordination Methods - - /** - * Phase 3: Coordinate background tasks with TimeSafari PlatformServiceMixin - */ - @PluginMethod - public void coordinateBackgroundTasks(PluginCall call) { - try { - Log.d(TAG, "Phase 3: Coordinating background tasks with PlatformServiceMixin"); - - if (timeSafariIntegration != null) { - timeSafariIntegration.refreshNow(); // Trigger fetch & reschedule - call.resolve(); - } else { - call.reject("TimeSafariIntegrationManager not initialized"); - } - } catch (Exception e) { - Log.e(TAG, "Error coordinating background tasks", e); - call.reject("Error: " + e.getMessage()); - } - } - - /** - * Phase 3: Handle app lifecycle events for TimeSafari coordination - */ - @PluginMethod - public void handleAppLifecycleEvent(PluginCall call) { - try { - String lifecycleEvent = call.getString("lifecycleEvent"); - if (lifecycleEvent == null) { - call.reject("lifecycleEvent parameter required"); - return; - } - - // These can be handled by the manager if needed - // For now, just log and resolve - Log.d(TAG, "Lifecycle event: " + lifecycleEvent); - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error handling lifecycle event", e); - call.reject("Error: " + e.getMessage()); - } - } - - /** - * Phase 3: Get coordination status for debugging - */ - @PluginMethod - public void getCoordinationStatus(PluginCall call) { - try { - if (timeSafariIntegration != null) { - TimeSafariIntegrationManager.StatusSnapshot status = - timeSafariIntegration.getStatusSnapshot(); - - JSObject result = new JSObject(); - result.put("activeDid", status.activeDid); - result.put("apiServerUrl", status.apiServerUrl); - result.put("notificationsGranted", status.notificationsGranted); - result.put("exactAlarmCapable", status.exactAlarmCapable); - result.put("channelId", status.channelId); - result.put("channelImportance", status.channelImportance); - - call.resolve(result); - } else { - call.reject("TimeSafariIntegrationManager not initialized"); - } - } catch (Exception e) { - Log.e(TAG, "Error getting coordination status", e); - call.reject("Error: " + e.getMessage()); - } - } - - // Daily Reminder Methods - - /** - * Ensure reminder manager is initialized - */ - private void ensureReminderManagerInitialized() { - if (reminderManager == null) { - if (scheduler == null) { - alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); - scheduler = new DailyNotificationScheduler(getContext(), alarmManager); - } - reminderManager = new DailyReminderManager(getContext(), scheduler); - } - } - - @PluginMethod - public void scheduleDailyReminder(PluginCall call) { - try { - Log.d(TAG, "Scheduling daily reminder"); - - // Ensure reminder manager is initialized - ensureReminderManagerInitialized(); - - // Extract reminder options - String id = call.getString("id"); - String title = call.getString("title"); - String body = call.getString("body"); - String time = call.getString("time"); - boolean sound = call.getBoolean("sound", true); - boolean vibration = call.getBoolean("vibration", true); - String priority = call.getString("priority", "normal"); - boolean repeatDaily = call.getBoolean("repeatDaily", true); - String timezone = call.getString("timezone"); - - // Validate required parameters - if (id == null || title == null || body == null || time == null) { - call.reject("Missing required parameters: id, title, body, time"); - return; - } - - // Delegate to reminder manager - boolean scheduled = reminderManager.scheduleReminder(id, title, body, time, - sound, vibration, priority, - repeatDaily, timezone); - - if (scheduled) { - Log.i(TAG, "Daily reminder scheduled successfully: " + id); - call.resolve(); - } else { - call.reject("Failed to schedule daily reminder"); - } - - } catch (Exception e) { - Log.e(TAG, "Error scheduling daily reminder", e); - call.reject("Daily reminder scheduling failed: " + e.getMessage()); - } - } - - @PluginMethod - public void cancelDailyReminder(PluginCall call) { - try { - Log.d(TAG, "Cancelling daily reminder"); - - // Ensure reminder manager is initialized - ensureReminderManagerInitialized(); - - String reminderId = call.getString("reminderId"); - if (reminderId == null) { - call.reject("Missing reminderId parameter"); - return; - } - - // Delegate to reminder manager - boolean cancelled = reminderManager.cancelReminder(reminderId); - - if (cancelled) { - Log.i(TAG, "Daily reminder cancelled: " + reminderId); - call.resolve(); - } else { - call.reject("Failed to cancel daily reminder"); - } - - } catch (Exception e) { - Log.e(TAG, "Error cancelling daily reminder", e); - call.reject("Daily reminder cancellation failed: " + e.getMessage()); - } - } - - @PluginMethod - public void getScheduledReminders(PluginCall call) { - try { - Log.d(TAG, "Getting scheduled reminders"); - - // Ensure reminder manager is initialized - ensureReminderManagerInitialized(); - - // Delegate to reminder manager - java.util.List reminders = reminderManager.getReminders(); - - // Convert to JSObject array - JSObject result = new JSObject(); - result.put("reminders", reminders); - - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error getting scheduled reminders", e); - call.reject("Failed to get scheduled reminders: " + e.getMessage()); - } - } - - @PluginMethod - public void updateDailyReminder(PluginCall call) { - try { - Log.d(TAG, "Updating daily reminder"); - - // Ensure reminder manager is initialized - ensureReminderManagerInitialized(); - - String reminderId = call.getString("reminderId"); - if (reminderId == null) { - call.reject("Missing reminderId parameter"); - return; - } - - // Extract updated options - String title = call.getString("title"); - String body = call.getString("body"); - String time = call.getString("time"); - Boolean sound = call.getBoolean("sound"); - Boolean vibration = call.getBoolean("vibration"); - String priority = call.getString("priority"); - Boolean repeatDaily = call.getBoolean("repeatDaily"); - String timezone = call.getString("timezone"); - - // Cancel existing reminder (use prefixed ID) - scheduler.cancelNotification("reminder_" + reminderId); - - // Update in database - // Delegate to reminder manager - boolean updated = reminderManager.updateReminder(reminderId, title, body, time, - sound, vibration, priority, - repeatDaily, timezone); - - if (updated) { - Log.i(TAG, "Daily reminder updated: " + reminderId); - call.resolve(); - } else { - call.reject("Failed to update daily reminder"); - } - - } catch (Exception e) { - Log.e(TAG, "Error updating daily reminder", e); - call.reject("Daily reminder update failed: " + e.getMessage()); - } - } - - // MARK: - Integration Point Refactor (PR1): SPI Registration Methods - - /** - * Enable or disable native fetcher (Integration Point Refactor PR1) - * - * Native fetcher is required for background workers. If disabled, - * background fetches will fail gracefully. - * - * @param call Plugin call with "enable" boolean parameter - */ - @PluginMethod - public void enableNativeFetcher(PluginCall call) { - try { - Boolean enable = call.getBoolean("enable", true); - - if (enable == null) { - call.reject("Missing 'enable' parameter"); - return; - } - - nativeFetcherEnabled = enable; - Log.i(TAG, "SPI: Native fetcher " + (enable ? "enabled" : "disabled")); - - JSObject result = new JSObject(); - result.put("enabled", nativeFetcherEnabled); - result.put("registered", nativeFetcher != null); - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error enabling/disabling native fetcher", e); - call.reject("Failed to update native fetcher state: " + e.getMessage()); - } - } - - /** - * Set scheduling policy configuration (Integration Point Refactor PR1) - * - * Updates the scheduling policy used by the plugin for retry backoff, - * prefetch timing, deduplication, and cache TTL. - * - * @param call Plugin call with SchedulingPolicy JSON object - */ - @PluginMethod - public void setPolicy(PluginCall call) { - try { - JSObject policyJson = call.getObject("policy"); - - if (policyJson == null) { - call.reject("Missing 'policy' parameter"); - return; - } - - // Parse retry backoff (required) - JSObject backoffJson = policyJson.getJSObject("retryBackoff"); - if (backoffJson == null) { - call.reject("Missing required 'policy.retryBackoff' parameter"); - return; - } - - SchedulingPolicy.RetryBackoff retryBackoff = new SchedulingPolicy.RetryBackoff( - backoffJson.has("minMs") ? backoffJson.getLong("minMs") : 2000L, - backoffJson.has("maxMs") ? backoffJson.getLong("maxMs") : 600000L, - backoffJson.has("factor") ? backoffJson.getDouble("factor") : 2.0, - backoffJson.has("jitterPct") ? backoffJson.getInt("jitterPct") : 20 - ); - - // Create policy with backoff - SchedulingPolicy policy = new SchedulingPolicy(retryBackoff); - - // Parse optional fields - if (policyJson.has("prefetchWindowMs")) { - Long prefetchWindow = policyJson.getLong("prefetchWindowMs"); - if (prefetchWindow != null) { - policy.prefetchWindowMs = prefetchWindow; - } - } - - if (policyJson.has("maxBatchSize")) { - Integer maxBatch = policyJson.getInteger("maxBatchSize"); - if (maxBatch != null) { - policy.maxBatchSize = maxBatch; - } - } - - if (policyJson.has("dedupeHorizonMs")) { - Long horizon = policyJson.getLong("dedupeHorizonMs"); - if (horizon != null) { - policy.dedupeHorizonMs = horizon; - } - } - - if (policyJson.has("cacheTtlSeconds")) { - Integer ttl = policyJson.getInteger("cacheTtlSeconds"); - if (ttl != null) { - policy.cacheTtlSeconds = ttl; - } - } - - if (policyJson.has("exactAlarmsAllowed")) { - Boolean exactAllowed = policyJson.getBoolean("exactAlarmsAllowed"); - if (exactAllowed != null) { - policy.exactAlarmsAllowed = exactAllowed; - } - } - - if (policyJson.has("fetchTimeoutMs")) { - Long timeout = policyJson.getLong("fetchTimeoutMs"); - if (timeout != null) { - policy.fetchTimeoutMs = timeout; - } - } - - // Update policy - this.schedulingPolicy = policy; - - Log.i(TAG, "SPI: Scheduling policy updated - prefetchWindow=" + - policy.prefetchWindowMs + "ms, maxBatch=" + policy.maxBatchSize + - ", dedupeHorizon=" + policy.dedupeHorizonMs + "ms"); - - call.resolve(); - - } catch (Exception e) { - Log.e(TAG, "Error setting scheduling policy", e); - call.reject("Failed to set policy: " + e.getMessage()); - } - } - - /** - * Set JavaScript content fetcher (Integration Point Refactor PR1 - stub for PR3) - * - * This is a stub implementation for PR1. Full JavaScript bridge will be - * implemented in PR3. For now, this method logs a warning and resolves. - * - * JS fetchers are ONLY used for foreground/manual refresh. Background - * workers must use native fetcher. - * - * @param call Plugin call (will be implemented in PR3) - */ - @PluginMethod - public void setJsContentFetcher(PluginCall call) { - try { - Log.w(TAG, "SPI: setJsContentFetcher called but not yet implemented (PR3)"); - Log.w(TAG, "SPI: JS fetcher will only be used for foreground operations"); - - // For PR1, just resolve - full implementation in PR3 - JSObject result = new JSObject(); - result.put("warning", "JS fetcher support not yet implemented (coming in PR3)"); - result.put("note", "Background workers use native fetcher only"); - call.resolve(result); - - } catch (Exception e) { - Log.e(TAG, "Error in setJsContentFetcher stub", e); - call.reject("JS fetcher registration failed: " + e.getMessage()); - } - } - - /** - * Get current SPI configuration status (helper for debugging) - * - * @return Current SPI state - */ - public SchedulingPolicy getSchedulingPolicy() { - return schedulingPolicy; - } - - /** - * Check if native fetcher is enabled and registered - * - * @return True if native fetcher is enabled and registered - */ - public boolean isNativeFetcherAvailable() { - return nativeFetcherEnabled && nativeFetcher != null; - } - -} diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt index a00d2ec..4848106 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt +++ b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationPlugin.kt @@ -1,7 +1,17 @@ package com.timesafari.dailynotification +import android.Manifest +import android.app.Activity +import android.app.AlarmManager import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.PowerManager import android.util.Log +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import com.getcapacitor.JSObject import com.getcapacitor.Plugin import com.getcapacitor.PluginCall @@ -20,24 +30,82 @@ import org.json.JSONObject * @version 1.1.0 */ @CapacitorPlugin(name = "DailyNotification") -class DailyNotificationPlugin : Plugin() { +open class DailyNotificationPlugin : Plugin() { companion object { private const val TAG = "DNP-PLUGIN" + + /** + * Static registry for native content fetcher + * Thread-safe: Volatile ensures visibility across threads + */ + @Volatile + private var nativeFetcher: NativeNotificationContentFetcher? = null + + /** + * Get the registered native fetcher (called from Java code) + * + * @return Registered NativeNotificationContentFetcher or null if not registered + */ + @JvmStatic + fun getNativeFetcherStatic(): NativeNotificationContentFetcher? { + return nativeFetcher + } + + /** + * Register a native content fetcher + * + * @param fetcher The native fetcher implementation to register + */ + @JvmStatic + fun registerNativeFetcher(fetcher: NativeNotificationContentFetcher?) { + nativeFetcher = fetcher + Log.i(TAG, "Native fetcher ${if (fetcher != null) "registered" else "unregistered"}") + } + + /** + * Set the native content fetcher (alias for registerNativeFetcher) + * + * @param fetcher The native fetcher implementation to register + */ + @JvmStatic + fun setNativeFetcher(fetcher: NativeNotificationContentFetcher?) { + registerNativeFetcher(fetcher) + } } - private lateinit var db: DailyNotificationDatabase + private var db: DailyNotificationDatabase? = null override fun load() { super.load() - db = DailyNotificationDatabase.getDatabase(context) - Log.i(TAG, "Daily Notification Plugin loaded") + try { + if (context == null) { + Log.e(TAG, "Context is null, cannot initialize database") + return + } + db = DailyNotificationDatabase.getDatabase(context) + Log.i(TAG, "Daily Notification Plugin loaded successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize Daily Notification Plugin", e) + // Don't throw - allow plugin to load but database operations will fail gracefully + } + } + + private fun getDatabase(): DailyNotificationDatabase { + if (db == null) { + if (context == null) { + throw IllegalStateException("Plugin not initialized: context is null") + } + db = DailyNotificationDatabase.getDatabase(context) + } + return db!! } @PluginMethod fun configure(call: PluginCall) { try { - val options = call.getObject("options") + // Capacitor passes the object directly via call.data + val options = call.data Log.i(TAG, "Configure called with options: $options") // Store configuration in database @@ -56,6 +124,385 @@ class DailyNotificationPlugin : Plugin() { } } + @PluginMethod + fun checkPermissionStatus(call: PluginCall) { + try { + if (context == null) { + return call.reject("Context not available") + } + + Log.i(TAG, "Checking permission status") + + var notificationsEnabled = false + var exactAlarmEnabled = false + var wakeLockEnabled = false + + // Check POST_NOTIFICATIONS permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationsEnabled = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + notificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + // Check exact alarm permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + exactAlarmEnabled = alarmManager?.canScheduleExactAlarms() ?: false + } else { + exactAlarmEnabled = true // Pre-Android 12, exact alarms are always allowed + } + + // Check wake lock permission (usually granted by default) + val powerManager = context.getSystemService(Context.POWER_SERVICE) as? PowerManager + wakeLockEnabled = powerManager != null + + val allPermissionsGranted = notificationsEnabled && exactAlarmEnabled && wakeLockEnabled + + val result = JSObject().apply { + put("notificationsEnabled", notificationsEnabled) + put("exactAlarmEnabled", exactAlarmEnabled) + put("wakeLockEnabled", wakeLockEnabled) + put("allPermissionsGranted", allPermissionsGranted) + } + + Log.i(TAG, "Permission status: notifications=$notificationsEnabled, exactAlarm=$exactAlarmEnabled, wakeLock=$wakeLockEnabled, all=$allPermissionsGranted") + call.resolve(result) + + } catch (e: Exception) { + Log.e(TAG, "Failed to check permission status", e) + call.reject("Permission check failed: ${e.message}") + } + } + + @PluginMethod + fun requestNotificationPermissions(call: PluginCall) { + try { + val activity = activity ?: return call.reject("Activity not available") + val context = context ?: return call.reject("Context not available") + + Log.i(TAG, "Requesting notification permissions") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // For Android 13+, request POST_NOTIFICATIONS permission + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + // Already granted + val result = JSObject().apply { + put("status", "granted") + put("granted", true) + put("notifications", "granted") + } + call.resolve(result) + } else { + // Request permission + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 1001 // Request code + ) + // Note: Permission result will be handled by onRequestPermissionsResult + // For now, resolve with pending status + val result = JSObject().apply { + put("status", "prompt") + put("granted", false) + put("notifications", "prompt") + } + call.resolve(result) + } + } else { + // For older versions, permissions are granted at install time + val result = JSObject().apply { + put("status", "granted") + put("granted", true) + put("notifications", "granted") + } + call.resolve(result) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to request notification permissions", e) + call.reject("Permission request failed: ${e.message}") + } + } + + @PluginMethod + fun configureNativeFetcher(call: PluginCall) { + try { + // Capacitor passes the object directly via call.data + val options = call.data ?: return call.reject("Options are required") + + // Support both jwtToken and jwtSecret for backward compatibility + val apiBaseUrl = options.getString("apiBaseUrl") ?: return call.reject("apiBaseUrl is required") + val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required") + val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required") + + val nativeFetcher = getNativeFetcherStatic() + if (nativeFetcher == null) { + return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.") + } + + Log.i(TAG, "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid") + + // Call the native fetcher's configure method + // Note: This assumes the native fetcher has a configure method + // If the native fetcher interface doesn't have configure, we'll need to handle it differently + try { + // Store configuration in database for later use + val configId = "native_fetcher_config" + val configValue = JSONObject().apply { + put("apiBaseUrl", apiBaseUrl) + put("activeDid", activeDid) + put("jwtToken", jwtToken) + }.toString() + + CoroutineScope(Dispatchers.IO).launch { + try { + val config = com.timesafari.dailynotification.entities.NotificationConfigEntity( + configId, null, "native_fetcher", "config", configValue, "json" + ) + getDatabase().notificationConfigDao().insertConfig(config) + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to store native fetcher config", e) + call.reject("Failed to store configuration: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Native fetcher configuration failed", e) + call.reject("Native fetcher configuration failed: ${e.message}") + } + } catch (e: Exception) { + Log.e(TAG, "Configure native fetcher error", e) + call.reject("Configuration error: ${e.message}") + } + } + + @PluginMethod + fun getNotificationStatus(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val schedules = getDatabase().scheduleDao().getAll() + val notifySchedules = schedules.filter { it.kind == "notify" && it.enabled } + + // Get last notification time from history + val history = getDatabase().historyDao().getRecent(100) // Get last 100 entries + val lastNotification = history + .filter { it.kind == "notify" && it.outcome == "success" } + .maxByOrNull { it.occurredAt } + val lastNotificationTime = lastNotification?.occurredAt ?: 0 + + val result = JSObject().apply { + put("isEnabled", notifySchedules.isNotEmpty()) + put("isScheduled", notifySchedules.isNotEmpty()) + put("lastNotificationTime", lastNotificationTime) + put("nextNotificationTime", notifySchedules.minOfOrNull { it.nextRunAt ?: Long.MAX_VALUE } ?: 0) + put("scheduledCount", notifySchedules.size) + put("pending", notifySchedules.size) // Alias for scheduledCount + put("settings", JSObject().apply { + put("enabled", notifySchedules.isNotEmpty()) + put("count", notifySchedules.size) + }) + } + + call.resolve(result) + } catch (e: Exception) { + Log.e(TAG, "Failed to get notification status", e) + call.reject("Failed to get notification status: ${e.message}") + } + } + } + + @PluginMethod + fun scheduleDailyReminder(call: PluginCall) { + // Alias for scheduleDailyNotification for backward compatibility + // scheduleDailyReminder accepts same parameters as scheduleDailyNotification + try { + // Capacitor passes the object directly via call.data + val options = call.data ?: return call.reject("Options are required") + + // Extract required fields, with defaults + val time = options.getString("time") ?: return call.reject("Time is required") + val title = options.getString("title") ?: "Daily Reminder" + val body = options.getString("body") ?: "" + val sound = options.getBoolean("sound") ?: true + val priority = options.getString("priority") ?: "default" + + Log.i(TAG, "Scheduling daily reminder: time=$time, title=$title") + + // Convert HH:mm time to cron expression (daily at specified time) + val cronExpression = convertTimeToCron(time) + + CoroutineScope(Dispatchers.IO).launch { + try { + val config = UserNotificationConfig( + enabled = true, + schedule = cronExpression, + title = title, + body = body, + sound = sound, + vibration = options.getBoolean("vibration") ?: true, + priority = priority + ) + + val nextRunTime = calculateNextRunTime(cronExpression) + + // Schedule AlarmManager notification + NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) + + // Store schedule in database + val scheduleId = options.getString("id") ?: "daily_reminder_${System.currentTimeMillis()}" + val schedule = Schedule( + id = scheduleId, + kind = "notify", + cron = cronExpression, + clockTime = time, + enabled = true, + nextRunAt = nextRunTime + ) + getDatabase().scheduleDao().upsert(schedule) + + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to schedule daily reminder", e) + call.reject("Daily reminder scheduling failed: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Schedule daily reminder error", e) + call.reject("Daily reminder error: ${e.message}") + } + } + + @PluginMethod + fun openExactAlarmSettings(call: PluginCall) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val intent = Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + activity?.startActivity(intent) + call.resolve() + } else { + call.reject("Exact alarm settings are only available on Android 12+") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to open exact alarm settings", e) + call.reject("Failed to open exact alarm settings: ${e.message}") + } + } + + @PluginMethod + fun isChannelEnabled(call: PluginCall) { + try { + val channelId = call.getString("channelId") ?: "daily_notification_channel" + val enabled = NotificationManagerCompat.from(context).areNotificationsEnabled() + + // Get notification channel importance if available + var importance = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = context?.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager? + val channel = notificationManager?.getNotificationChannel(channelId) + importance = channel?.importance ?: android.app.NotificationManager.IMPORTANCE_DEFAULT + } + + val result = JSObject().apply { + put("enabled", enabled) + put("channelId", channelId) + put("importance", importance) + } + call.resolve(result) + } catch (e: Exception) { + Log.e(TAG, "Failed to check channel status", e) + call.reject("Failed to check channel status: ${e.message}") + } + } + + @PluginMethod + fun openChannelSettings(call: PluginCall) { + try { + val channelId = call.getString("channelId") ?: "daily_notification_channel" + val intent = Intent(android.provider.Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context?.packageName) + putExtra(android.provider.Settings.EXTRA_CHANNEL_ID, channelId) + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + activity?.startActivity(intent) + val result = JSObject().apply { + put("opened", true) + put("channelId", channelId) + } + call.resolve(result) + } catch (e: Exception) { + Log.e(TAG, "Failed to start activity", e) + val result = JSObject().apply { + put("opened", false) + put("channelId", channelId) + put("error", e.message) + } + call.resolve(result) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to open channel settings", e) + call.reject("Failed to open channel settings: ${e.message}") + } + } + + @PluginMethod + fun checkStatus(call: PluginCall) { + // Comprehensive status check + try { + if (context == null) { + return call.reject("Context not available") + } + + var postNotificationsGranted = false + var channelEnabled = false + var exactAlarmsGranted = false + var channelImportance = 0 + val channelId = "daily_notification_channel" + + // Check POST_NOTIFICATIONS permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + postNotificationsGranted = context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + } else { + postNotificationsGranted = NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + // Check exact alarms permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + exactAlarmsGranted = alarmManager.canScheduleExactAlarms() + } else { + exactAlarmsGranted = true // Always available on older Android versions + } + + // Check channel status + channelEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager? + val channel = notificationManager?.getNotificationChannel(channelId) + channelImportance = channel?.importance ?: android.app.NotificationManager.IMPORTANCE_DEFAULT + channelEnabled = channel?.importance != android.app.NotificationManager.IMPORTANCE_NONE + } + + val canScheduleNow = postNotificationsGranted && channelEnabled && exactAlarmsGranted + + val result = JSObject().apply { + put("canScheduleNow", canScheduleNow) + put("postNotificationsGranted", postNotificationsGranted) + put("channelEnabled", channelEnabled) + put("exactAlarmsGranted", exactAlarmsGranted) + put("channelImportance", channelImportance) + put("channelId", channelId) + } + + call.resolve(result) + } catch (e: Exception) { + Log.e(TAG, "Failed to check status", e) + call.reject("Failed to check status: ${e.message}") + } + } + @PluginMethod fun scheduleContentFetch(call: PluginCall) { try { @@ -77,7 +524,7 @@ class DailyNotificationPlugin : Plugin() { enabled = config.enabled, nextRunAt = calculateNextRunTime(config.schedule) ) - db.scheduleDao().upsert(schedule) + getDatabase().scheduleDao().upsert(schedule) call.resolve() } catch (e: Exception) { @@ -91,6 +538,76 @@ class DailyNotificationPlugin : Plugin() { } } + @PluginMethod + fun scheduleDailyNotification(call: PluginCall) { + try { + // Capacitor passes the object directly via call.data + val options = call.data ?: return call.reject("Options are required") + + val time = options.getString("time") ?: return call.reject("Time is required") + val title = options.getString("title") ?: "Daily Notification" + val body = options.getString("body") ?: "" + val sound = options.getBoolean("sound") ?: true + val priority = options.getString("priority") ?: "default" + val url = options.getString("url") // Optional URL for prefetch + + Log.i(TAG, "Scheduling daily notification: time=$time, title=$title") + + // Convert HH:mm time to cron expression (daily at specified time) + val cronExpression = convertTimeToCron(time) + + CoroutineScope(Dispatchers.IO).launch { + try { + val config = UserNotificationConfig( + enabled = true, + schedule = cronExpression, + title = title, + body = body, + sound = sound, + vibration = true, + priority = priority + ) + + val nextRunTime = calculateNextRunTime(cronExpression) + + // Schedule AlarmManager notification + NotifyReceiver.scheduleExactNotification(context, nextRunTime, config) + + // Schedule prefetch 5 minutes before notification (if URL provided) + if (url != null) { + val fetchTime = nextRunTime - (5 * 60 * 1000L) // 5 minutes before + FetchWorker.scheduleDelayedFetch( + context, + fetchTime, + nextRunTime, + url + ) + Log.i(TAG, "Prefetch scheduled: fetchTime=$fetchTime, notificationTime=$nextRunTime") + } + + // Store schedule in database + val schedule = Schedule( + id = "daily_${System.currentTimeMillis()}", + kind = "notify", + cron = cronExpression, + clockTime = time, + enabled = true, + nextRunAt = nextRunTime + ) + getDatabase().scheduleDao().upsert(schedule) + + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to schedule daily notification", e) + call.reject("Daily notification scheduling failed: ${e.message}") + } + } + } catch (e: Exception) { + Log.e(TAG, "Schedule daily notification error", e) + call.reject("Daily notification error: ${e.message}") + } + } + @PluginMethod fun scheduleUserNotification(call: PluginCall) { try { @@ -114,7 +631,7 @@ class DailyNotificationPlugin : Plugin() { enabled = config.enabled, nextRunAt = nextRunTime ) - db.scheduleDao().upsert(schedule) + getDatabase().scheduleDao().upsert(schedule) call.resolve() } catch (e: Exception) { @@ -131,9 +648,11 @@ class DailyNotificationPlugin : Plugin() { @PluginMethod fun scheduleDualNotification(call: PluginCall) { try { - val configJson = call.getObject("config") - val contentFetchConfig = parseContentFetchConfig(configJson.getObject("contentFetch")) - val userNotificationConfig = parseUserNotificationConfig(configJson.getObject("userNotification")) + val configJson = call.getObject("config") ?: return call.reject("Config is required") + val contentFetchObj = configJson.getJSObject("contentFetch") ?: return call.reject("contentFetch config is required") + val userNotificationObj = configJson.getJSObject("userNotification") ?: return call.reject("userNotification config is required") + val contentFetchConfig = parseContentFetchConfig(contentFetchObj) + val userNotificationConfig = parseUserNotificationConfig(userNotificationObj) Log.i(TAG, "Scheduling dual notification") @@ -161,8 +680,8 @@ class DailyNotificationPlugin : Plugin() { nextRunAt = nextRunTime ) - db.scheduleDao().upsert(fetchSchedule) - db.scheduleDao().upsert(notifySchedule) + getDatabase().scheduleDao().upsert(fetchSchedule) + getDatabase().scheduleDao().upsert(notifySchedule) call.resolve() } catch (e: Exception) { @@ -180,9 +699,9 @@ class DailyNotificationPlugin : Plugin() { fun getDualScheduleStatus(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { - val enabledSchedules = db.scheduleDao().getEnabled() - val latestCache = db.contentCacheDao().getLatest() - val recentHistory = db.historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L)) + val enabledSchedules = getDatabase().scheduleDao().getEnabled() + val latestCache = getDatabase().contentCacheDao().getLatest() + val recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L)) val status = JSObject().apply { put("nextRuns", enabledSchedules.map { it.nextRunAt }) @@ -205,8 +724,8 @@ class DailyNotificationPlugin : Plugin() { @PluginMethod fun registerCallback(call: PluginCall) { try { - val name = call.getString("name") - val callback = call.getObject("callback") + val name = call.getString("name") ?: return call.reject("Callback name is required") + val callback = call.getObject("callback") ?: return call.reject("Callback data is required") Log.i(TAG, "Registering callback: $name") @@ -214,14 +733,14 @@ class DailyNotificationPlugin : Plugin() { try { val callbackRecord = Callback( id = name, - kind = callback.getString("kind", "local"), - target = callback.getString("target", ""), + kind = callback.getString("kind") ?: "local", + target = callback.getString("target") ?: "", headersJson = callback.getString("headers"), enabled = true, createdAt = System.currentTimeMillis() ) - db.callbackDao().upsert(callbackRecord) + getDatabase().callbackDao().upsert(callbackRecord) call.resolve() } catch (e: Exception) { Log.e(TAG, "Failed to register callback", e) @@ -238,7 +757,7 @@ class DailyNotificationPlugin : Plugin() { fun getContentCache(call: PluginCall) { CoroutineScope(Dispatchers.IO).launch { try { - val latestCache = db.contentCacheDao().getLatest() + val latestCache = getDatabase().contentCacheDao().getLatest() val result = JSObject() if (latestCache != null) { @@ -257,27 +776,784 @@ class DailyNotificationPlugin : Plugin() { } } + // ============================================================================ + // DATABASE ACCESS METHODS + // ============================================================================ + // These methods provide TypeScript/JavaScript access to the plugin's internal + // SQLite database. All operations run on background threads for thread safety. + // ============================================================================ + + // SCHEDULES MANAGEMENT + + @PluginMethod + fun getSchedules(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val options = call.getObject("options") + val kind = options?.getString("kind") + val enabled = options?.getBoolean("enabled") + + val schedules = when { + kind != null && enabled != null -> + getDatabase().scheduleDao().getByKindAndEnabled(kind, enabled) + kind != null -> + getDatabase().scheduleDao().getByKind(kind) + enabled != null -> + if (enabled) getDatabase().scheduleDao().getEnabled() else getDatabase().scheduleDao().getAll().filter { !it.enabled } + else -> + getDatabase().scheduleDao().getAll() + } + + // Return array wrapped in JSObject - Capacitor will serialize correctly + val schedulesArray = org.json.JSONArray() + schedules.forEach { schedulesArray.put(scheduleToJson(it)) } + + call.resolve(JSObject().apply { + put("schedules", schedulesArray) + }) + } catch (e: Exception) { + Log.e(TAG, "Failed to get schedules", e) + call.reject("Failed to get schedules: ${e.message}") + } + } + } + + @PluginMethod + fun getSchedule(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Schedule ID is required") + + val schedule = getDatabase().scheduleDao().getById(id) + + if (schedule != null) { + call.resolve(scheduleToJson(schedule)) + } else { + call.resolve(JSObject().apply { put("schedule", null) }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get schedule", e) + call.reject("Failed to get schedule: ${e.message}") + } + } + } + + @PluginMethod + fun createSchedule(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val scheduleJson = call.getObject("schedule") + ?: return@launch call.reject("Schedule data is required") + + val kindStr = scheduleJson.getString("kind") ?: return@launch call.reject("Schedule kind is required") + val id = scheduleJson.getString("id") ?: "${kindStr}_${System.currentTimeMillis()}" + val schedule = Schedule( + id = id, + kind = kindStr, + cron = scheduleJson.getString("cron"), + clockTime = scheduleJson.getString("clockTime"), + enabled = scheduleJson.getBoolean("enabled") ?: true, + jitterMs = scheduleJson.getInt("jitterMs") ?: 0, + backoffPolicy = scheduleJson.getString("backoffPolicy") ?: "exp", + stateJson = scheduleJson.getString("stateJson") + ) + + getDatabase().scheduleDao().upsert(schedule) + call.resolve(scheduleToJson(schedule)) + } catch (e: Exception) { + Log.e(TAG, "Failed to create schedule", e) + call.reject("Failed to create schedule: ${e.message}") + } + } + } + + @PluginMethod + fun updateSchedule(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Schedule ID is required") + + val updates = call.getObject("updates") + ?: return@launch call.reject("Updates are required") + + val existing = getDatabase().scheduleDao().getById(id) + ?: return@launch call.reject("Schedule not found: $id") + + // Update fields + getDatabase().scheduleDao().update( + id = id, + enabled = updates.getBoolean("enabled")?.let { it }, + cron = updates.getString("cron"), + clockTime = updates.getString("clockTime"), + jitterMs = updates.getInt("jitterMs")?.let { it }, + backoffPolicy = updates.getString("backoffPolicy"), + stateJson = updates.getString("stateJson") + ) + + // Update run times if provided + val lastRunAt = updates.getLong("lastRunAt") + val nextRunAt = updates.getLong("nextRunAt") + if (lastRunAt != null || nextRunAt != null) { + getDatabase().scheduleDao().updateRunTimes(id, lastRunAt, nextRunAt) + } + + val updated = getDatabase().scheduleDao().getById(id) + call.resolve(scheduleToJson(updated!!)) + } catch (e: Exception) { + Log.e(TAG, "Failed to update schedule", e) + call.reject("Failed to update schedule: ${e.message}") + } + } + } + + @PluginMethod + fun deleteSchedule(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Schedule ID is required") + + getDatabase().scheduleDao().deleteById(id) + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to delete schedule", e) + call.reject("Failed to delete schedule: ${e.message}") + } + } + } + + @PluginMethod + fun enableSchedule(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Schedule ID is required") + + val enabled = call.getBoolean("enabled") ?: true + + getDatabase().scheduleDao().setEnabled(id, enabled) + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to enable/disable schedule", e) + call.reject("Failed to update schedule: ${e.message}") + } + } + } + + @PluginMethod + fun calculateNextRunTime(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val schedule = call.getString("schedule") + ?: return@launch call.reject("Schedule expression is required") + + val nextRun = calculateNextRunTime(schedule) + + call.resolve(JSObject().apply { + put("nextRunAt", nextRun) + }) + } catch (e: Exception) { + Log.e(TAG, "Failed to calculate next run time", e) + call.reject("Failed to calculate next run time: ${e.message}") + } + } + } + + // CONTENT CACHE MANAGEMENT + + @PluginMethod + fun getContentCacheById(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val options = call.getObject("options") + val id = options?.getString("id") + + val cache = if (id != null) { + getDatabase().contentCacheDao().getById(id) + } else { + getDatabase().contentCacheDao().getLatest() + } + + if (cache != null) { + call.resolve(contentCacheToJson(cache)) + } else { + call.resolve(JSObject().apply { put("contentCache", null) }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get content cache", e) + call.reject("Failed to get content cache: ${e.message}") + } + } + } + + @PluginMethod + fun getLatestContentCache(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val cache = getDatabase().contentCacheDao().getLatest() + + if (cache != null) { + call.resolve(contentCacheToJson(cache)) + } else { + call.resolve(JSObject().apply { put("contentCache", null) }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get latest content cache", e) + call.reject("Failed to get latest content cache: ${e.message}") + } + } + } + + @PluginMethod + fun getContentCacheHistory(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val limit = call.getInt("limit") ?: 10 + + val history = getDatabase().contentCacheDao().getHistory(limit) + + val historyArray = org.json.JSONArray() + history.forEach { historyArray.put(contentCacheToJson(it)) } + + call.resolve(JSObject().apply { + put("history", historyArray) + }) + } catch (e: Exception) { + Log.e(TAG, "Failed to get content cache history", e) + call.reject("Failed to get content cache history: ${e.message}") + } + } + } + + @PluginMethod + fun saveContentCache(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val contentJson = call.getObject("content") + ?: return@launch call.reject("Content data is required") + + val id = contentJson.getString("id") ?: "cache_${System.currentTimeMillis()}" + val payload = contentJson.getString("payload") + ?: return@launch call.reject("Payload is required") + val ttlSeconds = contentJson.getInt("ttlSeconds") + ?: return@launch call.reject("TTL seconds is required") + + val cache = ContentCache( + id = id, + fetchedAt = System.currentTimeMillis(), + ttlSeconds = ttlSeconds, + payload = payload.toByteArray(), + meta = contentJson.getString("meta") + ) + + getDatabase().contentCacheDao().upsert(cache) + call.resolve(contentCacheToJson(cache)) + } catch (e: Exception) { + Log.e(TAG, "Failed to save content cache", e) + call.reject("Failed to save content cache: ${e.message}") + } + } + } + + @PluginMethod + fun clearContentCacheEntries(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val options = call.getObject("options") + val olderThan = options?.getLong("olderThan") + + if (olderThan != null) { + getDatabase().contentCacheDao().deleteOlderThan(olderThan) + } else { + getDatabase().contentCacheDao().deleteAll() + } + + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to clear content cache", e) + call.reject("Failed to clear content cache: ${e.message}") + } + } + } + + // CALLBACKS MANAGEMENT + + @PluginMethod + fun getCallbacks(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val options = call.getObject("options") + val enabled = options?.getBoolean("enabled") + + val callbacks = if (enabled != null) { + getDatabase().callbackDao().getByEnabled(enabled) + } else { + getDatabase().callbackDao().getAll() + } + + val callbacksArray = org.json.JSONArray() + callbacks.forEach { callbacksArray.put(callbackToJson(it)) } + + call.resolve(JSObject().apply { + put("callbacks", callbacksArray) + }) + } catch (e: Exception) { + Log.e(TAG, "Failed to get callbacks", e) + call.reject("Failed to get callbacks: ${e.message}") + } + } + } + + @PluginMethod + fun getCallback(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Callback ID is required") + + val callback = getDatabase().callbackDao().getById(id) + + if (callback != null) { + call.resolve(callbackToJson(callback)) + } else { + call.resolve(JSObject().apply { put("callback", null) }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get callback", e) + call.reject("Failed to get callback: ${e.message}") + } + } + } + + @PluginMethod + fun registerCallbackConfig(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val callbackJson = call.getObject("callback") + ?: return@launch call.reject("Callback data is required") + + val id = callbackJson.getString("id") + ?: return@launch call.reject("Callback ID is required") + val kindStr = callbackJson.getString("kind") + ?: return@launch call.reject("Callback kind is required") + val targetStr = callbackJson.getString("target") + ?: return@launch call.reject("Callback target is required") + + val callback = Callback( + id = id, + kind = kindStr, + target = targetStr, + headersJson = callbackJson.getString("headersJson"), + enabled = callbackJson.getBoolean("enabled") ?: true, + createdAt = System.currentTimeMillis() + ) + + getDatabase().callbackDao().upsert(callback) + call.resolve(callbackToJson(callback)) + } catch (e: Exception) { + Log.e(TAG, "Failed to register callback", e) + call.reject("Failed to register callback: ${e.message}") + } + } + } + + @PluginMethod + fun updateCallback(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Callback ID is required") + + val updates = call.getObject("updates") + ?: return@launch call.reject("Updates are required") + + getDatabase().callbackDao().update( + id = id, + kind = updates.getString("kind"), + target = updates.getString("target"), + headersJson = updates.getString("headersJson"), + enabled = updates.getBoolean("enabled")?.let { it } + ) + + val updated = getDatabase().callbackDao().getById(id) + if (updated != null) { + call.resolve(callbackToJson(updated)) + } else { + call.reject("Callback not found after update") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to update callback", e) + call.reject("Failed to update callback: ${e.message}") + } + } + } + + @PluginMethod + fun deleteCallback(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Callback ID is required") + + getDatabase().callbackDao().deleteById(id) + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to delete callback", e) + call.reject("Failed to delete callback: ${e.message}") + } + } + } + + @PluginMethod + fun enableCallback(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val id = call.getString("id") + ?: return@launch call.reject("Callback ID is required") + + val enabled = call.getBoolean("enabled") ?: true + + getDatabase().callbackDao().setEnabled(id, enabled) + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to enable/disable callback", e) + call.reject("Failed to update callback: ${e.message}") + } + } + } + + // HISTORY MANAGEMENT + + @PluginMethod + fun getHistory(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val options = call.getObject("options") + val since = options?.getLong("since") + val kind = options?.getString("kind") + val limit = options?.getInt("limit") ?: 50 + + val history = when { + since != null && kind != null -> + getDatabase().historyDao().getSinceByKind(since, kind, limit) + since != null -> + getDatabase().historyDao().getSince(since).take(limit) + else -> + getDatabase().historyDao().getRecent(limit) + } + + val historyArray = org.json.JSONArray() + history.forEach { historyArray.put(historyToJson(it)) } + + call.resolve(JSObject().apply { + put("history", historyArray) + }) + } catch (e: Exception) { + Log.e(TAG, "Failed to get history", e) + call.reject("Failed to get history: ${e.message}") + } + } + } + + @PluginMethod + fun getHistoryStats(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val allHistory = getDatabase().historyDao().getRecent(Int.MAX_VALUE) + + val outcomes = mutableMapOf() + val kinds = mutableMapOf() + var mostRecent: Long? = null + var oldest: Long? = null + + allHistory.forEach { entry -> + outcomes[entry.outcome] = (outcomes[entry.outcome] ?: 0) + 1 + kinds[entry.kind] = (kinds[entry.kind] ?: 0) + 1 + + if (mostRecent == null || entry.occurredAt > mostRecent!!) { + mostRecent = entry.occurredAt + } + if (oldest == null || entry.occurredAt < oldest!!) { + oldest = entry.occurredAt + } + } + + call.resolve(JSObject().apply { + put("totalCount", allHistory.size) + put("outcomes", JSObject().apply { + outcomes.forEach { (k, v) -> put(k, v) } + }) + put("kinds", JSObject().apply { + kinds.forEach { (k, v) -> put(k, v) } + }) + put("mostRecent", mostRecent) + put("oldest", oldest) + }) + } catch (e: Exception) { + Log.e(TAG, "Failed to get history stats", e) + call.reject("Failed to get history stats: ${e.message}") + } + } + } + + // CONFIGURATION MANAGEMENT + + @PluginMethod + fun getConfig(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val key = call.getString("key") + ?: return@launch call.reject("Config key is required") + + val options = call.getObject("options") + val timesafariDid = options?.getString("timesafariDid") + + val entity = if (timesafariDid != null) { + getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid) + } else { + getDatabase().notificationConfigDao().getConfigByKey(key) + } + + if (entity != null) { + call.resolve(configToJson(entity)) + } else { + call.resolve(JSObject().apply { put("config", null) }) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to get config", e) + call.reject("Failed to get config: ${e.message}") + } + } + } + + @PluginMethod + fun getAllConfigs(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val options = call.getObject("options") + val timesafariDid = options?.getString("timesafariDid") + val configType = options?.getString("configType") + + val configs = when { + timesafariDid != null && configType != null -> { + getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid) + .filter { it.configType == configType } + } + timesafariDid != null -> { + getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid) + } + configType != null -> { + getDatabase().notificationConfigDao().getConfigsByType(configType) + } + else -> { + getDatabase().notificationConfigDao().getAllConfigs() + } + } + + val configsArray = org.json.JSONArray() + configs.forEach { configsArray.put(configToJson(it)) } + + call.resolve(JSObject().apply { + put("configs", configsArray) + }) + } catch (e: Exception) { + Log.e(TAG, "Failed to get all configs", e) + call.reject("Failed to get configs: ${e.message}") + } + } + } + + @PluginMethod + fun setConfig(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val configJson = call.getObject("config") + ?: return@launch call.reject("Config data is required") + + val id = configJson.getString("id") ?: "config_${System.currentTimeMillis()}" + val timesafariDid = configJson.getString("timesafariDid") + val configType = configJson.getString("configType") + ?: return@launch call.reject("Config type is required") + val configKey = configJson.getString("configKey") + ?: return@launch call.reject("Config key is required") + val configValue = configJson.getString("configValue") + ?: return@launch call.reject("Config value is required") + val configDataType = configJson.getString("configDataType", "string") + + val entity = com.timesafari.dailynotification.entities.NotificationConfigEntity( + id, timesafariDid, configType, configKey, configValue, configDataType + ) + + // Set optional fields + configJson.getString("metadata")?.let { entity.metadata = it } + configJson.getBoolean("isEncrypted", false)?.let { + entity.isEncrypted = it + configJson.getString("encryptionKeyId")?.let { entity.encryptionKeyId = it } + } + configJson.getLong("ttlSeconds")?.let { entity.ttlSeconds = it } + configJson.getBoolean("isActive", true)?.let { entity.isActive = it } + + getDatabase().notificationConfigDao().insertConfig(entity) + call.resolve(configToJson(entity)) + } catch (e: Exception) { + Log.e(TAG, "Failed to set config", e) + call.reject("Failed to set config: ${e.message}") + } + } + } + + @PluginMethod + fun updateConfig(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val key = call.getString("key") + ?: return@launch call.reject("Config key is required") + val value = call.getString("value") + ?: return@launch call.reject("Config value is required") + + val options = call.getObject("options") + val timesafariDid = options?.getString("timesafariDid") + + val entity = if (timesafariDid != null) { + getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid) + } else { + getDatabase().notificationConfigDao().getConfigByKey(key) + } + + if (entity == null) { + return@launch call.reject("Config not found") + } + + entity.updateValue(value) + getDatabase().notificationConfigDao().updateConfig(entity) + call.resolve(configToJson(entity)) + } catch (e: Exception) { + Log.e(TAG, "Failed to update config", e) + call.reject("Failed to update config: ${e.message}") + } + } + } + + @PluginMethod + fun deleteConfig(call: PluginCall) { + CoroutineScope(Dispatchers.IO).launch { + try { + val key = call.getString("key") + ?: return@launch call.reject("Config key is required") + + val options = call.getObject("options") + val timesafariDid = options?.getString("timesafariDid") + + val entity = if (timesafariDid != null) { + getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid) + } else { + getDatabase().notificationConfigDao().getConfigByKey(key) + } + + if (entity == null) { + return@launch call.reject("Config not found") + } + + getDatabase().notificationConfigDao().deleteConfig(entity.id) + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "Failed to delete config", e) + call.reject("Failed to delete config: ${e.message}") + } + } + } + + // Helper methods to convert entities to JSON + private fun scheduleToJson(schedule: Schedule): JSObject { + return JSObject().apply { + put("id", schedule.id) + put("kind", schedule.kind) + put("cron", schedule.cron) + put("clockTime", schedule.clockTime) + put("enabled", schedule.enabled) + put("lastRunAt", schedule.lastRunAt) + put("nextRunAt", schedule.nextRunAt) + put("jitterMs", schedule.jitterMs) + put("backoffPolicy", schedule.backoffPolicy) + put("stateJson", schedule.stateJson) + } + } + + private fun contentCacheToJson(cache: ContentCache): JSObject { + return JSObject().apply { + put("id", cache.id) + put("fetchedAt", cache.fetchedAt) + put("ttlSeconds", cache.ttlSeconds) + put("payload", String(cache.payload)) + put("meta", cache.meta) + } + } + + private fun callbackToJson(callback: Callback): JSObject { + return JSObject().apply { + put("id", callback.id) + put("kind", callback.kind) + put("target", callback.target) + put("headersJson", callback.headersJson) + put("enabled", callback.enabled) + put("createdAt", callback.createdAt) + } + } + + private fun historyToJson(history: History): JSObject { + return JSObject().apply { + put("id", history.id) + put("refId", history.refId) + put("kind", history.kind) + put("occurredAt", history.occurredAt) + put("durationMs", history.durationMs) + put("outcome", history.outcome) + put("diagJson", history.diagJson) + } + } + + private fun configToJson(config: com.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject { + return JSObject().apply { + put("id", config.id) + put("timesafariDid", config.timesafariDid) + put("configType", config.configType) + put("configKey", config.configKey) + put("configValue", config.configValue) + put("configDataType", config.configDataType) + put("isEncrypted", config.isEncrypted) + put("encryptionKeyId", config.encryptionKeyId) + put("createdAt", config.createdAt) + put("updatedAt", config.updatedAt) + put("ttlSeconds", config.ttlSeconds) + put("isActive", config.isActive) + put("metadata", config.metadata) + } + } + // Helper methods private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig { + val callbacksObj = configJson.getJSObject("callbacks") return ContentFetchConfig( - enabled = configJson.getBoolean("enabled", true), - schedule = configJson.getString("schedule", "0 9 * * *"), + enabled = configJson.getBoolean("enabled") ?: true, + schedule = configJson.getString("schedule") ?: "0 9 * * *", url = configJson.getString("url"), timeout = configJson.getInt("timeout"), retryAttempts = configJson.getInt("retryAttempts"), retryDelay = configJson.getInt("retryDelay"), callbacks = CallbackConfig( - apiService = configJson.getObject("callbacks")?.getString("apiService"), - database = configJson.getObject("callbacks")?.getString("database"), - reporting = configJson.getObject("callbacks")?.getString("reporting") + apiService = callbacksObj?.getString("apiService"), + database = callbacksObj?.getString("database"), + reporting = callbacksObj?.getString("reporting") ) ) } private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig { return UserNotificationConfig( - enabled = configJson.getBoolean("enabled", true), - schedule = configJson.getString("schedule", "0 9 * * *"), + enabled = configJson.getBoolean("enabled") ?: true, + schedule = configJson.getString("schedule") ?: "0 9 * * *", title = configJson.getString("title"), body = configJson.getString("body"), sound = configJson.getBoolean("sound"), @@ -291,4 +1567,31 @@ class DailyNotificationPlugin : Plugin() { val now = System.currentTimeMillis() return now + (24 * 60 * 60 * 1000L) // Next day } + + /** + * Convert HH:mm time string to cron expression (daily at specified time) + * Example: "09:30" -> "30 9 * * *" + */ + private fun convertTimeToCron(time: String): String { + try { + val parts = time.split(":") + if (parts.size != 2) { + throw IllegalArgumentException("Invalid time format: $time. Expected HH:mm") + } + val hour = parts[0].toInt() + val minute = parts[1].toInt() + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw IllegalArgumentException("Invalid time values: hour=$hour, minute=$minute") + } + + // Cron format: minute hour day month day-of-week + // Daily at specified time: "minute hour * * *" + return "$minute $hour * * *" + } catch (e: Exception) { + Log.e(TAG, "Failed to convert time to cron: $time", e) + // Default to 9:00 AM if conversion fails + return "0 9 * * *" + } + } } diff --git a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java b/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java index 2051b70..14e4c5f 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DailyNotificationWorker.java +++ b/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); diff --git a/android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt b/android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt index cda440c..f832cc8 100644 --- a/android/src/main/java/com/timesafari/dailynotification/DatabaseSchema.kt +++ b/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 + @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 + + @Query("SELECT * FROM schedules WHERE kind = :kind") + suspend fun getByKind(kind: String): List + + @Query("SELECT * FROM schedules WHERE kind = :kind AND enabled = :enabled") + suspend fun getByKindAndEnabled(kind: String, enabled: Boolean): List + @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 + @Query("SELECT * FROM callbacks") + suspend fun getAll(): List + + @Query("SELECT * FROM callbacks WHERE enabled = :enabled") + suspend fun getByEnabled(enabled: Boolean): List + + @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 + @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 + + @Query("SELECT * FROM history ORDER BY occurredAt DESC LIMIT :limit") + suspend fun getRecent(limit: Int): List + @Query("DELETE FROM history WHERE occurredAt < :cutoffTime") suspend fun deleteOlderThan(cutoffTime: Long) diff --git a/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt b/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt index 79e5273..183b32d 100644 --- a/android/src/main/java/com/timesafari/dailynotification/FetchWorker.kt +++ b/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() + .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() + .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 - } - } -} diff --git a/android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java b/android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java deleted file mode 100644 index cca3b8e..0000000 --- a/android/src/main/java/com/timesafari/dailynotification/database/DailyNotificationDatabase.java +++ /dev/null @@ -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; - } -} diff --git a/android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java b/android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java index 8219a87..32c447d 100644 --- a/android/src/main/java/com/timesafari/dailynotification/storage/DailyNotificationStorageRoom.java +++ b/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 ===== diff --git a/docs/DATABASE_INTERFACES.md b/docs/DATABASE_INTERFACES.md new file mode 100644 index 0000000..a8abfb3 --- /dev/null +++ b/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` + +#### 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` + +#### 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` + +#### Delete Schedule + +```typescript +await DailyNotification.deleteSchedule('notify_1234567890'); +``` + +**Returns**: `Promise` + +#### Enable/Disable Schedule + +```typescript +// Disable schedule +await DailyNotification.enableSchedule('notify_1234567890', false); + +// Enable schedule +await DailyNotification.enableSchedule('notify_1234567890', true); +``` + +**Returns**: `Promise` + +#### 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` (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` + +#### Get Content Cache by ID + +```typescript +const cache = await DailyNotification.getContentCacheById({ + id: 'cache_1234567890' +}); +``` + +**Returns**: `Promise` + +#### 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` + +#### 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` + +### 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` + +#### 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` + +#### 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` + +#### Update Configuration + +```typescript +await DailyNotification.updateConfig( + 'notification_sound_enabled', + 'false', + { timesafariDid: 'did:ethr:0x...' } +); +``` + +**Returns**: `Promise` + +#### Delete Configuration + +```typescript +await DailyNotification.deleteConfig('notification_sound_enabled', { + timesafariDid: 'did:ethr:0x...' +}); +``` + +**Returns**: `Promise` + +### 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` + +#### 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` + +#### Update Callback + +```typescript +await DailyNotification.updateCallback('on_notify_delivered', { + enabled: false, + headersJson: JSON.stringify({ 'Authorization': 'Bearer newtoken' }) +}); +``` + +**Returns**: `Promise` + +#### Delete Callback + +```typescript +await DailyNotification.deleteCallback('on_notify_delivered'); +``` + +**Returns**: `Promise` + +#### Enable/Disable Callback + +```typescript +await DailyNotification.enableCallback('on_notify_delivered', false); +``` + +**Returns**: `Promise` + +### 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` + +## 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 { + 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) { + 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. + diff --git a/docs/DATABASE_INTERFACES_IMPLEMENTATION.md b/docs/DATABASE_INTERFACES_IMPLEMENTATION.md new file mode 100644 index 0000000..7a5ff69 --- /dev/null +++ b/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. + diff --git a/src/definitions.ts b/src/definitions.ts index ccfddeb..7dcbecd 100644 --- a/src/definitions.ts +++ b/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; } +// ============================================================================ +// 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; + /** Count by kind */ + kinds: Record; + /** 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; checkPermissions(): Promise; requestPermissions(): Promise; + checkPermissionStatus(): Promise; + requestNotificationPermissions(): Promise; + isChannelEnabled(channelId?: string): Promise<{ enabled: boolean; channelId: string }>; + openChannelSettings(channelId?: string): Promise; + checkStatus(): Promise; // New dual scheduling methods scheduleContentFetch(config: ContentFetchConfig): Promise; @@ -443,6 +641,272 @@ export interface DailyNotificationPlugin { unregisterCallback(name: string): Promise; getRegisteredCallbacks(): Promise; + // ============================================================================ + // 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; + + /** + * 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; + + /** + * 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): Promise; + + /** + * Delete a schedule + * + * @param id Schedule ID + * @returns Promise resolving when deletion completes + */ + deleteSchedule(id: string): Promise; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * Get the latest content cache entry + * + * @returns Promise resolving to latest ContentCache object or null + */ + getLatestContentCache(): Promise; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * 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; + + /** + * Register a new callback + * + * @param callback Callback configuration + * @returns Promise resolving to created Callback object + */ + registerCallbackConfig(callback: CreateCallbackInput): Promise; + + /** + * 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): Promise; + + /** + * Delete a callback + * + * @param id Callback ID + * @returns Promise resolving when deletion completes + */ + deleteCallback(id: string): Promise; + + /** + * 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; + + /** + * 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; + // Phase 1: ActiveDid Management Methods (Option A Implementation) setActiveDidFromHost(activeDid: string): Promise; onActiveDidChange(callback: (newActiveDid: string) => Promise): void; diff --git a/test-apps/android-test-app/app/src/main/AndroidManifest.xml b/test-apps/android-test-app/app/src/main/AndroidManifest.xml index 0cd446e..99045d2 100644 --- a/test-apps/android-test-app/app/src/main/AndroidManifest.xml +++ b/test-apps/android-test-app/app/src/main/AndroidManifest.xml @@ -34,6 +34,13 @@ + + + +