feat(android): consolidate databases and add prefetch scheduling

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

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

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

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

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

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

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

The unified database ensures schedule persistence across reboots
and provides a single source of truth for all plugin data.
Prefetch scheduling enables content caching before notifications
fire, improving offline-first reliability.
This commit is contained in:
Matthew Raymer
2025-11-06 06:28:00 +00:00
parent d9bdeb6d02
commit 18106e5ba8
17 changed files with 3310 additions and 3114 deletions

View File

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