From eefd5455ed86140a2aac0169090e748de398beee Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Thu, 30 Oct 2025 07:04:16 +0000 Subject: [PATCH] feat(spi): add native fetcher SPI interface for background content fetching - Add NativeNotificationContentFetcher interface for host app implementations - Add FetchContext class to pass fetch parameters (trigger, scheduledTime, fetchTime) - Add SchedulingPolicy class for retry backoff configuration - Add TypeScript definitions for content fetcher SPI in src/definitions.ts - Export SPI types from src/index.ts This enables host apps to provide their own content fetching implementation for background workers, following the Integration Point Refactor (PR2). --- .../dailynotification/FetchContext.java | 130 ++++++++++++ .../NativeNotificationContentFetcher.java | 86 ++++++++ .../dailynotification/SchedulingPolicy.java | 198 ++++++++++++++++++ src/definitions.ts | 43 ++++ src/index.ts | 1 + 5 files changed, 458 insertions(+) create mode 100644 android/plugin/src/main/java/com/timesafari/dailynotification/FetchContext.java create mode 100644 android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java create mode 100644 android/plugin/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/FetchContext.java b/android/plugin/src/main/java/com/timesafari/dailynotification/FetchContext.java new file mode 100644 index 0000000..8c7d5c2 --- /dev/null +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/FetchContext.java @@ -0,0 +1,130 @@ +/** + * FetchContext.java + * + * Context information provided to content fetchers about why a fetch was triggered. + * + * This class is part of the Integration Point Refactor (PR1) SPI implementation. + * It provides fetchers with metadata about the fetch request, including trigger + * type, scheduling information, and optional metadata. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Context provided to content fetchers about why fetch was triggered + * + * This follows the TypeScript interface from src/types/content-fetcher.ts and + * ensures type safety between JS and native fetcher implementations. + */ +public class FetchContext { + + /** + * Reason why the fetch was triggered + * + * Valid values: "background_work", "prefetch", "manual", "scheduled" + */ + @NonNull + public final String trigger; + + /** + * When notification is scheduled for (optional, epoch milliseconds) + * + * Only present when trigger is "prefetch" or "scheduled" + */ + @Nullable + public final Long scheduledTime; + + /** + * When the fetch was triggered (required, epoch milliseconds) + */ + public final long fetchTime; + + /** + * Additional context metadata (optional) + * + * Plugin may populate with app state, network info, etc. + * Fetcher can use for logging, debugging, or conditional logic. + */ + @NonNull + public final Map metadata; + + /** + * Constructor with all fields + * + * @param trigger Trigger type (required) + * @param scheduledTime Scheduled time (optional) + * @param fetchTime When fetch triggered (required) + * @param metadata Additional metadata (optional, can be null) + */ + public FetchContext( + @NonNull String trigger, + @Nullable Long scheduledTime, + long fetchTime, + @Nullable Map metadata) { + if (trigger == null || trigger.isEmpty()) { + throw new IllegalArgumentException("trigger is required"); + } + + this.trigger = trigger; + this.scheduledTime = scheduledTime; + this.fetchTime = fetchTime; + this.metadata = metadata != null ? + Collections.unmodifiableMap(new HashMap<>(metadata)) : + Collections.emptyMap(); + } + + /** + * Constructor with minimal fields (no metadata) + * + * @param trigger Trigger type + * @param scheduledTime Scheduled time (can be null) + * @param fetchTime When fetch triggered + */ + public FetchContext( + @NonNull String trigger, + @Nullable Long scheduledTime, + long fetchTime) { + this(trigger, scheduledTime, fetchTime, null); + } + + /** + * Get metadata value by key + * + * @param key Metadata key + * @return Value or null if not present + */ + @Nullable + public Object getMetadata(@NonNull String key) { + return metadata.get(key); + } + + /** + * Check if metadata contains key + * + * @param key Metadata key + * @return True if key exists + */ + public boolean hasMetadata(@NonNull String key) { + return metadata.containsKey(key); + } + + @Override + public String toString() { + return "FetchContext{" + + "trigger='" + trigger + '\'' + + ", scheduledTime=" + scheduledTime + + ", fetchTime=" + fetchTime + + ", metadataSize=" + metadata.size() + + '}'; + } +} + diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java b/android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java new file mode 100644 index 0000000..2b58454 --- /dev/null +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java @@ -0,0 +1,86 @@ +/** + * NativeNotificationContentFetcher.java + * + * Service Provider Interface (SPI) for native content fetchers. + * + * This interface is part of the Integration Point Refactor (PR1) that allows + * host apps to provide their own content fetching logic without hardcoding + * TimeSafari-specific code in the plugin. + * + * Host apps implement this interface in native code (Kotlin/Java) and register + * it with the plugin. The plugin calls this interface from background workers + * (WorkManager) to fetch notification content. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import androidx.annotation.NonNull; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Native content fetcher interface for host app implementations + * + * This interface enables the plugin to call host app's native code for + * fetching notification content. This is the ONLY path used by background + * workers, as JavaScript bridges are unreliable in background contexts. + * + * Implementation Requirements: + * - Must be thread-safe (may be called from WorkManager background threads) + * - Must complete within reasonable time (plugin enforces timeout) + * - Should return empty list on failure rather than throwing exceptions + * - Should handle errors gracefully and log for debugging + * + * Example Implementation: + *
+ * class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
+ *     private final TimeSafariApi api;
+ *     private final TokenProvider tokenProvider;
+ *     
+ *     @Override
+ *     public CompletableFuture> fetchContent(
+ *             FetchContext context) {
+ *         return CompletableFuture.supplyAsync(() -> {
+ *             try {
+ *                 String jwt = tokenProvider.freshToken();
+ *                 // Fetch from TimeSafari API
+ *                 // Convert to NotificationContent[]
+ *                 return notificationContents;
+ *             } catch (Exception e) {
+ *                 Log.e("Fetcher", "Fetch failed", e);
+ *                 return Collections.emptyList();
+ *             }
+ *         });
+ *     }
+ * }
+ * 
+ */ +public interface NativeNotificationContentFetcher { + + /** + * Fetch notification content from external source + * + * This method is called by the plugin when: + * - Background fetch work is triggered (WorkManager) + * - Prefetch is scheduled before notification time + * - Manual refresh is requested (if native fetcher enabled) + * + * The plugin will: + * - Enforce a timeout (default 30 seconds, configurable via SchedulingPolicy) + * - Handle empty lists gracefully (no notifications scheduled) + * - Log errors for debugging + * - Retry on failure based on SchedulingPolicy + * + * @param context Context about why fetch was triggered, including + * trigger type, scheduled time, and optional metadata + * @return CompletableFuture that resolves to list of NotificationContent. + * Empty list indicates no content available (not an error). + * The future should complete exceptionally only on unrecoverable errors. + */ + @NonNull + CompletableFuture> fetchContent(@NonNull FetchContext context); +} + diff --git a/android/plugin/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java b/android/plugin/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java new file mode 100644 index 0000000..f28bb21 --- /dev/null +++ b/android/plugin/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java @@ -0,0 +1,198 @@ +/** + * SchedulingPolicy.java + * + * Policy configuration for notification scheduling, fetching, and retry behavior. + * + * This class is part of the Integration Point Refactor (PR1) SPI implementation. + * It allows host apps to configure scheduling behavior including retry backoff, + * prefetch timing, deduplication windows, and cache TTL. + * + * @author Matthew Raymer + * @version 1.0.0 + */ + +package com.timesafari.dailynotification; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Scheduling policy configuration + * + * Controls how the plugin schedules fetches, handles retries, manages + * deduplication, and enforces TTL policies. + * + * This follows the TypeScript interface from src/types/content-fetcher.ts + * and ensures consistency between JS and native configuration. + */ +public class SchedulingPolicy { + + /** + * How early to prefetch before scheduled notification time (milliseconds) + * + * Example: If set to 300000 (5 minutes), and notification is scheduled for + * 8:00 AM, the fetch will be triggered at 7:55 AM. + * + * Default: 5 minutes (300000ms) + */ + @Nullable + public Long prefetchWindowMs; + + /** + * Retry backoff configuration (required) + * + * Controls exponential backoff behavior for failed fetches + */ + @NonNull + public RetryBackoff retryBackoff; + + /** + * Maximum items to fetch per batch + * + * Limits the number of NotificationContent items that can be fetched + * in a single operation. Helps prevent oversized responses. + * + * Default: 50 + */ + @Nullable + public Integer maxBatchSize; + + /** + * Deduplication window (milliseconds) + * + * Prevents duplicate notifications within this time window. Plugin + * uses dedupeKey (or id) to detect duplicates. + * + * Default: 24 hours (86400000ms) + */ + @Nullable + public Long dedupeHorizonMs; + + /** + * Default cache TTL if item doesn't specify ttlSeconds (seconds) + * + * Used when NotificationContent doesn't have ttlSeconds set. + * Determines how long cached content remains valid. + * + * Default: 6 hours (21600 seconds) + */ + @Nullable + public Integer cacheTtlSeconds; + + /** + * Whether exact alarms are allowed (Android 12+) + * + * Controls whether plugin should attempt to use exact alarms. + * Requires SCHEDULE_EXACT_ALARM permission on Android 12+. + * + * Default: false (use inexact alarms) + */ + @Nullable + public Boolean exactAlarmsAllowed; + + /** + * Fetch timeout in milliseconds + * + * Maximum time to wait for native fetcher to complete. + * Plugin enforces this timeout when calling fetchContent(). + * + * Default: 30 seconds (30000ms) + */ + @Nullable + public Long fetchTimeoutMs; + + /** + * Default constructor with required field + * + * @param retryBackoff Retry backoff configuration (required) + */ + public SchedulingPolicy(@NonNull RetryBackoff retryBackoff) { + this.retryBackoff = retryBackoff; + } + + /** + * Retry backoff configuration + * + * Controls exponential backoff behavior for retryable failures. + * Delay = min(max(minMs, lastDelay * factor), maxMs) * (1 + jitterPct/100 * random) + */ + public static class RetryBackoff { + + /** + * Minimum delay between retries (milliseconds) + * + * First retry will wait at least this long. + * Default: 2000ms (2 seconds) + */ + public long minMs; + + /** + * Maximum delay between retries (milliseconds) + * + * Retry delay will never exceed this value. + * Default: 600000ms (10 minutes) + */ + public long maxMs; + + /** + * Exponential backoff multiplier + * + * Each retry multiplies previous delay by this factor. + * Example: factor=2 means delays: 2s, 4s, 8s, 16s, ... + * Default: 2.0 + */ + public double factor; + + /** + * Jitter percentage (0-100) + * + * Adds randomness to prevent thundering herd. + * Final delay = calculatedDelay * (1 + jitterPct/100 * random(0-1)) + * Default: 20 (20% jitter) + */ + public int jitterPct; + + /** + * Default constructor with sensible defaults + */ + public RetryBackoff() { + this.minMs = 2000; + this.maxMs = 600000; + this.factor = 2.0; + this.jitterPct = 20; + } + + /** + * Constructor with all parameters + * + * @param minMs Minimum delay (ms) + * @param maxMs Maximum delay (ms) + * @param factor Exponential multiplier + * @param jitterPct Jitter percentage (0-100) + */ + public RetryBackoff(long minMs, long maxMs, double factor, int jitterPct) { + this.minMs = minMs; + this.maxMs = maxMs; + this.factor = factor; + this.jitterPct = jitterPct; + } + } + + /** + * Create default policy with sensible defaults + * + * @return Default SchedulingPolicy instance + */ + @NonNull + public static SchedulingPolicy createDefault() { + SchedulingPolicy policy = new SchedulingPolicy(new RetryBackoff()); + policy.prefetchWindowMs = 300000L; // 5 minutes + policy.maxBatchSize = 50; + policy.dedupeHorizonMs = 86400000L; // 24 hours + policy.cacheTtlSeconds = 21600; // 6 hours + policy.exactAlarmsAllowed = false; + policy.fetchTimeoutMs = 30000L; // 30 seconds + return policy; + } +} + diff --git a/src/definitions.ts b/src/definitions.ts index 17a1122..10173d0 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -8,6 +8,12 @@ * @version 2.0.0 */ +// Import SPI types from content-fetcher.ts +import type { + SchedulingPolicy, + JsNotificationContentFetcher +} from './types/content-fetcher'; + export interface NotificationResponse { id: string; title: string; @@ -442,6 +448,43 @@ export interface DailyNotificationPlugin { * Update an existing daily reminder */ updateDailyReminder(reminderId: string, options: DailyReminderOptions): Promise; + + // Integration Point Refactor (PR1): SPI Registration Methods + + /** + * Set JavaScript content fetcher for foreground operations + * + * NOTE: This is a stub in PR1. Full implementation coming in PR3. + * JS fetchers are ONLY used for foreground/manual refresh. + * Background workers must use native fetcher. + * + * @param fetcher JavaScript fetcher implementation + */ + setJsContentFetcher(fetcher: JsNotificationContentFetcher): void; + + /** + * Enable or disable native fetcher + * + * Native fetcher is required for background workers. If disabled, + * background fetches will fail gracefully. + * + * @param enable Whether to enable native fetcher + * @returns Promise with enabled and registered status + */ + enableNativeFetcher(enable: boolean): Promise<{ + enabled: boolean; + registered: boolean; + }>; + + /** + * Set scheduling policy configuration + * + * Updates the scheduling policy used by the plugin for retry backoff, + * prefetch timing, deduplication, and cache TTL. + * + * @param policy Scheduling policy configuration + */ + setPolicy(policy: SchedulingPolicy): Promise; } // Phase 1: TimeSafari Endorser.ch API Interfaces diff --git a/src/index.ts b/src/index.ts index 7b56abc..01952c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ const DailyNotification = registerPlugin('DailyNotifica observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Daily Notification Plugin initialized'); export * from './definitions'; +export * from './types/content-fetcher'; // Export SPI types export * from './observability'; export * from './timesafari-integration'; export * from './timesafari-storage-adapter';