Browse Source

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).
master
Matthew Raymer 2 days ago
parent
commit
eefd5455ed
  1. 130
      android/plugin/src/main/java/com/timesafari/dailynotification/FetchContext.java
  2. 86
      android/plugin/src/main/java/com/timesafari/dailynotification/NativeNotificationContentFetcher.java
  3. 198
      android/plugin/src/main/java/com/timesafari/dailynotification/SchedulingPolicy.java
  4. 43
      src/definitions.ts
  5. 1
      src/index.ts

130
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<String, Object> 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<String, Object> 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() +
'}';
}
}

86
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:
* <pre>
* class TimeSafariNativeFetcher implements NativeNotificationContentFetcher {
* private final TimeSafariApi api;
* private final TokenProvider tokenProvider;
*
* @Override
* public CompletableFuture<List<NotificationContent>> 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();
* }
* });
* }
* }
* </pre>
*/
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<List<NotificationContent>> fetchContent(@NonNull FetchContext context);
}

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

43
src/definitions.ts

@ -8,6 +8,12 @@
* @version 2.0.0 * @version 2.0.0
*/ */
// Import SPI types from content-fetcher.ts
import type {
SchedulingPolicy,
JsNotificationContentFetcher
} from './types/content-fetcher';
export interface NotificationResponse { export interface NotificationResponse {
id: string; id: string;
title: string; title: string;
@ -442,6 +448,43 @@ export interface DailyNotificationPlugin {
* Update an existing daily reminder * Update an existing daily reminder
*/ */
updateDailyReminder(reminderId: string, options: DailyReminderOptions): Promise<void>; updateDailyReminder(reminderId: string, options: DailyReminderOptions): Promise<void>;
// 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<void>;
} }
// Phase 1: TimeSafari Endorser.ch API Interfaces // Phase 1: TimeSafari Endorser.ch API Interfaces

1
src/index.ts

@ -13,6 +13,7 @@ const DailyNotification = registerPlugin<DailyNotificationPlugin>('DailyNotifica
observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Daily Notification Plugin initialized'); observability.logEvent('INFO', EVENT_CODES.FETCH_START, 'Daily Notification Plugin initialized');
export * from './definitions'; export * from './definitions';
export * from './types/content-fetcher'; // Export SPI types
export * from './observability'; export * from './observability';
export * from './timesafari-integration'; export * from './timesafari-integration';
export * from './timesafari-storage-adapter'; export * from './timesafari-storage-adapter';

Loading…
Cancel
Save