diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3aa15877..6392c86b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,30 @@ android:grantUriPermissions="true"> + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json index d89dc7dd..721bea0d 100644 --- a/android/app/src/main/assets/capacitor.plugins.json +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -35,10 +35,6 @@ "pkg": "@capawesome/capacitor-file-picker", "classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin" }, - { - "pkg": "@timesafari/daily-notification-plugin", - "classpath": "com.timesafari.dailynotification.DailyNotificationPlugin" - }, { "pkg": "@timesafari/daily-notification-plugin", "classpath": "com.timesafari.dailynotification.DailyNotificationPlugin" diff --git a/doc/daily-notification-plugin-integration-plan.md b/doc/daily-notification-plugin-integration-plan.md index 4637554f..35c8aa5a 100644 --- a/doc/daily-notification-plugin-integration-plan.md +++ b/doc/daily-notification-plugin-integration-plan.md @@ -241,7 +241,7 @@ export interface ScheduleOptions { export interface NativeFetcherConfig { apiServer: string; - jwt: string; + jwt: string; // Ignored - generated automatically by configureNativeFetcher starredPlanHandleIds: string[]; } ``` @@ -250,6 +250,63 @@ export interface NativeFetcherConfig { - **Capacitor**: Full implementation, all methods functional - **Web/Electron**: Status/permission methods return `null`, scheduling methods throw errors with clear messages +### Authentication & Token Management + +#### Background Prefetch Authentication + +The daily notification plugin requires authentication tokens for background prefetch operations. The implementation uses a **hybrid token refresh strategy** that balances security with offline capability. + +**Token Generation** (`src/libs/crypto/index.ts`): +- Function: `accessTokenForBackground(did, expirationMinutes?)` +- Default expiration: **72 hours** (4320 minutes) +- Token type: JWT with ES256K signing +- Payload: `{ exp, iat, iss: did }` + +**Why 72 Hours?** +- Balances security (read-only prefetch operations) with offline capability +- Reduces need for app to wake itself for token refresh +- Allows plugin to work offline for extended periods (e.g., weekend trips) +- Longer than typical prefetch windows (5 minutes before notification) + +**Token Refresh Strategy (Hybrid Approach)**: + +1. **Proactive Refresh Triggers**: + - Component mount (`DailyNotificationSection.mounted()`) + - App resume (Capacitor `resume` event) + - Notification enabled (when user enables daily notifications) + +2. **Refresh Implementation** (`DailyNotificationSection.refreshNativeFetcherConfig()`): + - Checks if notifications are supported and enabled + - Retrieves API server URL from settings + - Retrieves starred plans from settings + - Calls `configureNativeFetcher()` to generate fresh token + - Errors are logged but don't interrupt user experience + +3. **Offline Behavior**: + - If token expires while offline → plugin uses cached content + - Next time app opens → token automatically refreshed + - No app wake-up required (refresh happens when app is already open) + +**Configuration Flow** (`CapacitorPlatformService.configureNativeFetcher()`): + +1. Retrieves active DID from `active_identity` table (single source of truth) +2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()` +3. Configures plugin with API server URL, active DID, and JWT token +4. Plugin stores token in its Room database for background workers + +**Security Considerations**: +- Tokens are used only for read-only prefetch operations +- Tokens are stored securely in plugin's Room database +- Tokens are refreshed proactively to minimize exposure window +- No private keys are exposed to native code +- Token generation happens in TypeScript (no Java crypto compatibility issues) + +**Error Handling**: +- Returns `null` if active DID not found (no user logged in) +- Returns `null` if JWT generation fails +- Logs errors but doesn't throw (allows graceful degradation) +- Refresh failures don't interrupt user experience (plugin uses cached content) + ### Component Architecture #### Views Structure diff --git a/package-lock.json b/package-lock.json index dec7d1a5..1ca0e8a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -152,7 +152,7 @@ }, "../daily-notification-plugin": { "name": "@timesafari/daily-notification-plugin", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "workspaces": [ "packages/*" diff --git a/src/components/notifications/DailyNotificationSection.vue b/src/components/notifications/DailyNotificationSection.vue index 48533a95..9858014d 100644 --- a/src/components/notifications/DailyNotificationSection.vue +++ b/src/components/notifications/DailyNotificationSection.vue @@ -206,9 +206,146 @@ export default class DailyNotificationSection extends Vue { /** * Initialize component state on mount * Checks platform support and syncs with plugin state + * + * **Token Refresh on Mount:** + * - Refreshes native fetcher configuration to ensure plugin has valid token + * - This handles cases where app was closed for extended periods + * - Token refresh happens automatically without user interaction + * + * **App Resume Listener:** + * - Listens for Capacitor 'resume' event to refresh token when app comes to foreground + * - Ensures plugin always has fresh token for background prefetch operations + * - Cleaned up in `beforeDestroy()` lifecycle hook */ async mounted(): Promise { await this.initializeState(); + // Refresh native fetcher configuration on mount + // This ensures plugin has valid token even if app was closed for extended periods + await this.refreshNativeFetcherConfig(); + // Listen for app resume events to refresh token when app comes to foreground + // This is part of the proactive token refresh strategy + document.addEventListener("resume", this.handleAppResume); + } + + /** + * Cleanup on component destroy + */ + beforeDestroy(): void { + document.removeEventListener("resume", this.handleAppResume); + } + + /** + * Handle app resume event - refresh native fetcher configuration + * + * This method is called when the app comes to foreground (via Capacitor 'resume' event). + * It proactively refreshes the JWT token to ensure the plugin has valid authentication + * for background prefetch operations. + * + * **Why refresh on resume?** + * - Tokens expire after 72 hours + * - App may have been closed for extended periods + * - Refreshing ensures plugin has valid token for next prefetch cycle + * - No user interaction required - happens automatically + * + * @see {@link refreshNativeFetcherConfig} For implementation details + */ + async handleAppResume(): Promise { + logger.debug( + "[DailyNotificationSection] App resumed, refreshing native fetcher config", + ); + await this.refreshNativeFetcherConfig(); + } + + /** + * Refresh native fetcher configuration with fresh JWT token + * + * This method ensures the daily notification plugin has a valid authentication token + * for background prefetch operations. It's called proactively to prevent token expiration + * issues during offline periods. + * + * **Refresh Triggers:** + * - Component mount (when notification settings page loads) + * - App resume (when app comes to foreground) + * - Notification enabled (when user enables daily notifications) + * + * **Token Refresh Strategy (Hybrid Approach):** + * - Tokens are valid for 72 hours (see `accessTokenForBackground`) + * - Tokens are refreshed proactively when app is already open + * - If token expires while offline, plugin uses cached content + * - Next time app opens, token is automatically refreshed + * + * **Why This Approach?** + * - No app wake-up required (tokens refresh when app is already open) + * - Works offline (72-hour validity supports extended offline periods) + * - Automatic (no user interaction required) + * - Includes starred plans (fetcher receives user's starred plans for prefetch) + * - Graceful degradation (if refresh fails, cached content still works) + * + * **Error Handling:** + * - Errors are logged but not shown to user (background operation) + * - Returns early if notifications not supported or disabled + * - Returns early if API server not configured + * - Failures don't interrupt user experience + * + * @returns Promise that resolves when refresh completes (or fails silently) + * + * @example + * ```typescript + * // Called automatically on mount/resume + * await this.refreshNativeFetcherConfig(); + * ``` + * + * @see {@link CapacitorPlatformService.configureNativeFetcher} For token generation + * @see {@link accessTokenForBackground} For 72-hour token generation + */ + async refreshNativeFetcherConfig(): Promise { + try { + const platformService = PlatformServiceFactory.getInstance(); + + // Early return: Only refresh if notifications are supported and enabled + // This prevents unnecessary work when notifications aren't being used + if (!this.notificationsSupported || !this.nativeNotificationEnabled) { + return; + } + + // Get settings for API server and starred plans + // API server tells plugin where to fetch content from + // Starred plans tell plugin which plans to prefetch + const settings = await this.$accountSettings(); + const apiServer = settings.apiServer || ""; + + if (!apiServer) { + logger.warn( + "[DailyNotificationSection] No API server configured, skipping native fetcher refresh", + ); + return; + } + + // Get starred plans from settings + // These are passed to the plugin so it knows which plans to prefetch + const starredPlanHandleIds = settings.starredPlanHandleIds || []; + + // Configure native fetcher with fresh token + // The jwt parameter is ignored - configureNativeFetcher generates it automatically + // This ensures we always have a fresh token with current expiration time + await platformService.configureNativeFetcher({ + apiServer, + jwt: "", // Will be generated automatically by configureNativeFetcher + starredPlanHandleIds, + }); + + logger.info( + "[DailyNotificationSection] Native fetcher configuration refreshed", + ); + } catch (error) { + // Don't show error to user - this is a background operation + // Failures are logged for debugging but don't interrupt user experience + // If refresh fails, plugin will use existing token (if still valid) or cached content + logger.error( + "[DailyNotificationSection] Failed to refresh native fetcher config:", + error, + ); + } } /** @@ -235,8 +372,7 @@ export default class DailyNotificationSection extends Vue { this.notificationsSupported = true; this.notificationStatus = status; - // CRITICAL: Sync with plugin state first (source of truth) - // Plugin may have an existing schedule even if settings don't + // Plugin state is the source of truth if (status.isScheduled && status.scheduledTime) { // Plugin has a scheduled notification - sync UI to match this.nativeNotificationEnabled = true; @@ -244,31 +380,11 @@ export default class DailyNotificationSection extends Vue { this.nativeNotificationTime = formatTimeForDisplay( status.scheduledTime, ); - - // Also sync settings to match plugin state - const settings = await this.$accountSettings(); - if (settings.nativeNotificationTime !== status.scheduledTime) { - await this.$saveSettings({ - nativeNotificationTime: status.scheduledTime, - nativeNotificationTitle: - settings.nativeNotificationTitle || this.nativeNotificationTitle, - nativeNotificationMessage: - settings.nativeNotificationMessage || - this.nativeNotificationMessage, - }); - } } else { - // No plugin schedule - check settings for user preference - const settings = await this.$accountSettings(); - const nativeNotificationTime = settings.nativeNotificationTime || ""; - this.nativeNotificationEnabled = !!nativeNotificationTime; - this.nativeNotificationTimeStorage = nativeNotificationTime; - - if (nativeNotificationTime) { - this.nativeNotificationTime = formatTimeForDisplay( - nativeNotificationTime, - ); - } + // No plugin schedule - UI defaults to disabled + this.nativeNotificationEnabled = false; + this.nativeNotificationTimeStorage = ""; + this.nativeNotificationTime = ""; } } catch (error) { logger.error("[DailyNotificationSection] Failed to initialize:", error); @@ -452,16 +568,14 @@ export default class DailyNotificationSection extends Vue { priority: "high", }); - // Save to settings - await this.$saveSettings({ - nativeNotificationTime: this.nativeNotificationTimeStorage, - nativeNotificationTitle: this.nativeNotificationTitle, - nativeNotificationMessage: this.nativeNotificationMessage, - }); - // Update UI state this.nativeNotificationEnabled = true; + // Refresh native fetcher configuration with fresh token + // This ensures plugin has valid authentication when notifications are first enabled + // Token will be valid for 72 hours, supporting offline prefetch operations + await this.refreshNativeFetcherConfig(); + this.notify.success( "Daily notification scheduled successfully", TIMEOUTS.SHORT, @@ -492,13 +606,6 @@ export default class DailyNotificationSection extends Vue { // Cancel notification via PlatformService await platformService.cancelDailyNotification(); - // Clear settings - await this.$saveSettings({ - nativeNotificationTime: "", - nativeNotificationTitle: "", - nativeNotificationMessage: "", - }); - // Update UI state this.nativeNotificationEnabled = false; this.nativeNotificationTime = ""; @@ -558,10 +665,7 @@ export default class DailyNotificationSection extends Vue { if (this.nativeNotificationEnabled) { await this.updateNotificationTime(this.nativeNotificationTimeStorage); } else { - // Just save the time preference - await this.$saveSettings({ - nativeNotificationTime: this.nativeNotificationTimeStorage, - }); + // Just update local state (time preference stored in component) this.showTimeEdit = false; this.notify.success("Notification time saved", TIMEOUTS.SHORT); } @@ -574,12 +678,9 @@ export default class DailyNotificationSection extends Vue { async updateNotificationTime(newTime: string): Promise { // newTime is in "HH:mm" format from HTML5 time input if (!this.nativeNotificationEnabled) { - // If notification is disabled, just save the time preference + // If notification is disabled, just update local state this.nativeNotificationTimeStorage = newTime; this.nativeNotificationTime = formatTimeForDisplay(newTime); - await this.$saveSettings({ - nativeNotificationTime: newTime, - }); this.showTimeEdit = false; return; } @@ -605,11 +706,6 @@ export default class DailyNotificationSection extends Vue { this.nativeNotificationTimeStorage = newTime; this.nativeNotificationTime = formatTimeForDisplay(newTime); - // 4. Save to settings - await this.$saveSettings({ - nativeNotificationTime: newTime, - }); - this.notify.success( "Notification time updated successfully", TIMEOUTS.SHORT, diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 7db68437..4a177786 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -199,14 +199,6 @@ const MIGRATIONS = [ ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT; `, }, - { - name: "006_add_nativeNotificationSettings_to_settings", - sql: ` - ALTER TABLE settings ADD COLUMN nativeNotificationTime TEXT; - ALTER TABLE settings ADD COLUMN nativeNotificationTitle TEXT; - ALTER TABLE settings ADD COLUMN nativeNotificationMessage TEXT; - `, - }, ]; /** diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 0af24058..493e4596 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -53,11 +53,6 @@ export type Settings = { notifyingReminderMessage?: string; // set to their chosen message for a daily reminder notifyingReminderTime?: string; // set to their chosen time for a daily reminder - // Native notification settings (Capacitor only) - nativeNotificationTime?: string; // "09:00" format (24-hour) - scheduled time for daily notification - nativeNotificationTitle?: string; // Default: "Daily Update" - notification title - nativeNotificationMessage?: string; // Default message - notification body text - partnerApiServer?: string; // partner server API URL passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts index b8ff2d57..ac17c392 100644 --- a/src/libs/crypto/index.ts +++ b/src/libs/crypto/index.ts @@ -104,6 +104,71 @@ export const accessToken = async (did?: string) => { } }; +/** + * Generate a longer-lived access token for background operations + * + * This function creates JWT tokens with extended validity (default 72 hours) for use + * in background prefetch operations. The longer expiration period allows the daily + * notification plugin to work offline for extended periods without requiring the app + * to be in the foreground to refresh tokens. + * + * **Token Refresh Strategy (Hybrid Approach):** + * - Tokens are valid for 72 hours (configurable) + * - Tokens are refreshed proactively when: + * - App comes to foreground (via Capacitor 'resume' event) + * - Component mounts (DailyNotificationSection) + * - Notifications are enabled + * - If token expires while offline, plugin uses cached content + * - Next time app opens, token is automatically refreshed + * + * **Why 72 Hours?** + * - Balances security (read-only prefetch operations) with offline capability + * - Reduces need for app to wake itself for token refresh + * - Allows plugin to work offline for extended periods (e.g., weekend trips) + * - Longer than typical prefetch windows (5 minutes before notification) + * + * **Security Considerations:** + * - Tokens are used only for read-only prefetch operations + * - Tokens are stored securely in plugin's Room database + * - Tokens are refreshed proactively to minimize exposure window + * - No private keys are exposed to native code + * + * @param {string} did - User DID (Decentralized Identifier) for token issuer + * @param {number} expirationMinutes - Optional expiration in minutes (defaults to 72 hours = 4320 minutes) + * @return {Promise} JWT token with extended validity, or empty string if no DID provided + * + * @example + * ```typescript + * // Generate token with default 72-hour expiration + * const token = await accessTokenForBackground("did:ethr:0x..."); + * + * // Generate token with custom expiration (24 hours) + * const token24h = await accessTokenForBackground("did:ethr:0x...", 24 * 60); + * ``` + * + * @see {@link accessToken} For short-lived tokens (1 minute) for regular API requests + * @see {@link createEndorserJwtForDid} For JWT creation implementation + */ +export const accessTokenForBackground = async ( + did?: string, + expirationMinutes?: number, +): Promise => { + if (!did) { + return ""; + } + + // Use provided expiration or default to 72 hours (4320 minutes) + // This allows background prefetch operations to work offline for extended periods + const expirationSeconds = expirationMinutes + ? expirationMinutes * 60 + : 72 * 60 * 60; // Default 72 hours + + const nowEpoch = Math.floor(Date.now() / 1000); + const endEpoch = nowEpoch + expirationSeconds; + const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; + return createEndorserJwtForDid(did, tokenPayload); +}; + /** * Extract JWT from various URL formats * @param jwtUrlText The URL containing the JWT diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index 30b8d8b4..6ff9b503 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -1543,6 +1543,51 @@ export class CapacitorPlatformService /** * Configure native fetcher for background operations + * + * This method configures the daily notification plugin's native content fetcher + * with authentication credentials for background prefetch operations. It automatically + * retrieves the active DID from the database and generates a fresh JWT token with + * 72-hour expiration. + * + * **Authentication Flow:** + * 1. Retrieves active DID from `active_identity` table (single source of truth) + * 2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()` + * 3. Configures plugin with API server URL, active DID, and JWT token + * 4. Plugin stores token in its Room database for background workers + * + * **Token Management:** + * - Tokens are valid for 72 hours (4320 minutes) + * - Tokens are refreshed proactively when app comes to foreground + * - If token expires while offline, plugin uses cached content + * - Token refresh happens automatically via `DailyNotificationSection.refreshNativeFetcherConfig()` + * + * **Offline-First Design:** + * - 72-hour validity supports extended offline periods + * - Plugin can prefetch content when online and use cached content when offline + * - No app wake-up required for token refresh (happens when app is already open) + * + * **Error Handling:** + * - Returns `null` if active DID not found (no user logged in) + * - Returns `null` if JWT generation fails + * - Logs errors but doesn't throw (allows graceful degradation) + * + * @param config - Native fetcher configuration + * @param config.apiServer - API server URL (optional, uses default if not provided) + * @param config.jwt - JWT token (ignored, generated automatically) + * @param config.starredPlanHandleIds - Array of starred plan handle IDs for prefetch + * @returns Promise that resolves when configured, or `null` if configuration failed + * + * @example + * ```typescript + * await platformService.configureNativeFetcher({ + * apiServer: "https://api.endorser.ch", + * jwt: "", // Generated automatically + * starredPlanHandleIds: ["plan-123", "plan-456"] + * }); + * ``` + * + * @see {@link accessTokenForBackground} For JWT token generation + * @see {@link DailyNotificationSection.refreshNativeFetcherConfig} For proactive token refresh * @see PlatformService.configureNativeFetcher */ async configureNativeFetcher( @@ -1553,16 +1598,57 @@ export class CapacitorPlatformService "@timesafari/daily-notification-plugin" ); - // Plugin expects apiBaseUrl, activeDid, and jwtToken - // We'll need to get activeDid from somewhere - for now pass empty string - // Components should provide activeDid when calling this + // Step 1: Get activeDid from database (single source of truth) + // This ensures we're using the correct user identity for authentication + const activeIdentity = await this.getActiveIdentity(); + const activeDid = activeIdentity.activeDid; + + if (!activeDid) { + logger.warn( + "[CapacitorPlatformService] No activeDid found, cannot configure native fetcher", + ); + return null; + } + + // Step 2: Generate JWT token for background operations + // Use 72-hour expiration for offline-first prefetch operations + // This allows the plugin to work offline for extended periods + const { accessTokenForBackground } = await import( + "../../libs/crypto/index" + ); + // Use 72 hours (4320 minutes) for background prefetch tokens + // This is longer than passkey expiration to support offline scenarios + const expirationMinutes = 72 * 60; // 72 hours + const jwtToken = await accessTokenForBackground( + activeDid, + expirationMinutes, + ); + + if (!jwtToken) { + logger.error("[CapacitorPlatformService] Failed to generate JWT token"); + return null; + } + + // Step 3: Get API server from config or use default + // This ensures the plugin knows where to fetch content from + const apiServer = + config.apiServer || + (await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER; + + // Step 4: Configure plugin with credentials + // Plugin stores these in its Room database for background workers await DailyNotification.configureNativeFetcher({ - apiBaseUrl: config.apiServer, - activeDid: "", // Should be provided by caller - jwtToken: config.jwt, + apiBaseUrl: apiServer, + activeDid, + jwtToken, }); - logger.info("[CapacitorPlatformService] Configured native fetcher"); + logger.info("[CapacitorPlatformService] Configured native fetcher", { + activeDid, + apiServer, + tokenExpirationHours: 72, + tokenExpirationMinutes: expirationMinutes, + }); } catch (error) { logger.error( "[CapacitorPlatformService] Failed to configure native fetcher:", diff --git a/vite.config.capacitor.mts b/vite.config.capacitor.mts index b47e5abe..e3bfd9f1 100644 --- a/vite.config.capacitor.mts +++ b/vite.config.capacitor.mts @@ -1,4 +1,23 @@ import { defineConfig } from "vite"; import { createBuildConfig } from "./vite.config.common.mts"; -export default defineConfig(async () => createBuildConfig('capacitor')); \ No newline at end of file +export default defineConfig(async () => { + const baseConfig = await createBuildConfig('capacitor'); + + return { + ...baseConfig, + build: { + ...baseConfig.build, + rollupOptions: { + ...baseConfig.build?.rollupOptions, + // Externalize Capacitor plugins that are bundled natively + external: [ + "@timesafari/daily-notification-plugin" + ], + output: { + ...baseConfig.build?.rollupOptions?.output, + } + } + } + }; +}); \ No newline at end of file